Skip to main content

doido_model/
entities.rs

1//! Entity export layout — `_entities/` holds regenerated SeaORM definitions;
2//! `app/models/<name>.rs` holds safe-to-edit extensions.
3
4use doido_core::{Inflector, Result};
5use std::collections::{HashMap, HashSet};
6use std::fs;
7use std::path::Path;
8
9/// Where `doido db migrate` and `doido db generate entity` write SeaORM entities.
10pub const DEFAULT_ENTITY_DIR: &str = "app/models/_entities";
11
12const MODELS_MOD_MARKER: &str = "@generated-models";
13const ENTITIES_MOD_MARKER: &str = "@generated-entities";
14
15/// Module names declared in an `_entities/mod.rs` (or `lib.rs`) index file.
16pub 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
32/// Model modules registered in `app/models/mod.rs` (excluding `_entities`).
33pub 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
49/// Default extension stub — re-exports the generated entity module (table name).
50pub 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
63/// Rewrites SeaORM CLI imports to the mandatory `doido::model::sea_orm` path
64/// and adds lint allows so exported entities compile under `-D warnings`.
65pub 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            // Normalized below — drop legacy single-path imports.
114        } 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
156/// Removes the default SeaORM `ActiveModelBehavior` impl from exported entity files.
157/// The impl belongs in `app/models/<name>.rs` extension stubs instead.
158fn 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
203/// Returns the `_entities/<name>` module re-exported by a model extension, if any.
204pub 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
221/// Returns true when some model extension already re-exports `entity_module`.
222pub 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
236/// Removes duplicate model extensions that re-export the same entity module.
237/// Keeps the canonical stub (prefer a module name different from the table name,
238/// e.g. `sku` over `skus` for entity `skus`).
239pub 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
298/// Creates missing `app/models/<name>.rs` extension stubs (with
299/// `ActiveModelBehavior`) for every entity module under `_entities/`.
300pub 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
334/// Returns true when a model extension re-exports an entity and provides
335/// `ActiveModelBehavior` for its `ActiveModel`.
336pub 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
341/// Inserts the default `ActiveModelBehavior` impl into model extensions that
342/// re-export an entity but predate the generator template update.
343pub 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
419/// Writes `_entities/active_model_behavior.rs` with fallback impls for entities
420/// that have no covering model extension (e.g. inline tutorial models).
421pub 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
466/// Post-processes exported entities so they compile inside a Doido app.
467pub 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
475/// Inserts `pub mod <module>;` into `app/models/mod.rs` just above the marker.
476pub 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
493/// Inserts `pub mod <module>;` into `_entities/mod.rs` just above the marker.
494pub 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}