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 #[allow(dead_code)]
29 config_path: Option<PathBuf>,
30
31 pub defaults: HashMap<&'static str, &'static str>,
33}
34
35impl ConfigResolver {
36 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 ("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 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 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 pub fn resolve(
91 &self,
92 key: &str,
93 cli_flag: Option<&str>,
94 env_var: Option<&str>,
95 ) -> Option<String> {
96 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 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 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 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 self.defaults.get(key).map(|s| s.to_string())
128 }
129
130 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 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 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 warn!(
168 "Configuration file '{}' is malformed, using defaults.",
169 path.display()
170 );
171 return None;
172 }
173 };
174
175 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 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 serde_yaml::Value::Sequence(_) | serde_yaml::Value::Tagged(_) => {
225 out.insert(prefix.to_string(), format!("{value:?}"));
226 }
227 }
228 }
229
230 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 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#[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 let resolver = ConfigResolver::new(None, None);
357 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 #[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 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 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 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 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}