use std::path::PathBuf;
use serde_json::Value;
pub fn discover_config_path(
env: &dyn super::env::Environment,
home_dir: Option<PathBuf>,
) -> Option<PathBuf> {
if let Some(env_path) = env.get_var("CLAWFT_CONFIG") {
let path = PathBuf::from(env_path);
return Some(path);
}
if let Some(home) = home_dir {
let clawft_path = home.join(".clawft").join("config.json");
#[cfg(feature = "native")]
{
if clawft_path.exists() {
return Some(clawft_path);
}
let nanobot_path = home.join(".nanobot").join("config.json");
if nanobot_path.exists() {
return Some(nanobot_path);
}
}
#[cfg(not(feature = "native"))]
{
return Some(clawft_path);
}
}
None
}
pub async fn load_config_raw(
fs: &dyn super::fs::FileSystem,
env: &dyn super::env::Environment,
) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
let home = fs.home_dir();
let path = discover_config_path(env, home);
let Some(path) = path else {
tracing::info!("no config file found, using defaults");
return Ok(Value::Object(serde_json::Map::new()));
};
if !fs.exists(&path).await {
tracing::warn!(
path = %path.display(),
"config path does not exist, using defaults"
);
return Ok(Value::Object(serde_json::Map::new()));
}
tracing::debug!(path = %path.display(), "loading config file");
let contents = fs
.read_to_string(&path)
.await
.map_err(|e| format!("failed to read config file {}: {}", path.display(), e))?;
let value: Value = serde_json::from_str(&contents)
.map_err(|e| format!("failed to parse config file {}: {}", path.display(), e))?;
Ok(normalize_keys(value))
}
pub fn normalize_keys(value: Value) -> Value {
match value {
Value::Object(map) => {
let mut new_map = serde_json::Map::new();
for (key, val) in map {
let snake_key = camel_to_snake(&key);
new_map.insert(snake_key, normalize_keys(val));
}
Value::Object(new_map)
}
Value::Array(arr) => Value::Array(arr.into_iter().map(normalize_keys).collect()),
other => other,
}
}
pub fn camel_to_snake(name: &str) -> String {
let chars: Vec<char> = name.chars().collect();
let mut result = String::with_capacity(name.len() + 4);
for (i, &ch) in chars.iter().enumerate() {
if ch.is_uppercase() && i > 0 {
let prev = chars[i - 1];
let next = chars.get(i + 1).copied();
if prev.is_lowercase()
|| (prev.is_uppercase() && next.is_some_and(|c| c.is_lowercase()))
{
result.push('_');
}
}
result.push(ch.to_ascii_lowercase());
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_camel_to_snake_basic() {
assert_eq!(camel_to_snake("camelCase"), "camel_case");
}
#[test]
fn test_camel_to_snake_multiple_words() {
assert_eq!(camel_to_snake("systemPrompt"), "system_prompt");
assert_eq!(camel_to_snake("contextWindow"), "context_window");
assert_eq!(
camel_to_snake("memoryConsolidation"),
"memory_consolidation"
);
}
#[test]
fn test_camel_to_snake_already_snake() {
assert_eq!(camel_to_snake("already_snake"), "already_snake");
}
#[test]
fn test_camel_to_snake_single_word() {
assert_eq!(camel_to_snake("model"), "model");
}
#[test]
fn test_camel_to_snake_all_upper() {
assert_eq!(camel_to_snake("HTML"), "html");
}
#[test]
fn test_camel_to_snake_acronym_then_word() {
assert_eq!(camel_to_snake("HTMLParser"), "html_parser");
assert_eq!(camel_to_snake("getHTMLParser"), "get_html_parser");
assert_eq!(camel_to_snake("simpleXML"), "simple_xml");
assert_eq!(camel_to_snake("XMLHTTPRequest"), "xmlhttp_request");
}
#[test]
fn test_camel_to_snake_leading_upper() {
assert_eq!(camel_to_snake("Config"), "config");
}
#[test]
fn test_camel_to_snake_empty() {
assert_eq!(camel_to_snake(""), "");
}
#[test]
fn test_normalize_keys_flat_object() {
let input = json!({
"systemPrompt": "hello",
"contextWindow": 4096
});
let expected = json!({
"system_prompt": "hello",
"context_window": 4096
});
assert_eq!(normalize_keys(input), expected);
}
#[test]
fn test_normalize_keys_nested_object() {
let input = json!({
"agentsConfig": {
"defaultModel": "gpt-4",
"maxTokens": 1024
}
});
let expected = json!({
"agents_config": {
"default_model": "gpt-4",
"max_tokens": 1024
}
});
assert_eq!(normalize_keys(input), expected);
}
#[test]
fn test_normalize_keys_array() {
let input = json!([
{"firstName": "Alice"},
{"firstName": "Bob"}
]);
let expected = json!([
{"first_name": "Alice"},
{"first_name": "Bob"}
]);
assert_eq!(normalize_keys(input), expected);
}
#[test]
fn test_normalize_keys_primitives_unchanged() {
assert_eq!(normalize_keys(json!(42)), json!(42));
assert_eq!(normalize_keys(json!("hello")), json!("hello"));
assert_eq!(normalize_keys(json!(true)), json!(true));
assert_eq!(normalize_keys(json!(null)), json!(null));
}
#[test]
fn test_normalize_keys_empty_object() {
assert_eq!(normalize_keys(json!({})), json!({}));
}
struct MockEnv {
vars: std::collections::HashMap<String, String>,
}
impl MockEnv {
fn new() -> Self {
Self {
vars: std::collections::HashMap::new(),
}
}
fn with_var(mut self, key: &str, value: &str) -> Self {
self.vars.insert(key.to_string(), value.to_string());
self
}
}
impl super::super::env::Environment for MockEnv {
fn get_var(&self, name: &str) -> Option<String> {
self.vars.get(name).cloned()
}
fn set_var(&self, _name: &str, _value: &str) {
}
fn remove_var(&self, _name: &str) {
}
}
#[test]
fn test_discover_env_var_takes_precedence() {
let env = MockEnv::new().with_var("CLAWFT_CONFIG", "/custom/config.json");
let result = discover_config_path(&env, Some(PathBuf::from("/home/user")));
assert_eq!(result, Some(PathBuf::from("/custom/config.json")));
}
#[test]
fn test_discover_no_home_no_env() {
let env = MockEnv::new();
let result = discover_config_path(&env, None);
assert_eq!(result, None);
}
#[test]
fn test_discover_home_but_no_files() {
let env = MockEnv::new();
let result = discover_config_path(
&env,
Some(PathBuf::from("/tmp/clawft_test_nonexistent_home")),
);
assert_eq!(result, None);
}
}