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    /// Cached parsed YAML root, loaded once at construction. Used by
28    /// `resolve_object` so it doesn't re-read+re-parse the file on every
29    /// call. `None` when the file is absent, unreadable, or malformed.
30    config_yaml: Option<serde_yaml::Value>,
31
32    /// Path to the config file that was loaded (or attempted).
33    #[allow(dead_code)]
34    config_path: Option<PathBuf>,
35
36    /// Built-in default values.
37    pub defaults: HashMap<&'static str, &'static str>,
38}
39
40impl ConfigResolver {
41    /// Default configuration values.
42    ///
43    /// Audit D9 (config cleanup, v0.6.x): the entries `sandbox.enabled`,
44    /// `cli.auto_approve`, `cli.stdin_buffer_limit`, and the four
45    /// `apcore-cli.*` namespace aliases were removed because no production
46    /// code path reads them via `resolve()`. Sandbox is configured via the
47    /// `--sandbox` CLI flag, auto-approve via `--yes`, the stdin buffer is
48    /// hard-coded, and namespace aliases are registered separately by
49    /// `apcore`'s Config Bus when the parent crate calls
50    /// `apcore::Config::register_namespace`. The cross-key file-lookup
51    /// mechanism (`alternate_key`) still works regardless — it does not
52    /// depend on these DEFAULTS entries.
53    pub const DEFAULTS: &'static [(&'static str, &'static str)] = &[
54        ("extensions.root", "./extensions"),
55        ("logging.level", "WARNING"),
56        ("cli.help_text_max_length", "1000"),
57        // FE-11 (v0.6.0)
58        ("cli.approval_timeout", "60"),
59        ("cli.strategy", "standard"),
60        ("cli.group_depth", "1"),
61        // Exposure filtering (FE-12)
62        ("expose.mode", "all"),
63        ("expose.include", "[]"),
64        ("expose.exclude", "[]"),
65    ];
66
67    /// Namespace key → legacy key mapping for backward compatibility.
68    const NAMESPACE_MAP: &'static [(&'static str, &'static str)] = &[
69        ("apcore-cli.stdin_buffer_limit", "cli.stdin_buffer_limit"),
70        ("apcore-cli.auto_approve", "cli.auto_approve"),
71        (
72            "apcore-cli.help_text_max_length",
73            "cli.help_text_max_length",
74        ),
75        ("apcore-cli.logging_level", "logging.level"),
76    ];
77
78    /// Create a new `ConfigResolver`.
79    ///
80    /// # Arguments
81    /// * `cli_flags`   — CLI flag overrides (e.g. `--extensions-dir → /path`)
82    /// * `config_path` — Optional explicit path to `apcore.yaml`
83    pub fn new(
84        cli_flags: Option<HashMap<String, Option<String>>>,
85        config_path: Option<PathBuf>,
86    ) -> Self {
87        let defaults = Self::DEFAULTS.iter().copied().collect();
88        // Parse the config file once; derive both the flat map and the raw
89        // Value from the single parse result to avoid reading the file twice.
90        let (config_file, config_yaml) = match config_path.as_ref() {
91            None => (None, None),
92            Some(path) => Self::load_config_both(path),
93        };
94
95        Self {
96            cli_flags: cli_flags.unwrap_or_default(),
97            config_file,
98            config_yaml,
99            config_path,
100            defaults,
101        }
102    }
103
104    /// Resolve a configuration value using 4-tier precedence.
105    ///
106    /// # Arguments
107    /// * `key`       — dot-separated config key (e.g. `"extensions.root"`)
108    /// * `cli_flag`  — optional CLI flag name to check in `_cli_flags`
109    /// * `env_var`   — optional environment variable name
110    ///
111    /// Returns `None` when the key is not present in any tier.
112    pub fn resolve(
113        &self,
114        key: &str,
115        cli_flag: Option<&str>,
116        env_var: Option<&str>,
117    ) -> Option<String> {
118        // Tier 1: CLI flag — present and value is Some(non-None string).
119        if let Some(flag) = cli_flag {
120            if let Some(Some(value)) = self.cli_flags.get(flag) {
121                return Some(value.clone());
122            }
123        }
124
125        // Tier 2: Environment variable — must be set and non-empty.
126        if let Some(var) = env_var {
127            if let Ok(env_value) = std::env::var(var) {
128                if !env_value.is_empty() {
129                    return Some(env_value);
130                }
131            }
132        }
133
134        // Tier 3: Config file — key must be present in the flattened map.
135        // Try both namespace and legacy keys for backward compatibility.
136        if let Some(ref file_map) = self.config_file {
137            if let Some(value) = file_map.get(key) {
138                return Some(value.clone());
139            }
140            // Try alternate key (namespace ↔ legacy)
141            if let Some(alt) = Self::alternate_key(key) {
142                if let Some(value) = file_map.get(alt) {
143                    return Some(value.clone());
144                }
145            }
146        }
147
148        // Tier 4: Built-in defaults.
149        self.defaults.get(key).map(|s| s.to_string())
150    }
151
152    /// Resolve a non-leaf (object-valued) key from the YAML config file.
153    ///
154    /// Unlike [`Self::resolve`], which returns a flattened scalar string,
155    /// this returns the raw `serde_yaml::Value` living at the requested
156    /// dot-path. Used by FE-13 (`apcli`) where the top-level key can be a
157    /// bool, a mapping, or absent.
158    ///
159    /// Only consults the config file (Tier 3) — CLI flags and env vars
160    /// carry scalar values only. Returns `None` when the file is absent,
161    /// unreadable, malformed, or the key is missing.
162    pub fn resolve_object(&self, key: &str) -> Option<serde_yaml::Value> {
163        // Walk the cached parsed YAML rather than re-reading + re-parsing on
164        // every call (review #16). The cache is populated once in `new()`.
165        let root = self.config_yaml.as_ref()?;
166        let mut cursor = root;
167        for segment in key.split('.') {
168            match cursor {
169                serde_yaml::Value::Mapping(map) => {
170                    cursor = map.get(serde_yaml::Value::String(segment.to_string()))?;
171                }
172                _ => return None,
173            }
174        }
175        Some(cursor.clone())
176    }
177
178    /// Read and parse the config file exactly once, returning both the flat
179    /// map (for `resolve`) and the raw Value (for `resolve_object`).
180    ///
181    /// Avoids the double-read that `load_config_file` + `load_config_yaml`
182    /// previously incurred on every `ConfigResolver::new` call.
183    fn load_config_both(
184        path: &PathBuf,
185    ) -> (Option<HashMap<String, String>>, Option<serde_yaml::Value>) {
186        let content = match std::fs::read_to_string(path) {
187            Ok(s) => s,
188            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return (None, None),
189            Err(e) => {
190                warn!(
191                    "Configuration file '{}' could not be read: {}",
192                    path.display(),
193                    e
194                );
195                return (None, None);
196            }
197        };
198
199        let parsed: serde_yaml::Value = match serde_yaml::from_str(&content) {
200            Ok(v) => v,
201            Err(_) => {
202                warn!(
203                    "Configuration file '{}' is malformed, using defaults.",
204                    path.display()
205                );
206                return (None, None);
207            }
208        };
209
210        if !matches!(parsed, serde_yaml::Value::Mapping(_)) {
211            warn!(
212                "Configuration file '{}' is malformed, using defaults.",
213                path.display()
214            );
215            return (None, None);
216        }
217
218        let mut flat = HashMap::new();
219        Self::flatten_yaml_value(parsed.clone(), "", &mut flat);
220        (Some(flat), Some(parsed))
221    }
222
223    /// Look up the alternate key (namespace ↔ legacy) for backward compatibility.
224    fn alternate_key(key: &str) -> Option<&'static str> {
225        for &(ns, legacy) in Self::NAMESPACE_MAP {
226            if key == ns {
227                return Some(legacy);
228            }
229            if key == legacy {
230                return Some(ns);
231            }
232        }
233        None
234    }
235
236    /// Recursively flatten a nested YAML value into dot-separated keys.
237    fn flatten_yaml_value(
238        value: serde_yaml::Value,
239        prefix: &str,
240        out: &mut HashMap<String, String>,
241    ) {
242        match value {
243            serde_yaml::Value::Mapping(map) => {
244                for (k, v) in map {
245                    let key_str = match k {
246                        serde_yaml::Value::String(s) => s,
247                        other => format!("{other:?}"),
248                    };
249                    let full_key = if prefix.is_empty() {
250                        key_str
251                    } else {
252                        format!("{prefix}.{key_str}")
253                    };
254                    Self::flatten_yaml_value(v, &full_key, out);
255                }
256            }
257            serde_yaml::Value::Bool(b) => {
258                out.insert(prefix.to_string(), b.to_string());
259            }
260            serde_yaml::Value::Number(n) => {
261                out.insert(prefix.to_string(), n.to_string());
262            }
263            serde_yaml::Value::String(s) => {
264                out.insert(prefix.to_string(), s);
265            }
266            serde_yaml::Value::Null => {
267                out.insert(prefix.to_string(), String::new());
268            }
269            // Sequences and tagged values are serialised as their debug repr;
270            // no spec requirement for nested array flattening.
271            serde_yaml::Value::Sequence(_) | serde_yaml::Value::Tagged(_) => {
272                out.insert(prefix.to_string(), format!("{value:?}"));
273            }
274        }
275    }
276
277    /// Recursively flatten a nested JSON map into dot-separated keys.
278    ///
279    /// Example: `{"extensions": {"root": "/path"}}` → `{"extensions.root": "/path"}`
280    pub fn flatten_dict(&self, map: serde_json::Value) -> HashMap<String, String> {
281        let mut out = HashMap::new();
282        Self::flatten_json_value(map, "", &mut out);
283        out
284    }
285
286    /// Recursively walk a `serde_json::Value` and collect dot-notation keys.
287    fn flatten_json_value(
288        value: serde_json::Value,
289        prefix: &str,
290        out: &mut HashMap<String, String>,
291    ) {
292        match value {
293            serde_json::Value::Object(obj) => {
294                for (k, v) in obj {
295                    let full_key = if prefix.is_empty() {
296                        k
297                    } else {
298                        format!("{prefix}.{k}")
299                    };
300                    Self::flatten_json_value(v, &full_key, out);
301                }
302            }
303            serde_json::Value::Bool(b) => {
304                out.insert(prefix.to_string(), b.to_string());
305            }
306            serde_json::Value::Number(n) => {
307                out.insert(prefix.to_string(), n.to_string());
308            }
309            serde_json::Value::String(s) => {
310                out.insert(prefix.to_string(), s);
311            }
312            serde_json::Value::Null => {
313                out.insert(prefix.to_string(), String::new());
314            }
315            serde_json::Value::Array(_) => {
316                out.insert(prefix.to_string(), value.to_string());
317            }
318        }
319    }
320}
321
322// ---------------------------------------------------------------------------
323// Unit tests
324// ---------------------------------------------------------------------------
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329
330    #[test]
331    fn test_config_resolver_instantiation() {
332        let resolver = ConfigResolver::new(None, None);
333        assert!(!resolver.defaults.is_empty());
334    }
335
336    #[test]
337    fn test_defaults_contains_expected_keys() {
338        // Audit D9 (v0.6.x): only keys actually consumed by resolve() at
339        // runtime live in DEFAULTS. The deleted keys (sandbox.enabled,
340        // cli.auto_approve, cli.stdin_buffer_limit, apcore-cli.* aliases)
341        // were dead — they're tested for absence by test_deleted_keys_absent.
342        let resolver = ConfigResolver::new(None, None);
343        for key in [
344            "extensions.root",
345            "logging.level",
346            "cli.help_text_max_length",
347            "cli.approval_timeout",
348            "cli.strategy",
349            "cli.group_depth",
350            "expose.mode",
351        ] {
352            assert!(
353                resolver.defaults.contains_key(key),
354                "missing default: {key}"
355            );
356        }
357    }
358
359    #[test]
360    fn test_deleted_keys_absent() {
361        // Verify the audit D9 cleanup didn't accidentally re-introduce dead keys.
362        let resolver = ConfigResolver::new(None, None);
363        for key in [
364            "sandbox.enabled",
365            "cli.auto_approve",
366            "cli.stdin_buffer_limit",
367            "apcore-cli.stdin_buffer_limit",
368            "apcore-cli.auto_approve",
369            "apcore-cli.help_text_max_length",
370            "apcore-cli.logging_level",
371        ] {
372            assert!(
373                !resolver.defaults.contains_key(key),
374                "deleted key reintroduced: {key}"
375            );
376        }
377    }
378
379    #[test]
380    fn test_default_logging_level_is_warning() {
381        let resolver = ConfigResolver::new(None, None);
382        assert_eq!(
383            resolver.defaults.get("logging.level"),
384            Some(&"WARNING"),
385            "logging.level default must be WARNING"
386        );
387    }
388
389    #[test]
390    fn test_fe11_defaults_present() {
391        let resolver = ConfigResolver::new(None, None);
392        assert_eq!(resolver.defaults.get("cli.approval_timeout"), Some(&"60"));
393        assert_eq!(resolver.defaults.get("cli.strategy"), Some(&"standard"));
394        assert_eq!(resolver.defaults.get("cli.group_depth"), Some(&"1"));
395    }
396
397    #[test]
398    fn test_resolve_tier1_cli_flag_wins() {
399        let mut flags = HashMap::new();
400        flags.insert(
401            "--extensions-dir".to_string(),
402            Some("/cli-path".to_string()),
403        );
404        let resolver = ConfigResolver::new(Some(flags), None);
405        let result = resolver.resolve(
406            "extensions.root",
407            Some("--extensions-dir"),
408            Some("APCORE_EXTENSIONS_ROOT"),
409        );
410        assert_eq!(result, Some("/cli-path".to_string()));
411    }
412
413    #[test]
414    fn test_resolve_tier2_env_var_wins() {
415        unsafe { std::env::set_var("APCORE_EXTENSIONS_ROOT_UNIT", "/env-path") };
416        let resolver = ConfigResolver::new(None, None);
417        let result = resolver.resolve("extensions.root", None, Some("APCORE_EXTENSIONS_ROOT_UNIT"));
418        assert_eq!(result, Some("/env-path".to_string()));
419        unsafe { std::env::remove_var("APCORE_EXTENSIONS_ROOT_UNIT") };
420    }
421
422    #[test]
423    fn test_resolve_tier3_config_file_wins() {
424        // Requires a temp file; skip in unit tests — covered in integration tests.
425        // Just verify the method exists and returns None when no file is loaded.
426        let resolver = ConfigResolver::new(None, None);
427        // With config_path = None, _config_file is None.
428        // The default for "extensions.root" should be returned (tier 4).
429        let result = resolver.resolve("extensions.root", None, None);
430        assert_eq!(result, Some("./extensions".to_string()));
431    }
432
433    #[test]
434    fn test_resolve_tier4_default_wins() {
435        let resolver = ConfigResolver::new(None, None);
436        let result = resolver.resolve("extensions.root", None, None);
437        assert_eq!(result, Some("./extensions".to_string()));
438    }
439
440    #[test]
441    fn test_flatten_dict_nested() {
442        let resolver = ConfigResolver::new(None, None);
443        let map = serde_json::json!({"extensions": {"root": "/path"}});
444        let result = resolver.flatten_dict(map);
445        assert_eq!(result.get("extensions.root"), Some(&"/path".to_string()));
446    }
447
448    #[test]
449    fn test_flatten_dict_deeply_nested() {
450        let resolver = ConfigResolver::new(None, None);
451        let map = serde_json::json!({"a": {"b": {"c": "deep"}}});
452        let result = resolver.flatten_dict(map);
453        assert_eq!(result.get("a.b.c"), Some(&"deep".to_string()));
454    }
455
456    // ---- Namespace-aware config resolution (apcore >= 0.15.0) ----
457
458    #[test]
459    fn test_namespace_alternate_key_map_intact() {
460        // Audit D9 (v0.6.x): the apcore-cli.* DEFAULTS entries were removed,
461        // but the cross-key NAMESPACE_MAP that powers `alternate_key()` is
462        // still authoritative. The map's destinations no longer need to be
463        // present in DEFAULTS — file lookup via alternate_key() works
464        // independently of the defaults dict.
465        for ns_key in [
466            "apcore-cli.stdin_buffer_limit",
467            "apcore-cli.auto_approve",
468            "apcore-cli.help_text_max_length",
469            "apcore-cli.logging_level",
470        ] {
471            assert!(
472                ConfigResolver::alternate_key(ns_key).is_some(),
473                "alternate_key map must still resolve {ns_key}"
474            );
475        }
476    }
477
478    #[test]
479    fn test_alternate_key_namespace_to_legacy() {
480        assert_eq!(
481            ConfigResolver::alternate_key("apcore-cli.stdin_buffer_limit"),
482            Some("cli.stdin_buffer_limit")
483        );
484        assert_eq!(
485            ConfigResolver::alternate_key("apcore-cli.auto_approve"),
486            Some("cli.auto_approve")
487        );
488        assert_eq!(
489            ConfigResolver::alternate_key("apcore-cli.logging_level"),
490            Some("logging.level")
491        );
492    }
493
494    #[test]
495    fn test_alternate_key_legacy_to_namespace() {
496        assert_eq!(
497            ConfigResolver::alternate_key("cli.stdin_buffer_limit"),
498            Some("apcore-cli.stdin_buffer_limit")
499        );
500        assert_eq!(
501            ConfigResolver::alternate_key("cli.auto_approve"),
502            Some("apcore-cli.auto_approve")
503        );
504        assert_eq!(
505            ConfigResolver::alternate_key("logging.level"),
506            Some("apcore-cli.logging_level")
507        );
508    }
509
510    #[test]
511    fn test_alternate_key_unknown_returns_none() {
512        assert_eq!(ConfigResolver::alternate_key("unknown.key"), None);
513        assert_eq!(ConfigResolver::alternate_key("extensions.root"), None);
514    }
515
516    #[test]
517    fn test_resolve_namespace_key_from_legacy_file() {
518        // Simulate a config file with legacy "cli.stdin_buffer_limit" key
519        let mut file_map = HashMap::new();
520        file_map.insert("cli.stdin_buffer_limit".to_string(), "5242880".to_string());
521        let resolver = ConfigResolver {
522            cli_flags: HashMap::new(),
523            config_file: Some(file_map),
524            config_yaml: None,
525            config_path: None,
526            defaults: ConfigResolver::DEFAULTS.iter().copied().collect(),
527        };
528        // Querying the namespace key should find the legacy key via fallback
529        let result = resolver.resolve("apcore-cli.stdin_buffer_limit", None, None);
530        assert_eq!(result, Some("5242880".to_string()));
531    }
532
533    #[test]
534    fn test_resolve_legacy_key_from_namespace_file() {
535        // Simulate a config file with namespace "apcore-cli.auto_approve" key
536        let mut file_map = HashMap::new();
537        file_map.insert("apcore-cli.auto_approve".to_string(), "true".to_string());
538        let resolver = ConfigResolver {
539            cli_flags: HashMap::new(),
540            config_file: Some(file_map),
541            config_yaml: None,
542            config_path: None,
543            defaults: ConfigResolver::DEFAULTS.iter().copied().collect(),
544        };
545        // Querying the legacy key should find the namespace key via fallback
546        let result = resolver.resolve("cli.auto_approve", None, None);
547        assert_eq!(result, Some("true".to_string()));
548    }
549
550    // ---- resolve_object (FE-13 non-leaf lookup) ----
551
552    fn write_tmp_yaml(body: &str) -> (tempfile::TempDir, PathBuf) {
553        let dir = tempfile::tempdir().unwrap();
554        let path = dir.path().join("apcore.yaml");
555        std::fs::write(&path, body).unwrap();
556        (dir, path)
557    }
558
559    #[test]
560    fn test_resolve_object_returns_bool_shorthand() {
561        let (_dir, path) = write_tmp_yaml("apcli: false\n");
562        let resolver = ConfigResolver::new(None, Some(path));
563        let v = resolver.resolve_object("apcli").expect("apcli key present");
564        assert!(matches!(v, serde_yaml::Value::Bool(false)));
565    }
566
567    #[test]
568    fn test_resolve_object_returns_mapping() {
569        let (_dir, path) =
570            write_tmp_yaml("apcli:\n  mode: include\n  include:\n    - list\n    - describe\n");
571        let resolver = ConfigResolver::new(None, Some(path));
572        let v = resolver.resolve_object("apcli").expect("apcli key present");
573        let map = match v {
574            serde_yaml::Value::Mapping(m) => m,
575            _ => panic!("expected mapping"),
576        };
577        let mode = map
578            .get(serde_yaml::Value::String("mode".to_string()))
579            .unwrap();
580        assert_eq!(mode.as_str(), Some("include"));
581    }
582
583    #[test]
584    fn test_resolve_object_missing_key_returns_none() {
585        let (_dir, path) = write_tmp_yaml("other: 42\n");
586        let resolver = ConfigResolver::new(None, Some(path));
587        assert!(resolver.resolve_object("apcli").is_none());
588    }
589
590    #[test]
591    fn test_resolve_object_no_config_file_returns_none() {
592        let resolver = ConfigResolver::new(None, None);
593        assert!(resolver.resolve_object("apcli").is_none());
594    }
595
596    #[test]
597    fn test_resolve_object_malformed_yaml_returns_none() {
598        let (_dir, path) = write_tmp_yaml("apcli: {unclosed\n");
599        let resolver = ConfigResolver::new(None, Some(path));
600        assert!(resolver.resolve_object("apcli").is_none());
601    }
602
603    #[test]
604    fn test_direct_key_takes_precedence_over_alternate() {
605        let mut file_map = HashMap::new();
606        file_map.insert("cli.help_text_max_length".to_string(), "500".to_string());
607        file_map.insert(
608            "apcore-cli.help_text_max_length".to_string(),
609            "2000".to_string(),
610        );
611        let resolver = ConfigResolver {
612            cli_flags: HashMap::new(),
613            config_file: Some(file_map),
614            config_yaml: None,
615            config_path: None,
616            defaults: ConfigResolver::DEFAULTS.iter().copied().collect(),
617        };
618        assert_eq!(
619            resolver.resolve("cli.help_text_max_length", None, None),
620            Some("500".to_string())
621        );
622        assert_eq!(
623            resolver.resolve("apcore-cli.help_text_max_length", None, None),
624            Some("2000".to_string())
625        );
626    }
627}