Skip to main content

clawft_platform/
config_loader.rs

1//! Configuration file discovery and loading.
2//!
3//! Ports the config discovery algorithm from Python `nanobot/config/loader.py`.
4//!
5//! The discovery order is:
6//! 1. `CLAWFT_CONFIG` environment variable (absolute path).
7//! 2. `~/.clawft/config.json`
8//! 3. `~/.nanobot/config.json` (legacy fallback).
9//! 4. If none found, return an empty JSON object (`{}`).
10//!
11//! JSON keys are normalized from camelCase to snake_case before returning,
12//! matching the Python behavior where Pydantic models use snake_case field names.
13
14use std::path::PathBuf;
15
16use serde_json::Value;
17
18/// Discover the config file path using the fallback chain.
19///
20/// Returns `None` if no config file exists at any of the candidate locations.
21/// The discovery order is:
22/// 1. Path from `CLAWFT_CONFIG` environment variable.
23/// 2. `~/.clawft/config.json`
24/// 3. `~/.nanobot/config.json`
25///
26/// On native targets, candidate paths are checked for existence using
27/// synchronous `Path::exists()`. On non-native targets (WASM), the first
28/// candidate path is returned without a filesystem existence check --
29/// the caller's async `fs.exists()` handles validation.
30pub fn discover_config_path(
31    env: &dyn super::env::Environment,
32    home_dir: Option<PathBuf>,
33) -> Option<PathBuf> {
34    // Step 1: Check CLAWFT_CONFIG env var
35    if let Some(env_path) = env.get_var("CLAWFT_CONFIG") {
36        let path = PathBuf::from(env_path);
37        return Some(path);
38    }
39
40    // Step 2 & 3: Check home directory paths
41    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        // On non-native (WASM) targets, return the preferred path without
57        // synchronous filesystem checks. The caller validates asynchronously.
58        #[cfg(not(feature = "native"))]
59        {
60            return Some(clawft_path);
61        }
62    }
63
64    None
65}
66
67/// Load raw JSON configuration using the discovery algorithm.
68///
69/// Returns the parsed and key-normalized JSON value. The caller (typically
70/// `clawft-types`) deserializes this into a typed `Config` struct.
71///
72/// If no config file is found, returns an empty JSON object.
73pub 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
105/// Convert camelCase JSON keys to snake_case recursively.
106///
107/// Processes objects and arrays recursively. Non-object/array values are
108/// returned unchanged. This matches the Python `convert_keys()` function
109/// from `nanobot/config/loader.py`.
110pub 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
125/// Convert a single camelCase string to snake_case.
126///
127/// Handles consecutive uppercase letters (acronyms) correctly:
128/// a run of uppercase letters like `"HTML"` is kept together, with an
129/// underscore inserted only before the last uppercase letter if it is
130/// followed by a lowercase letter (indicating the start of a new word).
131///
132/// # Examples
133/// ```
134/// # use clawft_platform::config_loader::camel_to_snake;
135/// assert_eq!(camel_to_snake("camelCase"), "camel_case");
136/// assert_eq!(camel_to_snake("systemPrompt"), "system_prompt");
137/// assert_eq!(camel_to_snake("already_snake"), "already_snake");
138/// assert_eq!(camel_to_snake("HTMLParser"), "html_parser");
139/// assert_eq!(camel_to_snake("getHTMLParser"), "get_html_parser");
140/// assert_eq!(camel_to_snake("simpleXML"), "simple_xml");
141/// ```
142pub 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            // Insert underscore before:
152            // 1. An uppercase letter preceded by a lowercase letter (camelCase boundary)
153            // 2. An uppercase letter followed by a lowercase letter, when preceded
154            //    by an uppercase letter (end of acronym: "HTMLParser" -> "html_parser")
155            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    // ── camel_to_snake tests ──────────────────────────────────────────
172
173    #[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        // Pure acronym stays together (no trailing lowercase to split on)
201        assert_eq!(camel_to_snake("HTML"), "html");
202    }
203
204    #[test]
205    fn test_camel_to_snake_acronym_then_word() {
206        // Acronym followed by a word: split before the last uppercase
207        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        // Leading uppercase is just lowered, no underscore
216        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    // ── normalize_keys tests ──────────────────────────────────────────
225
226    #[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    // ── discover_config_path tests ────────────────────────────────────
283
284    /// A minimal mock environment for config discovery tests.
285    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            // No-op for mock
309        }
310
311        fn remove_var(&self, _name: &str) {
312            // No-op for mock
313        }
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        // When home dir is given but no config files exist on disk,
333        // discover returns None (both .clawft and .nanobot paths don't exist).
334        let env = MockEnv::new();
335        // Use a path that definitely does not contain config files.
336        let result = discover_config_path(
337            &env,
338            Some(PathBuf::from("/tmp/clawft_test_nonexistent_home")),
339        );
340        assert_eq!(result, None);
341    }
342}