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