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
}
#[derive(Debug, Clone)]
pub struct ConfigLayers {
pub global: Value,
pub workspace: Option<Value>,
}
impl ConfigLayers {
pub fn merged(&self) -> Value {
let mut out = self.global.clone();
if let Some(ref ws) = self.workspace {
deep_merge(&mut out, ws);
}
out
}
pub fn has_workspace(&self) -> bool {
self.workspace.is_some()
}
}
pub async fn load_config_layers(
fs: &dyn super::fs::FileSystem,
env: &dyn super::env::Environment,
) -> Result<ConfigLayers, Box<dyn std::error::Error + Send + Sync>> {
let mut global = load_weave_toml(fs)
.await
.unwrap_or_else(|_| Value::Object(serde_json::Map::new()));
let home = fs.home_dir();
let json_path = discover_config_path(env, home);
if let Some(path) = json_path
&& fs.exists(&path).await
{
tracing::debug!(path = %path.display(), "loading JSON config");
match fs.read_to_string(&path).await {
Ok(contents) => match serde_json::from_str::<Value>(&contents) {
Ok(json_value) => {
let normalized = normalize_keys(json_value);
deep_merge(&mut global, &normalized);
}
Err(e) => {
tracing::warn!(
path = %path.display(),
error = %e,
"failed to parse JSON config, using weave.toml only"
);
}
},
Err(e) => {
tracing::warn!(
path = %path.display(),
error = %e,
"failed to read JSON config"
);
}
}
}
let workspace_path = PathBuf::from(".clawft").join("config.json");
let mut workspace = None;
if fs.exists(&workspace_path).await {
tracing::debug!(
path = %workspace_path.display(),
"loading workspace config overlay (kept split for permission ceiling)"
);
match fs.read_to_string(&workspace_path).await {
Ok(contents) => match serde_json::from_str::<Value>(&contents) {
Ok(json_value) => {
workspace = Some(normalize_keys(json_value));
}
Err(e) => tracing::warn!(
path = %workspace_path.display(),
error = %e,
"failed to parse workspace config; ignoring overlay"
),
},
Err(e) => tracing::warn!(
path = %workspace_path.display(),
error = %e,
"failed to read workspace config; ignoring overlay"
),
}
}
if global.as_object().is_none_or(|m| m.is_empty()) && workspace.is_none() {
tracing::info!("no config found (checked weave.toml + JSON), using defaults");
}
Ok(ConfigLayers { global, workspace })
}
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>> {
Ok(load_config_layers(fs, env).await?.merged())
}
async fn load_weave_toml(
fs: &dyn super::fs::FileSystem,
) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
load_weave_toml_file(fs, &PathBuf::from("weave.toml")).await
}
pub async fn load_weave_toml_file(
fs: &dyn super::fs::FileSystem,
toml_path: &std::path::Path,
) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
if !fs.exists(toml_path).await {
return Ok(Value::Object(serde_json::Map::new()));
}
tracing::debug!(path = %toml_path.display(), "loading weave.toml");
let contents = fs
.read_to_string(toml_path)
.await
.map_err(|e| format!("failed to read weave.toml: {e}"))?;
let toml_value: toml::Value = contents
.parse()
.map_err(|e| format!("failed to parse weave.toml: {e}"))?;
let json_str = serde_json::to_string(&toml_value)
.map_err(|e| format!("failed to convert weave.toml to JSON: {e}"))?;
let json_value: Value = serde_json::from_str(&json_str)
.map_err(|e| format!("failed to re-parse weave.toml as JSON: {e}"))?;
Ok(normalize_keys(json_value))
}
pub fn deep_merge(base: &mut Value, overlay: &Value) {
match (base, overlay) {
(Value::Object(base_map), Value::Object(overlay_map)) => {
for (key, overlay_val) in overlay_map {
let entry = base_map.entry(key.clone()).or_insert(Value::Null);
deep_merge(entry, overlay_val);
}
}
(base, overlay) => {
if !overlay.is_null() {
*base = overlay.clone();
}
}
}
}
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);
}
#[cfg(feature = "native")]
#[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);
}
use async_trait::async_trait;
use std::collections::HashMap;
use std::path::Path;
use std::sync::Mutex;
struct MockFs {
home: Option<PathBuf>,
files: Mutex<HashMap<PathBuf, String>>,
}
impl MockFs {
fn new(home: Option<PathBuf>) -> Self {
Self {
home,
files: Mutex::new(HashMap::new()),
}
}
fn with_file(self, path: impl Into<PathBuf>, contents: &str) -> Self {
self.files
.lock()
.unwrap()
.insert(path.into(), contents.to_string());
self
}
}
#[cfg_attr(not(feature = "browser"), async_trait)]
#[cfg_attr(feature = "browser", async_trait(?Send))]
impl super::super::fs::FileSystem for MockFs {
async fn read_to_string(&self, path: &Path) -> std::io::Result<String> {
self.files
.lock()
.unwrap()
.get(path)
.cloned()
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "not found"))
}
async fn write_string(&self, _: &Path, _: &str) -> std::io::Result<()> {
unimplemented!()
}
async fn append_string(&self, _: &Path, _: &str) -> std::io::Result<()> {
unimplemented!()
}
async fn exists(&self, path: &Path) -> bool {
self.files.lock().unwrap().contains_key(path)
}
async fn list_dir(&self, _: &Path) -> std::io::Result<Vec<PathBuf>> {
unimplemented!()
}
async fn create_dir_all(&self, _: &Path) -> std::io::Result<()> {
unimplemented!()
}
async fn remove_file(&self, _: &Path) -> std::io::Result<()> {
unimplemented!()
}
fn home_dir(&self) -> Option<PathBuf> {
self.home.clone()
}
async fn metadata(
&self,
path: &Path,
) -> std::io::Result<super::super::fs::FsMetadata> {
if self.exists(path).await {
Ok(super::super::fs::FsMetadata {
is_dir: false,
len: self
.files
.lock()
.unwrap()
.get(path)
.map(|s| s.len() as u64)
.unwrap_or(0),
})
} else {
Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"not found",
))
}
}
}
fn workspace_config_path() -> PathBuf {
PathBuf::from(".clawft").join("config.json")
}
#[tokio::test]
async fn workspace_overlay_applied_when_present() {
let workspace = r#"{
"routing": {
"permissions": {
"channels": { "agent.chat": { "level": 2 } }
}
}
}"#;
let fs = MockFs::new(Some(PathBuf::from("/tmp/clawft_test_no_home_xyz")))
.with_file(workspace_config_path(), workspace);
let env = MockEnv::new();
let merged = load_config_raw(&fs, &env).await.unwrap();
let lvl = merged
.pointer("/routing/permissions/channels/agent.chat/level")
.and_then(Value::as_u64)
.expect("level present after overlay");
assert_eq!(lvl, 2);
}
#[tokio::test]
async fn workspace_overlay_skipped_when_absent() {
let fs = MockFs::new(Some(PathBuf::from("/tmp/clawft_test_no_home_xyz")));
let env = MockEnv::new();
let merged = load_config_raw(&fs, &env).await.unwrap();
assert!(
merged.as_object().map(|m| m.is_empty()).unwrap_or(true),
"no layers present should yield empty merged config"
);
}
#[tokio::test]
async fn workspace_overlay_invalid_json_is_ignored() {
let fs = MockFs::new(Some(PathBuf::from("/tmp/clawft_test_no_home_xyz")))
.with_file(workspace_config_path(), "{ this is not json");
let env = MockEnv::new();
let merged = load_config_raw(&fs, &env).await.unwrap();
assert!(
merged.is_object(),
"loader returns ok despite parse failure"
);
}
#[tokio::test]
async fn workspace_overlay_keys_are_normalized_to_snake_case() {
let workspace = r#"{ "agentDefaults": { "maxTokens": 8192 } }"#;
let fs = MockFs::new(Some(PathBuf::from("/tmp/clawft_test_no_home_xyz")))
.with_file(workspace_config_path(), workspace);
let env = MockEnv::new();
let merged = load_config_raw(&fs, &env).await.unwrap();
assert_eq!(
merged
.pointer("/agent_defaults/max_tokens")
.and_then(Value::as_u64),
Some(8192)
);
}
#[tokio::test]
async fn load_config_layers_keeps_workspace_separate() {
let workspace = r#"{
"routing": {
"permissions": {
"channels": { "agent.chat": { "level": 2 } },
"user": { "level": 2 }
}
}
}"#;
let fs = MockFs::new(Some(PathBuf::from("/tmp/clawft_test_no_home_xyz")))
.with_file(workspace_config_path(), workspace);
let env = MockEnv::new();
let layers = load_config_layers(&fs, &env).await.unwrap();
assert!(layers.has_workspace(), "workspace overlay present");
assert!(
layers
.global
.pointer("/routing/permissions/user/level")
.is_none(),
"global must not contain workspace elevation"
);
let ws_lvl = layers
.workspace
.as_ref()
.and_then(|w| w.pointer("/routing/permissions/user/level"))
.and_then(Value::as_u64);
assert_eq!(ws_lvl, Some(2));
let merged = layers.merged();
assert_eq!(
merged
.pointer("/routing/permissions/user/level")
.and_then(Value::as_u64),
Some(2)
);
}
#[tokio::test]
async fn load_config_layers_no_workspace_when_absent() {
let fs = MockFs::new(Some(PathBuf::from("/tmp/clawft_test_no_home_xyz")));
let env = MockEnv::new();
let layers = load_config_layers(&fs, &env).await.unwrap();
assert!(!layers.has_workspace());
assert!(layers.workspace.is_none());
}
}