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