use std::path::Path;
pub(crate) fn ensure_settings_defaults(claude_config_dir: &Path) -> anyhow::Result<()> {
let settings_path = claude_config_dir.join("settings.json");
crate::core::settings_lock::with_settings_lock(&settings_path, || {
ensure_settings_defaults_locked(&settings_path)
})?
}
fn ensure_settings_defaults_locked(settings_path: &Path) -> anyhow::Result<()> {
let original =
crate::core::session_launch::malformed_backup::load_settings_object(settings_path)?;
let mut settings = original.clone();
let obj = settings
.as_object_mut()
.expect("load_settings_object only ever returns a JSON object");
obj.entry("outputStyle").or_insert_with(|| {
serde_json::Value::String(crate::core::session_launch::OUTPUT_STYLE.to_string())
});
let _ = crate::core::statusline_settings::apply_statusline_entry(obj);
obj.entry("attribution").or_insert_with(|| {
serde_json::json!({
"commit": crate::core::attribution::ATTRIBUTION_FOOTER,
"pr": crate::core::attribution::ATTRIBUTION_FOOTER,
})
});
let needs_write = settings != original;
if needs_write {
crate::core::settings_lock::publish(settings_path, &settings)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn ensure_settings_defaults_seeds_fresh_file() {
let tmp = TempDir::new().unwrap();
let cfg = tmp.path().to_path_buf();
ensure_settings_defaults(&cfg).unwrap();
let text = std::fs::read_to_string(cfg.join("settings.json")).unwrap();
let val: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(
val["outputStyle"].as_str(),
Some(crate::core::session_launch::OUTPUT_STYLE),
"outputStyle must be seeded with the tm default id"
);
let status_line = &val["statusLine"];
assert_eq!(status_line["type"].as_str(), Some("command"));
assert_eq!(status_line["padding"].as_i64(), Some(0));
assert!(
status_line["command"]
.as_str()
.is_some_and(|c| c.ends_with(" statusline")),
"statusLine.command must resolve to '<abs path> statusline', got {:?}",
status_line["command"]
);
}
#[test]
fn ensure_settings_defaults_preserves_existing_keys() {
let tmp = TempDir::new().unwrap();
let cfg = tmp.path().to_path_buf();
std::fs::write(
cfg.join("settings.json"),
serde_json::to_string_pretty(&serde_json::json!({
"theme": "dark",
"model": "opusplan",
}))
.unwrap(),
)
.unwrap();
ensure_settings_defaults(&cfg).unwrap();
let text = std::fs::read_to_string(cfg.join("settings.json")).unwrap();
let val: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(
val["theme"].as_str(),
Some("dark"),
"unrelated key preserved"
);
assert_eq!(
val["model"].as_str(),
Some("opusplan"),
"unrelated key preserved"
);
assert_eq!(
val["outputStyle"].as_str(),
Some(crate::core::session_launch::OUTPUT_STYLE)
);
assert!(val["statusLine"].is_object());
}
#[test]
fn ensure_settings_defaults_does_not_overwrite_customized_values() {
let tmp = TempDir::new().unwrap();
let cfg = tmp.path().to_path_buf();
std::fs::write(
cfg.join("settings.json"),
serde_json::to_string_pretty(&serde_json::json!({
"outputStyle": "teaching",
"statusLine": { "type": "command", "command": "my-custom-line", "padding": 2 },
}))
.unwrap(),
)
.unwrap();
ensure_settings_defaults(&cfg).unwrap();
let text = std::fs::read_to_string(cfg.join("settings.json")).unwrap();
let val: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(
val["outputStyle"].as_str(),
Some("teaching"),
"a pre-existing outputStyle must never be clobbered"
);
assert_eq!(
val["statusLine"]["command"].as_str(),
Some("my-custom-line"),
"a pre-existing statusLine must never be clobbered"
);
assert_eq!(val["statusLine"]["padding"].as_i64(), Some(2));
}
#[test]
fn ensure_settings_defaults_heals_stale_ephemeral_statusline() {
let tmp = TempDir::new().unwrap();
let cfg = tmp.path().to_path_buf();
let stale = "/tmp/target/debug/deps/tm-deadbeef statusline";
std::fs::write(
cfg.join("settings.json"),
serde_json::to_string_pretty(&serde_json::json!({
"statusLine": { "type": "command", "command": stale, "padding": 0 },
}))
.unwrap(),
)
.unwrap();
ensure_settings_defaults(&cfg).unwrap();
let text = std::fs::read_to_string(cfg.join("settings.json")).unwrap();
let val: serde_json::Value = serde_json::from_str(&text).unwrap();
let healed = val["statusLine"]["command"].as_str().unwrap();
assert_ne!(
healed, stale,
"a stale ephemeral statusLine must be rewritten"
);
assert!(
!healed.contains("target/debug"),
"healed command must not point at an ephemeral build path, got {healed}"
);
assert!(
healed.ends_with(" statusline"),
"healed command must still invoke the statusline subcommand, got {healed}"
);
}
#[cfg(unix)]
#[test]
fn ensure_settings_defaults_preserves_existing_valid_binary_statusline() {
let tmp = TempDir::new().unwrap();
let cfg = tmp.path().to_path_buf();
let custom = "/bin/sh statusline";
std::fs::write(
cfg.join("settings.json"),
serde_json::to_string_pretty(&serde_json::json!({
"statusLine": { "type": "command", "command": custom, "padding": 3 },
}))
.unwrap(),
)
.unwrap();
ensure_settings_defaults(&cfg).unwrap();
let text = std::fs::read_to_string(cfg.join("settings.json")).unwrap();
let val: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(
val["statusLine"]["command"].as_str(),
Some(custom),
"a valid, existing custom binary statusLine must never be clobbered"
);
assert_eq!(
val["statusLine"]["padding"].as_i64(),
Some(3),
"the rest of a valid custom statusLine must survive untouched"
);
}
#[test]
fn ensure_settings_defaults_is_idempotent() {
let tmp = TempDir::new().unwrap();
let cfg = tmp.path().to_path_buf();
ensure_settings_defaults(&cfg).unwrap();
let bytes_first = std::fs::read(cfg.join("settings.json")).unwrap();
ensure_settings_defaults(&cfg).unwrap();
let bytes_second = std::fs::read(cfg.join("settings.json")).unwrap();
assert_eq!(
bytes_first, bytes_second,
"a second call must not rewrite the file when nothing changed"
);
}
#[test]
fn ensure_settings_defaults_seeds_attribution() {
let tmp = TempDir::new().unwrap();
let cfg = tmp.path().to_path_buf();
ensure_settings_defaults(&cfg).unwrap();
let text = std::fs::read_to_string(cfg.join("settings.json")).unwrap();
let val: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(
val["attribution"]["commit"].as_str(),
Some(crate::core::attribution::ATTRIBUTION_FOOTER),
"attribution.commit must be the shared trusty-mpm footer"
);
assert_eq!(
val["attribution"]["pr"].as_str(),
Some(crate::core::attribution::ATTRIBUTION_FOOTER),
"attribution.pr must be the shared trusty-mpm footer"
);
}
#[test]
fn ensure_settings_defaults_preserves_operator_attribution() {
let tmp = TempDir::new().unwrap();
let cfg = tmp.path().to_path_buf();
std::fs::write(
cfg.join("settings.json"),
r#"{"attribution":{"commit":"operator text","pr":""}}"#,
)
.unwrap();
ensure_settings_defaults(&cfg).unwrap();
let text = std::fs::read_to_string(cfg.join("settings.json")).unwrap();
let val: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(
val["attribution"]["commit"].as_str(),
Some("operator text"),
"an operator-set attribution.commit must survive the seed"
);
assert_eq!(
val["attribution"]["pr"].as_str(),
Some(""),
"an operator-set attribution.pr must survive the seed"
);
}
}