Skip to main content

doido_generators/generators/
model.rs

1use crate::generator::{GeneratedFile, Generator};
2use crate::generators::field::Field;
3use crate::generators::migration_support::{
4    create_table_imports, create_table_up, register_migration, MIGRATION_LIB_BASE,
5    MIGRATION_SRC_DIR,
6};
7use crate::generators::{to_pascal, to_snake, to_table_name};
8use chrono::Utc;
9use doido_core::Result;
10
11/// Fallback `app/models/mod.rs` used when the app doesn't have one on disk yet.
12const MODELS_MOD_BASE: &str = include_str!("../../templates/new/app/models/mod.rs");
13/// Path to the application models module registry.
14const MODELS_MOD_PATH: &str = "app/models/mod.rs";
15
16pub struct ModelGenerator;
17
18impl Generator for ModelGenerator {
19    fn name(&self) -> &str {
20        "model"
21    }
22
23    fn generate(&self, args: &[&str]) -> Result<Vec<GeneratedFile>> {
24        let name = args.first().copied().ok_or_else(|| {
25            doido_core::anyhow::anyhow!("model generator requires a name argument")
26        })?;
27        let snake = to_snake(name);
28        // Pluralize via the inflector, honouring custom `config/inflection.yaml`
29        // rules (e.g. `person` → `people`, uncountables, irregulars).
30        let table_name = to_table_name(name);
31
32        // Remaining args are `name:type[:modifier...]` column specs.
33        let fields = Field::parse_all(&args[1..])?;
34
35        // Model file — one struct field per declared column.
36        let model = crate::templates::get("models/model.rs.template")
37            .replace("{table_name}", &table_name)
38            .replace("{fields}", &model_fields(&fields));
39
40        // Migration file. The SeaORM `DeriveMigrationName` derives the name from
41        // the module path, so the file/module name doubles as the migration id.
42        let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
43        let migration_module = format!("m{timestamp}_create_{table_name}_table");
44        let migration = crate::templates::get("models/migration.rs.template")
45            .replace("{migration_imports}", &create_table_imports(&fields))
46            .replace("{up_body}", &create_table_up(&table_name, &fields))
47            .replace("{table_name}", &table_name);
48
49        // Register the migration in db/migration/src/lib.rs, preserving any
50        // migrations already registered there.
51        let lib_path = format!("{MIGRATION_SRC_DIR}/lib.rs");
52        let existing =
53            std::fs::read_to_string(&lib_path).unwrap_or_else(|_| MIGRATION_LIB_BASE.to_string());
54        let lib = register_migration(&existing, &migration_module);
55
56        // Register the model's module in app/models/mod.rs, preserving existing
57        // registrations.
58        let models_mod_existing = std::fs::read_to_string(MODELS_MOD_PATH)
59            .unwrap_or_else(|_| MODELS_MOD_BASE.to_string());
60        let models_mod = register_model_module(&models_mod_existing, &snake);
61
62        // Model test stub (a standalone integration test target — a TODO
63        // placeholder needs no imports, so it compiles in the binary app crate).
64        let model_test = crate::templates::get("models/model_test.rs.template")
65            .replace("{Model}", &to_pascal(name))
66            .replace("{singular}", &snake);
67
68        Ok(vec![
69            GeneratedFile {
70                path: format!("app/models/{snake}.rs"),
71                content: model,
72            },
73            GeneratedFile {
74                path: format!("{MIGRATION_SRC_DIR}/{migration_module}.rs"),
75                content: migration,
76            },
77            GeneratedFile {
78                path: lib_path,
79                content: lib,
80            },
81            GeneratedFile {
82                path: MODELS_MOD_PATH.to_string(),
83                content: models_mod,
84            },
85            GeneratedFile {
86                path: format!("tests/{snake}_model_test.rs"),
87                content: model_test,
88            },
89        ])
90    }
91}
92
93/// Renders the SeaORM model struct fields (one per line, 4-space indented). The
94/// trailing newline keeps the closing `}` of the struct on its own line.
95fn model_fields(fields: &[Field]) -> String {
96    fields
97        .iter()
98        .map(|f| format!("    {}\n", f.model_field()))
99        .collect()
100}
101
102/// Inserts `pub mod <module>;` into `app/models/mod.rs` just above the
103/// `@generated-models` marker. Idempotent: if the module is already registered,
104/// the file is returned unchanged.
105fn register_model_module(models_mod: &str, module: &str) -> String {
106    let decl = format!("pub mod {module};");
107    if models_mod.lines().any(|l| l.trim() == decl) {
108        return models_mod.to_string();
109    }
110
111    let mut lines: Vec<String> = models_mod.lines().map(String::from).collect();
112    if let Some(i) = lines.iter().position(|l| l.contains("@generated-models")) {
113        lines.insert(i, decl);
114    } else {
115        lines.push(decl);
116    }
117    let mut out = lines.join("\n");
118    out.push('\n');
119    out
120}