Skip to main content

doido_generators/generators/
scaffold.rs

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