Skip to main content

doido_auth/generators/
scaffold.rs

1//! `doido generate auth:scaffold` — auth install (if needed) + auth-aware scaffold.
2
3use super::install::AuthInstallGenerator;
4use super::migration_support::{
5    create_table_imports, create_table_up, register_migration, MIGRATION_LIB_BASE,
6    MIGRATION_SRC_DIR,
7};
8use super::route_injector::{
9    inject_resources, read_controllers_mod, read_models_mod, read_routes, register_controller,
10    register_model_module, CONTROLLERS_MOD_PATH, MODELS_MOD_PATH, ROUTES_PATH,
11};
12use super::template;
13use super::{to_pascal, to_snake, to_table_name, AuthGenerator, Field, GeneratedFile};
14use chrono::Utc;
15use doido_core::Result;
16
17const USER_MODEL_PATH: &str = "app/models/user.rs";
18const ENTITIES_MOD_PATH: &str = "app/models/_entities/mod.rs";
19const ENTITIES_MOD_BASE: &str = include_str!("../../templates/new/app/models/_entities/mod.rs");
20
21pub struct AuthScaffoldGenerator;
22
23fn upsert_file(files: &mut Vec<GeneratedFile>, file: GeneratedFile) {
24    if let Some(i) = files.iter().position(|f| f.path == file.path) {
25        files[i] = file;
26    } else {
27        files.push(file);
28    }
29}
30
31fn model_fields(fields: &[Field]) -> String {
32    fields
33        .iter()
34        .map(|f| format!("    {}\n", f.model_field()))
35        .collect()
36}
37
38fn render_controller(
39    template: &str,
40    singular: &str,
41    plural: &str,
42    model: &str,
43    controller: &str,
44    fields: &[Field],
45) -> String {
46    let params_fields: String = fields
47        .iter()
48        .filter(|f| !f.is_user_reference())
49        .map(|f| format!("    {}\n", f.params_struct_field()))
50        .collect();
51    let active_model_sets: String = fields
52        .iter()
53        .filter(|f| !f.is_user_reference())
54        .map(|f| format!("            {}\n", f.active_model_set()))
55        .collect();
56    let active_model_assigns: String = fields
57        .iter()
58        .filter(|f| !f.is_user_reference())
59        .map(|f| format!("            {}\n", f.active_model_assign()))
60        .collect();
61
62    template
63        .replace("{params_fields}", &params_fields)
64        .replace("{active_model_sets}", &active_model_sets)
65        .replace("{active_model_assigns}", &active_model_assigns)
66        .replace("{Controller}", controller)
67        .replace("{Model}", model)
68        .replace("{singular}", singular)
69        .replace("{plural}", plural)
70}
71
72fn render_view(
73    template: &str,
74    singular: &str,
75    plural: &str,
76    model: &str,
77    fields: &[Field],
78) -> String {
79    let visible_fields: Vec<_> = fields.iter().filter(|f| !f.is_user_reference()).collect();
80
81    let table_headers: String = visible_fields
82        .iter()
83        .map(|f| format!("      <th>{}</th>\n", f.column_name()))
84        .collect();
85    let table_cells: String = visible_fields
86        .iter()
87        .map(|f| format!("      <td>{{{{ {singular}.{} }}}}</td>\n", f.column_name()))
88        .collect();
89    let show_fields: String = visible_fields
90        .iter()
91        .map(|f| {
92            let col = f.column_name();
93            format!("<p><strong>{col}:</strong> {{{{ {singular}.{col} }}}}</p>\n")
94        })
95        .collect();
96    let form_fields: String = visible_fields.iter().map(|f| form_field(f)).collect();
97
98    template
99        .replace("{table_headers}", &table_headers)
100        .replace("{table_cells}", &table_cells)
101        .replace("{show_fields}", &show_fields)
102        .replace("{form_fields}", &form_fields)
103        .replace("{Model}", model)
104        .replace("{singular}", singular)
105        .replace("{plural}", plural)
106}
107
108fn form_field(f: &Field) -> String {
109    let col = f.column_name();
110    match f.html_input_type() {
111        "textarea" => format!("  <label>{col}<br><textarea name=\"{col}\"></textarea></label>\n"),
112        "checkbox" => format!("  <label>{col} <input type=\"checkbox\" name=\"{col}\"></label>\n"),
113        input => format!("  <label>{col}<br><input type=\"{input}\" name=\"{col}\"></label>\n"),
114    }
115}
116
117fn ensure_user_reference(fields: &mut Vec<Field>) {
118    if !fields.iter().any(|f| f.is_user_reference()) {
119        fields.insert(
120            0,
121            Field::parse("user:references").expect("user:references is valid"),
122        );
123    }
124}
125
126impl AuthGenerator for AuthScaffoldGenerator {
127    fn name(&self) -> &str {
128        "auth:scaffold"
129    }
130
131    fn generate(&self, args: &[&str]) -> Result<Vec<GeneratedFile>> {
132        let api = args.contains(&"--api");
133        let positional: Vec<&str> = args
134            .iter()
135            .copied()
136            .filter(|a| !a.starts_with("--"))
137            .collect();
138
139        let name = positional.first().copied().ok_or_else(|| {
140            doido_core::anyhow::anyhow!("auth:scaffold generator requires a name argument")
141        })?;
142
143        let mut fields = Field::parse_all(&positional[1..])?;
144        ensure_user_reference(&mut fields);
145
146        let singular = to_snake(name);
147        let plural = to_table_name(name);
148        let model = to_pascal(name);
149        let controller = format!("{}Controller", to_pascal(&plural));
150
151        let mut files = Vec::new();
152
153        if !std::path::Path::new(USER_MODEL_PATH).exists() {
154            files.extend(AuthInstallGenerator.generate(&[])?);
155        }
156
157        let routes_base = files
158            .iter()
159            .find(|f| f.path == ROUTES_PATH)
160            .map(|f| f.content.clone())
161            .unwrap_or_else(read_routes);
162
163        let controllers_mod_base = files
164            .iter()
165            .find(|f| f.path == CONTROLLERS_MOD_PATH)
166            .map(|f| f.content.clone())
167            .unwrap_or_else(read_controllers_mod);
168
169        let models_mod_base = files
170            .iter()
171            .find(|f| f.path == MODELS_MOD_PATH)
172            .map(|f| f.content.clone())
173            .unwrap_or_else(read_models_mod);
174
175        let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
176        let migration_module = format!("m{timestamp}_create_{plural}_table");
177        let up_body = create_table_up(&plural, &fields);
178        let migration = template("migration.rs.template")
179            .replace("{migration_name}", &migration_module)
180            .replace("{migration_imports}", &create_table_imports(&fields))
181            .replace("{up_body}", &up_body)
182            .replace(
183                "{down_body}",
184                &super::migration_support::drop_table_down(&plural),
185            );
186
187        let lib_path = format!("{MIGRATION_SRC_DIR}/lib.rs");
188        let existing =
189            std::fs::read_to_string(&lib_path).unwrap_or_else(|_| MIGRATION_LIB_BASE.to_string());
190        let lib = register_migration(&existing, &migration_module);
191
192        let entity_content = template("scaffold/entity.rs.template")
193            .replace("{table_name}", &plural)
194            .replace("{fields}", &model_fields(&fields));
195
196        let extension_content = template("scaffold/model.rs.template")
197            .replace("{Model}", &model)
198            .replace("{table_name}", &plural);
199
200        let entities_mod_base = files
201            .iter()
202            .find(|f| f.path == ENTITIES_MOD_PATH)
203            .map(|f| f.content.clone())
204            .or_else(|| std::fs::read_to_string(ENTITIES_MOD_PATH).ok())
205            .unwrap_or_else(|| ENTITIES_MOD_BASE.to_string());
206        let entities_mod =
207            doido_model::entities::register_entity_module(&entities_mod_base, &plural);
208
209        let controller_template = if api {
210            template("scaffold/controller_api.rs.template")
211        } else {
212            template("scaffold/controller_html.rs.template")
213        };
214
215        for file in [
216            GeneratedFile {
217                path: format!("app/models/_entities/{plural}.rs"),
218                content: entity_content,
219            },
220            GeneratedFile {
221                path: ENTITIES_MOD_PATH.to_string(),
222                content: entities_mod,
223            },
224            GeneratedFile {
225                path: format!("app/models/{singular}.rs"),
226                content: extension_content,
227            },
228            GeneratedFile {
229                path: format!("{MIGRATION_SRC_DIR}/{migration_module}.rs"),
230                content: migration,
231            },
232            GeneratedFile {
233                path: lib_path,
234                content: lib,
235            },
236            GeneratedFile {
237                path: MODELS_MOD_PATH.to_string(),
238                content: register_model_module(&models_mod_base, &singular),
239            },
240            GeneratedFile {
241                path: format!("app/controllers/{plural}_controller.rs"),
242                content: render_controller(
243                    controller_template,
244                    &singular,
245                    &plural,
246                    &model,
247                    &controller,
248                    &fields,
249                ),
250            },
251            GeneratedFile {
252                path: CONTROLLERS_MOD_PATH.to_string(),
253                content: register_controller(&controllers_mod_base, &plural, &controller),
254            },
255            GeneratedFile {
256                path: ROUTES_PATH.to_string(),
257                content: inject_resources(&routes_base, &plural, &controller, api),
258            },
259        ] {
260            upsert_file(&mut files, file);
261        }
262
263        if !api {
264            for (file, rel) in [
265                ("index", "scaffold/views/index.html.tera"),
266                ("show", "scaffold/views/show.html.tera"),
267                ("new", "scaffold/views/new.html.tera"),
268                ("edit", "scaffold/views/edit.html.tera"),
269                ("_form", "scaffold/views/_form.html.tera"),
270            ] {
271                files.push(GeneratedFile {
272                    path: format!("app/views/{plural}/{file}.html.tera"),
273                    content: render_view(template(rel), &singular, &plural, &model, &fields),
274                });
275            }
276        }
277
278        Ok(files)
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    #[test]
287    fn adds_user_id_and_require_user() {
288        let files = AuthScaffoldGenerator
289            .generate(&["Post", "title:string"])
290            .unwrap();
291
292        let migration = files
293            .iter()
294            .find(|f| f.path.contains("create_posts_table"))
295            .expect("posts migration");
296        assert!(migration.content.contains("references(\"user\")"));
297
298        let controller = files
299            .iter()
300            .find(|f| f.path.ends_with("posts_controller.rs"))
301            .unwrap();
302        assert!(controller.content.contains("require_user"));
303        assert!(controller.content.contains("Column::UserId"));
304    }
305}