use super::Config;
use super::schema::{ConfigSchema, KeySchema};
pub fn set_by_key(key: &str, value: &str) -> Result<Config, crate::core::error::ConfigError> {
set_many_by_key(&[(key, value)])
}
pub fn set_many_by_key(
updates: &[(&str, &str)],
) -> Result<Config, crate::core::error::ConfigError> {
for (key, _) in updates {
if let Some(message) = crate::core::config::managed::refusal_for_key(key) {
return Err(crate::core::error::ConfigError::InvalidValue {
key: (*key).to_string(),
message,
});
}
}
let schema = ConfigSchema::generate();
let path = Config::path().ok_or(crate::core::error::ConfigError::MissingPath)?;
let guard = crate::config_io::ConfigWriteGuard::acquire(&path).map_err(|message| {
crate::core::error::ConfigError::Save {
source: Box::new(crate::core::error::LeanCtxError::Config(message)),
}
})?;
let mut table = load_config_as_table_at(guard.path())?;
for (key, value) in updates {
let key_schema =
schema
.lookup(key)
.ok_or_else(|| crate::core::error::ConfigError::UnknownKey {
key: (*key).to_string(),
})?;
let toml_value = parse_value(value, key_schema)?;
set_nested(&mut table, key, toml_value)?;
}
record_telemetry_choice(&mut table, updates)?;
let cfg: Config = toml::Value::Table(table)
.try_into()
.map_err(
|e: toml::de::Error| crate::core::error::ConfigError::InvalidValue {
key: updates
.iter()
.map(|(key, _)| *key)
.collect::<Vec<_>>()
.join(","),
message: e.to_string(),
},
)?;
cfg.save_to_locked(&guard)
.map_err(|e| crate::core::error::ConfigError::Save {
source: Box::new(e),
})?;
if updates.iter().any(|(key, _)| key.starts_with("hive.")) {
super::hive::apply_off_switches(&cfg.hive);
}
Ok(cfg)
}
#[must_use]
pub fn current_value(key: &str) -> Option<String> {
let table = load_config_as_table().ok()?;
let parts: Vec<&str> = key.split('.').collect();
let (parents, leaf) = parts.split_at(parts.len() - 1);
let mut current = &table;
for part in parents {
current = current.get(*part)?.as_table()?;
}
current.get(leaf[0]).map(display_toml_value)
}
#[doc(hidden)]
pub fn add_to_global_list(key: &str, entries: &[String]) -> Result<Vec<String>, String> {
let mut list = super::global_string_list(key);
let added: Vec<String> = entries
.iter()
.filter(|p| !p.is_empty() && !list.contains(p))
.cloned()
.collect();
if added.is_empty() {
return Ok(added);
}
list.extend(added.iter().cloned());
set_by_key(key, &list.join(","))
.map(|_| ())
.map_err(|e| e.to_string())?;
Ok(added)
}
fn display_toml_value(value: &toml::Value) -> String {
match value {
toml::Value::String(s) => s.clone(),
toml::Value::Array(items) => items
.iter()
.map(display_toml_value)
.collect::<Vec<_>>()
.join(", "),
other => other.to_string(),
}
}
fn load_config_as_table() -> Result<toml::Table, crate::core::error::ConfigError> {
let path = Config::path().ok_or(crate::core::error::ConfigError::MissingPath)?;
load_config_as_table_at(&path)
}
fn load_config_as_table_at(
path: &std::path::Path,
) -> Result<toml::Table, crate::core::error::ConfigError> {
if !path.exists() {
return Ok(toml::Table::new());
}
let raw = std::fs::read_to_string(path)
.map_err(|source| crate::core::error::ConfigError::Read { source })?;
raw.parse::<toml::Table>()
.map_err(|source| crate::core::error::ConfigError::ParseToml { source })
}
fn parse_value(
value: &str,
schema: &KeySchema,
) -> Result<toml::Value, crate::core::error::ConfigError> {
match schema.ty.as_str() {
"bool" | "bool?" => match value {
"true" | "1" | "yes" => Ok(toml::Value::Boolean(true)),
"false" | "0" | "no" => Ok(toml::Value::Boolean(false)),
_ => Err(crate::core::error::ConfigError::ExpectedBool {
value: value.to_string(),
}),
},
"u8" | "u16" | "u32" | "u64" | "usize" | "u64?" => {
let n: i64 =
value
.parse()
.map_err(|_| crate::core::error::ConfigError::ExpectedInteger {
value: value.to_string(),
})?;
if n < 0 {
return Err(crate::core::error::ConfigError::ExpectedUnsignedInteger {
value: value.to_string(),
});
}
Ok(toml::Value::Integer(n))
}
"f32" | "f64" => {
let n: f64 =
value
.parse()
.map_err(|_| crate::core::error::ConfigError::ExpectedNumber {
value: value.to_string(),
})?;
Ok(toml::Value::Float(n))
}
"string" | "string?" => Ok(toml::Value::String(value.to_string())),
"enum" => {
if let Some(ref allowed) = schema.values
&& !allowed.iter().any(|v| v == value)
{
return Err(crate::core::error::ConfigError::InvalidEnumValue {
value: value.to_string(),
allowed: allowed.join(", "),
});
}
Ok(toml::Value::String(value.to_string()))
}
"string[]" | "array" => {
let items: Vec<toml::Value> = value
.split(',')
.map(|s| toml::Value::String(s.trim().to_string()))
.filter(|v| v.as_str() != Some(""))
.collect();
Ok(toml::Value::Array(items))
}
"table" => Err(crate::core::error::ConfigError::CannotSetTable {
value: value.to_string(),
}),
other => {
tracing::debug!("Unknown schema type '{other}', treating value as string");
Ok(toml::Value::String(value.to_string()))
}
}
}
fn record_telemetry_choice(
table: &mut toml::Table,
updates: &[(&str, &str)],
) -> Result<(), crate::core::error::ConfigError> {
if updates
.iter()
.any(|(key, _)| *key == "telemetry.preference")
{
return Ok(());
}
let enabled = table
.get("telemetry")
.and_then(|telemetry| telemetry.get("enabled"))
.and_then(toml::Value::as_bool);
let Some(enabled) =
enabled.filter(|_| updates.iter().any(|(key, _)| *key == "telemetry.enabled"))
else {
return Ok(());
};
let preference = if enabled {
"explicitly_enabled"
} else {
"explicitly_disabled"
};
set_nested(
table,
"telemetry.preference",
toml::Value::String(preference.to_string()),
)
}
fn set_nested(
table: &mut toml::Table,
key: &str,
value: toml::Value,
) -> Result<(), crate::core::error::ConfigError> {
let parts: Vec<&str> = key.split('.').collect();
let (parents, leaf) = parts.split_at(parts.len() - 1);
let mut current = table;
for part in parents {
current = current
.entry(*part)
.or_insert_with(|| toml::Value::Table(toml::Table::new()))
.as_table_mut()
.ok_or_else(|| crate::core::error::ConfigError::NonTableParent {
key: key.to_string(),
part: (*part).to_string(),
})?;
}
current.insert(leaf[0].to_string(), value);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_set_of_a_managed_member_is_refused_before_any_write() {
use crate::core::config::managed::{managed_from_toml_for_test, with_managed_for_test};
let isolated = crate::core::data_dir::isolated_data_dir();
let managed = managed_from_toml_for_test(
"[hive]\nurl = \"https://hive.acme.example\"\norg = \"org_acme\"\n",
);
let result = with_managed_for_test(Some(managed), || {
set_many_by_key(&[
("hive.auto_capture", "false"),
("hive.url", "https://hive.user.example"),
])
});
match result {
Err(crate::core::error::ConfigError::InvalidValue { key, message }) => {
assert_eq!(key, "hive.url");
assert!(message.contains("managed by org_acme"), "{message}");
}
other => panic!("expected a managed refusal, got {other:?}"),
}
assert!(!isolated.path().join("config.toml").exists());
}
#[test]
fn parse_bool_values() {
let schema = KeySchema {
ty: "bool".to_string(),
default: serde_json::json!(false),
description: String::new(),
values: None,
env_override: None,
};
assert_eq!(
parse_value("true", &schema).unwrap(),
toml::Value::Boolean(true)
);
assert_eq!(
parse_value("false", &schema).unwrap(),
toml::Value::Boolean(false)
);
assert!(parse_value("maybe", &schema).is_err());
}
#[test]
fn parse_integer_values() {
let schema = KeySchema {
ty: "u32".to_string(),
default: serde_json::json!(0),
description: String::new(),
values: None,
env_override: None,
};
assert_eq!(
parse_value("42", &schema).unwrap(),
toml::Value::Integer(42)
);
assert!(parse_value("-1", &schema).is_err());
assert!(parse_value("abc", &schema).is_err());
}
#[test]
fn parse_enum_validates_allowed() {
let schema = KeySchema {
ty: "enum".to_string(),
default: serde_json::json!("off"),
description: String::new(),
values: Some(vec!["off".into(), "lite".into(), "full".into()]),
env_override: None,
};
assert_eq!(
parse_value("lite", &schema).unwrap(),
toml::Value::String("lite".into())
);
assert!(parse_value("invalid", &schema).is_err());
}
#[test]
fn parse_string_array() {
let schema = KeySchema {
ty: "string[]".to_string(),
default: serde_json::json!([]),
description: String::new(),
values: None,
env_override: None,
};
let result = parse_value("a, b, c", &schema).unwrap();
let arr = result.as_array().unwrap();
assert_eq!(arr.len(), 3);
assert_eq!(arr[0].as_str().unwrap(), "a");
assert_eq!(arr[2].as_str().unwrap(), "c");
}
#[test]
fn set_nested_creates_intermediate_tables() {
let mut table = toml::Table::new();
set_nested(
&mut table,
"proxy.anthropic_upstream",
toml::Value::String("https://example.com".into()),
)
.unwrap();
let proxy = table["proxy"].as_table().unwrap();
assert_eq!(
proxy["anthropic_upstream"].as_str().unwrap(),
"https://example.com"
);
}
#[test]
fn decision_loop_keys_round_trip_through_cli_setter() {
let schema = ConfigSchema::generate();
let mut table = toml::Table::new();
for (key, value) in [
("decision_loop.enabled", "false"),
("decision_loop.max_filter_level", "0"),
] {
let key_schema = schema.lookup(key).expect("decision-loop key in schema");
set_nested(&mut table, key, parse_value(value, key_schema).unwrap()).unwrap();
}
let cfg: Config = toml::Value::Table(table).try_into().unwrap();
assert!(!cfg.decision_loop.enabled);
assert_eq!(cfg.decision_loop.max_filter_level, 0);
}
#[test]
fn manual_telemetry_switch_is_recorded_as_explicit_choice() {
for (value, preference) in [
("false", "explicitly_disabled"),
("true", "explicitly_enabled"),
] {
let mut table = toml::Table::new();
set_nested(
&mut table,
"telemetry.enabled",
toml::Value::Boolean(value == "true"),
)
.unwrap();
record_telemetry_choice(&mut table, &[("telemetry.enabled", value)]).unwrap();
assert_eq!(table["telemetry"]["preference"].as_str(), Some(preference));
}
let mut table = toml::Table::new();
set_nested(&mut table, "telemetry.enabled", toml::Value::Boolean(false)).unwrap();
record_telemetry_choice(&mut table, &[("profile", "coder")]).unwrap();
assert!(table["telemetry"].get("preference").is_none());
set_nested(
&mut table,
"telemetry.preference",
toml::Value::String("default_on".into()),
)
.unwrap();
record_telemetry_choice(
&mut table,
&[
("telemetry.enabled", "false"),
("telemetry.preference", "default_on"),
],
)
.unwrap();
assert_eq!(
table["telemetry"]["preference"].as_str(),
Some("default_on")
);
}
#[test]
fn set_nested_flat_key() {
let mut table = toml::Table::new();
set_nested(&mut table, "ultra_compact", toml::Value::Boolean(true)).unwrap();
assert!(table["ultra_compact"].as_bool().unwrap());
}
#[test]
fn set_nested_rejects_non_table_intermediate() {
let mut table = toml::Table::new();
table.insert("proxy".into(), toml::Value::String("oops".into()));
let err = set_nested(&mut table, "proxy.port", toml::Value::Integer(8080)).unwrap_err();
assert!(err.to_string().contains("non-table"), "got: {err}");
}
#[test]
fn display_toml_value_renders_user_facing_form() {
assert_eq!(
display_toml_value(&toml::Value::String("enforce".into())),
"enforce"
);
assert_eq!(display_toml_value(&toml::Value::Boolean(false)), "false");
assert_eq!(display_toml_value(&toml::Value::Integer(8080)), "8080");
assert_eq!(
display_toml_value(&toml::Value::Array(vec![
toml::Value::String("a".into()),
toml::Value::String("b".into()),
])),
"a, b"
);
}
}