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", "INFO"),
40 ("sandbox.enabled", "false"),
41 ("cli.stdin_buffer_limit", "10485760"),
42 ("cli.auto_approve", "false"),
43 ];
44
45 pub fn new(
51 cli_flags: Option<HashMap<String, Option<String>>>,
52 config_path: Option<PathBuf>,
53 ) -> Self {
54 let defaults = Self::DEFAULTS.iter().copied().collect();
55 let config_file = config_path.as_ref().and_then(Self::load_config_file);
56
57 Self {
58 cli_flags: cli_flags.unwrap_or_default(),
59 config_file,
60 config_path,
61 defaults,
62 }
63 }
64
65 pub fn resolve(
74 &self,
75 key: &str,
76 cli_flag: Option<&str>,
77 env_var: Option<&str>,
78 ) -> Option<String> {
79 if let Some(flag) = cli_flag {
81 if let Some(Some(value)) = self.cli_flags.get(flag) {
82 return Some(value.clone());
83 }
84 }
85
86 if let Some(var) = env_var {
88 if let Ok(env_value) = std::env::var(var) {
89 if !env_value.is_empty() {
90 return Some(env_value);
91 }
92 }
93 }
94
95 if let Some(ref file_map) = self.config_file {
97 if let Some(value) = file_map.get(key) {
98 return Some(value.clone());
99 }
100 }
101
102 self.defaults.get(key).map(|s| s.to_string())
104 }
105
106 fn load_config_file(path: &PathBuf) -> Option<HashMap<String, String>> {
110 let content = match std::fs::read_to_string(path) {
111 Ok(s) => s,
112 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
113 return None;
115 }
116 Err(e) => {
117 warn!(
118 "Configuration file '{}' could not be read: {}",
119 path.display(),
120 e
121 );
122 return None;
123 }
124 };
125
126 let parsed: serde_yaml::Value = match serde_yaml::from_str(&content) {
127 Ok(v) => v,
128 Err(_) => {
129 warn!(
131 "Configuration file '{}' is malformed, using defaults.",
132 path.display()
133 );
134 return None;
135 }
136 };
137
138 if !matches!(parsed, serde_yaml::Value::Mapping(_)) {
140 warn!(
141 "Configuration file '{}' is malformed, using defaults.",
142 path.display()
143 );
144 return None;
145 }
146
147 let mut out = HashMap::new();
148 Self::flatten_yaml_value(parsed, "", &mut out);
149 Some(out)
150 }
151
152 fn flatten_yaml_value(
154 value: serde_yaml::Value,
155 prefix: &str,
156 out: &mut HashMap<String, String>,
157 ) {
158 match value {
159 serde_yaml::Value::Mapping(map) => {
160 for (k, v) in map {
161 let key_str = match k {
162 serde_yaml::Value::String(s) => s,
163 other => format!("{other:?}"),
164 };
165 let full_key = if prefix.is_empty() {
166 key_str
167 } else {
168 format!("{prefix}.{key_str}")
169 };
170 Self::flatten_yaml_value(v, &full_key, out);
171 }
172 }
173 serde_yaml::Value::Bool(b) => {
174 out.insert(prefix.to_string(), b.to_string());
175 }
176 serde_yaml::Value::Number(n) => {
177 out.insert(prefix.to_string(), n.to_string());
178 }
179 serde_yaml::Value::String(s) => {
180 out.insert(prefix.to_string(), s);
181 }
182 serde_yaml::Value::Null => {
183 out.insert(prefix.to_string(), String::new());
184 }
185 serde_yaml::Value::Sequence(_) | serde_yaml::Value::Tagged(_) => {
188 out.insert(prefix.to_string(), format!("{value:?}"));
189 }
190 }
191 }
192
193 pub fn flatten_dict(&self, map: serde_json::Value) -> HashMap<String, String> {
197 let mut out = HashMap::new();
198 Self::flatten_json_value(map, "", &mut out);
199 out
200 }
201
202 fn flatten_json_value(
204 value: serde_json::Value,
205 prefix: &str,
206 out: &mut HashMap<String, String>,
207 ) {
208 match value {
209 serde_json::Value::Object(obj) => {
210 for (k, v) in obj {
211 let full_key = if prefix.is_empty() {
212 k
213 } else {
214 format!("{prefix}.{k}")
215 };
216 Self::flatten_json_value(v, &full_key, out);
217 }
218 }
219 serde_json::Value::Bool(b) => {
220 out.insert(prefix.to_string(), b.to_string());
221 }
222 serde_json::Value::Number(n) => {
223 out.insert(prefix.to_string(), n.to_string());
224 }
225 serde_json::Value::String(s) => {
226 out.insert(prefix.to_string(), s);
227 }
228 serde_json::Value::Null => {
229 out.insert(prefix.to_string(), String::new());
230 }
231 serde_json::Value::Array(_) => {
232 out.insert(prefix.to_string(), value.to_string());
233 }
234 }
235 }
236}
237
238#[cfg(test)]
243mod tests {
244 use super::*;
245
246 #[test]
247 fn test_config_resolver_instantiation() {
248 let resolver = ConfigResolver::new(None, None);
249 assert!(!resolver.defaults.is_empty());
250 }
251
252 #[test]
253 fn test_defaults_contains_expected_keys() {
254 let resolver = ConfigResolver::new(None, None);
255 for key in [
256 "extensions.root",
257 "logging.level",
258 "sandbox.enabled",
259 "cli.stdin_buffer_limit",
260 "cli.auto_approve",
261 ] {
262 assert!(
263 resolver.defaults.contains_key(key),
264 "missing default: {key}"
265 );
266 }
267 }
268
269 #[test]
270 fn test_default_logging_level_is_info() {
271 let resolver = ConfigResolver::new(None, None);
272 assert_eq!(
273 resolver.defaults.get("logging.level"),
274 Some(&"INFO"),
275 "logging.level default must be INFO, not WARNING"
276 );
277 }
278
279 #[test]
280 fn test_default_auto_approve_is_false() {
281 let resolver = ConfigResolver::new(None, None);
282 assert_eq!(
283 resolver.defaults.get("cli.auto_approve"),
284 Some(&"false"),
285 "cli.auto_approve default must be false"
286 );
287 }
288
289 #[test]
290 fn test_resolve_tier1_cli_flag_wins() {
291 let mut flags = HashMap::new();
292 flags.insert(
293 "--extensions-dir".to_string(),
294 Some("/cli-path".to_string()),
295 );
296 let resolver = ConfigResolver::new(Some(flags), None);
297 let result = resolver.resolve(
298 "extensions.root",
299 Some("--extensions-dir"),
300 Some("APCORE_EXTENSIONS_ROOT"),
301 );
302 assert_eq!(result, Some("/cli-path".to_string()));
303 }
304
305 #[test]
306 fn test_resolve_tier2_env_var_wins() {
307 unsafe { std::env::set_var("APCORE_EXTENSIONS_ROOT_UNIT", "/env-path") };
308 let resolver = ConfigResolver::new(None, None);
309 let result = resolver.resolve("extensions.root", None, Some("APCORE_EXTENSIONS_ROOT_UNIT"));
310 assert_eq!(result, Some("/env-path".to_string()));
311 unsafe { std::env::remove_var("APCORE_EXTENSIONS_ROOT_UNIT") };
312 }
313
314 #[test]
315 fn test_resolve_tier3_config_file_wins() {
316 let resolver = ConfigResolver::new(None, None);
319 let result = resolver.resolve("extensions.root", None, None);
322 assert_eq!(result, Some("./extensions".to_string()));
323 }
324
325 #[test]
326 fn test_resolve_tier4_default_wins() {
327 let resolver = ConfigResolver::new(None, None);
328 let result = resolver.resolve("extensions.root", None, None);
329 assert_eq!(result, Some("./extensions".to_string()));
330 }
331
332 #[test]
333 fn test_flatten_dict_nested() {
334 let resolver = ConfigResolver::new(None, None);
335 let map = serde_json::json!({"extensions": {"root": "/path"}});
336 let result = resolver.flatten_dict(map);
337 assert_eq!(result.get("extensions.root"), Some(&"/path".to_string()));
338 }
339
340 #[test]
341 fn test_flatten_dict_deeply_nested() {
342 let resolver = ConfigResolver::new(None, None);
343 let map = serde_json::json!({"a": {"b": {"c": "deep"}}});
344 let result = resolver.flatten_dict(map);
345 assert_eq!(result.get("a.b.c"), Some(&"deep".to_string()));
346 }
347}