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