1use std::collections::HashMap;
5use std::path::PathBuf;
6
7use tracing::warn;
8
9pub struct ConfigResolver {
20 pub cli_flags: HashMap<String, Option<String>>,
22
23 pub config_file: Option<HashMap<String, String>>,
26
27 config_yaml: Option<serde_yaml::Value>,
31
32 #[allow(dead_code)]
34 config_path: Option<PathBuf>,
35
36 pub defaults: HashMap<&'static str, &'static str>,
38}
39
40impl ConfigResolver {
41 pub const DEFAULTS: &'static [(&'static str, &'static str)] = &[
54 ("extensions.root", "./extensions"),
55 ("logging.level", "WARNING"),
56 ("cli.help_text_max_length", "1000"),
57 ("cli.approval_timeout", "60"),
59 ("cli.strategy", "standard"),
60 ("cli.group_depth", "1"),
61 ("expose.mode", "all"),
63 ("expose.include", "[]"),
64 ("expose.exclude", "[]"),
65 ];
66
67 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 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 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 pub fn resolve(
113 &self,
114 key: &str,
115 cli_flag: Option<&str>,
116 env_var: Option<&str>,
117 ) -> Option<String> {
118 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 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 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 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 self.defaults.get(key).map(|s| s.to_string())
150 }
151
152 pub fn resolve_object(&self, key: &str) -> Option<serde_yaml::Value> {
163 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 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 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 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 serde_yaml::Value::Sequence(_) | serde_yaml::Value::Tagged(_) => {
272 out.insert(prefix.to_string(), format!("{value:?}"));
273 }
274 }
275 }
276
277 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 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#[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 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 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 let resolver = ConfigResolver::new(None, None);
427 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 #[test]
459 fn test_namespace_alternate_key_map_intact() {
460 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 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 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 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 let result = resolver.resolve("cli.auto_approve", None, None);
547 assert_eq!(result, Some("true".to_string()));
548 }
549
550 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}