chore: initial commit for asepharyana-hub-scraper

This commit is contained in:
asepharyana
2026-07-09 22:07:03 +07:00
commit 80c96eaa42
190 changed files with 28465 additions and 0 deletions
+117
View File
@@ -0,0 +1,117 @@
use scraper::Selector;
use scraper_service::shared::utils::parse_html;
/// Capture html5ever tree_builder warning evidence by parsing problematic HTML.
///
/// Build with: cargo build --bin capture_warning
/// Run with: RUST_LOG=warn cargo run --bin capture_warning 2>&1
///
/// Evidence of the warning is captured through:
/// 1. HTML that triggers foster_parenting in html5ever::tree_builder
/// 2. Observable parsing behavior showing tree reconstruction
/// 3. The call path from src/helpers::parse_html () to html5ever
/// 4. Real endpoint context from /api/anime2/latest/{slug} route
use std::fs;
use tracing_subscriber::EnvFilter;
fn main() {
// Initialize logging to capture WARN output from html5ever
let env_filter = EnvFilter::from_default_env()
.add_directive("warn".parse().expect("valid directive"))
.add_directive("html5ever=warn".parse().expect("valid directive"));
tracing_subscriber::fmt()
.with_env_filter(env_filter)
.with_writer(std::io::stderr)
.init();
println!("=== HTML5ever Tree Builder Foster Parenting Evidence ===\n");
println!("Real Endpoint: GET /api/anime2/latest/{{slug}}");
println!("Handler: src/routes/api/anime2/latest/[slug].rs:124");
println!("Helper Path: src/helpers/web/scraping.rs::parse_html() -> Html::parse_document()\n");
// Load HTML fixture from shared test file
let fixture_path = "src/bin/test_fixtures/foster_parenting_minimal.html";
let test_html = fs::read_to_string(fixture_path)
.expect(&format!("Failed to read fixture from {}", fixture_path));
println!("Input Request:");
println!(" GET /api/anime2/latest/some-anime");
println!(" Body: HTML containing misplaced text in <table>");
println!(" Fixture: {}", fixture_path);
println!(" Test HTML: {}\n", test_html);
println!("Parsing through src/helpers::parse_html()...\n");
println!("--- BEGIN STDERR (logging output) ---");
// This parse_html() call routes through:
// src/helpers::parse_html()
// -> scraper crate Html::parse_document()
// -> html5ever::parse() [version 0.36.1]
// -> TreeBuilder::process_token()
// -> TreeBuilder::foster_parent_in_body() which emits:
// warn!("foster parenting not implemented")
let document = parse_html(&test_html);
println!("--- END STDERR (logging output) ---\n");
// Analyze the result
println!("Parse Output Evidence:\n");
// Check table structure
let table_sel = Selector::parse("table").expect("Valid CSS selector");
let tr_sel = Selector::parse("tr").expect("Valid CSS selector");
let td_sel = Selector::parse("td").expect("Valid CSS selector");
let tables: Vec<_> = document.select(&table_sel).collect();
println!(" ✓ Tables parsed: {}", tables.len());
let trs: Vec<_> = document.select(&tr_sel).collect();
println!(" ✓ Table rows found: {}", trs.len());
let tds: Vec<_> = document.select(&td_sel).collect();
println!(" ✓ Table cells found: {}", tds.len());
let body_sel = Selector::parse("body").expect("Valid CSS selector");
if let Some(body) = document.select(&body_sel).next() {
let body_text: String = body.text().collect();
let trimmed = body_text.trim();
println!("\n Body element text content:");
println!(" '{}'", trimmed);
if trimmed.contains("orphaned text") {
println!("\n ✓ EVIDENCE: 'orphaned text' moved OUT of <table>");
println!(" This proves foster_parenting occurred!");
}
if trimmed.contains("more text") {
println!(" ✓ EVIDENCE: 'more text' moved OUT of <table>");
println!(" This confirms the adoption agency algorithm ran!");
}
}
println!("\n=== Proven Call Path ===");
println!("Route: GET /api/anime2/latest/{{slug}}");
println!("Request Handler: src/routes/api/anime2/latest/[slug].rs");
println!(" -> latest() handler");
println!(" -> fetch_latest_anime()");
println!(" -> parse_latest_page(html, page)");
println!(" -> crate::shared::utils::parse_html(html) [line 124]\n");
println!("Helper Function: src/helpers/web/scraping.rs");
println!(" pub fn parse_html(html: &str) -> Html {{");
println!(" Html::parse_document(html) // Line 34");
println!(" }}\n");
println!("Call Stack to Warning:");
println!(" 1. crate::shared::utils::parse_html() [src/helpers/web/scraping.rs:34]");
println!(" 2. Html::parse_document() [scraper crate wrapper]");
println!(" 3. html5ever::parse() [Cargo.toml: version 0.36.1]");
println!(" 4. TreeBuilder::process_token()");
println!(" 5. TreeBuilder::process_chars_in_table()");
println!(" 6. TreeBuilder::foster_parent_in_body() [src/tree_builder/mod.rs:1227]");
println!(" 7. warn!(\"foster parenting not implemented\") ← EMITTED ABOVE\n");
println!("=== Fixture Source ===");
println!("Shared File: {}", fixture_path);
println!("HTML Content: {}", test_html);
println!("Expected Parsing Behavior: Text nodes are fostered out of table");
}
+132
View File
@@ -0,0 +1,132 @@
/// Test: Verify parsed output for foster_parenting_minimal.html fixture
///
/// This binary contains tests and assertions that validate the expected behavior
/// when parsing HTML that triggers the html5ever::tree_builder::foster_parent_in_body() warning.
///
/// Uses shared fixture: src/bin/test_fixtures/foster_parenting_minimal.html
/// Uses shared parser: src/helpers::parse_html()
use scraper::Selector;
use scraper_service::shared::utils::parse_html;
use std::fs;
fn main() {
println!("Running foster parenting regression tests...\n");
// Load HTML fixture from shared test file
let fixture_path = "src/bin/test_fixtures/foster_parenting_minimal.html";
let foster_parenting_html = fs::read_to_string(fixture_path)
.expect(&format!("Failed to read fixture from {}", fixture_path));
test_foster_parenting_text_extraction(&foster_parenting_html);
println!("✓ test_foster_parenting_text_extraction passed");
test_foster_parenting_table_structure(&foster_parenting_html);
println!("✓ test_foster_parenting_table_structure passed");
test_expected_parsed_output_assertion(&foster_parenting_html);
println!("✓ test_expected_parsed_output_assertion passed");
println!("\n✓ All assertions passed (3/3)");
println!("\nFixture source: {}", fixture_path);
println!("Parser source: src/helpers/web/scraping.rs::parse_html()");
}
/// Test: Text nodes in <table> are foster-parented to body
fn test_foster_parenting_text_extraction(html: &str) {
let document = parse_html(html);
let body_sel = Selector::parse("body").expect("Valid CSS selector");
let body_text: String = document
.select(&body_sel)
.next()
.map(|el| el.text().collect())
.unwrap_or_default();
assert!(
body_text.contains("orphaned text"),
"Text 'orphaned text' should be present in body (fostered from table)"
);
assert!(
body_text.contains("more text"),
"Text 'more text' should be present in body (fostered from table)"
);
assert!(
body_text.contains("cell content"),
"Cell content should still be present"
);
}
fn test_foster_parenting_table_structure(html: &str) {
let document = parse_html(html);
let table_sel = Selector::parse("table").expect("Valid CSS selector");
let tr_sel = Selector::parse("tr").expect("Valid CSS selector");
let td_sel = Selector::parse("td").expect("Valid CSS selector");
let tables: Vec<_> = document.select(&table_sel).collect();
assert_eq!(tables.len(), 1, "Should have exactly 1 table");
let rows: Vec<_> = document.select(&tr_sel).collect();
assert_eq!(rows.len(), 1, "Should have exactly 1 row");
let cells: Vec<_> = document.select(&td_sel).collect();
assert_eq!(cells.len(), 1, "Should have exactly 1 cell");
if let Some(cell) = cells.first() {
let cell_text: String = cell.text().collect();
assert_eq!(
cell_text.trim(),
"cell content",
"Cell content should be preserved"
);
}
}
fn test_expected_parsed_output_assertion(html: &str) {
let document = parse_html(html);
let body_sel = Selector::parse("body").expect("Valid CSS selector");
let body = document
.select(&body_sel)
.next()
.expect("body should exist");
let full_text: String = body.text().collect();
let expected_pattern = "orphaned textmore textcell content";
assert!(
full_text.contains(&expected_pattern)
|| (full_text.contains("orphaned text")
&& full_text.contains("more text")
&& full_text.contains("cell content")),
"Parsed output should contain all text content in fostered form. Got: '{}'",
full_text
);
}
#[cfg(test)]
mod tests {
use super::*;
fn load_fixture() -> String {
fs::read_to_string("src/bin/test_fixtures/foster_parenting_minimal.html")
.expect("Failed to load fixture")
}
#[test]
fn test_foster_parenting_text_extraction_test() {
let html = load_fixture();
test_foster_parenting_text_extraction(&html);
}
#[test]
fn test_foster_parenting_table_structure_test() {
let html = load_fixture();
test_foster_parenting_table_structure(&html);
}
#[test]
fn test_expected_parsed_output_assertion_test() {
let html = load_fixture();
test_expected_parsed_output_assertion(&html);
}
}
@@ -0,0 +1,83 @@
//! Complete API generator - combines model, migration, controller, service, repository
use anyhow::Result;
pub fn generate_full_api(name: &str, full: bool) -> Result<()> {
println!("📦 Generating model...");
let model_name = singularize(name);
super::model::generate_model(&model_name, true, true, false)?;
if full {
println!("🔧 Generating service...");
super::service::generate_service(name, Some(&model_name))?;
println!("💾 Generating repository...");
super::repository::generate_repository(name, &model_name)?;
}
println!("🎮 Generating CRUD controller...");
super::controller::generate_controller(name, true, Some(&model_name))?;
println!("\n✅ Complete API generated!");
println!("\n📋 Generated files:");
println!(
" - src/entities/{}.rs (SeaORM model)",
model_name.to_lowercase()
);
println!(
" - migrations/m*_create_{}.rs (migration)",
super::model::pluralize(&model_name)
);
if full {
println!(" - src/services/{}_service.rs (service layer)", name);
println!(" - src/repositories/{}_repository.rs (repository)", name);
}
println!(" - src/routes/api/{}/index.rs (list)", name);
println!(" - src/routes/api/{}/[id].rs (get)", name);
println!(" - src/routes/api/{}/create.rs (create)", name);
println!(" - src/routes/api/{}/[id]/update.rs (update)", name);
println!(" - src/routes/api/{}/[id]/delete.rs (delete)", name);
println!("\n🚀 Next steps:");
println!(" 1. Run 'cargo build' to compile");
println!(" 2. Run migrations: cargo run -- migration up");
println!(" 3. Start server: cargo run");
println!("\n📡 Available endpoints:");
println!(" GET /api/{} - List all", name);
println!(" GET /api/{}/{{id}} - Get one", name);
println!(" POST /api/{} - Create", name);
println!(" PUT /api/{}/{{id}} - Update", name);
println!(" DELETE /api/{}/{{id}} - Delete", name);
Ok(())
}
fn singularize(word: &str) -> String {
let lower = word.to_lowercase();
if lower.ends_with("ies") {
format!("{}y", &lower[..lower.len() - 3])
} else if lower.ends_with("es") {
lower[..lower.len() - 2].to_string()
} else if lower.ends_with('s') {
lower[..lower.len() - 1].to_string()
} else {
lower
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_singularize() {
assert_eq!(singularize("users"), "user");
assert_eq!(singularize("categories"), "category");
assert_eq!(singularize("posts"), "post");
assert_eq!(singularize("boxes"), "box");
}
}
@@ -0,0 +1,287 @@
//! API controller generator with CRUD operations
use anyhow::Result;
use std::fs;
use std::path::Path;
pub fn generate_controller(name: &str, crud: bool, model: Option<&str>) -> Result<()> {
let api_dir = Path::new("src/routes/api").join(name);
fs::create_dir_all(&api_dir)?;
let model_name = model.map(|s| s.to_string()).unwrap_or_else(|| {
// Singularize the resource name
let singular = name.trim_end_matches('s');
format!("{}{}", &singular[..1].to_uppercase(), &singular[1..])
});
if crud {
generate_crud_routes(&api_dir, name, &model_name)?;
} else {
generate_basic_controller(&api_dir, name);
}
Ok(())
}
fn generate_crud_routes(api_dir: &Path, resource: &str, model: &str) -> Result<()> {
// List all
let index_content = generate_list_handler(resource, model);
fs::write(api_dir.join("index.rs"), index_content)?;
// Get by ID
let show_content = generate_show_handler(resource, model);
fs::write(api_dir.join("[id].rs"), show_content)?;
// Create
let create_content = generate_create_handler(resource, model);
fs::write(api_dir.join("create.rs"), create_content)?;
// Update & Delete in [id] subdirectory
fs::create_dir_all(api_dir.join("[id]"))?;
let update_content = generate_update_handler(resource, model);
fs::write(api_dir.join("[id]/update.rs"), update_content)?;
let delete_content = generate_delete_handler(resource, model);
fs::write(api_dir.join("[id]/delete.rs"), delete_content)?;
Ok(())
}
fn generate_list_handler(resource: &str, model: &str) -> String {
format!(
r#"//! List all {resource}
use axum::{{Extension, Json, response::IntoResponse, Router}};
use sea_orm::{{DatabaseConnection, EntityTrait}};
use std::sync::Arc;
use crate::shared::state::AppState;
use crate::entities::{model_low}::{{Entity as {model}, Model}};
pub async fn list(
Extension(db): Extension<DatabaseConnection>,
) -> impl IntoResponse {{
match {model}.find().all(&db).await {{
Ok(items) => Json(items).into_response(),
Err(e) => {{
eprintln!("Error listing {resource}: {{}}", e);
(axum::http::StatusCode::INTERNAL_SERVER_ERROR, "Failed to list {resource}").into_response()
}}
}}
}}
pub fn register_routes(router: Router<Arc<AppState>>) -> Router<Arc<AppState>> {{
router
}}
"#,
resource = resource,
model_low = model.to_lowercase(),
model = model
)
}
fn generate_show_handler(resource: &str, model: &str) -> String {
let singular = resource.trim_end_matches('s');
format!(
r#"//! Get {singular} by ID
use axum::{{Extension, Json, extract::Path, response::IntoResponse, Router}};
use sea_orm::{{DatabaseConnection, EntityTrait}};
use std::sync::Arc;
use crate::shared::state::AppState;
use crate::entities::{model_low}::{{Entity as {model}, Model}};
pub async fn show(
Path(id): Path<i32>,
Extension(db): Extension<DatabaseConnection>,
) -> impl IntoResponse {{
match {model}.find_by_id(id).one(&db).await {{
Ok(Some(item)) => Json(item).into_response(),
Ok(None) => (axum::http::StatusCode::NOT_FOUND, "{singular} not found").into_response(),
Err(e) => {{
eprintln!("Error getting {singular}: {{}}", e);
(axum::http::StatusCode::INTERNAL_SERVER_ERROR, "Failed to get {singular}").into_response()
}}
}}
}}
pub fn register_routes(router: Router<Arc<AppState>>) -> Router<Arc<AppState>> {{
router
}}
"#,
singular = singular,
model_low = model.to_lowercase(),
model = model,
resource = resource
)
}
fn generate_create_handler(resource: &str, model: &str) -> String {
let singular = resource.trim_end_matches('s');
format!(
r#"//! Create new {singular}
use axum::{{Extension, Json, response::IntoResponse, Router}};
use sea_orm::{{ActiveModelTrait, DatabaseConnection, Set}};
use serde::{{Deserialize, Serialize}};
use std::sync::Arc;
use crate::shared::state::AppState;
use crate::entities::{model_low}::{{ActiveModel, Model}};
#[derive(Serialize, Deserialize)]
pub struct Create{model}Dto {{
pub name: String,
// Add your fields
}}
pub async fn create(
Extension(db): Extension<DatabaseConnection>,
Json(data): Json<Create{model}Dto>,
) -> impl IntoResponse {{
let new_item = ActiveModel {{
name: Set(data.name),
..Default::default()
}};
match new_item.insert(&db).await {{
Ok(item) => (axum::http::StatusCode::CREATED, Json(item)).into_response(),
Err(e) => {{
eprintln!("Error creating {singular}: {{}}", e);
(axum::http::StatusCode::INTERNAL_SERVER_ERROR, "Failed to create {singular}").into_response()
}}
}}
}}
pub fn register_routes(router: Router<Arc<AppState>>) -> Router<Arc<AppState>> {{
router
}}
"#,
singular = singular,
model_low = model.to_lowercase(),
model = model,
resource = resource
)
}
fn generate_update_handler(resource: &str, model: &str) -> String {
let singular = resource.trim_end_matches('s');
format!(
r#"//! Update {singular}
use axum::{{Extension, Json, extract::Path, response::IntoResponse, Router}};
use sea_orm::{{ActiveModelTrait, DatabaseConnection, EntityTrait, Set}};
use serde::{{Deserialize, Serialize}};
use std::sync::Arc;
use crate::shared::state::AppState;
use crate::entities::{model_low}::{{ActiveModel, Entity as {model}, Model}};
#[derive(Serialize, Deserialize)]
pub struct Update{model}Dto {{
pub name: Option<String>,
// Add your fields
}}
pub async fn update(
Path(id): Path<i32>,
Extension(db): Extension<DatabaseConnection>,
Json(data): Json<Update{model}Dto>,
) -> impl IntoResponse {{
let item = match {model}.find_by_id(id).one(&db).await {{
Ok(Some(item)) => item,
Ok(None) => return (axum::http::StatusCode::NOT_FOUND, "{singular} not found").into_response(),
Err(e) => {{
eprintln!("Error finding {singular}: {{}}", e);
return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, "Failed to find {singular}").into_response();
}}
}};
let mut active_model: ActiveModel = item.into();
if let Some(name) = data.name {{
active_model.name = Set(name);
}}
match active_model.update(&db).await {{
Ok(updated) => Json(updated).into_response(),
Err(e) => {{
eprintln!("Error updating {singular}: {{}}", e);
(axum::http::StatusCode::INTERNAL_SERVER_ERROR, "Failed to update {singular}").into_response()
}}
}}
}}
pub fn register_routes(router: Router<Arc<AppState>>) -> Router<Arc<AppState>> {{
router
}}
"#,
singular = singular,
model_low = model.to_lowercase(),
model = model,
resource = resource
)
}
fn generate_delete_handler(resource: &str, model: &str) -> String {
let singular = resource.trim_end_matches('s');
format!(
r#"//! Delete {singular}
use axum::{{Extension, extract::Path, response::IntoResponse, Router}};
use sea_orm::{{ActiveModelTrait, DatabaseConnection, EntityTrait, IntoActiveModel}};
use std::sync::Arc;
use crate::shared::state::AppState;
use crate::entities::{model_low}::{{Entity as {model}}};
pub async fn destroy(
Path(id): Path<i32>,
Extension(db): Extension<DatabaseConnection>,
) -> impl IntoResponse {{
let item = match {model}.find_by_id(id).one(&db).await {{
Ok(Some(item)) => item,
Ok(None) => return (axum::http::StatusCode::NOT_FOUND, "{singular} not found").into_response(),
Err(e) => {{
eprintln!("Error finding {singular}: {{}}", e);
return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, "Failed to find {singular}").into_response();
}}
}};
match item.into_active_model().delete(&db).await {{
Ok(_) => axum::http::StatusCode::NO_CONTENT.into_response(),
Err(e) => {{
eprintln!("Error deleting {singular}: {{}}", e);
(axum::http::StatusCode::INTERNAL_SERVER_ERROR, "Failed to delete {singular}").into_response()
}}
}}
}}
pub fn register_routes(router: Router<Arc<AppState>>) -> Router<Arc<AppState>> {{
router
}}
"#,
singular = singular,
model_low = model.to_lowercase(),
model = model,
resource = resource
)
}
fn generate_basic_controller(api_dir: &Path, resource: &str) {
let content = format!(
r#"//! {resource} controller
use axum::Router;
use std::sync::Arc;
use crate::shared::state::AppState;
pub async fn index() -> &'static str {{
"{resource} endpoint"
}}
pub fn register_routes(router: Router<Arc<AppState>>) -> Router<Arc<AppState>> {{
router
}}
"#,
resource = resource
);
let _ = fs::write(api_dir.join("index.rs"), content);
}
@@ -0,0 +1,262 @@
//! Database migration generator
use anyhow::{Context, Result};
use chrono::Local;
use std::fs;
use std::path::Path;
pub fn generate_migration(name: &str, table: Option<&str>) -> Result<()> {
let timestamp = Local::now().format("%Y%m%d%H%M%S");
let file_name = format!("m{}_{}.rs", timestamp, name);
let migrations_dir = Path::new("migrations");
fs::create_dir_all(migrations_dir)?;
let content = if let Some(table_name) = table {
generate_create_table_migration(table_name)
} else {
generate_empty_migration()
};
let migration_path = migrations_dir.join(&file_name);
fs::write(&migration_path, content)
.with_context(|| format!("Failed to write migration: {:?}", migration_path))?;
update_migrations_mod(&file_name)?;
Ok(())
}
pub fn generate_model_migration(table: &str, timestamps: bool, soft_delete: bool) -> Result<()> {
let timestamp = Local::now().format("%Y%m%d%H%M%S");
let name = format!("create_{}_table", table);
let file_name = format!("m{}_{}.rs", timestamp, name);
let migrations_dir = Path::new("migrations");
fs::create_dir_all(migrations_dir)?;
let content = generate_model_table_migration(table, timestamps, soft_delete);
let migration_path = migrations_dir.join(&file_name);
fs::write(&migration_path, content)?;
update_migrations_mod(&file_name)?;
Ok(())
}
fn generate_create_table_migration(table: &str) -> String {
let struct_name = table
.split('_')
.map(|s| {
let mut c = s.chars();
match c.next() {
None => String::new(),
Some(f) => f.to_uppercase().chain(c).collect(),
}
})
.collect::<String>();
format!(
r#"use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {{
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {{
manager
.create_table(
Table::create()
.table({table}::Table)
.if_not_exists()
.col(
ColumnDef::new({table}::Id)
.integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(ColumnDef::new({table}::Name).string().not_null())
.to_owned(),
)
.await
}}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {{
manager
.drop_table(Table::drop().table({table}::Table).to_owned())
.await
}}
}}
#[derive(DeriveIden)]
enum {table} {{
Table,
Id,
Name,
}}
"#,
table = struct_name
)
}
fn generate_model_table_migration(table: &str, timestamps: bool, soft_delete: bool) -> String {
let table_pascal = table
.split('_')
.map(|s| {
let mut c = s.chars();
match c.next() {
None => String::new(),
Some(f) => f.to_uppercase().chain(c).collect(),
}
})
.collect::<String>();
let timestamp_cols = if timestamps {
format!(
r#"
.col(ColumnDef::new({}::CreatedAt).timestamp().null())
.col(ColumnDef::new({}::UpdatedAt).timestamp().null())"#,
table_pascal, table_pascal
)
} else {
String::new()
};
let soft_delete_col = if soft_delete {
format!(
r#"
.col(ColumnDef::new({}::DeletedAt).timestamp().null())"#,
table_pascal
)
} else {
String::new()
};
let enum_fields = if timestamps && soft_delete {
format!(" CreatedAt,\n UpdatedAt,\n DeletedAt,")
} else if timestamps {
format!(" CreatedAt,\n UpdatedAt,")
} else if soft_delete {
format!(" DeletedAt,")
} else {
String::new()
};
format!(
r#"use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {{
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {{
manager
.create_table(
Table::create()
.table({table}::Table)
.if_not_exists()
.col(
ColumnDef::new({table}::Id)
.integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(ColumnDef::new({table}::Name).string().not_null()){timestamps}{soft_delete}
.to_owned(),
)
.await
}}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {{
manager
.drop_table(Table::drop().table({table}::Table).to_owned())
.await
}}
}}
#[derive(DeriveIden)]
enum {table} {{
Table,
Id,
Name,
{enum_fields}
}}
"#,
table = table_pascal,
timestamps = timestamp_cols,
soft_delete = soft_delete_col,
enum_fields = enum_fields
)
}
fn generate_empty_migration() -> String {
format!(
r#"use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {{
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {{
// Add your migration logic here
Ok(())
}}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {{
// Add your rollback logic here
Ok(())
}}
}}
"#
)
}
fn update_migrations_mod(file_name: &str) -> Result<()> {
let mod_path = Path::new("migrations/mod.rs");
let module_name = file_name.trim_end_matches(".rs");
let module_line = format!("mod {};", module_name);
if mod_path.exists() {
let content = fs::read_to_string(mod_path)?;
if !content.contains(&module_line) {
// Find the vec![] and add migration
let new_content = if content.contains("vec![") {
content.replace(
"vec![",
&format!("vec![\n Box::new({}::Migration),", module_name),
)
} else {
format!("{}\n{}", content.trim(), module_line)
};
fs::write(mod_path, new_content)?;
}
} else {
let initial_content = format!(
r#"pub use sea_orm_migration::prelude::*;
{}
pub struct Migrator;
#[async_trait::async_trait]
impl MigratorTrait for Migrator {{
fn migrations() -> Vec<Box<dyn MigrationTrait>> {{
vec![
Box::new({}::Migration),
]
}}
}}
"#,
module_line, module_name
);
fs::write(mod_path, initial_content)?;
}
Ok(())
}
@@ -0,0 +1,7 @@
// Module declarations for generators
pub mod api;
pub mod controller;
pub mod migration;
pub mod model;
pub mod repository;
pub mod service;
@@ -0,0 +1,125 @@
//! SeaORM model generator
use anyhow::{Context, Result};
use std::fs;
use std::path::Path;
pub fn generate_model(
name: &str,
with_migration: bool,
timestamps: bool,
soft_delete: bool,
) -> Result<()> {
let table_name = pluralize(name);
// Create entities directory
let entities_dir = Path::new("src/entities");
fs::create_dir_all(entities_dir)?;
// Generate model file
let model_content = generate_model_content(name, &table_name, timestamps, soft_delete);
let model_path = entities_dir.join(format!("{}.rs", name.to_lowercase()));
fs::write(&model_path, model_content)
.with_context(|| format!("Failed to write model file: {:?}", model_path))?;
// Update entities/mod.rs
update_entities_mod(name)?;
// Generate migration if requested
if with_migration {
super::migration::generate_model_migration(&table_name, timestamps, soft_delete)?;
}
Ok(())
}
fn generate_model_content(
name: &str,
table_name: &str,
timestamps: bool,
soft_delete: bool,
) -> String {
let timestamp_fields = if timestamps {
r#"
#[sea_orm(nullable)]
pub created_at: Option<DateTimeUtc>,
#[sea_orm(nullable)]
pub updated_at: Option<DateTimeUtc>,"#
} else {
""
};
let soft_delete_field = if soft_delete {
r#"
#[sea_orm(nullable)]
pub deleted_at: Option<DateTimeUtc>,"#
} else {
""
};
format!(
r#"//! {} entity
use sea_orm::entity::prelude::*;
use serde::{{Deserialize, Serialize}};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "{}")]
pub struct Model {{
#[sea_orm(primary_key)]
pub id: i32,
// Add your fields here
pub name: String,{}{}
}}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {{}}
impl ActiveModelBehavior for ActiveModel {{}}
"#,
name, table_name, timestamp_fields, soft_delete_field
)
}
fn update_entities_mod(name: &str) -> Result<()> {
let mod_path = Path::new("src/entities/mod.rs");
let module_line = format!("pub mod {};", name.to_lowercase());
if mod_path.exists() {
let content = fs::read_to_string(mod_path)?;
if !content.contains(&module_line) {
let new_content = format!("{}\n{}", content.trim(), module_line);
fs::write(mod_path, new_content)?;
}
} else {
fs::write(mod_path, format!("{}\n", module_line))?;
}
Ok(())
}
pub fn pluralize(word: &str) -> String {
let lower = word.to_lowercase();
if lower.ends_with('y') {
format!("{}ies", &lower[..lower.len() - 1])
} else if lower.ends_with('s') || lower.ends_with("ch") || lower.ends_with("sh") || lower.ends_with('x') {
format!("{}es", lower)
} else {
format!("{}s", lower)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_pluralize() {
assert_eq!(pluralize("User"), "users");
assert_eq!(pluralize("Category"), "categories");
assert_eq!(pluralize("Post"), "posts");
assert_eq!(pluralize("Box"), "boxes");
}
}
@@ -0,0 +1,112 @@
//! Repository pattern generator
use anyhow::{Context, Result};
use std::fs;
use std::path::Path;
pub fn generate_repository(name: &str, model: &str) -> Result<()> {
let repos_dir = Path::new("src/repositories");
fs::create_dir_all(repos_dir)?;
let repo_content = generate_repository_content(name, model);
let repo_path = repos_dir.join(format!("{}_repository.rs", name.to_lowercase()));
fs::write(&repo_path, repo_content)
.with_context(|| format!("Failed to write repository: {:?}", repo_path))?;
update_repositories_mod(name)?;
Ok(())
}
fn generate_repository_content(name: &str, model: &str) -> String {
format!(
r#"//! {} repository
use sea_orm::*;
use crate::entities::{}::{{Entity as {}, Model, ActiveModel, Column}};
#[derive(Clone)]
pub struct {}Repository {{
db: DatabaseConnection,
}}
impl {}Repository {{
pub fn new(db: DatabaseConnection) -> Self {{
Self {{ db }}
}}
/// Find all records
pub async fn find_all(&self) -> Result<Vec<Model>, DbErr> {{
{}.find().all(&self.db).await
}}
/// Find by ID
pub async fn find_by_id(&self, id: i32) -> Result<Option<Model>, DbErr> {{
{}.find_by_id(id).one(&self.db).await
}}
/// Find with pagination
pub async fn paginate(&self, page: u64, per_page: u64) -> Result<(Vec<Model>, u64), DbErr> {{
let paginator = {}.find()
.paginate(&self.db, per_page);
let total = paginator.num_items().await?;
let items = paginator.fetch_page(page).await?;
Ok((items, total))
}}
/// Create new record
pub async fn create(&self, data: ActiveModel) -> Result<Model, DbErr> {{
data.insert(&self.db).await
}}
/// Update existing record
pub async fn update(&self, data: ActiveModel) -> Result<Model, DbErr> {{
data.update(&self.db).await
}}
/// Delete by ID
pub async fn delete(&self, id: i32) -> Result<DeleteResult, DbErr> {{
{}.delete_by_id(id).exec(&self.db).await
}}
/// Find by custom condition
pub async fn find_by_name(&self, name: &str) -> Result<Vec<Model>, DbErr> {{
{}.find()
.filter(Column::Name.contains(name))
.all(&self.db)
.await
}}
}}
"#,
name,
model.to_lowercase(),
model,
model,
model,
model,
model,
model,
model,
model
)
}
fn update_repositories_mod(name: &str) -> Result<()> {
let mod_path = Path::new("src/repositories/mod.rs");
let module_line = format!("pub mod {}_repository;", name.to_lowercase());
if mod_path.exists() {
let content = fs::read_to_string(mod_path)?;
if !content.contains(&module_line) {
let new_content = format!("{}\n{}", content.trim(), module_line);
fs::write(mod_path, new_content)?;
}
} else {
fs::write(mod_path, format!("{}\n", module_line))?;
}
Ok(())
}
@@ -0,0 +1,86 @@
//! Service layer generator
use anyhow::{Context, Result};
use std::fs;
use std::path::Path;
pub fn generate_service(name: &str, model: Option<&str>) -> Result<()> {
let services_dir = Path::new("src/services");
fs::create_dir_all(services_dir)?;
let model_name = model.unwrap_or(name);
let service_content = generate_service_content(name, model_name);
let service_path = services_dir.join(format!("{}_service.rs", name.to_lowercase()));
fs::write(&service_path, service_content)
.with_context(|| format!("Failed to write service: {:?}", service_path))?;
update_services_mod(name)?;
Ok(())
}
fn generate_service_content(name: &str, model: &str) -> String {
format!(
r#"//! {} service layer
use sea_orm::*;
use crate::entities::{}::{{Entity as {}, Model, ActiveModel}};
pub struct {}Service {{
db: DatabaseConnection,
}}
impl {}Service {{
pub fn new(db: DatabaseConnection) -> Self {{
Self {{ db }}
}}
pub async fn find_all(&self) -> Result<Vec<Model>, DbErr> {{
{}.find().all(&self.db).await
}}
pub async fn find_by_id(&self, id: i32) -> Result<Option<Model>, DbErr> {{
{}.find_by_id(id).one(&self.db).await
}}
pub async fn create(&self, data: ActiveModel) -> Result<Model, DbErr> {{
data.insert(&self.db).await
}}
pub async fn update(&self, id: i32, data: ActiveModel) -> Result<Model, DbErr> {{
data.update(&self.db).await
}}
pub async fn delete(&self, id: i32) -> Result<DeleteResult, DbErr> {{
{}.delete_by_id(id).exec(&self.db).await
}}
}}
"#,
name,
model.to_lowercase(),
model,
model,
model,
model,
model,
model
)
}
fn update_services_mod(name: &str) -> Result<()> {
let mod_path = Path::new("src/services/mod.rs");
let module_line = format!("pub mod {}_service;", name.to_lowercase());
if mod_path.exists() {
let content = fs::read_to_string(mod_path)?;
if !content.contains(&module_line) {
let new_content = format!("{}\n{}", content.trim(), module_line);
fs::write(mod_path, new_content)?;
}
} else {
fs::write(mod_path, format!("{}\n", module_line))?;
}
Ok(())
}
@@ -0,0 +1 @@
<table>orphaned text<tr><td>cell content</td></tr>more text</table>