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