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 #[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 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
143fn 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 (_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 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 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 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 let config: RomanceConfig = merged_value.try_into()?;
208 Ok(config)
209 }
210
211 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 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 #[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 #[test]
266 fn default_codegen_when_section_omitted() {
267 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 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 #[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 #[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 #[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 #[test]
491 fn load_missing_file_errors() {
492 let dir = tempfile::tempdir().unwrap();
493 assert!(RomanceConfig::load(dir.path()).is_err());
494 }
495
496 #[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 #[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 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 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 assert_eq!(config.backend.port, 8080);
600 assert_eq!(config.backend.database_url, "postgres://localhost/test");
602 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 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 assert!(config.features.validation);
644 assert_eq!(config.frontend.port, 5173);
645 }
646
647 #[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}