use std::collections::BTreeSet;
use serde_json::{Map, Value};
use crate::Config;
#[derive(Debug, thiserror::Error)]
pub enum DotPathError {
#[error("unknown config key '{key}'{hint}")]
UnknownKey { key: String, hint: String },
#[error("invalid value for '{key}': {message}")]
InvalidValue { key: String, message: String },
#[error("'{key}' is a secret: {guidance}")]
SecretPath { key: String, guidance: String },
#[error("cannot set '{key}': {reason}")]
Unsupported { key: String, reason: String },
}
#[derive(Debug)]
pub struct DotPathSetOutcome {
pub config: Config,
pub old_value: Option<Value>,
pub new_value: Value,
}
pub fn parse_cli_value(raw: &str) -> Value {
serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.to_string()))
}
pub fn apply_dot_path_set(
current: &Config,
key: &str,
value: Value,
) -> Result<DotPathSetOutcome, DotPathError> {
let segments: Vec<&str> = key.split('.').collect();
if key.trim().is_empty() || segments.iter().any(|s| s.is_empty()) {
return Err(DotPathError::InvalidValue {
key: key.to_string(),
message: "empty path segment (keys are dot-separated, e.g. `server.port`)".to_string(),
});
}
guard_reserved_paths(&segments, key)?;
let before = serde_json::to_value(current).map_err(|e| DotPathError::InvalidValue {
key: key.to_string(),
message: format!("failed to serialize current config: {e}"),
})?;
let old_value = resolve_path(&before, &segments).cloned();
let mut patched = before;
apply_path(&mut patched, &segments, key, value.clone())?;
let mut config: Config =
serde_json::from_value(patched).map_err(|e| DotPathError::InvalidValue {
key: key.to_string(),
message: e.to_string(),
})?;
if let Some(extra_key) = first_new_extra_key(current, &config) {
return Err(DotPathError::UnknownKey {
key: extra_key,
hint: " (not a recognized config field)".to_string(),
});
}
let mcp_subtree = matches!(segments[0], "mcpServers" | "mcp");
if !mcp_subtree {
let after = serde_json::to_value(&config).map_err(|e| DotPathError::InvalidValue {
key: key.to_string(),
message: format!("failed to serialize updated config: {e}"),
})?;
match resolve_path(&after, &segments) {
Some(round_tripped) if values_equivalent(round_tripped, &value) => {}
Some(round_tripped) => {
return Err(DotPathError::InvalidValue {
key: key.to_string(),
message: format!(
"value did not survive a config round-trip (stored form would be \
{round_tripped}); refusing to write"
),
});
}
None if is_vanishing_default(&value) => {}
None => {
return Err(DotPathError::UnknownKey {
key: key.to_string(),
hint: " (the config schema has no such field; nothing would be stored)"
.to_string(),
});
}
}
}
config.proxy_auth = current.proxy_auth.clone();
Ok(DotPathSetOutcome {
config,
old_value,
new_value: value,
})
}
pub fn diff_json(before: &Value, after: &Value) -> Vec<(String, Option<Value>, Option<Value>)> {
let mut out = Vec::new();
diff_json_inner(before, after, String::new(), &mut out);
out
}
fn diff_json_inner(
before: &Value,
after: &Value,
path: String,
out: &mut Vec<(String, Option<Value>, Option<Value>)>,
) {
if before == after {
return;
}
match (before, after) {
(Value::Object(b), Value::Object(a)) => {
let keys: BTreeSet<&String> = b.keys().chain(a.keys()).collect();
for k in keys {
let child = if path.is_empty() {
k.to_string()
} else {
format!("{path}.{k}")
};
match (b.get(k), a.get(k)) {
(Some(bv), Some(av)) => diff_json_inner(bv, av, child, out),
(Some(bv), None) => out.push((child, Some(bv.clone()), None)),
(None, Some(av)) => out.push((child, None, Some(av.clone()))),
(None, None) => unreachable!("key came from the union of both maps"),
}
}
}
(Value::Array(b), Value::Array(a)) if b.len() == a.len() => {
for (i, (bv, av)) in b.iter().zip(a.iter()).enumerate() {
diff_json_inner(bv, av, format!("{path}.{i}"), out);
}
}
_ => out.push((path, Some(before.clone()), Some(after.clone()))),
}
}
fn guard_reserved_paths(segments: &[&str], key: &str) -> Result<(), DotPathError> {
if segments.iter().any(|s| s.ends_with("_encrypted")) {
return Err(DotPathError::Unsupported {
key: key.to_string(),
reason: "encrypted fields are derived automatically from their plaintext \
counterpart on save; set the plaintext key instead"
.to_string(),
});
}
match segments {
["proxy_auth", ..] => Err(DotPathError::Unsupported {
key: key.to_string(),
reason: "proxy credentials are managed via the web UI settings (stored \
encrypted as `proxy_auth_encrypted`)"
.to_string(),
}),
["providers", p, "api_key"] => Err(DotPathError::SecretPath {
key: key.to_string(),
guidance: format!(
"API keys are encrypted at rest; use the dedicated setter \
(`bamboo config set providers.{p}.api_key <key>` routes there automatically)"
),
}),
["provider_instances", id, "api_key"] => Err(DotPathError::SecretPath {
key: key.to_string(),
guidance: format!(
"instance API keys are encrypted at rest; use the dedicated setter \
(`bamboo config set provider_instances.{id}.api_key <key>` routes there \
automatically)"
),
}),
["notifications", "ntfy", "token"] | ["notifications", "bark", "device_key"] => {
Err(DotPathError::SecretPath {
key: key.to_string(),
guidance: "notification-channel secrets are encrypted at rest; the CLI \
routes this key through the dedicated setter automatically"
.to_string(),
})
}
["subagents", "broker", ..] => Err(DotPathError::Unsupported {
key: key.to_string(),
reason: "the broker client config is runtime-only and not persisted in \
config.json"
.to_string(),
}),
_ => Ok(()),
}
}
fn resolve_path<'a>(root: &'a Value, segments: &[&str]) -> Option<&'a Value> {
let mut node = root;
for seg in segments {
node = match node {
Value::Object(map) => map.get(*seg)?,
Value::Array(items) => items.get(seg.parse::<usize>().ok()?)?,
_ => return None,
};
}
Some(node)
}
fn apply_path(
root: &mut Value,
segments: &[&str],
key: &str,
value: Value,
) -> Result<(), DotPathError> {
let mut node = root;
for (i, seg) in segments.iter().enumerate() {
let last = i + 1 == segments.len();
match node {
Value::Object(map) => {
if last {
map.insert(seg.to_string(), value);
return Ok(());
}
node = map
.entry(seg.to_string())
.or_insert_with(|| Value::Object(Map::new()));
}
Value::Array(items) => {
let idx = seg
.parse::<usize>()
.map_err(|_| DotPathError::InvalidValue {
key: key.to_string(),
message: format!(
"'{}' is an array; segment '{seg}' must be a numeric index",
segments[..i].join(".")
),
})?;
if last {
if idx < items.len() {
items[idx] = value;
} else if idx == items.len() {
items.push(value);
} else {
return Err(DotPathError::InvalidValue {
key: key.to_string(),
message: format!(
"array index {idx} out of range for '{}' (length {})",
segments[..i].join("."),
items.len()
),
});
}
return Ok(());
}
node = items
.get_mut(idx)
.ok_or_else(|| DotPathError::InvalidValue {
key: key.to_string(),
message: format!(
"array index {idx} out of range for '{}'",
segments[..i].join(".")
),
})?;
}
other => {
return Err(DotPathError::InvalidValue {
key: key.to_string(),
message: format!(
"cannot descend into '{}': it holds {} — not an object/array",
segments[..i].join("."),
json_type_name(other)
),
});
}
}
}
unreachable!("segments is non-empty; the loop always returns on the last segment");
}
fn json_type_name(v: &Value) -> &'static str {
match v {
Value::Null => "null",
Value::Bool(_) => "a boolean",
Value::Number(_) => "a number",
Value::String(_) => "a string",
Value::Array(_) => "an array",
Value::Object(_) => "an object",
}
}
fn is_vanishing_default(value: &Value) -> bool {
match value {
Value::Null => true,
Value::String(s) => s.is_empty(),
Value::Array(a) => a.is_empty(),
Value::Object(o) => o.is_empty(),
_ => false,
}
}
fn values_equivalent(a: &Value, b: &Value) -> bool {
if a == b {
return true;
}
match (a.as_f64(), b.as_f64()) {
(Some(x), Some(y)) => (x - y).abs() <= f64::EPSILON * x.abs().max(y.abs()).max(1.0) * 4.0,
_ => false,
}
}
fn extra_key_paths(config: &Config) -> BTreeSet<String> {
let mut keys: BTreeSet<String> = config.extra.keys().cloned().collect();
keys.extend(config.server.extra.keys().map(|k| format!("server.{k}")));
keys.extend(
config
.providers
.extra
.keys()
.map(|k| format!("providers.{k}")),
);
macro_rules! provider_extra {
($field:ident) => {
if let Some(p) = &config.providers.$field {
keys.extend(
p.extra
.keys()
.map(|k| format!("providers.{}.{k}", stringify!($field))),
);
}
};
}
provider_extra!(openai);
provider_extra!(anthropic);
provider_extra!(gemini);
provider_extra!(copilot);
provider_extra!(bodhi);
for (id, instance) in &config.provider_instances {
keys.extend(
instance
.extra
.keys()
.map(|k| format!("provider_instances.{id}.{k}")),
);
}
keys
}
fn first_new_extra_key(current: &Config, candidate: &Config) -> Option<String> {
let before = extra_key_paths(current);
extra_key_paths(candidate)
.into_iter()
.find(|k| !before.contains(k))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ProxyAuth;
use serde_json::json;
use std::path::PathBuf;
fn default_config() -> Config {
Config::from_data_dir_without_env(Some(PathBuf::from(
"/nonexistent-bamboo-dot-path-test-dir",
)))
}
#[test]
fn parse_cli_value_json_first_then_string() {
assert_eq!(parse_cli_value("9999"), json!(9999));
assert_eq!(parse_cli_value("true"), json!(true));
assert_eq!(parse_cli_value("null"), Value::Null);
assert_eq!(parse_cli_value(r#"{"a":1}"#), json!({"a":1}));
assert_eq!(parse_cli_value("[1,2]"), json!([1, 2]));
assert_eq!(parse_cli_value("anthropic"), json!("anthropic"));
assert_eq!(parse_cli_value(r#""true""#), json!("true"));
}
#[test]
fn valid_set_updates_typed_field() {
let config = default_config();
let outcome = apply_dot_path_set(&config, "server.port", json!(19999)).unwrap();
assert_eq!(outcome.config.server.port, 19999);
assert_eq!(outcome.old_value, Some(json!(9562)));
assert_eq!(outcome.new_value, json!(19999));
}
#[test]
fn valid_set_creates_missing_intermediate_objects() {
let config = default_config();
assert!(config.providers.anthropic.is_none());
let outcome =
apply_dot_path_set(&config, "providers.anthropic.model", json!("claude-x")).unwrap();
assert_eq!(
outcome
.config
.providers
.anthropic
.as_ref()
.unwrap()
.model
.as_deref(),
Some("claude-x")
);
}
#[test]
fn type_mismatch_is_rejected_with_key_in_error() {
let config = default_config();
let err = apply_dot_path_set(&config, "server.port", json!("not-a-port")).unwrap_err();
match err {
DotPathError::InvalidValue { key, .. } => assert_eq!(key, "server.port"),
other => panic!("expected InvalidValue, got {other:?}"),
}
}
#[test]
fn unknown_root_key_is_rejected_not_written_to_extra() {
let config = default_config();
let err = apply_dot_path_set(&config, "serverr", json!({"port": 1})).unwrap_err();
assert!(matches!(err, DotPathError::UnknownKey { ref key, .. } if key == "serverr"));
}
#[test]
fn unknown_nested_key_captured_by_extra_is_rejected() {
let config = default_config();
let err = apply_dot_path_set(&config, "server.prot", json!(8080)).unwrap_err();
assert!(matches!(err, DotPathError::UnknownKey { ref key, .. } if key == "server.prot"));
let err = apply_dot_path_set(&config, "providers.grok.model", json!("g1")).unwrap_err();
assert!(matches!(err, DotPathError::UnknownKey { ref key, .. } if key == "providers.grok"));
}
#[test]
fn unknown_key_dropped_by_strict_struct_is_rejected() {
let config = default_config();
let err = apply_dot_path_set(&config, "keyword_masking.nope", json!(true)).unwrap_err();
assert!(
matches!(err, DotPathError::UnknownKey { ref key, .. } if key == "keyword_masking.nope")
);
}
#[test]
fn existing_extension_keys_remain_editable() {
let mut config = default_config();
config
.extra
.insert("setup".to_string(), json!({"completed": false}));
let outcome = apply_dot_path_set(&config, "setup.completed", json!(true)).unwrap();
assert_eq!(outcome.config.extra["setup"], json!({"completed": true}));
}
#[test]
fn secret_paths_are_refused_by_the_generic_setter() {
let config = default_config();
for key in [
"providers.anthropic.api_key",
"providers.bodhi.api_key",
"provider_instances.work.api_key",
"notifications.ntfy.token",
"notifications.bark.device_key",
] {
let err = apply_dot_path_set(&config, key, json!("sk-secret")).unwrap_err();
assert!(
matches!(err, DotPathError::SecretPath { .. }),
"expected SecretPath for {key}, got {err:?}"
);
}
for key in [
"proxy_auth.username",
"providers.anthropic.api_key_encrypted",
"proxy_auth_encrypted",
"subagents.broker.token",
] {
let err = apply_dot_path_set(&config, key, json!("x")).unwrap_err();
assert!(
matches!(err, DotPathError::Unsupported { .. }),
"expected Unsupported for {key}, got {err:?}"
);
}
}
#[test]
fn vanishing_default_values_are_accepted() {
let config = default_config();
let outcome = apply_dot_path_set(&config, "server.tls", Value::Null).unwrap();
assert!(outcome.config.server.tls.is_none());
}
#[test]
fn mcp_subtree_is_type_checked_but_shape_exempt() {
let config = default_config();
let outcome = apply_dot_path_set(
&config,
"mcpServers.everything",
json!({"command": "npx", "args": ["mcp-everything"]}),
)
.unwrap();
let server = outcome
.config
.mcp
.servers
.iter()
.find(|s| s.id == "everything")
.expect("server added");
assert!(server.enabled);
let err = apply_dot_path_set(
&config,
"mcpServers.bad",
json!({"command": "npx", "url": "http://x"}),
)
.unwrap_err();
assert!(matches!(err, DotPathError::InvalidValue { .. }));
}
#[test]
fn proxy_auth_survives_an_unrelated_generic_set() {
let mut config = default_config();
config.proxy_auth = Some(ProxyAuth {
username: "alice".to_string(),
password: "hunter2".to_string(),
});
let outcome =
apply_dot_path_set(&config, "http_proxy", json!("http://proxy:8080")).unwrap();
assert_eq!(
outcome
.config
.proxy_auth
.as_ref()
.map(|a| a.username.as_str()),
Some("alice")
);
assert_eq!(outcome.config.http_proxy, "http://proxy:8080");
}
#[test]
fn generic_set_preserves_api_key_encrypted_at_rest() {
let _guard = crate::test_support::env_cache_lock_acquire();
let dir = tempfile::tempdir().expect("tempdir");
let data_dir = dir.path().to_path_buf();
let mut config = Config::from_data_dir_without_env(Some(data_dir.clone()));
config.providers.anthropic =
Some(serde_json::from_value(json!({"api_key": "sk-ant-super-secret"})).unwrap());
config.save_to_dir(data_dir.clone()).expect("seed save");
let on_disk = std::fs::read_to_string(data_dir.join("config.json")).unwrap();
assert!(
!on_disk.contains("sk-ant-super-secret"),
"plaintext key must never be written to disk"
);
let cipher_before = serde_json::from_str::<Value>(&on_disk).unwrap()["providers"]
["anthropic"]["api_key_encrypted"]
.as_str()
.expect("encrypted key present")
.to_string();
let loaded = Config::from_data_dir_without_env(Some(data_dir.clone()));
assert_eq!(
loaded.providers.anthropic.as_ref().unwrap().api_key,
"sk-ant-super-secret",
"hydration sanity check"
);
let outcome =
apply_dot_path_set(&loaded, "providers.anthropic.model", json!("claude-x")).unwrap();
outcome.config.save_to_dir(data_dir.clone()).expect("save");
let on_disk = std::fs::read_to_string(data_dir.join("config.json")).unwrap();
assert!(!on_disk.contains("sk-ant-super-secret"));
let root: Value = serde_json::from_str(&on_disk).unwrap();
assert_eq!(root["providers"]["anthropic"]["model"], json!("claude-x"));
assert_eq!(
root["providers"]["anthropic"]["api_key_encrypted"]
.as_str()
.expect("encrypted key still present"),
cipher_before,
"an unrelated generic set must not churn or drop the stored ciphertext"
);
let reloaded = Config::from_data_dir_without_env(Some(data_dir));
assert_eq!(
reloaded.providers.anthropic.as_ref().unwrap().api_key,
"sk-ant-super-secret"
);
}
#[test]
fn diff_json_reports_changed_paths() {
let before = json!({"a": {"b": 1, "keep": true}, "gone": 1});
let after = json!({"a": {"b": 2, "keep": true}, "new": [1]});
let diff = diff_json(&before, &after);
assert!(diff.contains(&("a.b".to_string(), Some(json!(1)), Some(json!(2)))));
assert!(diff.contains(&("gone".to_string(), Some(json!(1)), None)));
assert!(diff.contains(&("new".to_string(), None, Some(json!([1])))));
assert_eq!(diff.len(), 3);
}
}