oxur_cli/config/
loader.rs

1//! Configuration loader with layered resolution
2//!
3//! Loads configuration from multiple sources with precedence:
4//! 1. Defaults (lowest)
5//! 2. Config file
6//! 3. Environment variables
7//! 4. CLI arguments (highest)
8
9use super::{paths, ReplConfig};
10use anyhow::{Context, Result};
11
12/// Configuration loader with builder-style API
13pub struct ConfigLoader {
14    config: ReplConfig,
15}
16
17impl ConfigLoader {
18    /// Create a new config loader with defaults
19    pub fn new() -> Self {
20        Self { config: ReplConfig::default() }
21    }
22
23    /// Load configuration from file if it exists
24    ///
25    /// Uses XDG paths with fallback (see [`paths::find_config_file`])
26    pub fn with_file(mut self) -> Result<Self> {
27        if let Some(path) = paths::find_config_file() {
28            let content = std::fs::read_to_string(&path)
29                .with_context(|| format!("Failed to read config file: {}", path.display()))?;
30
31            let file_config: ReplConfig = toml::from_str(&content)
32                .with_context(|| format!("Failed to parse config file: {}", path.display()))?;
33
34            self.config.merge(file_config);
35        }
36        Ok(self)
37    }
38
39    /// Apply environment variable overrides
40    ///
41    /// Supported variables:
42    /// - `OXUR_REPL_PROMPT` - Primary prompt
43    /// - `OXUR_REPL_CONTINUATION_PROMPT` - Continuation prompt
44    /// - `OXUR_REPL_BANNER` - Custom banner
45    /// - `OXUR_REPL_COLOR` - Enable/disable colors (true/false/1/0)
46    /// - `OXUR_REPL_EDIT_MODE` - Editing mode (emacs/vi)
47    /// - `OXUR_HISTORY_ENABLED` - Enable/disable history
48    /// - `OXUR_HISTORY_MAX_SIZE` - Maximum history entries
49    pub fn with_env(mut self) -> Self {
50        // Terminal config from environment
51        if let Ok(prompt) = std::env::var("OXUR_REPL_PROMPT") {
52            self.config.terminal.prompt = prompt;
53        }
54
55        if let Ok(cont_prompt) = std::env::var("OXUR_REPL_CONTINUATION_PROMPT") {
56            self.config.terminal.continuation_prompt = cont_prompt;
57        }
58
59        if let Ok(banner) = std::env::var("OXUR_REPL_BANNER") {
60            self.config.terminal.banner = Some(banner);
61        }
62
63        if let Ok(val) = std::env::var("OXUR_REPL_COLOR") {
64            self.config.terminal.color_enabled = parse_bool(&val);
65        }
66
67        if let Ok(mode) = std::env::var("OXUR_REPL_EDIT_MODE") {
68            if let Some(edit_mode) = parse_edit_mode(&mode) {
69                self.config.terminal.edit_mode = edit_mode;
70            }
71        }
72
73        // History config from environment
74        if let Ok(val) = std::env::var("OXUR_HISTORY_ENABLED") {
75            self.config.history.enabled = parse_bool(&val);
76        }
77
78        if let Ok(val) = std::env::var("OXUR_HISTORY_MAX_SIZE") {
79            if let Ok(size) = val.parse() {
80                self.config.history.max_size = Some(size);
81            }
82        }
83
84        self
85    }
86
87    /// Apply CLI argument overrides
88    ///
89    /// This is the final layer with highest precedence.
90    pub fn with_cli_overrides(mut self, no_color: bool) -> Self {
91        if no_color {
92            self.config.terminal.color_enabled = false;
93        }
94        self
95    }
96
97    /// Build the final configuration
98    pub fn build(self) -> ReplConfig {
99        self.config
100    }
101}
102
103impl Default for ConfigLoader {
104    fn default() -> Self {
105        Self::new()
106    }
107}
108
109/// Parse a boolean from various string representations
110fn parse_bool(s: &str) -> bool {
111    matches!(s.to_lowercase().as_str(), "true" | "1" | "yes" | "on")
112}
113
114/// Parse edit mode from string
115fn parse_edit_mode(s: &str) -> Option<super::EditMode> {
116    match s.to_lowercase().as_str() {
117        "emacs" => Some(super::EditMode::Emacs),
118        "vi" | "vim" => Some(super::EditMode::Vi),
119        _ => None,
120    }
121}
122
123/// Load complete configuration with all layers
124///
125/// Convenience function that applies all configuration layers:
126/// 1. Defaults
127/// 2. Config file (if exists)
128/// 3. Environment variables
129/// 4. CLI arguments
130pub fn load_config(no_color: bool) -> Result<ReplConfig> {
131    ConfigLoader::new().with_file()?.with_env().with_cli_overrides(no_color).build().pipe(Ok)
132}
133
134/// Extension trait for pipe operator
135trait Pipe: Sized {
136    fn pipe<F, R>(self, f: F) -> R
137    where
138        F: FnOnce(Self) -> R,
139    {
140        f(self)
141    }
142}
143
144impl<T> Pipe for T {}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    #[test]
151    fn test_parse_bool_true() {
152        assert!(parse_bool("true"));
153        assert!(parse_bool("TRUE"));
154        assert!(parse_bool("1"));
155        assert!(parse_bool("yes"));
156        assert!(parse_bool("on"));
157    }
158
159    #[test]
160    fn test_parse_bool_false() {
161        assert!(!parse_bool("false"));
162        assert!(!parse_bool("FALSE"));
163        assert!(!parse_bool("0"));
164        assert!(!parse_bool("no"));
165        assert!(!parse_bool("off"));
166        assert!(!parse_bool(""));
167        assert!(!parse_bool("invalid"));
168    }
169
170    #[test]
171    fn test_parse_edit_mode() {
172        assert_eq!(parse_edit_mode("emacs"), Some(super::super::EditMode::Emacs));
173        assert_eq!(parse_edit_mode("EMACS"), Some(super::super::EditMode::Emacs));
174        assert_eq!(parse_edit_mode("vi"), Some(super::super::EditMode::Vi));
175        assert_eq!(parse_edit_mode("vim"), Some(super::super::EditMode::Vi));
176        assert_eq!(parse_edit_mode("invalid"), None);
177    }
178
179    #[test]
180    fn test_loader_defaults() {
181        let config = ConfigLoader::new().build();
182        assert_eq!(config.terminal.prompt, "oxur> ");
183        assert!(config.terminal.color_enabled);
184        assert!(config.history.enabled);
185    }
186
187    #[test]
188    fn test_loader_with_cli_overrides() {
189        let config = ConfigLoader::new().with_cli_overrides(true).build();
190        assert!(!config.terminal.color_enabled);
191    }
192
193    #[test]
194    fn test_loader_chain() {
195        // Test that the builder chain works without errors
196        let result = ConfigLoader::new()
197            .with_file() // May or may not find a file
198            .map(|l| l.with_env())
199            .map(|l| l.with_cli_overrides(false))
200            .map(|l| l.build());
201
202        assert!(result.is_ok());
203    }
204}