chore: initial commit for asepharyana-hub-scraper
This commit is contained in:
@@ -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");
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user