#![allow(clippy::redundant_pub_crate)]
use std::io;
use std::path::{Path, PathBuf};
use serde_json::{json, Value};
use crate::atomic::atomic_write;
use crate::driver::CliEnv;
#[must_use]
pub(crate) fn default_settings_path(env: &CliEnv) -> Option<PathBuf> {
env.home
.as_ref()
.filter(|h| !h.is_empty())
.map(|h| PathBuf::from(h).join(".claude").join("settings.json"))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum InstallationStatus {
NotPresent,
NotInstalled,
Installed { command: String },
Other { command: String },
}
pub(crate) fn detect_installation_status(path: &Path) -> io::Result<InstallationStatus> {
let raw = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(InstallationStatus::NotPresent),
Err(e) => return Err(e),
};
let value: Value = serde_json::from_str(&raw)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;
match value.get("statusLine") {
None | Some(Value::Null) => Ok(InstallationStatus::NotInstalled),
Some(obj) => {
let command = obj
.get("command")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
if command.contains("linesmith") {
Ok(InstallationStatus::Installed { command })
} else {
Ok(InstallationStatus::Other { command })
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum InstallOutcome {
Created,
Updated,
}
pub(crate) fn install(path: &Path, command: &str) -> io::Result<InstallOutcome> {
let (mut root, outcome) = match std::fs::read_to_string(path) {
Ok(raw) => {
let v: Value = serde_json::from_str(&raw)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;
backup_to_bak(path, &raw)?;
(v, InstallOutcome::Updated)
}
Err(e) if e.kind() == io::ErrorKind::NotFound => (
Value::Object(serde_json::Map::new()),
InstallOutcome::Created,
),
Err(e) => return Err(e),
};
let obj = root.as_object_mut().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
"settings.json root must be a JSON object",
)
})?;
obj.insert(
"statusLine".to_string(),
json!({
"type": "command",
"command": command,
"padding": 0,
}),
);
let serialized = serde_json::to_string_pretty(&root)? + "\n";
atomic_write(path, &serialized)?;
Ok(outcome)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum UninstallOutcome {
NoFile,
NoStatusLine,
Removed,
}
pub(crate) fn uninstall(path: &Path) -> io::Result<UninstallOutcome> {
let raw = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(UninstallOutcome::NoFile),
Err(e) => return Err(e),
};
let mut root: Value = serde_json::from_str(&raw)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;
let obj = root.as_object_mut().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
"settings.json root must be a JSON object",
)
})?;
if obj.remove("statusLine").is_none() {
return Ok(UninstallOutcome::NoStatusLine);
}
backup_to_bak(path, &raw)?;
let serialized = serde_json::to_string_pretty(&root)? + "\n";
atomic_write(path, &serialized)?;
Ok(UninstallOutcome::Removed)
}
fn backup_to_bak(path: &Path, contents: &str) -> io::Result<()> {
let mut bak = path.as_os_str().to_owned();
bak.push(".bak");
atomic_write(Path::new(&bak), contents)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn settings_path(tmp: &TempDir) -> PathBuf {
tmp.path().join("settings.json")
}
#[test]
fn detect_returns_not_present_when_file_missing() {
let tmp = TempDir::new().expect("tempdir");
let status = detect_installation_status(&settings_path(&tmp)).expect("detect");
assert_eq!(status, InstallationStatus::NotPresent);
}
#[test]
fn detect_returns_not_installed_when_no_status_line_key() {
let tmp = TempDir::new().expect("tempdir");
let path = settings_path(&tmp);
fs::write(&path, r#"{ "theme": "dracula" }"#).expect("seed");
let status = detect_installation_status(&path).expect("detect");
assert_eq!(status, InstallationStatus::NotInstalled);
}
#[test]
fn detect_returns_installed_when_status_line_references_linesmith() {
let tmp = TempDir::new().expect("tempdir");
let path = settings_path(&tmp);
fs::write(
&path,
r#"{ "statusLine": { "type": "command", "command": "linesmith --config /tmp/x" } }"#,
)
.expect("seed");
match detect_installation_status(&path).expect("detect") {
InstallationStatus::Installed { command } => {
assert!(command.contains("linesmith"));
assert!(command.contains("--config"));
}
other => panic!("expected Installed, got {other:?}"),
}
}
#[test]
fn detect_returns_other_when_status_line_points_elsewhere() {
let tmp = TempDir::new().expect("tempdir");
let path = settings_path(&tmp);
fs::write(
&path,
r#"{ "statusLine": { "type": "command", "command": "ccstatusline" } }"#,
)
.expect("seed");
match detect_installation_status(&path).expect("detect") {
InstallationStatus::Other { command } => assert_eq!(command, "ccstatusline"),
other => panic!("expected Other, got {other:?}"),
}
}
#[test]
fn detect_surfaces_invalid_json_as_io_invalid_data() {
let tmp = TempDir::new().expect("tempdir");
let path = settings_path(&tmp);
fs::write(&path, "{ not json").expect("seed");
let err = detect_installation_status(&path).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
}
#[test]
fn install_creates_file_when_absent() {
let tmp = TempDir::new().expect("tempdir");
let path = settings_path(&tmp);
let outcome = install(&path, "linesmith").expect("install");
assert_eq!(outcome, InstallOutcome::Created);
let raw = fs::read_to_string(&path).expect("read");
let parsed: Value = serde_json::from_str(&raw).expect("parse");
assert_eq!(parsed["statusLine"]["command"].as_str(), Some("linesmith"),);
assert_eq!(parsed["statusLine"]["type"].as_str(), Some("command"));
assert!(!path.with_extension("json.bak").exists(),);
}
#[test]
fn install_preserves_unrelated_top_level_keys() {
let tmp = TempDir::new().expect("tempdir");
let path = settings_path(&tmp);
fs::write(
&path,
r#"{ "model": "claude-sonnet-4-5", "permissions": { "allow": ["Edit"] } }"#,
)
.expect("seed");
install(&path, "linesmith").expect("install");
let raw = fs::read_to_string(&path).expect("read");
let parsed: Value = serde_json::from_str(&raw).expect("parse");
assert_eq!(parsed["model"].as_str(), Some("claude-sonnet-4-5"));
assert_eq!(parsed["permissions"]["allow"][0].as_str(), Some("Edit"),);
assert_eq!(parsed["statusLine"]["command"].as_str(), Some("linesmith"),);
}
#[test]
fn install_backs_up_prior_settings_to_bak_sibling() {
let tmp = TempDir::new().expect("tempdir");
let path = settings_path(&tmp);
let prior = r#"{ "statusLine": { "type": "command", "command": "ccstatusline" } }"#;
fs::write(&path, prior).expect("seed");
let outcome = install(&path, "linesmith").expect("install");
assert_eq!(outcome, InstallOutcome::Updated);
let bak = path.with_extension("json.bak");
assert!(bak.exists(), "expected .bak sibling at {}", bak.display());
assert_eq!(fs::read_to_string(&bak).expect("read bak"), prior);
let raw = fs::read_to_string(&path).expect("read");
assert!(raw.contains("linesmith"));
}
#[test]
fn install_writes_linesmith_block_with_padding_zero() {
let tmp = TempDir::new().expect("tempdir");
let path = settings_path(&tmp);
install(&path, "linesmith --config /tmp/x").expect("install");
let raw = fs::read_to_string(&path).expect("read");
let parsed: Value = serde_json::from_str(&raw).expect("parse");
let block = &parsed["statusLine"];
assert_eq!(block["type"].as_str(), Some("command"));
assert_eq!(block["command"].as_str(), Some("linesmith --config /tmp/x"),);
assert_eq!(block["padding"].as_i64(), Some(0));
}
#[test]
fn uninstall_returns_no_file_when_settings_absent() {
let tmp = TempDir::new().expect("tempdir");
let outcome = uninstall(&settings_path(&tmp)).expect("uninstall");
assert_eq!(outcome, UninstallOutcome::NoFile);
}
#[test]
fn uninstall_returns_no_status_line_when_key_missing() {
let tmp = TempDir::new().expect("tempdir");
let path = settings_path(&tmp);
fs::write(&path, r#"{ "model": "claude-sonnet-4-5" }"#).expect("seed");
let outcome = uninstall(&path).expect("uninstall");
assert_eq!(outcome, UninstallOutcome::NoStatusLine);
assert!(!path.with_extension("json.bak").exists());
}
#[test]
fn uninstall_removes_status_line_and_preserves_other_keys() {
let tmp = TempDir::new().expect("tempdir");
let path = settings_path(&tmp);
let prior = r#"{
"model": "claude-sonnet-4-5",
"statusLine": { "type": "command", "command": "linesmith" }
}"#;
fs::write(&path, prior).expect("seed");
let outcome = uninstall(&path).expect("uninstall");
assert_eq!(outcome, UninstallOutcome::Removed);
let raw = fs::read_to_string(&path).expect("read");
let parsed: Value = serde_json::from_str(&raw).expect("parse");
assert_eq!(parsed["model"].as_str(), Some("claude-sonnet-4-5"));
assert!(parsed.get("statusLine").is_none());
let bak = path.with_extension("json.bak");
assert_eq!(fs::read_to_string(&bak).expect("read bak"), prior);
}
#[test]
fn install_then_uninstall_round_trips_to_no_status_line() {
let tmp = TempDir::new().expect("tempdir");
let path = settings_path(&tmp);
fs::write(&path, r#"{ "model": "claude-sonnet-4-5" }"#).expect("seed");
install(&path, "linesmith").expect("install");
uninstall(&path).expect("uninstall");
let final_raw = fs::read_to_string(&path).expect("read");
let parsed: Value = serde_json::from_str(&final_raw).expect("parse");
assert!(parsed.get("statusLine").is_none());
assert_eq!(parsed["model"].as_str(), Some("claude-sonnet-4-5"));
}
}