1use super::{
14 default_config_path, profile::ProfileStore, ConfigError, OrcsConfig, PROJECT_CONFIG_DIR,
15 PROJECT_CONFIG_FILE,
16};
17use std::path::{Path, PathBuf};
18use tracing::debug;
19
20#[derive(Debug, Clone, Default)]
40pub struct EnvOverrides {
41 pub debug: Option<bool>,
43 pub auto_approve: Option<bool>,
45 pub verbose: Option<bool>,
47 pub color: Option<bool>,
49 pub scripts_auto_load: Option<bool>,
51 pub experimental: Option<bool>,
53 pub model: Option<String>,
55 pub session_path: Option<PathBuf>,
57 pub builtins_dir: Option<PathBuf>,
59 pub profile: Option<String>,
61}
62
63impl EnvOverrides {
64 pub fn from_env() -> Result<Self, ConfigError> {
73 Ok(Self {
74 debug: read_env_bool("ORCS_DEBUG")?,
75 auto_approve: read_env_bool("ORCS_AUTO_APPROVE")?,
76 verbose: read_env_bool("ORCS_VERBOSE")?,
77 color: read_env_bool("ORCS_COLOR")?,
78 scripts_auto_load: read_env_bool("ORCS_SCRIPTS_AUTO_LOAD")?,
79 experimental: read_env_bool("ORCS_EXPERIMENTAL")?,
80 model: read_env_string("ORCS_MODEL"),
81 session_path: read_env_string("ORCS_SESSION_PATH").map(PathBuf::from),
82 builtins_dir: read_env_string("ORCS_BUILTINS_DIR").map(PathBuf::from),
83 profile: read_env_string("ORCS_PROFILE"),
84 })
85 }
86}
87
88fn read_env_bool(name: &str) -> Result<Option<bool>, ConfigError> {
93 match std::env::var(name) {
94 Ok(val) => parse_bool(&val)
95 .map(Some)
96 .ok_or_else(|| ConfigError::invalid_env_var(name, "expected bool")),
97 Err(_) => Ok(None),
98 }
99}
100
101fn read_env_string(name: &str) -> Option<String> {
103 std::env::var(name).ok()
104}
105
106#[derive(Debug, Clone)]
119pub struct ConfigLoader {
120 global_config_path: Option<PathBuf>,
122
123 project_root: Option<PathBuf>,
125
126 profile: Option<String>,
130
131 env_overrides: Option<EnvOverrides>,
137
138 skip_env: bool,
140
141 skip_global: bool,
143
144 skip_project: bool,
146}
147
148impl ConfigLoader {
149 #[must_use]
151 pub fn new() -> Self {
152 Self {
153 global_config_path: None,
154 project_root: None,
155 profile: None,
156 env_overrides: None,
157 skip_env: false,
158 skip_global: false,
159 skip_project: false,
160 }
161 }
162
163 #[must_use]
165 pub fn with_global_config(mut self, path: impl Into<PathBuf>) -> Self {
166 self.global_config_path = Some(path.into());
167 self
168 }
169
170 #[must_use]
174 pub fn with_project_root(mut self, path: impl Into<PathBuf>) -> Self {
175 self.project_root = Some(path.into());
176 self
177 }
178
179 #[must_use]
185 pub fn with_profile(mut self, name: impl Into<String>) -> Self {
186 self.profile = Some(name.into());
187 self
188 }
189
190 #[must_use]
197 pub fn with_env_overrides(mut self, overrides: EnvOverrides) -> Self {
198 self.env_overrides = Some(overrides);
199 self
200 }
201
202 #[must_use]
206 pub fn skip_env_vars(mut self) -> Self {
207 self.skip_env = true;
208 self
209 }
210
211 #[must_use]
213 pub fn skip_global_config(mut self) -> Self {
214 self.skip_global = true;
215 self
216 }
217
218 #[must_use]
220 pub fn skip_project_config(mut self) -> Self {
221 self.skip_project = true;
222 self
223 }
224
225 pub fn load(&self) -> Result<OrcsConfig, ConfigError> {
232 let overrides = self.resolve_env_overrides()?;
234
235 let mut config = OrcsConfig::default();
237
238 if !self.skip_global {
240 let global_path = self
241 .global_config_path
242 .clone()
243 .unwrap_or_else(default_config_path);
244
245 if let Some(global_config) = self.load_file(&global_path)? {
246 debug!(path = %global_path.display(), "Loaded global config");
247 config.merge(&global_config);
248 }
249 }
250
251 if !self.skip_project {
253 if let Some(ref project_root) = self.project_root {
254 let project_config_path = project_root
255 .join(PROJECT_CONFIG_DIR)
256 .join(PROJECT_CONFIG_FILE);
257
258 if let Some(project_config) = self.load_file(&project_config_path)? {
259 debug!(
260 path = %project_config_path.display(),
261 project = %project_root.display(),
262 "Loaded project config"
263 );
264 config.merge(&project_config);
265 }
266 }
267 }
268
269 let profile_name = self
272 .profile
273 .clone()
274 .or_else(|| overrides.as_ref().and_then(|o| o.profile.clone()));
275
276 if let Some(ref name) = profile_name {
277 let store = ProfileStore::new(self.project_root.as_deref());
278 match store.load(name) {
279 Ok(profile_def) => {
280 if let Some(ref profile_config) = profile_def.config {
281 debug!(profile = %name, "Applying profile config overlay");
282 config.merge(profile_config);
283 }
284 }
285 Err(e) => {
286 debug!(profile = %name, error = %e, "Profile not found, skipping config overlay");
287 }
288 }
289 }
290
291 if let Some(ref ov) = overrides {
293 Self::apply_overrides(&mut config, ov);
294 }
295
296 Ok(config)
297 }
298
299 fn resolve_env_overrides(&self) -> Result<Option<EnvOverrides>, ConfigError> {
305 if self.skip_env {
306 return Ok(None);
307 }
308
309 if let Some(ref ov) = self.env_overrides {
310 return Ok(Some(ov.clone()));
311 }
312
313 EnvOverrides::from_env().map(Some)
314 }
315
316 fn load_file(&self, path: &Path) -> Result<Option<OrcsConfig>, ConfigError> {
318 if !path.exists() {
319 return Ok(None);
320 }
321
322 let content = std::fs::read_to_string(path).map_err(|e| ConfigError::read_file(path, e))?;
323
324 let config =
325 OrcsConfig::from_toml(&content).map_err(|e| ConfigError::parse_toml(path, e))?;
326
327 Ok(Some(config))
328 }
329
330 fn apply_overrides(config: &mut OrcsConfig, ov: &EnvOverrides) {
334 if let Some(v) = ov.debug {
335 config.debug = v;
336 }
337 if let Some(v) = ov.auto_approve {
338 config.hil.auto_approve = v;
339 }
340 if let Some(v) = ov.verbose {
341 config.ui.verbose = v;
342 }
343 if let Some(v) = ov.color {
344 config.ui.color = v;
345 }
346 if let Some(v) = ov.scripts_auto_load {
347 config.scripts.auto_load = v;
348 }
349 if let Some(ref v) = ov.model {
350 config.model.default.clone_from(v);
351 }
352 if let Some(ref v) = ov.session_path {
353 config.paths.session_dir = Some(v.clone());
354 }
355 if let Some(ref v) = ov.builtins_dir {
356 config.components.builtins_dir = v.clone();
357 }
358 if let Some(true) = ov.experimental {
359 config.components.activate_experimental();
360 }
361 }
362}
363
364impl Default for ConfigLoader {
365 fn default() -> Self {
366 Self::new()
367 }
368}
369
370fn parse_bool(s: &str) -> Option<bool> {
374 match s.to_lowercase().as_str() {
375 "true" | "1" | "yes" | "on" => Some(true),
376 "false" | "0" | "no" | "off" => Some(false),
377 _ => None,
378 }
379}
380
381pub fn save_global_config(config: &OrcsConfig) -> Result<(), ConfigError> {
389 let path = default_config_path();
390
391 if let Some(parent) = path.parent() {
393 if !parent.exists() {
394 std::fs::create_dir_all(parent).map_err(|e| ConfigError::create_dir(parent, e))?;
395 }
396 }
397
398 let toml = config.to_toml()?;
399 std::fs::write(&path, toml).map_err(|e| ConfigError::write_file(&path, e))?;
400
401 Ok(())
402}
403
404#[cfg(test)]
405mod tests {
406 use super::*;
407 use tempfile::TempDir;
408
409 fn create_config_file(dir: &Path, content: &str) -> PathBuf {
410 let path = dir.join("config.toml");
411 std::fs::write(&path, content).expect("should write config file to temp dir");
412 path
413 }
414
415 #[test]
416 fn load_defaults_only() {
417 let config = ConfigLoader::new()
418 .skip_global_config()
419 .skip_project_config()
420 .skip_env_vars()
421 .load()
422 .expect("should load config with all sources skipped");
423
424 assert_eq!(config, OrcsConfig::default());
425 }
426
427 #[test]
428 fn load_global_config() {
429 let temp = TempDir::new().expect("should create temp dir for global config test");
430 let config_path = create_config_file(
431 temp.path(),
432 r#"
433debug = true
434
435[model]
436default = "test-model"
437"#,
438 );
439
440 let config = ConfigLoader::new()
441 .with_global_config(&config_path)
442 .skip_project_config()
443 .skip_env_vars()
444 .load()
445 .expect("should load config from global config file");
446
447 assert!(config.debug);
448 assert_eq!(config.model.default, "test-model");
449 }
450
451 #[test]
452 fn load_project_overrides_global() {
453 let global_temp = TempDir::new().expect("should create temp dir for global config");
454 let project_temp = TempDir::new().expect("should create temp dir for project config");
455
456 let orcs_dir = project_temp.path().join(".orcs");
458 std::fs::create_dir_all(&orcs_dir).expect("should create .orcs dir in project temp");
459
460 let global_path = create_config_file(
462 global_temp.path(),
463 r#"
464debug = true
465
466[model]
467default = "global-model"
468"#,
469 );
470
471 create_config_file(
473 &orcs_dir,
474 r#"
475[model]
476default = "project-model"
477"#,
478 );
479
480 let config = ConfigLoader::new()
481 .with_global_config(&global_path)
482 .with_project_root(project_temp.path())
483 .skip_env_vars()
484 .load()
485 .expect("should load config with project overriding global");
486
487 assert!(config.debug);
489 assert_eq!(config.model.default, "project-model");
491 }
492
493 #[test]
494 fn missing_config_files_ok() {
495 let config = ConfigLoader::new()
496 .with_global_config("/nonexistent/path/config.toml")
497 .with_project_root("/nonexistent/project")
498 .skip_env_vars()
499 .load()
500 .expect("should load defaults when config files are missing");
501
502 assert_eq!(config, OrcsConfig::default());
504 }
505
506 #[test]
507 fn parse_bool_values() {
508 assert_eq!(parse_bool("true"), Some(true));
509 assert_eq!(parse_bool("TRUE"), Some(true));
510 assert_eq!(parse_bool("1"), Some(true));
511 assert_eq!(parse_bool("yes"), Some(true));
512 assert_eq!(parse_bool("on"), Some(true));
513
514 assert_eq!(parse_bool("false"), Some(false));
515 assert_eq!(parse_bool("FALSE"), Some(false));
516 assert_eq!(parse_bool("0"), Some(false));
517 assert_eq!(parse_bool("no"), Some(false));
518 assert_eq!(parse_bool("off"), Some(false));
519
520 assert_eq!(parse_bool("invalid"), None);
521 }
522
523 #[test]
524 fn load_with_profile_overlay() {
525 let project_temp = TempDir::new().expect("should create temp dir for profile test");
526
527 let profiles_dir = project_temp.path().join(".orcs").join("profiles");
529 std::fs::create_dir_all(&profiles_dir).expect("should create profiles dir");
530 std::fs::write(
531 profiles_dir.join("test-profile.toml"),
532 r#"
533[profile]
534name = "test-profile"
535description = "Test profile"
536
537[config]
538debug = true
539
540[config.model]
541default = "profile-model"
542"#,
543 )
544 .expect("should write test-profile.toml");
545
546 let config = ConfigLoader::new()
547 .skip_global_config()
548 .with_project_root(project_temp.path())
549 .with_profile("test-profile")
550 .skip_env_vars()
551 .load()
552 .expect("should load config with profile overlay applied");
553
554 assert!(config.debug);
555 assert_eq!(config.model.default, "profile-model");
556 }
557
558 #[test]
559 fn load_with_nonexistent_profile_ignores() {
560 let config = ConfigLoader::new()
561 .skip_global_config()
562 .skip_project_config()
563 .with_profile("nonexistent")
564 .skip_env_vars()
565 .load()
566 .expect("should load defaults when profile does not exist");
567
568 assert_eq!(config, OrcsConfig::default());
570 }
571
572 #[test]
575 fn env_overrides_applied() {
576 let overrides = EnvOverrides {
577 debug: Some(true),
578 model: Some("env-model".into()),
579 ..Default::default()
580 };
581
582 let config = ConfigLoader::new()
583 .skip_global_config()
584 .skip_project_config()
585 .with_env_overrides(overrides)
586 .load()
587 .expect("should load config with env overrides applied");
588
589 assert!(config.debug);
590 assert_eq!(config.model.default, "env-model");
591 }
592
593 #[test]
594 fn env_overrides_all_fields() {
595 let overrides = EnvOverrides {
596 debug: Some(true),
597 auto_approve: Some(true),
598 verbose: Some(true),
599 color: Some(false),
600 scripts_auto_load: Some(false),
601 experimental: None,
602 model: Some("override-model".into()),
603 session_path: Some(PathBuf::from("/custom/sessions")),
604 builtins_dir: None,
605 profile: None,
606 };
607
608 let config = ConfigLoader::new()
609 .skip_global_config()
610 .skip_project_config()
611 .with_env_overrides(overrides)
612 .load()
613 .expect("should load config with all env override fields applied");
614
615 assert!(config.debug);
616 assert!(config.hil.auto_approve);
617 assert!(config.ui.verbose);
618 assert!(!config.ui.color);
619 assert!(!config.scripts.auto_load);
620 assert_eq!(config.model.default, "override-model");
621 assert_eq!(
622 config.paths.session_dir,
623 Some(PathBuf::from("/custom/sessions"))
624 );
625 }
626
627 #[test]
628 fn skip_env_ignores_injected_overrides() {
629 let overrides = EnvOverrides {
630 debug: Some(true),
631 ..Default::default()
632 };
633
634 let config = ConfigLoader::new()
636 .skip_global_config()
637 .skip_project_config()
638 .with_env_overrides(overrides)
639 .skip_env_vars()
640 .load()
641 .expect("should load defaults when skip_env_vars overrides injected overrides");
642
643 assert!(!config.debug); }
645
646 #[test]
647 fn env_profile_activates_profile() {
648 let project_temp = TempDir::new().expect("should create temp dir for env profile test");
649
650 let profiles_dir = project_temp.path().join(".orcs").join("profiles");
651 std::fs::create_dir_all(&profiles_dir).expect("should create profiles dir");
652 std::fs::write(
653 profiles_dir.join("env-profile.toml"),
654 r#"
655[profile]
656name = "env-profile"
657
658[config]
659debug = true
660"#,
661 )
662 .expect("should write env-profile.toml");
663
664 let overrides = EnvOverrides {
665 profile: Some("env-profile".into()),
666 ..Default::default()
667 };
668
669 let config = ConfigLoader::new()
670 .skip_global_config()
671 .with_project_root(project_temp.path())
672 .with_env_overrides(overrides)
673 .load()
674 .expect("should load config with env profile activated");
675
676 assert!(config.debug);
677 }
678
679 #[test]
680 fn explicit_profile_overrides_env_profile() {
681 let project_temp =
682 TempDir::new().expect("should create temp dir for explicit profile override test");
683 let profiles_dir = project_temp.path().join(".orcs").join("profiles");
684 std::fs::create_dir_all(&profiles_dir).expect("should create profiles dir");
685
686 std::fs::write(
687 profiles_dir.join("env-profile.toml"),
688 r#"
689[profile]
690name = "env-profile"
691
692[config.model]
693default = "env-model"
694"#,
695 )
696 .expect("should write env-profile.toml");
697
698 std::fs::write(
699 profiles_dir.join("explicit-profile.toml"),
700 r#"
701[profile]
702name = "explicit-profile"
703
704[config.model]
705default = "explicit-model"
706"#,
707 )
708 .expect("should write explicit-profile.toml");
709
710 let overrides = EnvOverrides {
711 profile: Some("env-profile".into()),
712 ..Default::default()
713 };
714
715 let config = ConfigLoader::new()
716 .skip_global_config()
717 .with_project_root(project_temp.path())
718 .with_profile("explicit-profile")
719 .with_env_overrides(overrides)
720 .load()
721 .expect("should load config with explicit profile overriding env profile");
722
723 assert_eq!(config.model.default, "explicit-model");
725 }
726
727 #[test]
728 fn env_overrides_default_is_empty() {
729 let ov = EnvOverrides::default();
730 assert!(ov.debug.is_none());
731 assert!(ov.auto_approve.is_none());
732 assert!(ov.verbose.is_none());
733 assert!(ov.color.is_none());
734 assert!(ov.scripts_auto_load.is_none());
735 assert!(ov.model.is_none());
736 assert!(ov.session_path.is_none());
737 assert!(ov.profile.is_none());
738 }
739
740 #[test]
741 fn empty_overrides_preserve_defaults() {
742 let config = ConfigLoader::new()
743 .skip_global_config()
744 .skip_project_config()
745 .with_env_overrides(EnvOverrides::default())
746 .load()
747 .expect("should load config preserving defaults with empty overrides");
748
749 assert_eq!(config, OrcsConfig::default());
750 }
751
752 #[test]
753 fn env_experimental_activates_components() {
754 let overrides = EnvOverrides {
755 experimental: Some(true),
756 ..Default::default()
757 };
758
759 let config = ConfigLoader::new()
760 .skip_global_config()
761 .skip_project_config()
762 .with_env_overrides(overrides)
763 .load()
764 .expect("should load config with experimental components activated");
765
766 assert!(config.components.load.contains(&"life_game".to_string()));
768 }
769
770 #[test]
771 fn env_experimental_false_does_not_activate() {
772 let overrides = EnvOverrides {
773 experimental: Some(false),
774 ..Default::default()
775 };
776
777 let config = ConfigLoader::new()
778 .skip_global_config()
779 .skip_project_config()
780 .with_env_overrides(overrides)
781 .load()
782 .expect("should load config without activating experimental when false");
783
784 assert!(!config.components.load.contains(&"life_game".to_string()));
786 }
787
788 #[test]
789 fn env_overrides_default_experimental_is_none() {
790 let ov = EnvOverrides::default();
791 assert!(ov.experimental.is_none());
792 }
793}