use std::{
fs, io,
path::{Path, PathBuf},
};
use anyhow::{Context, Result, bail};
use serde_json::{Map, Value};
use crate::{
indicator::{self, Inherit, KEY as OWNED},
profile::{DEFAULT_PROFILE, Profile, Store, write_private_file},
};
const FILE: &str = "settings.json";
pub fn path(profile: &Profile) -> PathBuf {
profile.claude_home.join(FILE)
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct Copied {
pub copied: Vec<String>,
pub kept: Vec<String>,
}
impl Copied {
pub fn changed(&self) -> bool {
!self.copied.is_empty()
}
}
pub fn copy(source: &Profile, target: &Profile, overwrite: bool) -> Result<Copied> {
if source.claude_home == target.claude_home {
bail!(
"'{}' is your existing Claude Code configuration, which is what Ditto copies from",
target.name
);
}
let from = read(&path(source))?;
let mut into = read(&path(target))?;
let mut result = Copied::default();
let theirs = from.get(OWNED).cloned();
for (key, value) in from {
if key == OWNED {
continue;
}
match into.get(&key) {
Some(existing) if *existing == value => {}
Some(_) if !overwrite => result.kept.push(key),
_ => {
into.insert(key.clone(), value);
result.copied.push(key);
}
}
}
let current = into.get(OWNED).cloned();
match indicator::inherit(theirs.as_ref(), current.as_ref(), overwrite)? {
Inherit::Install(entry) => {
into.insert(OWNED.to_owned(), entry);
result.copied.push(OWNED.to_owned());
}
Inherit::Keep => result.kept.push(OWNED.to_owned()),
Inherit::Already | Inherit::Nothing => {}
}
if result.changed() {
write(&path(target), &into)?;
}
result.copied.sort_unstable();
result.kept.sort_unstable();
Ok(result)
}
pub fn seed(store: &Store, profile: &Profile) -> Copied {
match store.load_profile(DEFAULT_PROFILE) {
Ok(source) => copy(&source, profile, false).unwrap_or_default(),
Err(_) => Copied::default(),
}
}
pub fn read(path: &Path) -> Result<Map<String, Value>> {
let contents = match fs::read_to_string(path) {
Ok(contents) => contents,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Map::new()),
Err(error) => {
return Err(error).with_context(|| format!("could not read {}", path.display()));
}
};
if contents.trim().is_empty() {
return Ok(Map::new());
}
match serde_json::from_str(&contents)
.with_context(|| format!("could not parse {}", path.display()))?
{
Value::Object(settings) => Ok(settings),
_ => bail!("{} does not hold a JSON object", path.display()),
}
}
pub fn write(path: &Path, settings: &Map<String, Value>) -> Result<()> {
let mut contents = serde_json::to_string_pretty(settings)
.context("could not serialize Claude Code settings")?;
contents.push('\n');
write_private_file(path, &contents)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn store(root: &Path) -> Store {
Store::new(root.join("ditto"), root.join("home"))
}
fn given(profile: &Profile, contents: &str) {
fs::create_dir_all(&profile.claude_home).unwrap();
fs::write(path(profile), contents).unwrap();
}
fn settings(profile: &Profile) -> Value {
Value::Object(read(&path(profile)).unwrap())
}
#[test]
fn copies_the_users_own_settings_into_a_profile() {
let temporary = tempdir().unwrap();
let store = store(temporary.path());
let source = store.load_profile(DEFAULT_PROFILE).unwrap();
given(
&source,
r#"{"permissions":{"defaultMode":"auto"},"model":"opus"}"#,
);
let target = store.create_profile("work").unwrap();
let copied = copy(&source, &target, false).unwrap();
assert_eq!(copied.copied, ["model", "permissions"]);
assert_eq!(settings(&target)["permissions"]["defaultMode"], "auto");
assert_eq!(settings(&target)["model"], "opus");
}
#[test]
fn keeps_the_status_line_you_had_and_draws_the_profile_in_front() {
let temporary = tempdir().unwrap();
let store = store(temporary.path());
let source = store.load_profile(DEFAULT_PROFILE).unwrap();
given(
&source,
r#"{"statusLine":{"type":"command","command":"mine.sh"},"theme":"dark"}"#,
);
let target = store.create_profile("work").unwrap();
let copied = copy(&source, &target, false).unwrap();
assert_eq!(copied.copied, ["statusLine", "theme"]);
let installed = settings(&target)["statusLine"]["command"]
.as_str()
.unwrap()
.to_owned();
assert!(installed.contains("statusline"), "{installed}");
assert!(installed.contains("mine.sh"), "{installed}");
}
#[test]
fn replaces_the_bare_indicator_a_launch_left_behind() {
let temporary = tempdir().unwrap();
let store = store(temporary.path());
let source = store.load_profile(DEFAULT_PROFILE).unwrap();
given(
&source,
r#"{"statusLine":{"type":"command","command":"mine.sh"}}"#,
);
let target = store.create_profile("work").unwrap();
crate::indicator::enable(&target, crate::indicator::Existing::LeaveAlone).unwrap();
let copied = copy(&source, &target, false).unwrap();
assert_eq!(copied.copied, ["statusLine"]);
assert!(
settings(&target)["statusLine"]["command"]
.as_str()
.unwrap()
.contains("mine.sh")
);
}
#[test]
fn leaves_a_status_line_the_profile_chose_for_itself() {
let temporary = tempdir().unwrap();
let store = store(temporary.path());
let source = store.load_profile(DEFAULT_PROFILE).unwrap();
given(
&source,
r#"{"statusLine":{"type":"command","command":"mine.sh"}}"#,
);
let target = store.create_profile("work").unwrap();
given(
&target,
r#"{"statusLine":{"type":"command","command":"theirs.sh"}}"#,
);
let copied = copy(&source, &target, false).unwrap();
assert_eq!(copied.kept, ["statusLine"]);
assert_eq!(settings(&target)["statusLine"]["command"], "theirs.sh");
}
#[test]
fn leaves_settings_the_profile_has_already_answered() {
let temporary = tempdir().unwrap();
let store = store(temporary.path());
let source = store.load_profile(DEFAULT_PROFILE).unwrap();
given(&source, r#"{"model":"opus","theme":"dark"}"#);
let target = store.create_profile("work").unwrap();
given(&target, r#"{"model":"sonnet"}"#);
let copied = copy(&source, &target, false).unwrap();
assert_eq!(copied.copied, ["theme"]);
assert_eq!(copied.kept, ["model"]);
assert_eq!(settings(&target)["model"], "sonnet");
}
#[test]
fn replaces_answered_settings_only_when_asked_to() {
let temporary = tempdir().unwrap();
let store = store(temporary.path());
let source = store.load_profile(DEFAULT_PROFILE).unwrap();
given(&source, r#"{"model":"opus"}"#);
let target = store.create_profile("work").unwrap();
given(&target, r#"{"model":"sonnet"}"#);
let copied = copy(&source, &target, true).unwrap();
assert_eq!(copied.copied, ["model"]);
assert!(copied.kept.is_empty());
assert_eq!(settings(&target)["model"], "opus");
}
#[test]
fn says_nothing_about_settings_that_already_match() {
let temporary = tempdir().unwrap();
let store = store(temporary.path());
let source = store.load_profile(DEFAULT_PROFILE).unwrap();
given(&source, r#"{"theme":"dark"}"#);
let target = store.create_profile("work").unwrap();
given(&target, r#"{"theme":"dark"}"#);
let copied = copy(&source, &target, false).unwrap();
assert!(!copied.changed());
assert!(copied.kept.is_empty());
}
#[test]
fn refuses_to_copy_the_default_profile_onto_itself() {
let temporary = tempdir().unwrap();
let store = store(temporary.path());
let source = store.load_profile(DEFAULT_PROFILE).unwrap();
assert!(copy(&source, &source, false).is_err());
}
#[test]
fn a_broken_settings_file_does_not_stop_a_quiet_copy() {
let temporary = tempdir().unwrap();
let store = store(temporary.path());
let source = store.load_profile(DEFAULT_PROFILE).unwrap();
given(&source, "{ not json");
let target = store.create_profile("work").unwrap();
assert!(!seed(&store, &target).changed());
assert!(copy(&source, &target, false).is_err());
}
}