Skip to main content

romance_core/generator/
context.rs

1use crate::config::RomanceConfig;
2use crate::entity::{FieldType, ValidationRule};
3use crate::utils;
4use anyhow::Result;
5use std::path::Path;
6
7/// All marker strings used across generators.
8pub mod markers {
9    pub const MODS: &str = "// === ROMANCE:MODS ===";
10    pub const ROUTES: &str = "// === ROMANCE:ROUTES ===";
11    pub const MIGRATION_MODS: &str = "// === ROMANCE:MIGRATION_MODS ===";
12    pub const MIGRATIONS: &str = "// === ROMANCE:MIGRATIONS ===";
13    pub const RELATIONS: &str = "// === ROMANCE:RELATIONS ===";
14    pub const RELATION_HANDLERS: &str = "// === ROMANCE:RELATION_HANDLERS ===";
15    pub const RELATION_ROUTES: &str = "// === ROMANCE:RELATION_ROUTES ===";
16    pub const MIDDLEWARE: &str = "// === ROMANCE:MIDDLEWARE ===";
17    pub const IMPORTS: &str = "// === ROMANCE:IMPORTS ===";
18    pub const APP_ROUTES: &str = "{/* === ROMANCE:APP_ROUTES === */}";
19    pub const NAV_LINKS: &str = "{/* === ROMANCE:NAV_LINKS === */}";
20    pub const OPENAPI_PATHS: &str = "// === ROMANCE:OPENAPI_PATHS ===";
21    pub const OPENAPI_SCHEMAS: &str = "// === ROMANCE:OPENAPI_SCHEMAS ===";
22    pub const OPENAPI_TAGS: &str = "// === ROMANCE:OPENAPI_TAGS ===";
23    pub const SEEDS: &str = "// === ROMANCE:SEEDS ===";
24    pub const CUSTOM: &str = "// === ROMANCE:CUSTOM ===";
25}
26
27/// Project-level feature flags loaded once from `romance.toml`.
28pub struct ProjectFeatures {
29    pub soft_delete: bool,
30    pub has_validation: bool,
31    pub has_search: bool,
32    pub has_audit: bool,
33    pub has_multitenancy: bool,
34    pub has_auth: bool,
35    pub api_prefix: String,
36}
37
38impl ProjectFeatures {
39    /// Load feature flags from `romance.toml` at `project_root`.
40    /// Falls back to defaults if the config file is missing.
41    pub fn load(project_root: &Path) -> Self {
42        let config = RomanceConfig::load(project_root).ok();
43        let soft_delete = config.as_ref().map(|c| c.has_feature("soft_delete")).unwrap_or(false);
44        let has_validation = config.as_ref().map(|c| c.has_feature("validation")).unwrap_or(false);
45        let has_search = config.as_ref().map(|c| c.has_feature("search")).unwrap_or(false);
46        let has_audit = config.as_ref().map(|c| c.has_feature("audit_log")).unwrap_or(false);
47        let has_multitenancy = config.as_ref().map(|c| c.has_feature("multitenancy")).unwrap_or(false);
48        let has_auth = project_root.join("backend/src/auth.rs").exists();
49        let api_prefix = config.as_ref()
50            .and_then(|c| c.backend.api_prefix.clone())
51            .unwrap_or_else(|| "/api".to_string());
52
53        Self {
54            soft_delete,
55            has_validation,
56            has_search,
57            has_audit,
58            has_multitenancy,
59            has_auth,
60            api_prefix,
61        }
62    }
63}
64
65/// Convert validation rules to JSON values for template context.
66pub fn validation_rules_to_json(rules: &[ValidationRule]) -> Vec<serde_json::Value> {
67    rules
68        .iter()
69        .map(|v| match v {
70            ValidationRule::Min(n) => serde_json::json!({"type": "min", "value": n}),
71            ValidationRule::Max(n) => serde_json::json!({"type": "max", "value": n}),
72            ValidationRule::Email => serde_json::json!({"type": "email"}),
73            ValidationRule::Url => serde_json::json!({"type": "url"}),
74            ValidationRule::Regex(r) => serde_json::json!({"type": "regex", "value": r}),
75            ValidationRule::Required => serde_json::json!({"type": "required"}),
76            ValidationRule::Unique => serde_json::json!({"type": "unique"}),
77        })
78        .collect()
79}
80
81/// Determine the filter strategy based on field type.
82///
83/// - `"contains"` for String/Text types (partial match with ILIKE)
84/// - `"eq"` for exact-match types (bool, int, uuid, etc.)
85/// - `"skip"` for types that shouldn't be filtered (Json, File, Image)
86pub fn filter_method(field_type: &FieldType) -> &'static str {
87    match field_type {
88        FieldType::String | FieldType::Text | FieldType::Enum(_) => "contains",
89        FieldType::Bool
90        | FieldType::Int32
91        | FieldType::Int64
92        | FieldType::Float64
93        | FieldType::Decimal
94        | FieldType::Uuid
95        | FieldType::DateTime
96        | FieldType::Date => "eq",
97        FieldType::Json | FieldType::File | FieldType::Image => "skip",
98    }
99}
100
101/// Check if a field type is numeric.
102pub fn is_numeric(field_type: &FieldType) -> bool {
103    matches!(
104        field_type,
105        FieldType::Int32 | FieldType::Int64 | FieldType::Float64 | FieldType::Decimal
106    )
107}
108
109/// Register a backend module: adds `pub mod` to entities/handlers/routes mod.rs
110/// and merges the router in routes/mod.rs.
111pub fn register_backend_module(backend_src: &Path, module_name: &str) -> Result<()> {
112    let routes_mod = backend_src.join("routes/mod.rs");
113    utils::insert_at_marker(
114        &routes_mod,
115        markers::ROUTES,
116        &format!("        .merge({module_name}::router())"),
117    )?;
118    utils::insert_at_marker(
119        &routes_mod,
120        markers::MODS,
121        &format!("pub mod {};", module_name),
122    )?;
123
124    let entities_mod = backend_src.join("entities/mod.rs");
125    utils::insert_at_marker(
126        &entities_mod,
127        markers::MODS,
128        &format!("pub mod {};", module_name),
129    )?;
130
131    let handlers_mod = backend_src.join("handlers/mod.rs");
132    utils::insert_at_marker(
133        &handlers_mod,
134        markers::MODS,
135        &format!("pub mod {};", module_name),
136    )?;
137
138    Ok(())
139}
140
141/// Register a migration module in `backend/migration/src/lib.rs`.
142pub fn register_migration(project_root: &Path, migration_module: &str) -> Result<()> {
143    let lib_path = project_root.join("backend/migration/src/lib.rs");
144    utils::insert_at_marker(
145        &lib_path,
146        markers::MIGRATION_MODS,
147        &format!("mod {};", migration_module),
148    )?;
149    utils::insert_at_marker(
150        &lib_path,
151        markers::MIGRATIONS,
152        &format!("            Box::new({}::Migration),", migration_module),
153    )?;
154    Ok(())
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use std::io::Write;
161
162    #[test]
163    fn validation_rules_to_json_empty() {
164        let json = validation_rules_to_json(&[]);
165        assert!(json.is_empty());
166    }
167
168    #[test]
169    fn validation_rules_to_json_all_types() {
170        let rules = vec![
171            ValidationRule::Min(3),
172            ValidationRule::Max(100),
173            ValidationRule::Email,
174            ValidationRule::Url,
175            ValidationRule::Regex("^[a-z]+$".to_string()),
176            ValidationRule::Required,
177            ValidationRule::Unique,
178        ];
179        let json = validation_rules_to_json(&rules);
180        assert_eq!(json.len(), 7);
181        assert_eq!(json[0]["type"], "min");
182        assert_eq!(json[0]["value"], 3);
183        assert_eq!(json[1]["type"], "max");
184        assert_eq!(json[1]["value"], 100);
185        assert_eq!(json[2]["type"], "email");
186        assert_eq!(json[3]["type"], "url");
187        assert_eq!(json[4]["type"], "regex");
188        assert_eq!(json[4]["value"], "^[a-z]+$");
189        assert_eq!(json[5]["type"], "required");
190        assert_eq!(json[6]["type"], "unique");
191    }
192
193    #[test]
194    fn filter_method_contains_for_strings() {
195        assert_eq!(filter_method(&FieldType::String), "contains");
196        assert_eq!(filter_method(&FieldType::Text), "contains");
197        assert_eq!(filter_method(&FieldType::Enum(vec!["A".into()])), "contains");
198    }
199
200    #[test]
201    fn filter_method_eq_for_exact_types() {
202        assert_eq!(filter_method(&FieldType::Bool), "eq");
203        assert_eq!(filter_method(&FieldType::Int32), "eq");
204        assert_eq!(filter_method(&FieldType::Uuid), "eq");
205        assert_eq!(filter_method(&FieldType::DateTime), "eq");
206    }
207
208    #[test]
209    fn filter_method_skip_for_complex_types() {
210        assert_eq!(filter_method(&FieldType::Json), "skip");
211        assert_eq!(filter_method(&FieldType::File), "skip");
212        assert_eq!(filter_method(&FieldType::Image), "skip");
213    }
214
215    #[test]
216    fn is_numeric_correct() {
217        assert!(is_numeric(&FieldType::Int32));
218        assert!(is_numeric(&FieldType::Int64));
219        assert!(is_numeric(&FieldType::Float64));
220        assert!(is_numeric(&FieldType::Decimal));
221        assert!(!is_numeric(&FieldType::String));
222        assert!(!is_numeric(&FieldType::Bool));
223        assert!(!is_numeric(&FieldType::Uuid));
224    }
225
226    #[test]
227    fn register_backend_module_inserts_mods_and_routes() {
228        let dir = tempfile::tempdir().unwrap();
229        let base = dir.path();
230
231        // Create directories
232        std::fs::create_dir_all(base.join("routes")).unwrap();
233        std::fs::create_dir_all(base.join("entities")).unwrap();
234        std::fs::create_dir_all(base.join("handlers")).unwrap();
235
236        // Write marker files
237        let mut f = std::fs::File::create(base.join("routes/mod.rs")).unwrap();
238        writeln!(f, "// === ROMANCE:MODS ===").unwrap();
239        writeln!(f, "// === ROMANCE:ROUTES ===").unwrap();
240
241        let mut f = std::fs::File::create(base.join("entities/mod.rs")).unwrap();
242        writeln!(f, "// === ROMANCE:MODS ===").unwrap();
243
244        let mut f = std::fs::File::create(base.join("handlers/mod.rs")).unwrap();
245        writeln!(f, "// === ROMANCE:MODS ===").unwrap();
246
247        register_backend_module(base, "product").unwrap();
248
249        let routes = std::fs::read_to_string(base.join("routes/mod.rs")).unwrap();
250        assert!(routes.contains("pub mod product;"));
251        assert!(routes.contains(".merge(product::router())"));
252
253        let entities = std::fs::read_to_string(base.join("entities/mod.rs")).unwrap();
254        assert!(entities.contains("pub mod product;"));
255
256        let handlers = std::fs::read_to_string(base.join("handlers/mod.rs")).unwrap();
257        assert!(handlers.contains("pub mod product;"));
258    }
259
260    #[test]
261    fn register_backend_module_errors_on_missing_marker() {
262        let dir = tempfile::tempdir().unwrap();
263        let base = dir.path();
264
265        std::fs::create_dir_all(base.join("routes")).unwrap();
266        std::fs::create_dir_all(base.join("entities")).unwrap();
267        std::fs::create_dir_all(base.join("handlers")).unwrap();
268
269        // routes/mod.rs without ROUTES marker
270        std::fs::write(base.join("routes/mod.rs"), "// no markers here\n").unwrap();
271        std::fs::write(base.join("entities/mod.rs"), "// === ROMANCE:MODS ===\n").unwrap();
272        std::fs::write(base.join("handlers/mod.rs"), "// === ROMANCE:MODS ===\n").unwrap();
273
274        let result = register_backend_module(base, "product");
275        assert!(result.is_err());
276    }
277
278    #[test]
279    fn register_migration_inserts_mod_and_box() {
280        let dir = tempfile::tempdir().unwrap();
281        std::fs::create_dir_all(dir.path().join("backend/migration/src")).unwrap();
282
283        let lib_content = r#"pub use sea_orm_migration::prelude::*;
284
285// === ROMANCE:MIGRATION_MODS ===
286
287pub struct Migrator;
288
289impl MigratorTrait for Migrator {
290    fn migrations() -> Vec<Box<dyn MigrationTrait>> {
291        vec![
292            // === ROMANCE:MIGRATIONS ===
293        ]
294    }
295}
296"#;
297        std::fs::write(
298            dir.path().join("backend/migration/src/lib.rs"),
299            lib_content,
300        )
301        .unwrap();
302
303        register_migration(dir.path(), "m20260216_create_product_table").unwrap();
304
305        let content =
306            std::fs::read_to_string(dir.path().join("backend/migration/src/lib.rs")).unwrap();
307        assert!(content.contains("mod m20260216_create_product_table;"));
308        assert!(content.contains("Box::new(m20260216_create_product_table::Migration),"));
309    }
310}