Skip to main content

orcs_runtime/config/
loader.rs

1//! Configuration loader with hierarchical merging.
2//!
3//! # Load Order
4//!
5//! 1. Default values (compile-time)
6//! 2. Global config (`~/.orcs/config.toml`)
7//! 3. Project config (`.orcs/config.toml`)
8//! 4. Profile config (`.orcs/profiles/{name}.toml` `[config]` section)
9//! 5. Environment overrides (`ORCS_*`, including `ORCS_PROFILE`)
10//!
11//! Each layer overrides the previous.
12
13use 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/// Environment variable overrides.
21///
22/// Captures all `ORCS_*` environment variables as typed fields.
23/// Constructed once via [`from_env()`](Self::from_env) at startup,
24/// or directly in tests without touching the process environment.
25///
26/// # Example
27///
28/// ```ignore
29/// // Production: read from process environment
30/// let overrides = EnvOverrides::from_env()?;
31///
32/// // Test: construct directly (no env mutation)
33/// let overrides = EnvOverrides {
34///     debug: Some(true),
35///     model: Some("test-model".into()),
36///     ..Default::default()
37/// };
38/// ```
39#[derive(Debug, Clone, Default)]
40pub struct EnvOverrides {
41    /// `ORCS_DEBUG`
42    pub debug: Option<bool>,
43    /// `ORCS_AUTO_APPROVE`
44    pub auto_approve: Option<bool>,
45    /// `ORCS_VERBOSE`
46    pub verbose: Option<bool>,
47    /// `ORCS_COLOR`
48    pub color: Option<bool>,
49    /// `ORCS_SCRIPTS_AUTO_LOAD`
50    pub scripts_auto_load: Option<bool>,
51    /// `ORCS_EXPERIMENTAL` - enable experimental components
52    pub experimental: Option<bool>,
53    /// `ORCS_MODEL`
54    pub model: Option<String>,
55    /// `ORCS_SESSION_PATH`
56    pub session_path: Option<PathBuf>,
57    /// `ORCS_BUILTINS_DIR`
58    pub builtins_dir: Option<PathBuf>,
59    /// `ORCS_PROFILE`
60    pub profile: Option<String>,
61}
62
63impl EnvOverrides {
64    /// Reads all `ORCS_*` environment variables from the process environment.
65    ///
66    /// This is the **only** place that calls `std::env::var` for config.
67    ///
68    /// # Errors
69    ///
70    /// Returns [`ConfigError::InvalidEnvVar`] if a boolean variable
71    /// contains an unparseable value.
72    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
88/// Reads a boolean environment variable.
89///
90/// Returns `Ok(None)` if not set, `Ok(Some(bool))` if valid,
91/// `Err` if set but not parseable as bool.
92fn 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
101/// Reads a string environment variable. Returns `None` if not set.
102fn read_env_string(name: &str) -> Option<String> {
103    std::env::var(name).ok()
104}
105
106/// Configuration loader with builder pattern.
107///
108/// # Example
109///
110/// ```ignore
111/// use orcs_runtime::config::ConfigLoader;
112///
113/// let config = ConfigLoader::new()
114///     .with_project_root("/path/to/project")
115///     .skip_env_vars()  // For testing
116///     .load()?;
117/// ```
118#[derive(Debug, Clone)]
119pub struct ConfigLoader {
120    /// Global config file path (defaults to ~/.orcs/config.toml).
121    global_config_path: Option<PathBuf>,
122
123    /// Project root directory.
124    project_root: Option<PathBuf>,
125
126    /// Profile name to activate (explicit, highest priority).
127    ///
128    /// When set, this takes precedence over `ORCS_PROFILE` env var.
129    profile: Option<String>,
130
131    /// Pre-built environment overrides.
132    ///
133    /// - `Some(overrides)` + `skip_env=false` → use these (test injection).
134    /// - `None` + `skip_env=false` → call `EnvOverrides::from_env()`.
135    /// - `skip_env=true` → no env overrides applied regardless.
136    env_overrides: Option<EnvOverrides>,
137
138    /// Skip environment variable loading.
139    skip_env: bool,
140
141    /// Skip global config loading.
142    skip_global: bool,
143
144    /// Skip project config loading.
145    skip_project: bool,
146}
147
148impl ConfigLoader {
149    /// Creates a new loader with default settings.
150    #[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    /// Sets a custom global config path.
164    #[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    /// Sets the project root directory.
171    ///
172    /// Project config will be loaded from `<project_root>/.orcs/config.toml`.
173    #[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    /// Sets the profile to activate.
180    ///
181    /// The profile's `[config]` section is merged as a layer
182    /// between project config and env vars. The profile is
183    /// loaded from `ProfileStore` search dirs.
184    #[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    /// Injects pre-built environment overrides.
191    ///
192    /// When set, `load()` uses these instead of reading `std::env::var`.
193    /// This enables deterministic testing without env mutation.
194    ///
195    /// Ignored if [`skip_env_vars()`](Self::skip_env_vars) is also called.
196    #[must_use]
197    pub fn with_env_overrides(mut self, overrides: EnvOverrides) -> Self {
198        self.env_overrides = Some(overrides);
199        self
200    }
201
202    /// Skips environment variable loading.
203    ///
204    /// Useful for testing with deterministic config.
205    #[must_use]
206    pub fn skip_env_vars(mut self) -> Self {
207        self.skip_env = true;
208        self
209    }
210
211    /// Skips global config loading.
212    #[must_use]
213    pub fn skip_global_config(mut self) -> Self {
214        self.skip_global = true;
215        self
216    }
217
218    /// Skips project config loading.
219    #[must_use]
220    pub fn skip_project_config(mut self) -> Self {
221        self.skip_project = true;
222        self
223    }
224
225    /// Loads and merges configuration from all sources.
226    ///
227    /// # Errors
228    ///
229    /// Returns [`ConfigError`] if any config file exists but cannot be parsed.
230    /// Missing config files are silently ignored.
231    pub fn load(&self) -> Result<OrcsConfig, ConfigError> {
232        // Resolve environment overrides once
233        let overrides = self.resolve_env_overrides()?;
234
235        // Start with defaults
236        let mut config = OrcsConfig::default();
237
238        // Layer 1: Global config
239        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        // Layer 2: Project config
252        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        // Layer 3: Profile config overlay
270        // Priority: explicit > env override
271        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        // Layer 4: Environment overrides
292        if let Some(ref ov) = overrides {
293            Self::apply_overrides(&mut config, ov);
294        }
295
296        Ok(config)
297    }
298
299    /// Resolves environment overrides based on builder state.
300    ///
301    /// - `skip_env=true` → `None` (no overrides)
302    /// - `env_overrides=Some(x)` → `Some(x)` (injected)
303    /// - otherwise → `Some(EnvOverrides::from_env()?)` (read from process)
304    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    /// Loads a config file, returning None if it doesn't exist.
317    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    /// Applies environment overrides to config.
331    ///
332    /// Pure function: reads from `EnvOverrides` fields only.
333    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
370/// Parses a boolean from string.
371///
372/// Accepts: "true", "false", "1", "0", "yes", "no" (case-insensitive).
373fn 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
381/// Saves a config to the global config file.
382///
383/// Creates the parent directory if needed.
384///
385/// # Errors
386///
387/// Returns [`ConfigError`] if the file cannot be written.
388pub fn save_global_config(config: &OrcsConfig) -> Result<(), ConfigError> {
389    let path = default_config_path();
390
391    // Ensure parent directory exists
392    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        // Create .orcs directory in project
457        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        // Global config
461        let global_path = create_config_file(
462            global_temp.path(),
463            r#"
464debug = true
465
466[model]
467default = "global-model"
468"#,
469        );
470
471        // Project config
472        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        // debug from global (not overridden in project)
488        assert!(config.debug);
489        // model from project (overrides global)
490        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        // Should return defaults
503        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        // Create profile
528        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        // Should fall back to defaults
569        assert_eq!(config, OrcsConfig::default());
570    }
571
572    // --- EnvOverrides tests (no set_var/remove_var) ---
573
574    #[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        // skip_env_vars takes precedence
635        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); // default, not overridden
644    }
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        // explicit profile wins over env
724        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        // life_game should now be in load
767        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        // life_game should NOT be in load
785        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}