Skip to main content

apcore_cli/
config.rs

1// apcore-cli — Configuration resolver.
2// Protocol spec: FE-07 (ConfigResolver, 4-tier precedence)
3
4use std::collections::HashMap;
5use std::path::PathBuf;
6
7use tracing::warn;
8
9// ---------------------------------------------------------------------------
10// ConfigResolver
11// ---------------------------------------------------------------------------
12
13/// Resolved configuration following 4-tier precedence:
14///
15/// 1. CLI flags   — highest priority
16/// 2. Environment variables
17/// 3. Config file (YAML, dot-flattened keys)
18/// 4. Built-in defaults — lowest priority
19pub struct ConfigResolver {
20    /// CLI flags map (flag name → value or None if not provided).
21    pub cli_flags: HashMap<String, Option<String>>,
22
23    /// Flattened key → value map loaded from the config file.
24    /// `None` if the file was not found or could not be parsed.
25    pub config_file: Option<HashMap<String, String>>,
26
27    /// Path to the config file that was loaded (or attempted).
28    #[allow(dead_code)]
29    config_path: Option<PathBuf>,
30
31    /// Built-in default values.
32    pub defaults: HashMap<&'static str, &'static str>,
33}
34
35impl ConfigResolver {
36    /// Default configuration values.
37    pub const DEFAULTS: &'static [(&'static str, &'static str)] = &[
38        ("extensions.root", "./extensions"),
39        ("logging.level", "INFO"),
40        ("sandbox.enabled", "false"),
41        ("cli.stdin_buffer_limit", "10485760"),
42        ("cli.auto_approve", "false"),
43    ];
44
45    /// Create a new `ConfigResolver`.
46    ///
47    /// # Arguments
48    /// * `cli_flags`   — CLI flag overrides (e.g. `--extensions-dir → /path`)
49    /// * `config_path` — Optional explicit path to `apcore.yaml`
50    pub fn new(
51        cli_flags: Option<HashMap<String, Option<String>>>,
52        config_path: Option<PathBuf>,
53    ) -> Self {
54        let defaults = Self::DEFAULTS.iter().copied().collect();
55        let config_file = config_path.as_ref().and_then(Self::load_config_file);
56
57        Self {
58            cli_flags: cli_flags.unwrap_or_default(),
59            config_file,
60            config_path,
61            defaults,
62        }
63    }
64
65    /// Resolve a configuration value using 4-tier precedence.
66    ///
67    /// # Arguments
68    /// * `key`       — dot-separated config key (e.g. `"extensions.root"`)
69    /// * `cli_flag`  — optional CLI flag name to check in `_cli_flags`
70    /// * `env_var`   — optional environment variable name
71    ///
72    /// Returns `None` when the key is not present in any tier.
73    pub fn resolve(
74        &self,
75        key: &str,
76        cli_flag: Option<&str>,
77        env_var: Option<&str>,
78    ) -> Option<String> {
79        // Tier 1: CLI flag — present and value is Some(non-None string).
80        if let Some(flag) = cli_flag {
81            if let Some(Some(value)) = self.cli_flags.get(flag) {
82                return Some(value.clone());
83            }
84        }
85
86        // Tier 2: Environment variable — must be set and non-empty.
87        if let Some(var) = env_var {
88            if let Ok(env_value) = std::env::var(var) {
89                if !env_value.is_empty() {
90                    return Some(env_value);
91                }
92            }
93        }
94
95        // Tier 3: Config file — key must be present in the flattened map.
96        if let Some(ref file_map) = self.config_file {
97            if let Some(value) = file_map.get(key) {
98                return Some(value.clone());
99            }
100        }
101
102        // Tier 4: Built-in defaults.
103        self.defaults.get(key).map(|s| s.to_string())
104    }
105
106    /// Load and flatten a YAML config file into dot-notation keys.
107    ///
108    /// Returns `None` if the file does not exist or cannot be parsed.
109    fn load_config_file(path: &PathBuf) -> Option<HashMap<String, String>> {
110        let content = match std::fs::read_to_string(path) {
111            Ok(s) => s,
112            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
113                // FR-DISP-005 AF-1: file not found — silent.
114                return None;
115            }
116            Err(e) => {
117                warn!(
118                    "Configuration file '{}' could not be read: {}",
119                    path.display(),
120                    e
121                );
122                return None;
123            }
124        };
125
126        let parsed: serde_yaml::Value = match serde_yaml::from_str(&content) {
127            Ok(v) => v,
128            Err(_) => {
129                // FR-DISP-005 AF-2: malformed YAML — log warning, use defaults.
130                warn!(
131                    "Configuration file '{}' is malformed, using defaults.",
132                    path.display()
133                );
134                return None;
135            }
136        };
137
138        // Root must be a mapping (dict). Scalars, sequences, and null are invalid.
139        if !matches!(parsed, serde_yaml::Value::Mapping(_)) {
140            warn!(
141                "Configuration file '{}' is malformed, using defaults.",
142                path.display()
143            );
144            return None;
145        }
146
147        let mut out = HashMap::new();
148        Self::flatten_yaml_value(parsed, "", &mut out);
149        Some(out)
150    }
151
152    /// Recursively flatten a nested YAML value into dot-separated keys.
153    fn flatten_yaml_value(
154        value: serde_yaml::Value,
155        prefix: &str,
156        out: &mut HashMap<String, String>,
157    ) {
158        match value {
159            serde_yaml::Value::Mapping(map) => {
160                for (k, v) in map {
161                    let key_str = match k {
162                        serde_yaml::Value::String(s) => s,
163                        other => format!("{other:?}"),
164                    };
165                    let full_key = if prefix.is_empty() {
166                        key_str
167                    } else {
168                        format!("{prefix}.{key_str}")
169                    };
170                    Self::flatten_yaml_value(v, &full_key, out);
171                }
172            }
173            serde_yaml::Value::Bool(b) => {
174                out.insert(prefix.to_string(), b.to_string());
175            }
176            serde_yaml::Value::Number(n) => {
177                out.insert(prefix.to_string(), n.to_string());
178            }
179            serde_yaml::Value::String(s) => {
180                out.insert(prefix.to_string(), s);
181            }
182            serde_yaml::Value::Null => {
183                out.insert(prefix.to_string(), String::new());
184            }
185            // Sequences and tagged values are serialised as their debug repr;
186            // no spec requirement for nested array flattening.
187            serde_yaml::Value::Sequence(_) | serde_yaml::Value::Tagged(_) => {
188                out.insert(prefix.to_string(), format!("{value:?}"));
189            }
190        }
191    }
192
193    /// Recursively flatten a nested JSON map into dot-separated keys.
194    ///
195    /// Example: `{"extensions": {"root": "/path"}}` → `{"extensions.root": "/path"}`
196    pub fn flatten_dict(&self, map: serde_json::Value) -> HashMap<String, String> {
197        let mut out = HashMap::new();
198        Self::flatten_json_value(map, "", &mut out);
199        out
200    }
201
202    /// Recursively walk a `serde_json::Value` and collect dot-notation keys.
203    fn flatten_json_value(
204        value: serde_json::Value,
205        prefix: &str,
206        out: &mut HashMap<String, String>,
207    ) {
208        match value {
209            serde_json::Value::Object(obj) => {
210                for (k, v) in obj {
211                    let full_key = if prefix.is_empty() {
212                        k
213                    } else {
214                        format!("{prefix}.{k}")
215                    };
216                    Self::flatten_json_value(v, &full_key, out);
217                }
218            }
219            serde_json::Value::Bool(b) => {
220                out.insert(prefix.to_string(), b.to_string());
221            }
222            serde_json::Value::Number(n) => {
223                out.insert(prefix.to_string(), n.to_string());
224            }
225            serde_json::Value::String(s) => {
226                out.insert(prefix.to_string(), s);
227            }
228            serde_json::Value::Null => {
229                out.insert(prefix.to_string(), String::new());
230            }
231            serde_json::Value::Array(_) => {
232                out.insert(prefix.to_string(), value.to_string());
233            }
234        }
235    }
236}
237
238// ---------------------------------------------------------------------------
239// Unit tests
240// ---------------------------------------------------------------------------
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    #[test]
247    fn test_config_resolver_instantiation() {
248        let resolver = ConfigResolver::new(None, None);
249        assert!(!resolver.defaults.is_empty());
250    }
251
252    #[test]
253    fn test_defaults_contains_expected_keys() {
254        let resolver = ConfigResolver::new(None, None);
255        for key in [
256            "extensions.root",
257            "logging.level",
258            "sandbox.enabled",
259            "cli.stdin_buffer_limit",
260            "cli.auto_approve",
261        ] {
262            assert!(
263                resolver.defaults.contains_key(key),
264                "missing default: {key}"
265            );
266        }
267    }
268
269    #[test]
270    fn test_default_logging_level_is_info() {
271        let resolver = ConfigResolver::new(None, None);
272        assert_eq!(
273            resolver.defaults.get("logging.level"),
274            Some(&"INFO"),
275            "logging.level default must be INFO, not WARNING"
276        );
277    }
278
279    #[test]
280    fn test_default_auto_approve_is_false() {
281        let resolver = ConfigResolver::new(None, None);
282        assert_eq!(
283            resolver.defaults.get("cli.auto_approve"),
284            Some(&"false"),
285            "cli.auto_approve default must be false"
286        );
287    }
288
289    #[test]
290    fn test_resolve_tier1_cli_flag_wins() {
291        let mut flags = HashMap::new();
292        flags.insert(
293            "--extensions-dir".to_string(),
294            Some("/cli-path".to_string()),
295        );
296        let resolver = ConfigResolver::new(Some(flags), None);
297        let result = resolver.resolve(
298            "extensions.root",
299            Some("--extensions-dir"),
300            Some("APCORE_EXTENSIONS_ROOT"),
301        );
302        assert_eq!(result, Some("/cli-path".to_string()));
303    }
304
305    #[test]
306    fn test_resolve_tier2_env_var_wins() {
307        unsafe { std::env::set_var("APCORE_EXTENSIONS_ROOT_UNIT", "/env-path") };
308        let resolver = ConfigResolver::new(None, None);
309        let result = resolver.resolve("extensions.root", None, Some("APCORE_EXTENSIONS_ROOT_UNIT"));
310        assert_eq!(result, Some("/env-path".to_string()));
311        unsafe { std::env::remove_var("APCORE_EXTENSIONS_ROOT_UNIT") };
312    }
313
314    #[test]
315    fn test_resolve_tier3_config_file_wins() {
316        // Requires a temp file; skip in unit tests — covered in integration tests.
317        // Just verify the method exists and returns None when no file is loaded.
318        let resolver = ConfigResolver::new(None, None);
319        // With config_path = None, _config_file is None.
320        // The default for "extensions.root" should be returned (tier 4).
321        let result = resolver.resolve("extensions.root", None, None);
322        assert_eq!(result, Some("./extensions".to_string()));
323    }
324
325    #[test]
326    fn test_resolve_tier4_default_wins() {
327        let resolver = ConfigResolver::new(None, None);
328        let result = resolver.resolve("extensions.root", None, None);
329        assert_eq!(result, Some("./extensions".to_string()));
330    }
331
332    #[test]
333    fn test_flatten_dict_nested() {
334        let resolver = ConfigResolver::new(None, None);
335        let map = serde_json::json!({"extensions": {"root": "/path"}});
336        let result = resolver.flatten_dict(map);
337        assert_eq!(result.get("extensions.root"), Some(&"/path".to_string()));
338    }
339
340    #[test]
341    fn test_flatten_dict_deeply_nested() {
342        let resolver = ConfigResolver::new(None, None);
343        let map = serde_json::json!({"a": {"b": {"c": "deep"}}});
344        let result = resolver.flatten_dict(map);
345        assert_eq!(result.get("a.b.c"), Some(&"deep".to_string()));
346    }
347}