clawft_platform/
config_loader.rs1use std::path::PathBuf;
15
16use serde_json::Value;
17
18pub fn discover_config_path(
31 env: &dyn super::env::Environment,
32 home_dir: Option<PathBuf>,
33) -> Option<PathBuf> {
34 if let Some(env_path) = env.get_var("CLAWFT_CONFIG") {
36 let path = PathBuf::from(env_path);
37 return Some(path);
38 }
39
40 if let Some(home) = home_dir {
42 let clawft_path = home.join(".clawft").join("config.json");
43
44 #[cfg(feature = "native")]
45 {
46 if clawft_path.exists() {
47 return Some(clawft_path);
48 }
49
50 let nanobot_path = home.join(".nanobot").join("config.json");
51 if nanobot_path.exists() {
52 return Some(nanobot_path);
53 }
54 }
55
56 #[cfg(not(feature = "native"))]
59 {
60 return Some(clawft_path);
61 }
62 }
63
64 None
65}
66
67pub async fn load_config_raw(
74 fs: &dyn super::fs::FileSystem,
75 env: &dyn super::env::Environment,
76) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
77 let home = fs.home_dir();
78 let path = discover_config_path(env, home);
79
80 let Some(path) = path else {
81 tracing::info!("no config file found, using defaults");
82 return Ok(Value::Object(serde_json::Map::new()));
83 };
84
85 if !fs.exists(&path).await {
86 tracing::warn!(
87 path = %path.display(),
88 "config path does not exist, using defaults"
89 );
90 return Ok(Value::Object(serde_json::Map::new()));
91 }
92
93 tracing::debug!(path = %path.display(), "loading config file");
94 let contents = fs
95 .read_to_string(&path)
96 .await
97 .map_err(|e| format!("failed to read config file {}: {}", path.display(), e))?;
98
99 let value: Value = serde_json::from_str(&contents)
100 .map_err(|e| format!("failed to parse config file {}: {}", path.display(), e))?;
101
102 Ok(normalize_keys(value))
103}
104
105pub fn normalize_keys(value: Value) -> Value {
111 match value {
112 Value::Object(map) => {
113 let mut new_map = serde_json::Map::new();
114 for (key, val) in map {
115 let snake_key = camel_to_snake(&key);
116 new_map.insert(snake_key, normalize_keys(val));
117 }
118 Value::Object(new_map)
119 }
120 Value::Array(arr) => Value::Array(arr.into_iter().map(normalize_keys).collect()),
121 other => other,
122 }
123}
124
125pub fn camel_to_snake(name: &str) -> String {
143 let chars: Vec<char> = name.chars().collect();
144 let mut result = String::with_capacity(name.len() + 4);
145
146 for (i, &ch) in chars.iter().enumerate() {
147 if ch.is_uppercase() && i > 0 {
148 let prev = chars[i - 1];
149 let next = chars.get(i + 1).copied();
150
151 if prev.is_lowercase()
156 || (prev.is_uppercase() && next.is_some_and(|c| c.is_lowercase()))
157 {
158 result.push('_');
159 }
160 }
161 result.push(ch.to_ascii_lowercase());
162 }
163 result
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169 use serde_json::json;
170
171 #[test]
174 fn test_camel_to_snake_basic() {
175 assert_eq!(camel_to_snake("camelCase"), "camel_case");
176 }
177
178 #[test]
179 fn test_camel_to_snake_multiple_words() {
180 assert_eq!(camel_to_snake("systemPrompt"), "system_prompt");
181 assert_eq!(camel_to_snake("contextWindow"), "context_window");
182 assert_eq!(
183 camel_to_snake("memoryConsolidation"),
184 "memory_consolidation"
185 );
186 }
187
188 #[test]
189 fn test_camel_to_snake_already_snake() {
190 assert_eq!(camel_to_snake("already_snake"), "already_snake");
191 }
192
193 #[test]
194 fn test_camel_to_snake_single_word() {
195 assert_eq!(camel_to_snake("model"), "model");
196 }
197
198 #[test]
199 fn test_camel_to_snake_all_upper() {
200 assert_eq!(camel_to_snake("HTML"), "html");
202 }
203
204 #[test]
205 fn test_camel_to_snake_acronym_then_word() {
206 assert_eq!(camel_to_snake("HTMLParser"), "html_parser");
208 assert_eq!(camel_to_snake("getHTMLParser"), "get_html_parser");
209 assert_eq!(camel_to_snake("simpleXML"), "simple_xml");
210 assert_eq!(camel_to_snake("XMLHTTPRequest"), "xmlhttp_request");
211 }
212
213 #[test]
214 fn test_camel_to_snake_leading_upper() {
215 assert_eq!(camel_to_snake("Config"), "config");
217 }
218
219 #[test]
220 fn test_camel_to_snake_empty() {
221 assert_eq!(camel_to_snake(""), "");
222 }
223
224 #[test]
227 fn test_normalize_keys_flat_object() {
228 let input = json!({
229 "systemPrompt": "hello",
230 "contextWindow": 4096
231 });
232 let expected = json!({
233 "system_prompt": "hello",
234 "context_window": 4096
235 });
236 assert_eq!(normalize_keys(input), expected);
237 }
238
239 #[test]
240 fn test_normalize_keys_nested_object() {
241 let input = json!({
242 "agentsConfig": {
243 "defaultModel": "gpt-4",
244 "maxTokens": 1024
245 }
246 });
247 let expected = json!({
248 "agents_config": {
249 "default_model": "gpt-4",
250 "max_tokens": 1024
251 }
252 });
253 assert_eq!(normalize_keys(input), expected);
254 }
255
256 #[test]
257 fn test_normalize_keys_array() {
258 let input = json!([
259 {"firstName": "Alice"},
260 {"firstName": "Bob"}
261 ]);
262 let expected = json!([
263 {"first_name": "Alice"},
264 {"first_name": "Bob"}
265 ]);
266 assert_eq!(normalize_keys(input), expected);
267 }
268
269 #[test]
270 fn test_normalize_keys_primitives_unchanged() {
271 assert_eq!(normalize_keys(json!(42)), json!(42));
272 assert_eq!(normalize_keys(json!("hello")), json!("hello"));
273 assert_eq!(normalize_keys(json!(true)), json!(true));
274 assert_eq!(normalize_keys(json!(null)), json!(null));
275 }
276
277 #[test]
278 fn test_normalize_keys_empty_object() {
279 assert_eq!(normalize_keys(json!({})), json!({}));
280 }
281
282 struct MockEnv {
286 vars: std::collections::HashMap<String, String>,
287 }
288
289 impl MockEnv {
290 fn new() -> Self {
291 Self {
292 vars: std::collections::HashMap::new(),
293 }
294 }
295
296 fn with_var(mut self, key: &str, value: &str) -> Self {
297 self.vars.insert(key.to_string(), value.to_string());
298 self
299 }
300 }
301
302 impl super::super::env::Environment for MockEnv {
303 fn get_var(&self, name: &str) -> Option<String> {
304 self.vars.get(name).cloned()
305 }
306
307 fn set_var(&self, _name: &str, _value: &str) {
308 }
310
311 fn remove_var(&self, _name: &str) {
312 }
314 }
315
316 #[test]
317 fn test_discover_env_var_takes_precedence() {
318 let env = MockEnv::new().with_var("CLAWFT_CONFIG", "/custom/config.json");
319 let result = discover_config_path(&env, Some(PathBuf::from("/home/user")));
320 assert_eq!(result, Some(PathBuf::from("/custom/config.json")));
321 }
322
323 #[test]
324 fn test_discover_no_home_no_env() {
325 let env = MockEnv::new();
326 let result = discover_config_path(&env, None);
327 assert_eq!(result, None);
328 }
329
330 #[test]
331 fn test_discover_home_but_no_files() {
332 let env = MockEnv::new();
335 let result = discover_config_path(
337 &env,
338 Some(PathBuf::from("/tmp/clawft_test_nonexistent_home")),
339 );
340 assert_eq!(result, None);
341 }
342}