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", "WARNING"),
40        ("sandbox.enabled", "false"),
41        ("cli.stdin_buffer_limit", "10485760"),
42        ("cli.auto_approve", "false"),
43        ("cli.help_text_max_length", "1000"),
44        // Namespace-mode aliases (apcore >= 0.15.0 Config Bus)
45        ("apcore-cli.stdin_buffer_limit", "10485760"),
46        ("apcore-cli.auto_approve", "false"),
47        ("apcore-cli.help_text_max_length", "1000"),
48        ("apcore-cli.logging_level", "WARNING"),
49    ];
50
51    /// Namespace key → legacy key mapping for backward compatibility.
52    const NAMESPACE_MAP: &'static [(&'static str, &'static str)] = &[
53        ("apcore-cli.stdin_buffer_limit", "cli.stdin_buffer_limit"),
54        ("apcore-cli.auto_approve", "cli.auto_approve"),
55        (
56            "apcore-cli.help_text_max_length",
57            "cli.help_text_max_length",
58        ),
59        ("apcore-cli.logging_level", "logging.level"),
60    ];
61
62    /// Create a new `ConfigResolver`.
63    ///
64    /// # Arguments
65    /// * `cli_flags`   — CLI flag overrides (e.g. `--extensions-dir → /path`)
66    /// * `config_path` — Optional explicit path to `apcore.yaml`
67    pub fn new(
68        cli_flags: Option<HashMap<String, Option<String>>>,
69        config_path: Option<PathBuf>,
70    ) -> Self {
71        let defaults = Self::DEFAULTS.iter().copied().collect();
72        let config_file = config_path.as_ref().and_then(Self::load_config_file);
73
74        Self {
75            cli_flags: cli_flags.unwrap_or_default(),
76            config_file,
77            config_path,
78            defaults,
79        }
80    }
81
82    /// Resolve a configuration value using 4-tier precedence.
83    ///
84    /// # Arguments
85    /// * `key`       — dot-separated config key (e.g. `"extensions.root"`)
86    /// * `cli_flag`  — optional CLI flag name to check in `_cli_flags`
87    /// * `env_var`   — optional environment variable name
88    ///
89    /// Returns `None` when the key is not present in any tier.
90    pub fn resolve(
91        &self,
92        key: &str,
93        cli_flag: Option<&str>,
94        env_var: Option<&str>,
95    ) -> Option<String> {
96        // Tier 1: CLI flag — present and value is Some(non-None string).
97        if let Some(flag) = cli_flag {
98            if let Some(Some(value)) = self.cli_flags.get(flag) {
99                return Some(value.clone());
100            }
101        }
102
103        // Tier 2: Environment variable — must be set and non-empty.
104        if let Some(var) = env_var {
105            if let Ok(env_value) = std::env::var(var) {
106                if !env_value.is_empty() {
107                    return Some(env_value);
108                }
109            }
110        }
111
112        // Tier 3: Config file — key must be present in the flattened map.
113        // Try both namespace and legacy keys for backward compatibility.
114        if let Some(ref file_map) = self.config_file {
115            if let Some(value) = file_map.get(key) {
116                return Some(value.clone());
117            }
118            // Try alternate key (namespace ↔ legacy)
119            if let Some(alt) = Self::alternate_key(key) {
120                if let Some(value) = file_map.get(alt) {
121                    return Some(value.clone());
122                }
123            }
124        }
125
126        // Tier 4: Built-in defaults.
127        self.defaults.get(key).map(|s| s.to_string())
128    }
129
130    /// Look up the alternate key (namespace ↔ legacy) for backward compatibility.
131    fn alternate_key(key: &str) -> Option<&'static str> {
132        for &(ns, legacy) in Self::NAMESPACE_MAP {
133            if key == ns {
134                return Some(legacy);
135            }
136            if key == legacy {
137                return Some(ns);
138            }
139        }
140        None
141    }
142
143    /// Load and flatten a YAML config file into dot-notation keys.
144    ///
145    /// Returns `None` if the file does not exist or cannot be parsed.
146    fn load_config_file(path: &PathBuf) -> Option<HashMap<String, String>> {
147        let content = match std::fs::read_to_string(path) {
148            Ok(s) => s,
149            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
150                // FR-DISP-005 AF-1: file not found — silent.
151                return None;
152            }
153            Err(e) => {
154                warn!(
155                    "Configuration file '{}' could not be read: {}",
156                    path.display(),
157                    e
158                );
159                return None;
160            }
161        };
162
163        let parsed: serde_yaml::Value = match serde_yaml::from_str(&content) {
164            Ok(v) => v,
165            Err(_) => {
166                // FR-DISP-005 AF-2: malformed YAML — log warning, use defaults.
167                warn!(
168                    "Configuration file '{}' is malformed, using defaults.",
169                    path.display()
170                );
171                return None;
172            }
173        };
174
175        // Root must be a mapping (dict). Scalars, sequences, and null are invalid.
176        if !matches!(parsed, serde_yaml::Value::Mapping(_)) {
177            warn!(
178                "Configuration file '{}' is malformed, using defaults.",
179                path.display()
180            );
181            return None;
182        }
183
184        let mut out = HashMap::new();
185        Self::flatten_yaml_value(parsed, "", &mut out);
186        Some(out)
187    }
188
189    /// Recursively flatten a nested YAML value into dot-separated keys.
190    fn flatten_yaml_value(
191        value: serde_yaml::Value,
192        prefix: &str,
193        out: &mut HashMap<String, String>,
194    ) {
195        match value {
196            serde_yaml::Value::Mapping(map) => {
197                for (k, v) in map {
198                    let key_str = match k {
199                        serde_yaml::Value::String(s) => s,
200                        other => format!("{other:?}"),
201                    };
202                    let full_key = if prefix.is_empty() {
203                        key_str
204                    } else {
205                        format!("{prefix}.{key_str}")
206                    };
207                    Self::flatten_yaml_value(v, &full_key, out);
208                }
209            }
210            serde_yaml::Value::Bool(b) => {
211                out.insert(prefix.to_string(), b.to_string());
212            }
213            serde_yaml::Value::Number(n) => {
214                out.insert(prefix.to_string(), n.to_string());
215            }
216            serde_yaml::Value::String(s) => {
217                out.insert(prefix.to_string(), s);
218            }
219            serde_yaml::Value::Null => {
220                out.insert(prefix.to_string(), String::new());
221            }
222            // Sequences and tagged values are serialised as their debug repr;
223            // no spec requirement for nested array flattening.
224            serde_yaml::Value::Sequence(_) | serde_yaml::Value::Tagged(_) => {
225                out.insert(prefix.to_string(), format!("{value:?}"));
226            }
227        }
228    }
229
230    /// Recursively flatten a nested JSON map into dot-separated keys.
231    ///
232    /// Example: `{"extensions": {"root": "/path"}}` → `{"extensions.root": "/path"}`
233    pub fn flatten_dict(&self, map: serde_json::Value) -> HashMap<String, String> {
234        let mut out = HashMap::new();
235        Self::flatten_json_value(map, "", &mut out);
236        out
237    }
238
239    /// Recursively walk a `serde_json::Value` and collect dot-notation keys.
240    fn flatten_json_value(
241        value: serde_json::Value,
242        prefix: &str,
243        out: &mut HashMap<String, String>,
244    ) {
245        match value {
246            serde_json::Value::Object(obj) => {
247                for (k, v) in obj {
248                    let full_key = if prefix.is_empty() {
249                        k
250                    } else {
251                        format!("{prefix}.{k}")
252                    };
253                    Self::flatten_json_value(v, &full_key, out);
254                }
255            }
256            serde_json::Value::Bool(b) => {
257                out.insert(prefix.to_string(), b.to_string());
258            }
259            serde_json::Value::Number(n) => {
260                out.insert(prefix.to_string(), n.to_string());
261            }
262            serde_json::Value::String(s) => {
263                out.insert(prefix.to_string(), s);
264            }
265            serde_json::Value::Null => {
266                out.insert(prefix.to_string(), String::new());
267            }
268            serde_json::Value::Array(_) => {
269                out.insert(prefix.to_string(), value.to_string());
270            }
271        }
272    }
273}
274
275// ---------------------------------------------------------------------------
276// Unit tests
277// ---------------------------------------------------------------------------
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    #[test]
284    fn test_config_resolver_instantiation() {
285        let resolver = ConfigResolver::new(None, None);
286        assert!(!resolver.defaults.is_empty());
287    }
288
289    #[test]
290    fn test_defaults_contains_expected_keys() {
291        let resolver = ConfigResolver::new(None, None);
292        for key in [
293            "extensions.root",
294            "logging.level",
295            "sandbox.enabled",
296            "cli.stdin_buffer_limit",
297            "cli.auto_approve",
298            "cli.help_text_max_length",
299        ] {
300            assert!(
301                resolver.defaults.contains_key(key),
302                "missing default: {key}"
303            );
304        }
305    }
306
307    #[test]
308    fn test_default_logging_level_is_warning() {
309        let resolver = ConfigResolver::new(None, None);
310        assert_eq!(
311            resolver.defaults.get("logging.level"),
312            Some(&"WARNING"),
313            "logging.level default must be WARNING"
314        );
315    }
316
317    #[test]
318    fn test_default_auto_approve_is_false() {
319        let resolver = ConfigResolver::new(None, None);
320        assert_eq!(
321            resolver.defaults.get("cli.auto_approve"),
322            Some(&"false"),
323            "cli.auto_approve default must be false"
324        );
325    }
326
327    #[test]
328    fn test_resolve_tier1_cli_flag_wins() {
329        let mut flags = HashMap::new();
330        flags.insert(
331            "--extensions-dir".to_string(),
332            Some("/cli-path".to_string()),
333        );
334        let resolver = ConfigResolver::new(Some(flags), None);
335        let result = resolver.resolve(
336            "extensions.root",
337            Some("--extensions-dir"),
338            Some("APCORE_EXTENSIONS_ROOT"),
339        );
340        assert_eq!(result, Some("/cli-path".to_string()));
341    }
342
343    #[test]
344    fn test_resolve_tier2_env_var_wins() {
345        unsafe { std::env::set_var("APCORE_EXTENSIONS_ROOT_UNIT", "/env-path") };
346        let resolver = ConfigResolver::new(None, None);
347        let result = resolver.resolve("extensions.root", None, Some("APCORE_EXTENSIONS_ROOT_UNIT"));
348        assert_eq!(result, Some("/env-path".to_string()));
349        unsafe { std::env::remove_var("APCORE_EXTENSIONS_ROOT_UNIT") };
350    }
351
352    #[test]
353    fn test_resolve_tier3_config_file_wins() {
354        // Requires a temp file; skip in unit tests — covered in integration tests.
355        // Just verify the method exists and returns None when no file is loaded.
356        let resolver = ConfigResolver::new(None, None);
357        // With config_path = None, _config_file is None.
358        // The default for "extensions.root" should be returned (tier 4).
359        let result = resolver.resolve("extensions.root", None, None);
360        assert_eq!(result, Some("./extensions".to_string()));
361    }
362
363    #[test]
364    fn test_resolve_tier4_default_wins() {
365        let resolver = ConfigResolver::new(None, None);
366        let result = resolver.resolve("extensions.root", None, None);
367        assert_eq!(result, Some("./extensions".to_string()));
368    }
369
370    #[test]
371    fn test_flatten_dict_nested() {
372        let resolver = ConfigResolver::new(None, None);
373        let map = serde_json::json!({"extensions": {"root": "/path"}});
374        let result = resolver.flatten_dict(map);
375        assert_eq!(result.get("extensions.root"), Some(&"/path".to_string()));
376    }
377
378    #[test]
379    fn test_flatten_dict_deeply_nested() {
380        let resolver = ConfigResolver::new(None, None);
381        let map = serde_json::json!({"a": {"b": {"c": "deep"}}});
382        let result = resolver.flatten_dict(map);
383        assert_eq!(result.get("a.b.c"), Some(&"deep".to_string()));
384    }
385
386    // ---- Namespace-aware config resolution (apcore >= 0.15.0) ----
387
388    #[test]
389    fn test_defaults_contain_namespace_keys() {
390        let resolver = ConfigResolver::new(None, None);
391        for key in [
392            "apcore-cli.stdin_buffer_limit",
393            "apcore-cli.auto_approve",
394            "apcore-cli.help_text_max_length",
395            "apcore-cli.logging_level",
396        ] {
397            assert!(
398                resolver.defaults.contains_key(key),
399                "missing namespace default: {key}"
400            );
401        }
402    }
403
404    #[test]
405    fn test_alternate_key_namespace_to_legacy() {
406        assert_eq!(
407            ConfigResolver::alternate_key("apcore-cli.stdin_buffer_limit"),
408            Some("cli.stdin_buffer_limit")
409        );
410        assert_eq!(
411            ConfigResolver::alternate_key("apcore-cli.auto_approve"),
412            Some("cli.auto_approve")
413        );
414        assert_eq!(
415            ConfigResolver::alternate_key("apcore-cli.logging_level"),
416            Some("logging.level")
417        );
418    }
419
420    #[test]
421    fn test_alternate_key_legacy_to_namespace() {
422        assert_eq!(
423            ConfigResolver::alternate_key("cli.stdin_buffer_limit"),
424            Some("apcore-cli.stdin_buffer_limit")
425        );
426        assert_eq!(
427            ConfigResolver::alternate_key("cli.auto_approve"),
428            Some("apcore-cli.auto_approve")
429        );
430        assert_eq!(
431            ConfigResolver::alternate_key("logging.level"),
432            Some("apcore-cli.logging_level")
433        );
434    }
435
436    #[test]
437    fn test_alternate_key_unknown_returns_none() {
438        assert_eq!(ConfigResolver::alternate_key("unknown.key"), None);
439        assert_eq!(ConfigResolver::alternate_key("extensions.root"), None);
440    }
441
442    #[test]
443    fn test_resolve_namespace_key_from_legacy_file() {
444        // Simulate a config file with legacy "cli.stdin_buffer_limit" key
445        let mut file_map = HashMap::new();
446        file_map.insert("cli.stdin_buffer_limit".to_string(), "5242880".to_string());
447        let resolver = ConfigResolver {
448            cli_flags: HashMap::new(),
449            config_file: Some(file_map),
450            config_path: None,
451            defaults: ConfigResolver::DEFAULTS.iter().copied().collect(),
452        };
453        // Querying the namespace key should find the legacy key via fallback
454        let result = resolver.resolve("apcore-cli.stdin_buffer_limit", None, None);
455        assert_eq!(result, Some("5242880".to_string()));
456    }
457
458    #[test]
459    fn test_resolve_legacy_key_from_namespace_file() {
460        // Simulate a config file with namespace "apcore-cli.auto_approve" key
461        let mut file_map = HashMap::new();
462        file_map.insert("apcore-cli.auto_approve".to_string(), "true".to_string());
463        let resolver = ConfigResolver {
464            cli_flags: HashMap::new(),
465            config_file: Some(file_map),
466            config_path: None,
467            defaults: ConfigResolver::DEFAULTS.iter().copied().collect(),
468        };
469        // Querying the legacy key should find the namespace key via fallback
470        let result = resolver.resolve("cli.auto_approve", None, None);
471        assert_eq!(result, Some("true".to_string()));
472    }
473
474    #[test]
475    fn test_direct_key_takes_precedence_over_alternate() {
476        let mut file_map = HashMap::new();
477        file_map.insert("cli.help_text_max_length".to_string(), "500".to_string());
478        file_map.insert(
479            "apcore-cli.help_text_max_length".to_string(),
480            "2000".to_string(),
481        );
482        let resolver = ConfigResolver {
483            cli_flags: HashMap::new(),
484            config_file: Some(file_map),
485            config_path: None,
486            defaults: ConfigResolver::DEFAULTS.iter().copied().collect(),
487        };
488        assert_eq!(
489            resolver.resolve("cli.help_text_max_length", None, None),
490            Some("500".to_string())
491        );
492        assert_eq!(
493            resolver.resolve("apcore-cli.help_text_max_length", None, None),
494            Some("2000".to_string())
495        );
496    }
497}