Skip to main content

doido_generators/generators/
scaffold.rs

1use crate::generator::{GeneratedFile, Generator};
2use crate::generators::field::Field;
3use crate::generators::helper::{
4    helper_names, helpers_mod_path, read_helpers_mod, register_helper_in_mod, render_helper_content,
5};
6use crate::generators::model::ModelGenerator;
7use crate::generators::{to_pascal, to_snake, to_table_name};
8use doido_core::Result;
9
10/// Fallbacks used when the app doesn't have these files on disk yet, kept in
11/// sync with the generated-app templates so injection lines up.
12const CONTROLLERS_MOD_BASE: &str = include_str!("../../templates/new/app/controllers/mod.rs");
13const ROUTES_BASE: &str = include_str!("../../templates/new/config/routes.rs");
14
15const CONTROLLERS_MOD_PATH: &str = "app/controllers/mod.rs";
16const ROUTES_PATH: &str = "config/routes.rs";
17
18pub struct ScaffoldGenerator;
19
20impl Generator for ScaffoldGenerator {
21    fn name(&self) -> &str {
22        "scaffold"
23    }
24
25    fn generate(&self, args: &[&str]) -> Result<Vec<GeneratedFile>> {
26        let api = args.contains(&"--api");
27        // Positional args (name + field specs); flags filtered out.
28        let positional: Vec<&str> = args
29            .iter()
30            .copied()
31            .filter(|a| !a.starts_with("--"))
32            .collect();
33        let name = positional.first().copied().ok_or_else(|| {
34            doido_core::anyhow::anyhow!("scaffold generator requires a name argument")
35        })?;
36        let fields = Field::parse_all(&positional[1..])?;
37
38        let singular = to_snake(name); // post
39        let plural = to_table_name(name); // posts
40        let model = to_pascal(name); // Post
41        let controller = format!("{}Controller", to_pascal(&plural)); // PostsController
42        let helper = format!("{}Helper", to_pascal(&plural)); // PostsHelper
43
44        let mut files = Vec::new();
45
46        // Model + migration + migration lib.rs + app/models/mod.rs.
47        files.extend(ModelGenerator.generate(&positional)?);
48
49        // Controller (HTML or API variant).
50        let controller_template = if api {
51            crate::templates::get("scaffold/controller_api.rs.template")
52        } else {
53            crate::templates::get("scaffold/controller_html.rs.template")
54        };
55        files.push(GeneratedFile {
56            path: format!("app/controllers/{plural}_controller.rs"),
57            content: render_controller(
58                &controller_template,
59                &singular,
60                &plural,
61                &model,
62                &controller,
63                &helper,
64                &fields,
65            ),
66        });
67
68        // Helper for shared controller logic.
69        let (helper_snake, _) = helper_names(&plural);
70        files.push(GeneratedFile {
71            path: format!("app/helpers/{helper_snake}.rs"),
72            content: render_helper_content(&plural, &plural),
73        });
74        files.push(GeneratedFile {
75            path: helpers_mod_path().to_string(),
76            content: register_helper_in_mod(&read_helpers_mod(), &plural),
77        });
78
79        // Register the controller module in app/controllers/mod.rs.
80        let mod_existing = std::fs::read_to_string(CONTROLLERS_MOD_PATH)
81            .unwrap_or_else(|_| CONTROLLERS_MOD_BASE.to_string());
82        files.push(GeneratedFile {
83            path: CONTROLLERS_MOD_PATH.to_string(),
84            content: register_controller(&mod_existing, &plural, &controller),
85        });
86
87        // Views (HTML mode only).
88        if !api {
89            for (file, rel) in [
90                ("index", "scaffold/views/index.html.tera"),
91                ("show", "scaffold/views/show.html.tera"),
92                ("new", "scaffold/views/new.html.tera"),
93                ("edit", "scaffold/views/edit.html.tera"),
94                ("_form", "scaffold/views/_form.html.tera"),
95            ] {
96                let template = crate::templates::get(rel);
97                files.push(GeneratedFile {
98                    path: format!("app/views/{plural}/{file}.html.tera"),
99                    content: render_view(&template, &singular, &plural, &model, &fields),
100                });
101            }
102
103            // Controller request tests — one per action (HTML mode only).
104            let test_template = crate::templates::get("scaffold/controller_test.rs.template");
105            files.push(GeneratedFile {
106                path: format!("tests/{plural}_controller_test.rs"),
107                content: render_controller(
108                    &test_template,
109                    &singular,
110                    &plural,
111                    &model,
112                    &controller,
113                    &helper,
114                    &fields,
115                ),
116            });
117        }
118
119        // Inject the RESTful routes into config/routes.rs.
120        let routes_existing =
121            std::fs::read_to_string(ROUTES_PATH).unwrap_or_else(|_| ROUTES_BASE.to_string());
122        files.push(GeneratedFile {
123            path: ROUTES_PATH.to_string(),
124            content: inject_route(&routes_existing, &plural, &controller, api),
125        });
126
127        Ok(files)
128    }
129}
130
131/// Fills the controller template's field-driven fragments and names.
132fn render_controller(
133    template: &str,
134    singular: &str,
135    plural: &str,
136    model: &str,
137    controller: &str,
138    helper: &str,
139    fields: &[Field],
140) -> String {
141    let params_fields: String = fields
142        .iter()
143        .map(|f| format!("    {}\n", f.params_struct_field()))
144        .collect();
145    let active_model_sets: String = fields
146        .iter()
147        .map(|f| format!("            {}\n", f.active_model_set()))
148        .collect();
149    let active_model_assigns: String = fields
150        .iter()
151        .map(|f| format!("            {}\n", f.active_model_assign()))
152        .collect();
153    // Sample urlencoded body for create/update request tests.
154    let form_body: String = fields
155        .iter()
156        .filter_map(Field::sample_form_pair)
157        .collect::<Vec<_>>()
158        .join("&");
159
160    template
161        .replace("{params_fields}", &params_fields)
162        .replace("{active_model_sets}", &active_model_sets)
163        .replace("{active_model_assigns}", &active_model_assigns)
164        .replace("{form_body}", &form_body)
165        .replace("{Controller}", controller)
166        .replace("{Helper}", helper)
167        .replace("{Model}", model)
168        .replace("{singular}", singular)
169        .replace("{plural}", plural)
170}
171
172/// Fills a view template's field-driven fragments and names.
173fn render_view(
174    template: &str,
175    singular: &str,
176    plural: &str,
177    model: &str,
178    fields: &[Field],
179) -> String {
180    let table_headers: String = fields
181        .iter()
182        .map(|f| format!("      <th>{}</th>\n", f.column_name()))
183        .collect();
184    let table_cells: String = fields
185        .iter()
186        .map(|f| format!("      <td>{{{{ {singular}.{} }}}}</td>\n", f.column_name()))
187        .collect();
188    let show_fields: String = fields
189        .iter()
190        .map(|f| {
191            let col = f.column_name();
192            format!("<p><strong>{col}:</strong> {{{{ {singular}.{col} }}}}</p>\n")
193        })
194        .collect();
195    let form_fields: String = fields.iter().map(form_field).collect();
196
197    template
198        .replace("{table_headers}", &table_headers)
199        .replace("{table_cells}", &table_cells)
200        .replace("{show_fields}", &show_fields)
201        .replace("{form_fields}", &form_fields)
202        .replace("{Model}", model)
203        .replace("{singular}", singular)
204        .replace("{plural}", plural)
205}
206
207/// One form control for a field, varying by HTML input type.
208fn form_field(f: &Field) -> String {
209    let col = f.column_name();
210    match f.html_input_type() {
211        "textarea" => format!("  <label>{col}<br><textarea name=\"{col}\"></textarea></label>\n"),
212        "checkbox" => format!("  <label>{col} <input type=\"checkbox\" name=\"{col}\"></label>\n"),
213        input => format!("  <label>{col}<br><input type=\"{input}\" name=\"{col}\"></label>\n"),
214    }
215}
216
217/// Appends `mod <name>_controller;` + `pub use …` to `app/controllers/mod.rs`.
218/// Idempotent: skips when the module is already declared.
219fn register_controller(controllers_mod: &str, plural: &str, controller: &str) -> String {
220    let module = format!("{plural}_controller");
221    let decl = format!("mod {module};");
222    if controllers_mod.lines().any(|l| l.trim() == decl) {
223        return controllers_mod.to_string();
224    }
225    let mut out = controllers_mod.trim_end().to_string();
226    out.push('\n');
227    out.push_str(&format!("mod {module};\n"));
228    out.push_str(&format!("pub use {module}::{controller};\n"));
229    out
230}
231
232/// Injects `use crate::controllers::<Controller>;` and a
233/// `resources!(<plural>, <Controller>);` line into `config/routes.rs`,
234/// preserving existing routes. Idempotent on the resources line.
235///
236/// API scaffolds omit the `new`/`edit` form actions (their controller has no
237/// such methods), so the injected route excludes them — mirroring Rails, where
238/// an API-only resource routes to only index/create/show/update/destroy.
239fn inject_route(routes: &str, plural: &str, controller: &str, api: bool) -> String {
240    let resources = if api {
241        format!("resources!({plural}, {controller}, except: [new, edit]);")
242    } else {
243        format!("resources!({plural}, {controller});")
244    };
245    if routes.contains(&resources) {
246        return routes.to_string();
247    }
248
249    let use_line = format!("use crate::controllers::{controller};");
250    let mut lines: Vec<String> = routes.lines().map(String::from).collect();
251
252    // Add the controller import after the last existing `use crate::controllers`
253    // line, or at the top otherwise.
254    if !routes.contains(&use_line) {
255        let pos = lines
256            .iter()
257            .rposition(|l| l.contains("use crate::controllers"))
258            .map(|i| i + 1)
259            .unwrap_or(0);
260        lines.insert(pos, use_line);
261    }
262
263    // Insert the resources! call as the last statement inside `routes! { … }`.
264    if let Some(open) = lines.iter().position(|l| l.contains("routes!")) {
265        // Find the matching closing brace of the routes! block.
266        if let Some(close_rel) = lines[open..].iter().position(|l| l.trim() == "}") {
267            let close = open + close_rel;
268            lines.insert(close, format!("        {resources}"));
269        }
270    }
271
272    let mut out = lines.join("\n");
273    out.push('\n');
274    out
275}