1use doido_core::{Inflector, Result};
5use std::collections::{HashMap, HashSet};
6use std::fs;
7use std::path::Path;
8
9pub const DEFAULT_ENTITY_DIR: &str = "app/models/_entities";
11
12const MODELS_MOD_MARKER: &str = "@generated-models";
13const ENTITIES_MOD_MARKER: &str = "@generated-entities";
14
15pub fn entity_modules(mod_content: &str) -> Vec<String> {
17 mod_content
18 .lines()
19 .filter_map(|line| {
20 let line = line.trim();
21 let rest = line.strip_prefix("pub mod ")?;
22 let name = rest.trim_end_matches(';').trim();
23 if name.is_empty() || matches!(name, "prelude" | "sea_orm_active_enums" | "lib") {
24 None
25 } else {
26 Some(name.to_string())
27 }
28 })
29 .collect()
30}
31
32pub fn model_modules(models_mod: &str) -> Vec<String> {
34 models_mod
35 .lines()
36 .filter_map(|line| {
37 let line = line.trim();
38 let rest = line.strip_prefix("pub mod ")?;
39 let name = rest.trim_end_matches(';').trim();
40 if name.is_empty() || name == "_entities" {
41 None
42 } else {
43 Some(name.to_string())
44 }
45 })
46 .collect()
47}
48
49pub fn extension_stub(model_module: &str, entity_module: &str) -> String {
51 format!(
52 "//! Model extensions for `{model_module}` — safe to edit; never overwritten by generators.\n\
53 //!\n\
54 //! The SeaORM entity definition lives in `_entities/{entity_module}.rs` and is\n\
55 //! regenerated on every `doido db migrate`.\n\
56 #![allow(dead_code, unused_imports)]\n\n\
57 pub use super::_entities::{entity_module}::*;\n\n\
58 use doido::model::sea_orm::ActiveModelBehavior;\n\n\
59 impl ActiveModelBehavior for ActiveModel {{}}\n"
60 )
61}
62
63pub fn rewrite_generated_imports(entities_dir: &Path) -> Result<()> {
66 for entry in fs::read_dir(entities_dir)? {
67 let path = entry?.path();
68 if !path.is_file() {
69 continue;
70 }
71 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
72 continue;
73 };
74 if name == "mod.rs" || name == "lib.rs" {
75 continue;
76 }
77 let content = fs::read_to_string(&path)?;
78 let rewritten = if name == "prelude.rs" {
79 ensure_inner_attribute(&content, "#![allow(unused_imports)]")
80 } else {
81 let with_allow =
82 ensure_inner_attribute(&content, "#![allow(dead_code, unused_imports)]");
83 let imports_fixed = rewrite_entity_file_imports(&with_allow);
84 strip_active_model_behavior(&imports_fixed)
85 };
86 if rewritten != content {
87 fs::write(path, rewritten)?;
88 }
89 }
90 Ok(())
91}
92
93fn rewrite_entity_file_imports(content: &str) -> String {
94 if content.contains("use doido::model::sea_orm as sea_orm")
95 && !content.contains("use doido::model::sea_orm;\n")
96 {
97 return content.to_string();
98 }
99
100 let mut prelude_found = false;
101 let mut extra_imports: Vec<String> = Vec::new();
102 let mut other_lines: Vec<String> = Vec::new();
103
104 for line in content.lines() {
105 let trimmed = line.trim();
106 if trimmed == "use sea_orm::entity::prelude::*;"
107 || trimmed == "use doido::model::sea_orm::entity::prelude::*;"
108 {
109 prelude_found = true;
110 } else if trimmed == "use doido::model::sea_orm;"
111 || trimmed == "use doido::model::sea_orm as sea_orm;"
112 {
113 } else if let Some(rest) = trimmed.strip_prefix("use sea_orm::") {
115 extra_imports.push(format!("use doido::model::sea_orm::{rest}"));
116 } else {
117 other_lines.push(line.to_string());
118 }
119 }
120
121 if !prelude_found
122 && extra_imports.is_empty()
123 && !content.contains("#[sea_orm")
124 && !content.contains("use doido::model::sea_orm")
125 {
126 return content.to_string();
127 }
128
129 let mut insert_at = 0;
130 for (i, line) in other_lines.iter().enumerate() {
131 let t = line.trim();
132 if t.is_empty() || t.starts_with("//!") || t.starts_with("//") || t.starts_with("#![") {
133 insert_at = i + 1;
134 } else if t.starts_with("use ") {
135 insert_at = i;
136 break;
137 } else {
138 break;
139 }
140 }
141
142 let mut imports = vec!["use doido::model::sea_orm as sea_orm;".to_string()];
143 if prelude_found || content.contains("#[sea_orm") {
144 imports.push("use doido::model::sea_orm::entity::prelude::*;".to_string());
145 }
146 imports.extend(extra_imports);
147
148 other_lines.splice(insert_at..insert_at, imports);
149 let mut out = other_lines.join("\n");
150 if content.ends_with('\n') {
151 out.push('\n');
152 }
153 out
154}
155
156fn strip_active_model_behavior(content: &str) -> String {
159 let mut lines: Vec<&str> = content.lines().collect();
160 lines.retain(|line| line.trim() != "impl ActiveModelBehavior for ActiveModel {}");
161 while lines.last().is_some_and(|l| l.trim().is_empty()) {
162 lines.pop();
163 }
164 let mut out = lines.join("\n");
165 if content.ends_with('\n') {
166 out.push('\n');
167 }
168 out
169}
170
171fn ensure_inner_attribute(content: &str, attr: &str) -> String {
172 if content.contains(attr) {
173 return content.to_string();
174 }
175 if attr == "#![allow(dead_code, unused_imports)]" && content.contains("#![allow(dead_code)]") {
176 return content.replace("#![allow(dead_code)]", attr);
177 }
178
179 let mut insert_at = 0;
180 for (i, line) in content.lines().enumerate() {
181 let t = line.trim();
182 if t.is_empty() || t.starts_with("//!") || t.starts_with("//") || t.starts_with("#![") {
183 insert_at = i + 1;
184 } else {
185 break;
186 }
187 }
188
189 let mut lines: Vec<String> = content.lines().map(String::from).collect();
190 lines.insert(insert_at, attr.to_string());
191 if insert_at == lines.len().saturating_sub(1)
192 || lines.get(insert_at + 1).is_none_or(|l| !l.is_empty())
193 {
194 lines.insert(insert_at + 1, String::new());
195 }
196 let mut out = lines.join("\n");
197 if content.ends_with('\n') {
198 out.push('\n');
199 }
200 out
201}
202
203pub fn reexported_entity_module(content: &str) -> Option<String> {
205 for line in content.lines() {
206 let trimmed = line.trim();
207 let Some(rest) = trimmed.strip_prefix("pub use super::_entities::") else {
208 continue;
209 };
210 let Some(entity) = rest.strip_suffix("::*;") else {
211 continue;
212 };
213 let entity = entity.trim();
214 if !entity.is_empty() {
215 return Some(entity.to_string());
216 }
217 }
218 None
219}
220
221pub fn entity_has_model_extension(models_dir: &Path, entity_module: &str) -> bool {
223 fs::read_dir(models_dir)
224 .ok()
225 .into_iter()
226 .flatten()
227 .filter_map(|entry| entry.ok())
228 .filter(|entry| {
229 entry.path().extension() == Some(std::ffi::OsStr::new("rs"))
230 && entry.file_name() != "mod.rs"
231 })
232 .filter_map(|entry| fs::read_to_string(entry.path()).ok())
233 .any(|content| reexported_entity_module(&content).as_deref() == Some(entity_module))
234}
235
236pub fn dedupe_model_extension_stubs(models_dir: &Path) -> Result<()> {
240 let models_mod_path = models_dir.join("mod.rs");
241 let models_mod = fs::read_to_string(&models_mod_path).unwrap_or_default();
242
243 let mut by_entity: HashMap<String, Vec<String>> = HashMap::new();
244 for entry in fs::read_dir(models_dir)? {
245 let path = entry?.path();
246 if path.extension().is_none_or(|ext| ext != "rs") {
247 continue;
248 }
249 let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
250 continue;
251 };
252 if stem == "mod" {
253 continue;
254 }
255 let content = fs::read_to_string(&path)?;
256 if let Some(entity) = reexported_entity_module(&content) {
257 by_entity.entry(entity).or_default().push(stem.to_string());
258 }
259 }
260
261 let mut models_mod_updated = models_mod.clone();
262 for (entity, mut models) in by_entity {
263 if models.len() <= 1 {
264 continue;
265 }
266 models.sort();
267 let keep = models
268 .iter()
269 .find(|name| **name != entity)
270 .or(models.first())
271 .expect("len > 1")
272 .clone();
273 for duplicate in &models {
274 if duplicate == &keep {
275 continue;
276 }
277 fs::remove_file(models_dir.join(format!("{duplicate}.rs")))?;
278 let decl = format!("pub mod {duplicate};");
279 models_mod_updated = models_mod_updated
280 .lines()
281 .filter(|line| line.trim() != decl)
282 .collect::<Vec<_>>()
283 .join("\n");
284 }
285 }
286
287 if models_mod_updated != models_mod {
288 let mut out = models_mod_updated;
289 if !out.ends_with('\n') {
290 out.push('\n');
291 }
292 fs::write(&models_mod_path, out)?;
293 }
294
295 Ok(())
296}
297
298pub fn ensure_model_extension_stubs(entities_dir: &Path, models_dir: &Path) -> Result<()> {
301 let entities_mod_path = entities_dir.join("mod.rs");
302 let models_mod_path = models_dir.join("mod.rs");
303
304 let entities_mod = fs::read_to_string(&entities_mod_path).unwrap_or_default();
305 let models_mod = fs::read_to_string(&models_mod_path).unwrap_or_default();
306
307 let entity_modules = entity_modules(&entities_mod);
308 let existing_models: HashSet<String> = model_modules(&models_mod).into_iter().collect();
309
310 let mut models_mod_updated = models_mod.clone();
311
312 for entity_module in entity_modules {
313 if entity_has_model_extension(models_dir, &entity_module) {
314 continue;
315 }
316 let model_module = Inflector::singularize(&entity_module);
317 let model_path = models_dir.join(format!("{model_module}.rs"));
318 if model_path.exists() || existing_models.contains(&model_module) {
319 continue;
320 }
321 fs::write(&model_path, extension_stub(&model_module, &entity_module))?;
322 models_mod_updated = register_model_module(&models_mod_updated, &model_module);
323 }
324
325 if models_mod_updated != models_mod {
326 fs::write(&models_mod_path, models_mod_updated)?;
327 }
328
329 Ok(())
330}
331
332const ACTIVE_MODEL_BEHAVIOR_IMPL: &str = "impl ActiveModelBehavior for ActiveModel {}";
333
334pub fn model_extension_covers_entity(content: &str, entity_module: &str) -> bool {
337 content.contains(&format!("pub use super::_entities::{entity_module}::*"))
338 && content.contains(ACTIVE_MODEL_BEHAVIOR_IMPL)
339}
340
341pub fn ensure_active_model_behavior_in_extensions(models_dir: &Path) -> Result<()> {
344 for entry in fs::read_dir(models_dir)? {
345 let path = entry?.path();
346 if path.extension().is_none_or(|ext| ext != "rs") {
347 continue;
348 }
349 if path.file_name().is_some_and(|name| name == "mod.rs") {
350 continue;
351 }
352 let content = fs::read_to_string(&path)?;
353 if !content.contains("pub use super::_entities::") {
354 continue;
355 }
356 if content.contains(ACTIVE_MODEL_BEHAVIOR_IMPL) {
357 continue;
358 }
359 let updated = inject_active_model_behavior(&content);
360 if updated != content {
361 fs::write(path, updated)?;
362 }
363 }
364 Ok(())
365}
366
367fn inject_active_model_behavior(content: &str) -> String {
368 let mut lines: Vec<String> = content.lines().map(String::from).collect();
369 let mut insert_at = None;
370 for (i, line) in lines.iter().enumerate() {
371 if line.trim().starts_with("pub use super::_entities::") {
372 insert_at = Some(i + 1);
373 break;
374 }
375 }
376 let Some(at) = insert_at else {
377 return content.to_string();
378 };
379 lines.insert(at, String::new());
380 lines.insert(
381 at + 1,
382 "use doido::model::sea_orm::ActiveModelBehavior;".to_string(),
383 );
384 lines.insert(at + 2, String::new());
385 lines.insert(at + 3, ACTIVE_MODEL_BEHAVIOR_IMPL.to_string());
386 let mut out = lines.join("\n");
387 if content.ends_with('\n') {
388 out.push('\n');
389 }
390 out
391}
392
393fn uncovered_entity_modules(entities_dir: &Path, models_dir: &Path) -> Result<Vec<String>> {
394 let entities_mod = fs::read_to_string(entities_dir.join("mod.rs")).unwrap_or_default();
395 let modules = entity_modules(&entities_mod);
396
397 let mut model_contents = Vec::new();
398 for entry in fs::read_dir(models_dir)? {
399 let path = entry?.path();
400 if path.extension().is_none_or(|ext| ext != "rs") {
401 continue;
402 }
403 if path.file_name().is_some_and(|name| name == "mod.rs") {
404 continue;
405 }
406 model_contents.push(fs::read_to_string(path)?);
407 }
408
409 Ok(modules
410 .into_iter()
411 .filter(|entity| {
412 !model_contents
413 .iter()
414 .any(|content| model_extension_covers_entity(content, entity))
415 })
416 .collect())
417}
418
419pub fn write_active_model_behavior_module(entities_dir: &Path, models_dir: &Path) -> Result<()> {
422 let uncovered = uncovered_entity_modules(entities_dir, models_dir)?;
423 let behavior_path = entities_dir.join("active_model_behavior.rs");
424 let entities_mod_path = entities_dir.join("mod.rs");
425 let entities_mod = fs::read_to_string(&entities_mod_path).unwrap_or_default();
426
427 if uncovered.is_empty() {
428 if behavior_path.exists() {
429 fs::remove_file(behavior_path)?;
430 }
431 let without = entities_mod
432 .lines()
433 .filter(|line| line.trim() != "pub mod active_model_behavior;")
434 .collect::<Vec<_>>()
435 .join("\n");
436 let mut cleaned = without;
437 if entities_mod.ends_with('\n') {
438 cleaned.push('\n');
439 }
440 if cleaned != entities_mod {
441 fs::write(entities_mod_path, cleaned)?;
442 }
443 return Ok(());
444 }
445
446 let mut body = String::from(
447 "//! Default ActiveModelBehavior for entities without a covering model extension.\n\
448 //! Regenerated on `doido db migrate` — do not edit.\n\
449 #![allow(dead_code)]\n\n\
450 use doido::model::sea_orm::ActiveModelBehavior;\n\n",
451 );
452 for entity in &uncovered {
453 body.push_str(&format!(
454 "impl ActiveModelBehavior for super::{entity}::ActiveModel {{}}\n"
455 ));
456 }
457 fs::write(behavior_path, body)?;
458
459 let updated = register_entity_module(&entities_mod, "active_model_behavior");
460 if updated != entities_mod {
461 fs::write(entities_mod_path, updated)?;
462 }
463 Ok(())
464}
465
466pub fn postprocess_entity_export(entities_dir: &Path, models_dir: &Path) -> Result<()> {
468 rewrite_generated_imports(entities_dir)?;
469 dedupe_model_extension_stubs(models_dir)?;
470 ensure_model_extension_stubs(entities_dir, models_dir)?;
471 ensure_active_model_behavior_in_extensions(models_dir)?;
472 write_active_model_behavior_module(entities_dir, models_dir)
473}
474
475pub fn register_model_module(models_mod: &str, module: &str) -> String {
477 let decl = format!("pub mod {module};");
478 if models_mod.lines().any(|l| l.trim() == decl) {
479 return models_mod.to_string();
480 }
481
482 let mut lines: Vec<String> = models_mod.lines().map(String::from).collect();
483 if let Some(i) = lines.iter().position(|l| l.contains(MODELS_MOD_MARKER)) {
484 lines.insert(i, decl);
485 } else {
486 lines.push(decl);
487 }
488 let mut out = lines.join("\n");
489 out.push('\n');
490 out
491}
492
493pub fn register_entity_module(entities_mod: &str, module: &str) -> String {
495 let decl = format!("pub mod {module};");
496 if entities_mod.lines().any(|l| l.trim() == decl) {
497 return entities_mod.to_string();
498 }
499
500 let mut lines: Vec<String> = entities_mod.lines().map(String::from).collect();
501 if let Some(i) = lines.iter().position(|l| l.contains(ENTITIES_MOD_MARKER)) {
502 lines.insert(i, decl);
503 } else {
504 lines.push(decl);
505 }
506 let mut out = lines.join("\n");
507 out.push('\n');
508 out
509}