Skip to main content

doido_generators/generators/
model.rs

1use crate::generator::{GeneratedFile, Generator};
2use crate::generators::field::Field;
3use crate::generators::{to_pascal, to_snake, to_table_name};
4use chrono::Utc;
5use doido_core::Result;
6
7/// Fallback migration `lib.rs` used when the app doesn't have one on disk yet;
8/// kept in sync with the generated-app template so injection markers line up.
9const MIGRATION_LIB_BASE: &str = include_str!("../../templates/new/db/migration/src/lib.rs");
10/// Fallback `app/models/mod.rs` used when the app doesn't have one on disk yet.
11const MODELS_MOD_BASE: &str = include_str!("../../templates/new/app/models/mod.rs");
12
13/// Directory holding the SeaORM migration crate's sources.
14const MIGRATION_SRC_DIR: &str = "db/migration/src";
15/// Path to the application models module registry.
16const MODELS_MOD_PATH: &str = "app/models/mod.rs";
17
18pub struct ModelGenerator;
19
20impl Generator for ModelGenerator {
21    fn name(&self) -> &str {
22        "model"
23    }
24
25    fn generate(&self, args: &[&str]) -> Result<Vec<GeneratedFile>> {
26        let name = args.first().copied().ok_or_else(|| {
27            doido_core::anyhow::anyhow!("model generator requires a name argument")
28        })?;
29        let snake = to_snake(name);
30        // Pluralize via the inflector, honouring custom `config/inflection.yaml`
31        // rules (e.g. `person` → `people`, uncountables, irregulars).
32        let table_name = to_table_name(name);
33
34        // Remaining args are `name:type[:modifier...]` column specs.
35        let fields = Field::parse_all(&args[1..])?;
36
37        // Model file — one struct field per declared column.
38        let model = crate::templates::get("models/model.rs.template")
39            .replace("{table_name}", &table_name)
40            .replace("{fields}", &model_fields(&fields));
41
42        // Migration file. The SeaORM `DeriveMigrationName` derives the name from
43        // the module path, so the file/module name doubles as the migration id.
44        let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
45        let migration_module = format!("m{timestamp}_create_{table_name}_table");
46        let migration = crate::templates::get("models/migration.rs.template")
47            .replace("{migration_imports}", &migration_imports(&fields))
48            .replace("{up_body}", &migration_up_body(&table_name, &fields))
49            .replace("{table_name}", &table_name);
50
51        // Register the migration in db/migration/src/lib.rs, preserving any
52        // migrations already registered there.
53        let lib_path = format!("{MIGRATION_SRC_DIR}/lib.rs");
54        let existing =
55            std::fs::read_to_string(&lib_path).unwrap_or_else(|_| MIGRATION_LIB_BASE.to_string());
56        let lib = register_migration(&existing, &migration_module);
57
58        // Register the model's module in app/models/mod.rs, preserving existing
59        // registrations.
60        let models_mod_existing = std::fs::read_to_string(MODELS_MOD_PATH)
61            .unwrap_or_else(|_| MODELS_MOD_BASE.to_string());
62        let models_mod = register_model_module(&models_mod_existing, &snake);
63
64        // Model test stub (a standalone integration test target — a TODO
65        // placeholder needs no imports, so it compiles in the binary app crate).
66        let model_test = crate::templates::get("models/model_test.rs.template")
67            .replace("{Model}", &to_pascal(name))
68            .replace("{singular}", &snake);
69
70        Ok(vec![
71            GeneratedFile {
72                path: format!("app/models/{snake}.rs"),
73                content: model,
74            },
75            GeneratedFile {
76                path: format!("{MIGRATION_SRC_DIR}/{migration_module}.rs"),
77                content: migration,
78            },
79            GeneratedFile {
80                path: lib_path,
81                content: lib,
82            },
83            GeneratedFile {
84                path: MODELS_MOD_PATH.to_string(),
85                content: models_mod,
86            },
87            GeneratedFile {
88                path: format!("tests/{snake}_model_test.rs"),
89                content: model_test,
90            },
91        ])
92    }
93}
94
95/// Renders the SeaORM model struct fields (one per line, 4-space indented). The
96/// trailing newline keeps the closing `}` of the struct on its own line.
97fn model_fields(fields: &[Field]) -> String {
98    fields
99        .iter()
100        .map(|f| format!("    {}\n", f.model_field()))
101        .collect()
102}
103
104/// The migration crate import line — pulls in `add_index` only when needed so
105/// generated code carries no unused imports.
106fn migration_imports(fields: &[Field]) -> String {
107    if fields.iter().any(Field::wants_index) {
108        "use doido_model::migration::{add_index, create_table, drop_table};".to_string()
109    } else {
110        "use doido_model::migration::{create_table, drop_table};".to_string()
111    }
112}
113
114/// Builds the body of `up()` — a `create_table` call carrying the declared
115/// columns, followed by any `add_index` calls for `:index` fields.
116fn migration_up_body(table_name: &str, fields: &[Field]) -> String {
117    // No columns: keep the original hint and an unused-arg-safe closure.
118    if fields.is_empty() {
119        return format!(
120            "        // `create_table` adds an auto-incrementing `id` primary key for you.\n\
121             \x20       // Add columns with the builder, e.g. `t.string(\"name\").not_null();`.\n\
122             \x20       create_table(manager, \"{table_name}\", |_t| {{}}).await\n"
123        );
124    }
125
126    let columns: String = fields
127        .iter()
128        .map(|f| format!("            {}\n", f.migration_line()))
129        .collect();
130
131    let indexes: Vec<&Field> = fields.iter().filter(|f| f.wants_index()).collect();
132
133    let mut body = String::new();
134    body.push_str(
135        "        // `create_table` adds an auto-incrementing `id` primary key for you.\n",
136    );
137    body.push_str(&format!(
138        "        create_table(manager, \"{table_name}\", |t| {{\n{columns}        }})\n"
139    ));
140
141    if indexes.is_empty() {
142        // Return the `create_table` result directly.
143        body.push_str("        .await\n");
144    } else {
145        body.push_str("        .await?;\n");
146        for f in indexes {
147            body.push_str(&format!(
148                "        add_index(manager, \"{table_name}\", &[\"{}\"]).await?;\n",
149                f.column_name()
150            ));
151        }
152        body.push_str("        Ok(())\n");
153    }
154
155    body
156}
157
158/// Inserts `pub mod <module>;` into `app/models/mod.rs` just above the
159/// `@generated-models` marker. Idempotent: if the module is already registered,
160/// the file is returned unchanged.
161fn register_model_module(models_mod: &str, module: &str) -> String {
162    let decl = format!("pub mod {module};");
163    if models_mod.lines().any(|l| l.trim() == decl) {
164        return models_mod.to_string();
165    }
166
167    let mut lines: Vec<String> = models_mod.lines().map(String::from).collect();
168    if let Some(i) = lines.iter().position(|l| l.contains("@generated-models")) {
169        lines.insert(i, decl);
170    } else {
171        lines.push(decl);
172    }
173    let mut out = lines.join("\n");
174    out.push('\n');
175    out
176}
177
178/// Inserts a `mod <module>;` declaration and a `Box::new(<module>::Migration)`
179/// registration into the migration crate's `lib.rs`, just above the generator
180/// markers. Indentation of the list entry mirrors the marker line.
181fn register_migration(lib: &str, module: &str) -> String {
182    let mut lines: Vec<String> = lib.lines().map(String::from).collect();
183
184    if let Some(i) = lines
185        .iter()
186        .position(|l| l.contains("@generated-migrations-mod"))
187    {
188        lines.insert(i, format!("mod {module};"));
189    }
190
191    if let Some(i) = lines
192        .iter()
193        .position(|l| l.contains("@generated-migrations-list"))
194    {
195        let indent: String = lines[i].chars().take_while(|c| c.is_whitespace()).collect();
196        lines.insert(i, format!("{indent}Box::new({module}::Migration),"));
197    }
198
199    let mut out = lines.join("\n");
200    out.push('\n');
201    out
202}