use std::path::Path;
use trusty_common::claude_config::write_json_atomic;
pub(crate) fn ensure_settings_defaults(claude_config_dir: &Path) -> anyhow::Result<()> {
let settings_path = claude_config_dir.join("settings.json");
let existing_value: Option<serde_json::Value> = std::fs::read_to_string(&settings_path)
.ok()
.and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
.filter(serde_json::Value::is_object);
let mut settings = existing_value
.clone()
.unwrap_or_else(|| serde_json::json!({}));
let obj = settings
.as_object_mut()
.expect("settings was constructed/filtered to be an object");
obj.entry("outputStyle").or_insert_with(|| {
serde_json::Value::String(crate::core::session_launch::OUTPUT_STYLE.to_string())
});
let statusline_needs_write = match obj.get("statusLine") {
None => true,
Some(existing) => crate::core::session_launch::is_stale_statusline_command(existing),
};
if statusline_needs_write {
obj.insert(
"statusLine".to_string(),
serde_json::json!({
"type": "command",
"command": crate::core::session_launch::resolve_statusline_command(),
"padding": 0
}),
);
}
let needs_write = existing_value.as_ref() != Some(&settings);
if needs_write {
write_json_atomic(&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"
);
}
}