Skip to main content

romance_core/
config.rs

1use anyhow::Result;
2use serde::{Deserialize, Serialize};
3use std::path::Path;
4
5#[derive(Debug, Serialize, Deserialize)]
6pub struct RomanceConfig {
7    pub project: ProjectConfig,
8    pub backend: BackendConfig,
9    pub frontend: FrontendConfig,
10    #[serde(default)]
11    pub codegen: CodegenConfig,
12    #[serde(default)]
13    pub features: FeaturesConfig,
14    #[serde(default)]
15    pub security: Option<SecurityConfig>,
16    #[serde(default)]
17    pub storage: Option<StorageConfig>,
18    #[serde(default)]
19    pub environment: EnvironmentConfig,
20}
21
22#[derive(Debug, Serialize, Deserialize)]
23pub struct EnvironmentConfig {
24    /// Active environment: "development", "staging", "production"
25    #[serde(default = "default_environment")]
26    pub active: String,
27}
28
29impl Default for EnvironmentConfig {
30    fn default() -> Self {
31        Self {
32            active: default_environment(),
33        }
34    }
35}
36
37fn default_environment() -> String {
38    std::env::var("ROMANCE_ENV").unwrap_or_else(|_| "development".to_string())
39}
40
41#[derive(Debug, Serialize, Deserialize)]
42pub struct ProjectConfig {
43    pub name: String,
44    pub description: Option<String>,
45}
46
47#[derive(Debug, Serialize, Deserialize)]
48pub struct BackendConfig {
49    pub port: u16,
50    pub database_url: String,
51    /// API route prefix (default: "/api"). Use for API versioning, e.g. "/api/v1".
52    pub api_prefix: Option<String>,
53}
54
55#[derive(Debug, Serialize, Deserialize)]
56pub struct FrontendConfig {
57    pub port: u16,
58    pub api_base_url: String,
59}
60
61#[derive(Debug, Default, Serialize, Deserialize)]
62pub struct CodegenConfig {
63    #[serde(default = "default_true")]
64    pub generate_openapi: bool,
65    #[serde(default = "default_true")]
66    pub generate_ts_types: bool,
67}
68
69#[derive(Debug, Default, Serialize, Deserialize)]
70pub struct FeaturesConfig {
71    #[serde(default)]
72    pub validation: bool,
73    #[serde(default)]
74    pub soft_delete: bool,
75    #[serde(default)]
76    pub audit_log: bool,
77    #[serde(default)]
78    pub search: bool,
79    #[serde(default)]
80    pub multitenancy: bool,
81}
82
83#[derive(Debug, Serialize, Deserialize)]
84pub struct SecurityConfig {
85    #[serde(default = "default_rate_limit")]
86    pub rate_limit_rpm: u32,
87    #[serde(default)]
88    pub cors_origins: Vec<String>,
89    #[serde(default)]
90    pub csrf: bool,
91}
92
93impl Default for SecurityConfig {
94    fn default() -> Self {
95        Self {
96            rate_limit_rpm: 60,
97            cors_origins: vec!["http://localhost:5173".to_string()],
98            csrf: false,
99        }
100    }
101}
102
103#[derive(Debug, Serialize, Deserialize)]
104pub struct StorageConfig {
105    #[serde(default = "default_storage_backend")]
106    pub backend: String,
107    #[serde(default = "default_upload_dir")]
108    pub upload_dir: String,
109    #[serde(default = "default_max_file_size")]
110    pub max_file_size: String,
111}
112
113impl Default for StorageConfig {
114    fn default() -> Self {
115        Self {
116            backend: "local".to_string(),
117            upload_dir: "./uploads".to_string(),
118            max_file_size: "10MB".to_string(),
119        }
120    }
121}
122
123fn default_true() -> bool {
124    true
125}
126
127fn default_rate_limit() -> u32 {
128    60
129}
130
131fn default_storage_backend() -> String {
132    "local".to_string()
133}
134
135fn default_upload_dir() -> String {
136    "./uploads".to_string()
137}
138
139fn default_max_file_size() -> String {
140    "10MB".to_string()
141}
142
143/// Deep-merge two TOML values. The `override_val` takes precedence over `base`.
144/// Tables are merged recursively; all other types are replaced.
145fn deep_merge(base: toml::Value, override_val: toml::Value) -> toml::Value {
146    match (base, override_val) {
147        (toml::Value::Table(mut base_table), toml::Value::Table(override_table)) => {
148            for (key, override_v) in override_table {
149                let merged = if let Some(base_v) = base_table.remove(&key) {
150                    deep_merge(base_v, override_v)
151                } else {
152                    override_v
153                };
154                base_table.insert(key, merged);
155            }
156            toml::Value::Table(base_table)
157        }
158        // For non-table types, the override wins
159        (_base, override_val) => override_val,
160    }
161}
162
163impl RomanceConfig {
164    pub fn load(dir: &Path) -> Result<Self> {
165        let path = dir.join("romance.toml");
166        let content = std::fs::read_to_string(&path)?;
167        let config: RomanceConfig = toml::from_str(&content)?;
168        Ok(config)
169    }
170
171    /// Load config with environment-specific overrides.
172    ///
173    /// 1. Loads base `romance.toml`
174    /// 2. Determines the active environment from `ROMANCE_ENV` env var
175    ///    (or from `[environment] active` in the base config), defaulting to "development"
176    /// 3. If `romance.{env}.toml` exists (e.g. `romance.production.toml`),
177    ///    deep-merges those overrides on top of the base config
178    ///
179    /// This is fully backward-compatible: projects without env-specific files
180    /// behave exactly as before.
181    pub fn load_with_env(dir: &Path) -> Result<Self> {
182        let base_path = dir.join("romance.toml");
183        let base_content = std::fs::read_to_string(&base_path)?;
184        let base_value: toml::Value = toml::from_str(&base_content)?;
185
186        // Determine active environment: ROMANCE_ENV takes priority, then config field
187        let env_name = std::env::var("ROMANCE_ENV").unwrap_or_else(|_| {
188            base_value
189                .get("environment")
190                .and_then(|e| e.get("active"))
191                .and_then(|a| a.as_str())
192                .unwrap_or("development")
193                .to_string()
194        });
195
196        // Check for environment-specific override file
197        let env_path = dir.join(format!("romance.{}.toml", env_name));
198        let merged_value = if env_path.exists() {
199            let env_content = std::fs::read_to_string(&env_path)?;
200            let env_value: toml::Value = toml::from_str(&env_content)?;
201            deep_merge(base_value, env_value)
202        } else {
203            base_value
204        };
205
206        // Deserialize the merged TOML value into RomanceConfig
207        let config: RomanceConfig = merged_value.try_into()?;
208        Ok(config)
209    }
210
211    /// Check if a feature is enabled.
212    pub fn has_feature(&self, feature: &str) -> bool {
213        match feature {
214            "validation" => self.features.validation,
215            "soft_delete" => self.features.soft_delete,
216            "audit_log" => self.features.audit_log,
217            "search" => self.features.search,
218            "multitenancy" => self.features.multitenancy,
219            _ => false,
220        }
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227    use std::io::Write;
228
229    /// Helper: write a romance.toml and return the tempdir.
230    fn write_config(toml_content: &str) -> tempfile::TempDir {
231        let dir = tempfile::tempdir().unwrap();
232        let path = dir.path().join("romance.toml");
233        let mut f = std::fs::File::create(&path).unwrap();
234        f.write_all(toml_content.as_bytes()).unwrap();
235        dir
236    }
237
238    // ── Loading a valid config ────────────────────────────────────────
239
240    #[test]
241    fn load_valid_config() {
242        let dir = write_config(
243            r#"
244[project]
245name = "my-app"
246
247[backend]
248port = 3000
249database_url = "postgres://localhost/mydb"
250
251[frontend]
252port = 5173
253api_base_url = "http://localhost:3000"
254"#,
255        );
256
257        let config = RomanceConfig::load(dir.path()).unwrap();
258        assert_eq!(config.project.name, "my-app");
259        assert_eq!(config.backend.port, 3000);
260        assert_eq!(config.frontend.port, 5173);
261    }
262
263    // ── Default values for optional sections ──────────────────────────
264
265    #[test]
266    fn default_codegen_when_section_omitted() {
267        // When [codegen] section is omitted entirely, CodegenConfig::default() is used
268        // which gives false for both fields (Rust Default for bool).
269        let dir = write_config(
270            r#"
271[project]
272name = "test"
273
274[backend]
275port = 3000
276database_url = "postgres://localhost/test"
277
278[frontend]
279port = 5173
280api_base_url = "http://localhost:3000"
281"#,
282        );
283
284        let config = RomanceConfig::load(dir.path()).unwrap();
285        assert!(!config.codegen.generate_openapi);
286        assert!(!config.codegen.generate_ts_types);
287    }
288
289    #[test]
290    fn codegen_fields_default_to_true_when_section_present() {
291        // When [codegen] section is present but fields are omitted,
292        // serde(default = "default_true") kicks in.
293        let dir = write_config(
294            r#"
295[project]
296name = "test"
297
298[backend]
299port = 3000
300database_url = "postgres://localhost/test"
301
302[frontend]
303port = 5173
304api_base_url = "http://localhost:3000"
305
306[codegen]
307"#,
308        );
309
310        let config = RomanceConfig::load(dir.path()).unwrap();
311        assert!(config.codegen.generate_openapi);
312        assert!(config.codegen.generate_ts_types);
313    }
314
315    #[test]
316    fn default_features_all_false() {
317        let dir = write_config(
318            r#"
319[project]
320name = "test"
321
322[backend]
323port = 3000
324database_url = "postgres://localhost/test"
325
326[frontend]
327port = 5173
328api_base_url = "http://localhost:3000"
329"#,
330        );
331
332        let config = RomanceConfig::load(dir.path()).unwrap();
333        assert!(!config.features.validation);
334        assert!(!config.features.soft_delete);
335        assert!(!config.features.audit_log);
336        assert!(!config.features.search);
337    }
338
339    // ── has_feature ───────────────────────────────────────────────────
340
341    #[test]
342    fn has_feature_enabled() {
343        let dir = write_config(
344            r#"
345[project]
346name = "test"
347
348[backend]
349port = 3000
350database_url = "postgres://localhost/test"
351
352[frontend]
353port = 5173
354api_base_url = "http://localhost:3000"
355
356[features]
357validation = true
358search = true
359"#,
360        );
361
362        let config = RomanceConfig::load(dir.path()).unwrap();
363        assert!(config.has_feature("validation"));
364        assert!(config.has_feature("search"));
365        assert!(!config.has_feature("soft_delete"));
366        assert!(!config.has_feature("audit_log"));
367    }
368
369    #[test]
370    fn has_feature_unknown_returns_false() {
371        let dir = write_config(
372            r#"
373[project]
374name = "test"
375
376[backend]
377port = 3000
378database_url = "postgres://localhost/test"
379
380[frontend]
381port = 5173
382api_base_url = "http://localhost:3000"
383"#,
384        );
385
386        let config = RomanceConfig::load(dir.path()).unwrap();
387        assert!(!config.has_feature("nonexistent_feature"));
388    }
389
390    // ── api_prefix ────────────────────────────────────────────────────
391
392    #[test]
393    fn api_prefix_none_by_default() {
394        let dir = write_config(
395            r#"
396[project]
397name = "test"
398
399[backend]
400port = 3000
401database_url = "postgres://localhost/test"
402
403[frontend]
404port = 5173
405api_base_url = "http://localhost:3000"
406"#,
407        );
408
409        let config = RomanceConfig::load(dir.path()).unwrap();
410        assert!(config.backend.api_prefix.is_none());
411    }
412
413    #[test]
414    fn api_prefix_custom_value() {
415        let dir = write_config(
416            r#"
417[project]
418name = "test"
419
420[backend]
421port = 3000
422database_url = "postgres://localhost/test"
423api_prefix = "/api/v1"
424
425[frontend]
426port = 5173
427api_base_url = "http://localhost:3000"
428"#,
429        );
430
431        let config = RomanceConfig::load(dir.path()).unwrap();
432        assert_eq!(config.backend.api_prefix.as_deref(), Some("/api/v1"));
433    }
434
435    // ── Security and storage configs ──────────────────────────────────
436
437    #[test]
438    fn security_config_defaults() {
439        let dir = write_config(
440            r#"
441[project]
442name = "test"
443
444[backend]
445port = 3000
446database_url = "postgres://localhost/test"
447
448[frontend]
449port = 5173
450api_base_url = "http://localhost:3000"
451
452[security]
453"#,
454        );
455
456        let config = RomanceConfig::load(dir.path()).unwrap();
457        let sec = config.security.unwrap();
458        assert_eq!(sec.rate_limit_rpm, 60);
459        assert!(!sec.csrf);
460    }
461
462    #[test]
463    fn storage_config_defaults() {
464        let dir = write_config(
465            r#"
466[project]
467name = "test"
468
469[backend]
470port = 3000
471database_url = "postgres://localhost/test"
472
473[frontend]
474port = 5173
475api_base_url = "http://localhost:3000"
476
477[storage]
478"#,
479        );
480
481        let config = RomanceConfig::load(dir.path()).unwrap();
482        let store = config.storage.unwrap();
483        assert_eq!(store.backend, "local");
484        assert_eq!(store.upload_dir, "./uploads");
485        assert_eq!(store.max_file_size, "10MB");
486    }
487
488    // ── Missing file returns error ────────────────────────────────────
489
490    #[test]
491    fn load_missing_file_errors() {
492        let dir = tempfile::tempdir().unwrap();
493        assert!(RomanceConfig::load(dir.path()).is_err());
494    }
495
496    // ── Project description optional ──────────────────────────────────
497
498    #[test]
499    fn project_description_optional() {
500        let dir = write_config(
501            r#"
502[project]
503name = "test"
504description = "A test project"
505
506[backend]
507port = 3000
508database_url = "postgres://localhost/test"
509
510[frontend]
511port = 5173
512api_base_url = "http://localhost:3000"
513"#,
514        );
515
516        let config = RomanceConfig::load(dir.path()).unwrap();
517        assert_eq!(config.project.description.as_deref(), Some("A test project"));
518    }
519
520    // ── Environment config ───────────────────────────────────────────
521
522    #[test]
523    fn default_environment_is_development() {
524        let dir = write_config(
525            r#"
526[project]
527name = "test"
528
529[backend]
530port = 3000
531database_url = "postgres://localhost/test"
532
533[frontend]
534port = 5173
535api_base_url = "http://localhost:3000"
536"#,
537        );
538
539        let config = RomanceConfig::load(dir.path()).unwrap();
540        assert_eq!(config.environment.active, "development");
541    }
542
543    #[test]
544    fn load_with_env_no_override_file() {
545        // Without an env-specific file, load_with_env behaves like load
546        let dir = write_config(
547            r#"
548[project]
549name = "test"
550
551[backend]
552port = 3000
553database_url = "postgres://localhost/test"
554
555[frontend]
556port = 5173
557api_base_url = "http://localhost:3000"
558"#,
559        );
560
561        let config = RomanceConfig::load_with_env(dir.path()).unwrap();
562        assert_eq!(config.project.name, "test");
563        assert_eq!(config.backend.port, 3000);
564    }
565
566    #[test]
567    fn load_with_env_merges_override() {
568        let dir = write_config(
569            r#"
570[project]
571name = "test"
572
573[backend]
574port = 3000
575database_url = "postgres://localhost/test"
576
577[frontend]
578port = 5173
579api_base_url = "http://localhost:3000"
580
581[environment]
582active = "production"
583"#,
584        );
585
586        // Write the production override file
587        let prod_path = dir.path().join("romance.production.toml");
588        let mut f = std::fs::File::create(&prod_path).unwrap();
589        f.write_all(
590            br#"
591[backend]
592port = 8080
593"#,
594        )
595        .unwrap();
596
597        let config = RomanceConfig::load_with_env(dir.path()).unwrap();
598        // port should be overridden
599        assert_eq!(config.backend.port, 8080);
600        // database_url should remain from base
601        assert_eq!(config.backend.database_url, "postgres://localhost/test");
602        // project name should remain from base
603        assert_eq!(config.project.name, "test");
604    }
605
606    #[test]
607    fn load_with_env_deep_merge_preserves_unrelated_sections() {
608        let dir = write_config(
609            r#"
610[project]
611name = "test"
612
613[backend]
614port = 3000
615database_url = "postgres://localhost/test"
616
617[frontend]
618port = 5173
619api_base_url = "http://localhost:3000"
620
621[environment]
622active = "staging"
623
624[features]
625validation = true
626"#,
627        );
628
629        // Write staging override that only touches backend
630        let staging_path = dir.path().join("romance.staging.toml");
631        let mut f = std::fs::File::create(&staging_path).unwrap();
632        f.write_all(
633            br#"
634[backend]
635port = 4000
636"#,
637        )
638        .unwrap();
639
640        let config = RomanceConfig::load_with_env(dir.path()).unwrap();
641        assert_eq!(config.backend.port, 4000);
642        // features should be preserved from base
643        assert!(config.features.validation);
644        assert_eq!(config.frontend.port, 5173);
645    }
646
647    // ── deep_merge unit tests ────────────────────────────────────────
648
649    #[test]
650    fn deep_merge_tables() {
651        let base: toml::Value = toml::from_str(
652            r#"
653[a]
654x = 1
655y = 2
656[b]
657z = 3
658"#,
659        )
660        .unwrap();
661
662        let over: toml::Value = toml::from_str(
663            r#"
664[a]
665x = 10
666"#,
667        )
668        .unwrap();
669
670        let merged = deep_merge(base, over);
671        let tbl = merged.as_table().unwrap();
672        let a = tbl["a"].as_table().unwrap();
673        assert_eq!(a["x"].as_integer().unwrap(), 10);
674        assert_eq!(a["y"].as_integer().unwrap(), 2);
675        assert_eq!(tbl["b"].as_table().unwrap()["z"].as_integer().unwrap(), 3);
676    }
677
678    #[test]
679    fn deep_merge_override_scalar() {
680        let base: toml::Value = toml::from_str("val = 1").unwrap();
681        let over: toml::Value = toml::from_str("val = 99").unwrap();
682        let merged = deep_merge(base, over);
683        assert_eq!(merged.as_table().unwrap()["val"].as_integer().unwrap(), 99);
684    }
685}