use std::{
env, fs,
io::{self, IsTerminal, Read, Write},
path::{Path, PathBuf},
process::{Command as Program, Stdio},
};
use anyhow::{Context, Result};
use directories::BaseDirs;
use serde_json::{Map, Value};
use crate::{
launch::Tool,
profile::{DEFAULT_PROFILE, Profile},
settings::{path as settings_path, read, write},
};
const SETTINGS: &str = "settings.json";
pub const KEY: &str = "statusLine";
const SUBCOMMAND: &str = "statusline";
const WITH: &str = "--with";
const WITH_ENCODED: &str = "--with-encoded";
const BINARY: &str = "ditto-cli";
const JOIN: &str = " │ ";
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,
Alongside,
Removed,
Restored,
Off,
Foreign,
Shadowed,
}
impl Indicator {
pub fn describe(self) -> &'static str {
match self {
Self::Installed => "status line installed",
Self::AlreadyOn => "status line already on",
Self::Alongside => "status line on, drawn in front of the one you already had",
Self::Removed => "status line removed",
Self::Restored => "status line removed, yours put back",
Self::Off => "status line off",
Self::Foreign => {
"left alone: this profile has its own status line, and --keep-mine draws both"
}
Self::Shadowed => "installed, but a status line that outranks it is showing instead",
}
}
pub fn key(self) -> &'static str {
match self {
Self::Installed => "installed",
Self::AlreadyOn => "already_on",
Self::Alongside => "alongside",
Self::Removed => "removed",
Self::Restored => "restored",
Self::Off => "off",
Self::Foreign => "foreign",
Self::Shadowed => "shadowed",
}
}
pub fn is_on(self) -> bool {
matches!(self, Self::Installed | Self::AlreadyOn | Self::Alongside)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Existing {
LeaveAlone,
KeepAlongside,
}
pub fn enable(profile: &Profile, existing: Existing) -> Result<Indicator> {
let ours = ditto_status_line()?;
update(profile, |settings| {
let current = settings.get(KEY).cloned();
match current {
Some(current) if is_ditto(¤t, Some(&ours)) => {
let entry = rebuilt(¤t, &ours);
let alongside = wrapped_command(&entry).is_some();
if entry == current {
let drawn = if alongside {
Indicator::Alongside
} else {
Indicator::AlreadyOn
};
return (drawn, false);
}
settings.insert(KEY.to_owned(), entry);
let drawn = if alongside {
Indicator::Alongside
} else {
Indicator::Installed
};
(drawn, true)
}
Some(theirs) => match keeping(&theirs, &ours) {
Some(entry) if existing == Existing::KeepAlongside => {
settings.insert(KEY.to_owned(), entry);
(Indicator::Alongside, true)
}
_ => (Indicator::Foreign, false),
},
None => {
settings.insert(KEY.to_owned(), ours.clone());
(Indicator::Installed, true)
}
}
})
}
pub fn disable(profile: &Profile) -> Result<Indicator> {
let ours = ditto_status_line().ok();
update(profile, |settings| {
let current = settings.get(KEY).cloned();
match current {
None => (Indicator::Off, false),
Some(current) if !is_ditto(¤t, ours.as_ref()) => (Indicator::Foreign, false),
Some(current) => match displaced(¤t) {
Some(theirs) => {
settings.insert(KEY.to_owned(), theirs);
(Indicator::Restored, true)
}
None => {
settings.remove(KEY);
(Indicator::Removed, true)
}
},
}
})
}
pub fn state(profile: &Profile) -> Result<Indicator> {
let ours = ditto_status_line().ok();
let settings = read(&settings_path(profile))?;
Ok(match settings.get(KEY) {
None => Indicator::Off,
Some(current) if !is_ditto(current, ours.as_ref()) => Indicator::Foreign,
Some(current) if wrapped_command(current).is_some() => Indicator::Alongside,
Some(_) => Indicator::AlreadyOn,
})
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Inherit {
Install(Value),
Already,
Keep,
Nothing,
}
pub fn inherit(
theirs: Option<&Value>,
current: Option<&Value>,
overwrite: bool,
) -> Result<Inherit> {
let Some(theirs) = theirs else {
return Ok(Inherit::Nothing);
};
let ours = ditto_status_line()?;
let wanted = match underneath(theirs, &ours) {
Some(own) => keeping(&own, &ours).unwrap_or_else(|| ours.clone()),
None => ours.clone(),
};
Ok(match current {
None => Inherit::Install(wanted),
Some(current) if *current == wanted => Inherit::Already,
Some(current) if is_ditto(current, Some(&ours)) && wrapped_command(current).is_none() => {
Inherit::Install(wanted)
}
Some(_) if overwrite => Inherit::Install(wanted),
Some(_) => Inherit::Keep,
})
}
fn underneath(entry: &Value, ours: &Value) -> Option<Value> {
if is_ditto(entry, Some(ours)) {
return displaced(entry);
}
Some(entry.clone())
}
pub fn enable_quietly(profile: &Profile) {
let _ = enable(profile, Existing::LeaveAlone);
}
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 ditto_status_line() -> Result<Value> {
let executable = env::current_exe().context("could not locate the Ditto CLI binary")?;
Ok(status_line_entry(&executable.to_string_lossy()))
}
fn status_line_entry(executable: &str) -> Value {
let command = format!("{} {SUBCOMMAND}", shell_quote(executable));
serde_json::json!({ "type": "command", "command": command })
}
#[cfg(not(windows))]
fn shell_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', r"'\''"))
}
#[cfg(windows)]
fn shell_quote(value: &str) -> String {
format!("\"{value}\"")
}
fn keeping(theirs: &Value, ours: &Value) -> Option<Value> {
if theirs.get("type").and_then(Value::as_str) != Some("command") {
return None;
}
let command = theirs.get("command").and_then(Value::as_str)?;
if command.trim().is_empty() {
return None;
}
let mut entry = wrapping(ours, command);
carry_over(theirs, &mut entry);
Some(entry)
}
fn rebuilt(current: &Value, ours: &Value) -> Value {
let mut entry = match wrapped_command(current) {
Some(command) => wrapping(ours, &command),
None => ours.clone(),
};
carry_over(current, &mut entry);
entry
}
fn displaced(current: &Value) -> Option<Value> {
let command = wrapped_command(current)?;
let mut entry = serde_json::json!({ "type": "command", "command": command });
carry_over(current, &mut entry);
Some(entry)
}
fn wrapping(ours: &Value, wrapped: &str) -> Value {
let ours = ours
.get("command")
.and_then(Value::as_str)
.unwrap_or_default();
let command = format!("{ours} {}", wrapped_argument(wrapped));
serde_json::json!({ "type": "command", "command": command })
}
fn carry_over(from: &Value, into: &mut Value) {
let (Some(from), Some(into)) = (from.as_object(), into.as_object_mut()) else {
return;
};
for (key, value) in from {
if key != "command" && key != "type" && !into.contains_key(key) {
into.insert(key.clone(), value.clone());
}
}
}
fn wrapped_command(entry: &Value) -> Option<String> {
let command = entry.get("command").and_then(Value::as_str)?;
if let Some((_, encoded)) = command.split_once(&format!("{WITH_ENCODED} ")) {
return percent_decode(encoded.trim());
}
let (_, quoted) = command.split_once(&format!("{WITH} "))?;
shell_unquote(quoted.trim())
}
#[cfg(not(windows))]
fn wrapped_argument(command: &str) -> String {
format!("{WITH} {}", shell_quote(command))
}
#[cfg(windows)]
fn wrapped_argument(command: &str) -> String {
if command.contains('"') || command.contains('%') {
format!("{WITH_ENCODED} {}", percent_encode(command))
} else {
format!("{WITH} {}", shell_quote(command))
}
}
fn shell_unquote(value: &str) -> Option<String> {
if let Some(inner) = value.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')) {
return Some(inner.replace(r"'\''", "'"));
}
value
.strip_prefix('"')
.and_then(|v| v.strip_suffix('"'))
.map(str::to_owned)
}
#[cfg_attr(not(windows), allow(dead_code))]
fn percent_encode(value: &str) -> String {
value
.bytes()
.map(|byte| match byte {
b'0'..=b'9' | b'A'..=b'Z' | b'a'..=b'z' | b'-' | b'_' | b'.' => {
String::from(byte as char)
}
_ => format!("%{byte:02X}"),
})
.collect()
}
fn percent_decode(value: &str) -> Option<String> {
let raw = value.as_bytes();
let mut decoded = Vec::with_capacity(raw.len());
let mut at = 0;
while at < raw.len() {
if raw[at] == b'%' {
let digits = value.get(at + 1..at + 3)?;
decoded.push(u8::from_str_radix(digits, 16).ok()?);
at += 3;
} else {
decoded.push(raw[at]);
at += 1;
}
}
String::from_utf8(decoded).ok()
}
fn is_ditto(entry: &Value, ours: Option<&Value>) -> bool {
if ours == Some(entry) {
return true;
}
let Some(command) = entry.get("command").and_then(Value::as_str) else {
return false;
};
let ours = ours
.and_then(|ours| ours.get("command"))
.and_then(Value::as_str);
if let Some(rest) = ours.and_then(|ours| command.strip_prefix(ours))
&& rest.trim_start().starts_with(WITH)
{
return true;
}
claims_ditto(command)
}
fn claims_ditto(command: &str) -> bool {
let words: Vec<&str> = command.split_whitespace().collect();
let Some(subcommand) = words.iter().position(|word| *word == SUBCOMMAND) else {
return false;
};
if subcommand == 0 || !names_binary(words[subcommand - 1]) {
return false;
}
match words.get(subcommand + 1) {
None => true,
Some(&WITH | &WITH_ENCODED) => words.len() > subcommand + 2,
Some(_) => false,
}
}
fn names_binary(word: &str) -> bool {
let word = word.trim_matches(['\'', '"']);
let name = word.rsplit(['/', '\\']).next().unwrap_or(word);
let name = name.to_ascii_lowercase();
name.strip_suffix(".exe").unwrap_or(&name) == BINARY
}
pub fn shadowed(outcome: Indicator) -> Indicator {
if outcome.is_on() && outranking_status_line() {
Indicator::Shadowed
} else {
outcome
}
}
fn outranking_status_line() -> bool {
let directory = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let home = BaseDirs::new().map(|base| base.home_dir().to_path_buf());
outranking_settings(&directory, home.as_deref())
.iter()
.any(|path| read(path).is_ok_and(|settings| settings.contains_key(KEY)))
}
fn outranking_settings(from: &Path, home: Option<&Path>) -> Vec<PathBuf> {
let mut paths = Vec::new();
for ancestor in from.ancestors() {
if Some(ancestor) == home {
break;
}
let project = ancestor.join(".claude");
paths.push(project.join("settings.local.json"));
paths.push(project.join(SETTINGS));
}
paths.push(PathBuf::from(MANAGED_SETTINGS));
paths
}
#[cfg(target_os = "macos")]
const MANAGED_SETTINGS: &str = "/Library/Application Support/ClaudeCode/managed-settings.json";
#[cfg(all(unix, not(target_os = "macos")))]
const MANAGED_SETTINGS: &str = "/etc/claude-code/managed-settings.json";
#[cfg(windows)]
const MANAGED_SETTINGS: &str = r"C:\ProgramData\ClaudeCode\managed-settings.json";
pub fn render(with: Option<String>, with_encoded: Option<String>) -> 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);
let ours = status_line(&name, account.as_deref(), colour());
let wrapped = with.or_else(|| with_encoded.as_deref().and_then(percent_decode));
match wrapped
.as_deref()
.and_then(|command| drawn_by(command, &payload))
{
Some(theirs) => println!("{}", joined(&ours, &theirs, colour())),
None => println!("{ours}"),
}
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 joined(ours: &str, theirs: &str, colour: bool) -> String {
let mut lines = theirs.lines();
let Some(first) = lines.next() else {
return ours.to_owned();
};
let separator = if colour {
format!("{DIM}{JOIN}{RESET}")
} else {
JOIN.to_owned()
};
let rest: String = lines.map(|line| format!("\n{line}")).collect();
format!("{ours}{separator}{first}{rest}")
}
fn drawn_by(command: &str, payload: &str) -> Option<String> {
let mut child = shell(command)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.ok()?;
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(payload.as_bytes());
}
let output = child.wait_with_output().ok()?;
if !output.status.success() {
return None;
}
let drawn = String::from_utf8_lossy(&output.stdout)
.trim_end()
.to_owned();
(!drawn.trim().is_empty()).then_some(drawn)
}
#[cfg(not(windows))]
fn shell(command: &str) -> Program {
let mut shell = Program::new("sh");
shell.arg("-c").arg(command);
shell
}
#[cfg(windows)]
fn shell(command: &str) -> Program {
use std::os::windows::process::CommandExt;
let mut shell = Program::new("cmd");
shell.arg("/C").raw_arg(command);
shell
}
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 _ = crossterm::execute!(stdout, crossterm::terminal::SetTitle(title(tool, profile)));
}
#[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"),
fx_home: root.join("fx-home"),
omp_home: root.join("omp"),
opencode: OpencodeHome {
data: root.join("opencode/data"),
config: root.join("opencode/config"),
state: root.join("opencode/state"),
},
pi_home: root.join("pi"),
prime_agent_home: root.join("prime-agent"),
generic: Vec::new(),
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, Existing::LeaveAlone)?,
Indicator::Installed
);
assert_eq!(state(&profile)?, Indicator::AlreadyOn);
assert_eq!(
enable(&profile, Existing::LeaveAlone)?,
Indicator::AlreadyOn
);
let installed = settings(&profile);
assert_eq!(installed["theme"], "dark");
assert_eq!(installed["model"], "opus");
assert!(is_ditto(
&installed["statusLine"],
ditto_status_line().ok().as_ref()
));
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, Existing::LeaveAlone)?, 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 draws_the_profile_in_front_of_a_status_line_asked_to_be_kept() -> Result<()> {
let temporary = tempfile::tempdir()?;
let profile = profile(temporary.path());
fs::create_dir_all(&profile.claude_home)?;
let theirs = serde_json::json!({
"type": "command",
"command": "npx -y ccstatusline@latest",
"padding": 0,
});
fs::write(
settings_path(&profile),
serde_json::json!({ "statusLine": theirs, "theme": "dark" }).to_string(),
)?;
assert_eq!(
enable(&profile, Existing::KeepAlongside)?,
Indicator::Alongside
);
assert_eq!(state(&profile)?, Indicator::Alongside);
let installed = &settings(&profile)["statusLine"];
assert_eq!(
wrapped_command(installed).as_deref(),
Some("npx -y ccstatusline@latest")
);
assert_eq!(installed["padding"], 0);
assert_eq!(
enable(&profile, Existing::LeaveAlone)?,
Indicator::Alongside
);
assert_eq!(
wrapped_command(&settings(&profile)["statusLine"]).as_deref(),
Some("npx -y ccstatusline@latest")
);
assert_eq!(disable(&profile)?, Indicator::Restored);
assert_eq!(settings(&profile)["statusLine"], theirs);
assert_eq!(settings(&profile)["theme"], "dark");
Ok(())
}
#[test]
fn will_not_claim_to_keep_a_status_line_it_cannot_run() -> Result<()> {
let temporary = tempfile::tempdir()?;
let profile = profile(temporary.path());
fs::create_dir_all(&profile.claude_home)?;
let theirs = r#"{"statusLine":{"type":"something-new","value":"…"}}"#;
fs::write(settings_path(&profile), theirs)?;
assert_eq!(
enable(&profile, Existing::KeepAlongside)?,
Indicator::Foreign
);
assert_eq!(settings(&profile)["statusLine"]["type"], "something-new");
Ok(())
}
#[test]
fn carries_an_awkward_command_there_and_back() {
let ours = status_line_entry("/usr/local/bin/ditto-cli");
for command in [
"bash ~/mine.sh",
r#"sh -c 'printf "%s" "$(git branch --show-current)"'"#,
"node C:\\Program Files\\bar\\line.js --flag",
"printf '100%% ready'",
] {
let entry = wrapping(&ours, command);
assert!(is_ditto(&entry, Some(&ours)), "{command}");
assert_eq!(wrapped_command(&entry).as_deref(), Some(command));
}
}
#[test]
fn percent_encoding_survives_a_round_trip() {
for value in ["", "plain", r#"a "b" & c% ^d"#, "üñî"] {
let encoded = percent_encode(value);
assert!(
encoded.chars().all(
|character| character.is_ascii_alphanumeric() || "-_.%".contains(character)
),
"{encoded}"
);
assert_eq!(percent_decode(&encoded).as_deref(), Some(value));
}
assert_eq!(percent_decode("%2"), None);
assert_eq!(percent_decode("%zz"), None);
}
#[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, Existing::LeaveAlone)?,
Indicator::Installed
);
assert_eq!(state(&profile)?, Indicator::AlreadyOn);
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, Existing::LeaveAlone).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 installed = status_line_entry("/usr/local/bin/ditto-cli");
let theirs = |command: &str| serde_json::json!({ "type": "command", "command": command });
assert!(is_ditto(&installed, Some(&installed)));
assert!(is_ditto(
&status_line_entry("/opt/ditto-cli/bin/ditto-cli"),
Some(&installed)
));
assert!(is_ditto(
&wrapping(&installed, "bash ~/mine.sh"),
Some(&installed)
));
assert!(!is_ditto(&theirs("bash ~/statusline.sh"), Some(&installed)));
for theirs in [
theirs("ditto-cli status | head -1"),
theirs("~/bin/ditto-cli-statusline"),
theirs("bash ~/.config/ditto-cli/statusline"),
theirs("ditto-cli statusline-mine"),
] {
assert!(!is_ditto(&theirs, Some(&installed)), "{theirs}");
}
}
#[test]
fn looks_for_settings_that_outrank_a_profile() {
let home = Path::new("/home/u");
let paths = outranking_settings(&home.join("code/repo/src"), Some(home));
assert!(paths.contains(&home.join("code/repo/.claude/settings.json")));
assert!(paths.contains(&home.join("code/repo/.claude/settings.local.json")));
assert!(paths.contains(&home.join("code/.claude/settings.json")));
assert!(paths.contains(&PathBuf::from(MANAGED_SETTINGS)));
assert!(!paths.contains(&home.join(".claude/settings.json")));
}
#[test]
fn says_a_status_line_is_on_only_when_it_will_be_seen() {
assert!(Indicator::Alongside.is_on());
assert!(!Indicator::Shadowed.is_on());
assert!(!Indicator::Foreign.is_on());
assert_eq!(shadowed(Indicator::Off), Indicator::Off);
}
#[test]
fn recognises_its_own_entry_whatever_the_binary_is_called() {
let ours = status_line_entry("/home/u/bin/statusline-helper");
assert!(is_ditto(&ours, Some(&ours)));
assert!(!is_ditto(&ours, None));
}
#[cfg(not(windows))]
#[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'"
);
}
#[cfg(windows)]
#[test]
fn quotes_paths_the_command_prompt_would_otherwise_split() {
assert_eq!(
shell_quote(r"C:\Program Files\ditto\ditto-cli.exe"),
"\"C:\\Program Files\\ditto\\ditto-cli.exe\""
);
assert_eq!(
shell_quote(r"C:\o'clock\ditto-cli.exe"),
"\"C:\\o'clock\\ditto-cli.exe\""
);
}
#[test]
fn draws_the_profile_with_and_without_an_account() {
assert_eq!(
status_line("work", Some("me@example.com"), false),
"⬖ work · me@example.com"
);
assert_eq!(status_line("work", None, false), "⬖ work");
assert!(status_line("work", None, true).contains(PURPLE));
}
#[test]
fn puts_the_profile_in_front_of_every_line_it_was_given() {
assert_eq!(
joined("⬖ work", "main ✓ 12k tokens", false),
"⬖ work │ main ✓ 12k tokens"
);
assert_eq!(
joined("⬖ work", "first\nsecond", false),
"⬖ work │ first\nsecond"
);
assert_eq!(joined("⬖ work", "", false), "⬖ work");
}
#[cfg(not(windows))]
#[test]
fn runs_the_kept_status_line_with_the_payload_claude_code_sent() {
let payload = r#"{"workspace":{"current_dir":"/tmp"}}"#;
assert_eq!(
drawn_by("cat", payload).as_deref(),
Some(payload),
"the command is owed the same payload Ditto was given"
);
assert_eq!(
drawn_by("printf 'a\\nb\\n'", payload).as_deref(),
Some("a\nb")
);
assert_eq!(drawn_by("exit 1", payload), None);
assert_eq!(drawn_by("printf ''", payload), None);
assert_eq!(drawn_by("no-such-status-line-command", payload), None);
}
#[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::Fx, &profile), "ditto:work — fx");
assert_eq!(title(Tool::Opencode, &profile), "ditto:work — opencode");
assert_eq!(
title(Tool::PrimeAgent, &profile),
"ditto:work — Prime Agent"
);
assert_eq!(title(Tool::Pi, &profile), "ditto:work — Pi");
assert!(sets_own_title(Tool::Claude));
assert!(!sets_own_title(Tool::Codex));
assert!(!sets_own_title(Tool::Fx));
assert!(!sets_own_title(Tool::PrimeAgent));
assert!(!sets_own_title(Tool::Pi));
}
}