1use 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}", ¶ms_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}