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 ];
45
46 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 pub fn resolve(
75 &self,
76 key: &str,
77 cli_flag: Option<&str>,
78 env_var: Option<&str>,
79 ) -> Option<String> {
80 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 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 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 self.defaults.get(key).map(|s| s.to_string())
105 }
106
107 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 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 warn!(
132 "Configuration file '{}' is malformed, using defaults.",
133 path.display()
134 );
135 return None;
136 }
137 };
138
139 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 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 serde_yaml::Value::Sequence(_) | serde_yaml::Value::Tagged(_) => {
189 out.insert(prefix.to_string(), format!("{value:?}"));
190 }
191 }
192 }
193
194 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 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#[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 let resolver = ConfigResolver::new(None, None);
321 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}