use std::{
env, fs,
io::{self, IsTerminal, Read, Write},
path::{Path, PathBuf},
process,
};
use anyhow::{Context, Result, bail};
use serde_json::{Map, Value};
use crate::{
launch::Tool,
profile::{DEFAULT_PROFILE, Profile, secure_file},
};
const SETTINGS: &str = "settings.json";
const SUBCOMMAND: &str = "statusline";
const BINARY: &str = "ditto-cli";
const PROFILE_VARIABLE: &str = "DITTO_PROFILE";
const PROFILE_MARK: &str = "⬖";
const PURPLE: &str = "\u{1b}[38;2;190;134;255m";
const DIM: &str = "\u{1b}[2m";
const RESET: &str = "\u{1b}[0m";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Indicator {
Installed,
AlreadyOn,
Removed,
Off,
Foreign,
}
impl Indicator {
pub fn describe(self) -> &'static str {
match self {
Self::Installed => "status line installed",
Self::AlreadyOn => "status line already on",
Self::Removed => "status line removed",
Self::Off => "status line off",
Self::Foreign => "left alone: this profile has its own status line",
}
}
}
pub fn enable(profile: &Profile) -> Result<Indicator> {
let entry = ditto_status_line()?;
update(profile, |settings| match settings.get("statusLine") {
Some(existing) if !is_ditto(existing) => (Indicator::Foreign, false),
Some(existing) if *existing == entry => (Indicator::AlreadyOn, false),
_ => {
settings.insert("statusLine".to_owned(), entry.clone());
(Indicator::Installed, true)
}
})
}
pub fn disable(profile: &Profile) -> Result<Indicator> {
update(profile, |settings| match settings.get("statusLine") {
None => (Indicator::Off, false),
Some(existing) if !is_ditto(existing) => (Indicator::Foreign, false),
Some(_) => {
settings.remove("statusLine");
(Indicator::Removed, true)
}
})
}
pub fn state(profile: &Profile) -> Result<Indicator> {
let settings = read(&settings_path(profile))?;
Ok(match settings.get("statusLine") {
None => Indicator::Off,
Some(existing) if is_ditto(existing) => Indicator::AlreadyOn,
Some(_) => Indicator::Foreign,
})
}
pub fn enable_quietly(profile: &Profile) {
let _ = enable(profile);
}
fn update(
profile: &Profile,
change: impl FnOnce(&mut Map<String, Value>) -> (Indicator, bool),
) -> Result<Indicator> {
let path = settings_path(profile);
let mut settings = read(&path)?;
let (outcome, changed) = change(&mut settings);
if changed {
write(&path, &settings)?;
}
Ok(outcome)
}
fn settings_path(profile: &Profile) -> PathBuf {
profile.claude_home.join(SETTINGS)
}
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()),
}
}
fn write(path: &Path, settings: &Map<String, Value>) -> Result<()> {
let parent = path
.parent()
.with_context(|| format!("{} has no parent directory", path.display()))?;
fs::create_dir_all(parent).with_context(|| format!("could not create {}", parent.display()))?;
let mut contents = serde_json::to_string_pretty(settings)
.context("could not serialize Claude Code settings")?;
contents.push('\n');
let temporary = parent.join(format!(".{SETTINGS}.{}.tmp", process::id()));
fs::write(&temporary, contents)
.with_context(|| format!("could not write {}", temporary.display()))?;
secure_file(&temporary)?;
if let Err(error) = fs::rename(&temporary, path) {
let _ = fs::remove_file(&temporary);
return Err(error).with_context(|| format!("could not replace {}", path.display()));
}
Ok(())
}
fn ditto_status_line() -> Result<Value> {
let executable = env::current_exe().context("could not locate the Ditto CLI binary")?;
let command = format!(
"{} {SUBCOMMAND}",
shell_quote(&executable.to_string_lossy())
);
Ok(serde_json::json!({ "type": "command", "command": command }))
}
fn shell_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', r"'\''"))
}
fn is_ditto(entry: &Value) -> bool {
entry
.get("command")
.and_then(Value::as_str)
.is_some_and(|command| {
let command = command.trim_end();
command.ends_with(SUBCOMMAND) && command.contains(BINARY)
})
}
pub fn render() -> Result<()> {
let mut payload = String::new();
let _ = io::stdin().read_to_string(&mut payload);
let config = env::var_os("CLAUDE_CONFIG_DIR").map(PathBuf::from);
let name = profile_name(config.as_deref());
let account = config.as_deref().and_then(account);
println!("{}", status_line(&name, account.as_deref(), colour()));
Ok(())
}
fn status_line(name: &str, account: Option<&str>, colour: bool) -> String {
let account = account.map(|account| format!(" · {account}"));
let account = account.as_deref().unwrap_or_default();
if colour {
format!("{PURPLE}{PROFILE_MARK} {name}{RESET}{DIM}{account}{RESET}")
} else {
format!("{PROFILE_MARK} {name}{account}")
}
}
fn colour() -> bool {
env::var_os("NO_COLOR").is_none()
}
fn profile_name(config: Option<&Path>) -> String {
if let Some(name) = env::var(PROFILE_VARIABLE)
.ok()
.filter(|name| !name.is_empty())
{
return name;
}
config
.and_then(profile_name_from_path)
.unwrap_or_else(|| DEFAULT_PROFILE.to_owned())
}
fn profile_name_from_path(config: &Path) -> Option<String> {
let profile = config.parent()?;
if profile.parent()?.file_name()? != "profiles" {
return None;
}
Some(profile.file_name()?.to_str()?.to_owned())
}
fn account(config: &Path) -> Option<String> {
let contents = fs::read_to_string(config.join(".claude.json")).ok()?;
let settings: Value = serde_json::from_str(&contents).ok()?;
settings
.get("oauthAccount")?
.get("emailAddress")?
.as_str()
.map(str::to_owned)
}
fn sets_own_title(tool: Tool) -> bool {
matches!(tool, Tool::Claude)
}
fn title(tool: Tool, profile: &Profile) -> String {
format!("ditto:{} — {}", profile.name, tool.label())
}
pub fn announce(tool: Tool, profile: &Profile) {
let mut stdout = io::stdout();
if sets_own_title(tool) || !stdout.is_terminal() {
return;
}
let _ = write!(stdout, "\u{1b}]0;{}\u{7}", title(tool, profile));
let _ = stdout.flush();
}
#[cfg(test)]
mod tests {
use super::*;
use crate::profile::OpencodeHome;
fn profile(root: &Path) -> Profile {
Profile {
name: "work".to_owned(),
claude_home: root.join("claude"),
codex_home: root.join("codex"),
omp_home: root.join("omp"),
opencode: OpencodeHome {
data: root.join("opencode/data"),
config: root.join("opencode/config"),
state: root.join("opencode/state"),
},
managed: true,
}
}
fn settings(profile: &Profile) -> Map<String, Value> {
read(&settings_path(profile)).unwrap()
}
#[test]
fn installs_once_and_leaves_the_rest_of_the_settings_alone() -> Result<()> {
let temporary = tempfile::tempdir()?;
let profile = profile(temporary.path());
fs::create_dir_all(&profile.claude_home)?;
fs::write(
settings_path(&profile),
r#"{"theme":"dark","model":"opus"}"#,
)?;
assert_eq!(enable(&profile)?, Indicator::Installed);
assert_eq!(state(&profile)?, Indicator::AlreadyOn);
assert_eq!(enable(&profile)?, Indicator::AlreadyOn);
let installed = settings(&profile);
assert_eq!(installed["theme"], "dark");
assert_eq!(installed["model"], "opus");
assert!(is_ditto(&installed["statusLine"]));
assert_eq!(disable(&profile)?, Indicator::Removed);
assert_eq!(state(&profile)?, Indicator::Off);
assert_eq!(disable(&profile)?, Indicator::Off);
assert_eq!(settings(&profile)["theme"], "dark");
Ok(())
}
#[test]
fn never_touches_a_status_line_someone_else_configured() -> Result<()> {
let temporary = tempfile::tempdir()?;
let profile = profile(temporary.path());
fs::create_dir_all(&profile.claude_home)?;
let theirs = r#"{"statusLine":{"type":"command","command":"bash ~/mine.sh"}}"#;
fs::write(settings_path(&profile), theirs)?;
assert_eq!(enable(&profile)?, Indicator::Foreign);
assert_eq!(state(&profile)?, Indicator::Foreign);
assert_eq!(disable(&profile)?, Indicator::Foreign);
assert_eq!(
settings(&profile)["statusLine"]["command"],
"bash ~/mine.sh"
);
Ok(())
}
#[test]
fn writes_a_settings_file_a_profile_does_not_have_yet() -> Result<()> {
let temporary = tempfile::tempdir()?;
let profile = profile(temporary.path());
assert_eq!(state(&profile)?, Indicator::Off);
assert_eq!(enable(&profile)?, Indicator::Installed);
assert!(is_ditto(&settings(&profile)["statusLine"]));
Ok(())
}
#[test]
fn refuses_to_guess_at_settings_it_cannot_read() -> Result<()> {
let temporary = tempfile::tempdir()?;
let profile = profile(temporary.path());
fs::create_dir_all(&profile.claude_home)?;
fs::write(settings_path(&profile), "{ not json")?;
assert!(enable(&profile).is_err());
assert_eq!(fs::read_to_string(settings_path(&profile))?, "{ not json");
Ok(())
}
#[test]
fn reads_the_profile_out_of_an_isolated_configuration_path() {
assert_eq!(
profile_name_from_path(Path::new("/home/u/.ditto/profiles/work/claude")).as_deref(),
Some("work")
);
assert_eq!(profile_name_from_path(Path::new("/home/u/.claude")), None);
assert_eq!(
profile_name_from_path(Path::new("/home/u/elsewhere/work/claude")),
None
);
}
#[test]
fn recognises_only_the_command_it_writes() {
let ours = ditto_status_line().unwrap();
assert!(is_ditto(&ours));
assert!(!is_ditto(&serde_json::json!({
"type": "command",
"command": "bash ~/statusline.sh",
})));
assert!(!is_ditto(&serde_json::json!({
"type": "command",
"command": "ditto-cli status | head -1",
})));
}
#[test]
fn quotes_paths_a_shell_would_otherwise_split() {
assert_eq!(
shell_quote("/opt/my tools/ditto-cli"),
"'/opt/my tools/ditto-cli'"
);
assert_eq!(
shell_quote("/o'clock/ditto-cli"),
r"'/o'\''clock/ditto-cli'"
);
}
#[test]
fn draws_the_profile_with_and_without_an_account() {
assert_eq!(
status_line("share", Some("me@example.com"), false),
"⬖ share · me@example.com"
);
assert_eq!(status_line("share", None, false), "⬖ share");
assert!(status_line("share", None, true).contains(PURPLE));
}
#[test]
fn titles_name_the_profile_except_where_the_tool_owns_them() {
let temporary = tempfile::tempdir().unwrap();
let profile = profile(temporary.path());
assert_eq!(title(Tool::Codex, &profile), "ditto:work — Codex");
assert_eq!(title(Tool::Opencode, &profile), "ditto:work — opencode");
assert!(sets_own_title(Tool::Claude));
assert!(!sets_own_title(Tool::Codex));
}
}