use std::collections::HashMap;
use std::env;
use std::io::{self, IsTerminal, Read};
use std::path::{Component, Path};
use dunce::canonicalize;
use ansi_str::AnsiStr;
use anyhow::{Context, Result};
use worktrunk::git::Repository;
use worktrunk::styling::{fix_dim_after_color_reset, terminal_width, truncate_visible};
use super::list::{self, CollectOptions, StatuslineSegment, json_output};
use crate::cli::OutputFormat;
struct ClaudeCodeContext {
current_dir: String,
model_name: Option<String>,
context_used_percentage: Option<f64>,
}
impl ClaudeCodeContext {
fn parse(input: &str) -> Option<Self> {
let v: serde_json::Value = serde_json::from_str(input).ok()?;
let current_dir = v
.pointer("/workspace/current_dir")
.and_then(|v| v.as_str())?
.to_string();
let model_name = v
.pointer("/model/display_name")
.and_then(|v| v.as_str())
.map(String::from);
let context_used_percentage = v
.pointer("/context_window/used_percentage")
.and_then(|v| v.as_f64());
Some(Self {
current_dir,
model_name,
context_used_percentage,
})
}
fn from_stdin() -> Option<Self> {
if io::stdin().is_terminal() {
return None;
}
let mut input = String::new();
io::stdin().read_to_string(&mut input).ok()?;
Self::parse(&input)
}
}
fn format_directory_fish_style(path: &Path) -> String {
let (suffix, tilde_prefix) = worktrunk::path::home_dir()
.and_then(|home| path.strip_prefix(&home).ok().map(|s| (s, true)))
.unwrap_or((path, false));
let components: Vec<_> = suffix
.components()
.filter_map(|c| match c {
Component::Normal(s) => Some(s.to_string_lossy()),
_ => None,
})
.collect();
let abbreviated = components
.iter()
.enumerate()
.map(|(i, s)| {
if i == components.len() - 1 {
s.to_string() } else {
s.chars().next().map(String::from).unwrap_or_default()
}
})
.collect::<Vec<_>>();
match (tilde_prefix, abbreviated.is_empty()) {
(true, true) => "~".to_string(),
(true, false) => format!("~/{}", abbreviated.join("/")),
(false, _) if path.is_absolute() => format!("/{}", abbreviated.join("/")),
(false, _) => abbreviated.join("/"),
}
}
const PRIORITY_DIRECTORY: u8 = 0;
const PRIORITY_MODEL: u8 = 1;
const PRIORITY_CONTEXT: u8 = 2;
fn format_context_gauge(percentage: f64) -> String {
let clamped = percentage.clamp(0.0, 100.0);
let symbol = match clamped as u32 {
0..=51 => 'π',
52..=77 => 'π',
78..=90 => 'π',
91..=97 => 'π',
_ => 'π',
};
format!("{symbol} {:.0}%", percentage)
}
pub fn run(format: OutputFormat) -> Result<()> {
worktrunk::config::suppress_warnings();
if matches!(format, OutputFormat::Json) {
return run_json();
}
let claude_code = matches!(format, OutputFormat::ClaudeCode);
let (cwd, model_name, context_used_percentage) = if claude_code {
let ctx = ClaudeCodeContext::from_stdin();
let current_dir = ctx
.as_ref()
.map(|c| c.current_dir.clone())
.unwrap_or_else(|| env::current_dir().unwrap_or_default().display().to_string());
let model = ctx.as_ref().and_then(|c| c.model_name.clone());
let context_pct = ctx.and_then(|c| c.context_used_percentage);
(Path::new(¤t_dir).to_path_buf(), model, context_pct)
} else {
(
env::current_dir().context("Failed to get current directory")?,
None,
None,
)
};
let mut segments: Vec<StatuslineSegment> = Vec::new();
let dir_str = if claude_code {
let formatted = format_directory_fish_style(&cwd);
if !formatted.is_empty() {
segments.push(StatuslineSegment::new(
formatted.clone(),
PRIORITY_DIRECTORY,
));
}
Some(formatted)
} else {
None
};
if let Ok(repo) = Repository::current()
&& repo.worktree_at(&cwd).git_dir().is_ok()
{
let git_segments = git_status_segments(&repo, &cwd, !claude_code)?;
let git_segments = if let Some(ref dir) = dir_str {
filter_redundant_branch(git_segments, dir)
} else {
git_segments
};
segments.extend(git_segments);
}
if let Some(model) = model_name {
segments.push(StatuslineSegment::new(format!("| {model}"), PRIORITY_MODEL));
}
if let Some(pct) = context_used_percentage {
segments.push(StatuslineSegment::new(
format_context_gauge(pct),
PRIORITY_CONTEXT,
));
}
if segments.is_empty() {
return Ok(());
}
let max_width = terminal_width();
let content_budget = max_width.saturating_sub(1);
let fitted_segments = StatuslineSegment::fit_to_width(segments, content_budget);
let output = StatuslineSegment::join(&fitted_segments);
let reset = anstyle::Reset;
let output = fix_dim_after_color_reset(&output);
let output = truncate_visible(&format!("{reset} {output}"), max_width);
println!("{}", output);
Ok(())
}
fn run_json() -> Result<()> {
let cwd = env::current_dir().context("Failed to get current directory")?;
let repo = Repository::current().context("Not in a git repository")?;
if repo.worktree_at(&cwd).git_dir().is_err() {
println!("[]");
return Ok(());
}
let worktrees = repo.list_worktrees()?;
let worktree_root = repo.current_worktree().root()?;
let current_worktree = worktrees.iter().find(|wt| {
canonicalize(&wt.path)
.map(|p| p == worktree_root)
.unwrap_or(false)
});
let Some(wt) = current_worktree else {
println!("[]");
return Ok(());
};
let is_home = repo
.primary_worktree()
.ok()
.flatten()
.is_some_and(|p| wt.path == p);
let mut item = list::build_worktree_item(wt, is_home, true, false);
let url_template = repo.url_template();
let options = CollectOptions {
url_template,
..Default::default()
};
list::populate_item(&repo, &mut item, options)?;
let mut all_vars = HashMap::new();
if let Some(branch) = &item.branch {
let entries = repo.vars_entries(branch);
if !entries.is_empty() {
all_vars.insert(branch.clone(), entries);
}
}
let json_item = json_output::JsonItem::from_list_item(&item, &mut all_vars);
let output = serde_json::to_string_pretty(&[json_item])?;
println!("{output}");
Ok(())
}
fn filter_redundant_branch(segments: Vec<StatuslineSegment>, dir: &str) -> Vec<StatuslineSegment> {
use super::list::columns::ColumnKind;
if let Some(branch_seg) = segments.iter().find(|s| s.kind == Some(ColumnKind::Branch)) {
let raw_branch = branch_seg.content.ansi_strip();
let normalized_branch = worktrunk::config::sanitize_branch_name(&raw_branch);
let pattern = format!(".{normalized_branch}");
if dir.ends_with(&pattern) {
return segments
.into_iter()
.filter(|s| s.kind != Some(ColumnKind::Branch))
.collect();
}
}
segments
}
fn git_status_segments(
repo: &Repository,
cwd: &Path,
include_links: bool,
) -> Result<Vec<StatuslineSegment>> {
use super::list::columns::ColumnKind;
let worktrees = repo.list_worktrees()?;
let worktree_root = repo.worktree_at(cwd).root()?;
let current_worktree = worktrees.iter().find(|wt| {
canonicalize(&wt.path)
.map(|p| p == worktree_root)
.unwrap_or(false)
});
let Some(wt) = current_worktree else {
if let Ok(Some(branch)) = repo.current_worktree().branch() {
return Ok(vec![StatuslineSegment::from_column(
branch.to_string(),
ColumnKind::Branch,
)]);
}
return Ok(vec![]);
};
if repo.default_branch().is_none() {
return Ok(vec![StatuslineSegment::from_column(
wt.branch.as_deref().unwrap_or("HEAD").to_string(),
ColumnKind::Branch,
)]);
}
let is_home = repo
.primary_worktree()
.ok()
.flatten()
.is_some_and(|p| wt.path == p);
let mut item = list::build_worktree_item(wt, is_home, true, false);
let url_template = repo.url_template();
let options = CollectOptions {
url_template,
..Default::default()
};
list::populate_item(repo, &mut item, options)?;
let segments = item.format_statusline_segments(include_links);
if segments.is_empty() {
Ok(vec![StatuslineSegment::from_column(
wt.branch.as_deref().unwrap_or("HEAD").to_string(),
ColumnKind::Branch,
)])
} else {
Ok(segments)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_directory_fish_style() {
#[cfg(unix)]
{
assert_eq!(
format_directory_fish_style(Path::new("/tmp/test")),
"/t/test"
);
assert_eq!(format_directory_fish_style(Path::new("/")), "/");
assert_eq!(
format_directory_fish_style(Path::new("/var/log/app")),
"/v/l/app"
);
}
if let Ok(home) = env::var("HOME") {
let test_path = format!("{home}/workspace/project");
let result = format_directory_fish_style(Path::new(&test_path));
assert!(result.starts_with("~/"), "Expected ~ prefix, got: {result}");
assert!(
result.ends_with("/project"),
"Expected /project suffix, got: {result}"
);
assert_eq!(format_directory_fish_style(Path::new(&home)), "~");
let path_outside_home = format!("{home}ed/nested");
let result = format_directory_fish_style(Path::new(&path_outside_home));
assert!(
!result.starts_with("~"),
"Path sharing HOME string prefix should not use ~: {result}"
);
}
}
#[test]
fn test_claude_code_context_parse_full() {
let json = r#"{
"hook_event_name": "Status",
"session_id": "abc123",
"cwd": "/current/working/directory",
"model": {
"id": "claude-opus-4-1",
"display_name": "Opus"
},
"workspace": {
"current_dir": "/home/user/project",
"project_dir": "/home/user/project"
},
"version": "1.0.80"
}"#;
let ctx = ClaudeCodeContext::parse(json).expect("should parse");
assert_eq!(ctx.current_dir, "/home/user/project");
assert_eq!(ctx.model_name, Some("Opus".to_string()));
}
#[test]
fn test_claude_code_context_parse_minimal() {
let json = r#"{
"workspace": {"current_dir": "/tmp/test"},
"model": {"display_name": "Haiku"}
}"#;
let ctx = ClaudeCodeContext::parse(json).expect("should parse");
assert_eq!(ctx.current_dir, "/tmp/test");
assert_eq!(ctx.model_name, Some("Haiku".to_string()));
}
#[test]
fn test_claude_code_context_parse_missing_model() {
let json = r#"{"workspace": {"current_dir": "/tmp/test"}}"#;
let ctx = ClaudeCodeContext::parse(json).expect("should parse");
assert_eq!(ctx.current_dir, "/tmp/test");
assert_eq!(ctx.model_name, None);
}
#[test]
fn test_claude_code_context_parse_missing_workspace() {
let json = r#"{"model": {"display_name": "Sonnet"}}"#;
assert!(
ClaudeCodeContext::parse(json).is_none(),
"Missing current_dir should return None"
);
}
#[test]
fn test_claude_code_context_parse_empty() {
assert!(ClaudeCodeContext::parse("").is_none());
}
#[test]
fn test_claude_code_context_parse_invalid_json() {
assert!(ClaudeCodeContext::parse("not json").is_none());
assert!(ClaudeCodeContext::parse("{invalid}").is_none());
}
#[test]
fn test_branch_deduplication_with_slashes() {
let dir = "~/w/insta.claude-fix-snapshot-merge-conflicts-xyz";
let branch = "claude/fix-snapshot-merge-conflicts-xyz";
let normalized_branch = worktrunk::config::sanitize_branch_name(branch);
let pattern = format!(".{normalized_branch}");
assert!(
dir.ends_with(&pattern),
"Directory '{}' should end with pattern '{}' (normalized from branch '{}')",
dir,
pattern,
branch
);
}
#[test]
fn test_statusline_truncation() {
use color_print::cformat;
let long_line =
cformat!("main <cyan>?</><dim>^</> http://very-long-branch-name.localhost:3000");
let truncated = truncate_visible(&long_line, 30);
assert!(
truncated.contains('β¦'),
"Truncated line should contain ellipsis: {truncated}"
);
let visible: String = truncated
.chars()
.filter(|c| !c.is_ascii_control())
.collect();
let original_visible: String = long_line
.chars()
.filter(|c| !c.is_ascii_control())
.collect();
assert!(
visible.len() < original_visible.len(),
"Truncated should be shorter: {} vs {}",
visible.len(),
original_visible.len()
);
}
#[test]
fn test_context_gauge_formatting() {
assert_eq!(format_context_gauge(0.0), "π 0%");
assert_eq!(format_context_gauge(51.0), "π 51%");
assert_eq!(format_context_gauge(52.0), "π 52%");
assert_eq!(format_context_gauge(77.0), "π 77%");
assert_eq!(format_context_gauge(78.0), "π 78%");
assert_eq!(format_context_gauge(90.0), "π 90%");
assert_eq!(format_context_gauge(91.0), "π 91%");
assert_eq!(format_context_gauge(97.0), "π 97%");
assert_eq!(format_context_gauge(98.0), "π 98%");
assert_eq!(format_context_gauge(100.0), "π 100%");
}
#[test]
fn test_context_gauge_fractional_percentages() {
assert_eq!(format_context_gauge(42.7), "π 43%"); assert_eq!(format_context_gauge(0.4), "π 0%");
assert_eq!(format_context_gauge(0.5), "π 0%"); assert_eq!(format_context_gauge(1.5), "π 2%"); assert_eq!(format_context_gauge(99.9), "π 100%");
}
#[test]
fn test_context_gauge_edge_cases() {
assert_eq!(format_context_gauge(-5.0), "π -5%");
assert_eq!(format_context_gauge(-0.1), "π -0%");
assert_eq!(format_context_gauge(105.0), "π 105%");
assert_eq!(format_context_gauge(150.0), "π 150%");
}
#[test]
fn test_claude_code_context_parse_with_context_window() {
let json = r#"{
"workspace": {"current_dir": "/tmp/test"},
"model": {"display_name": "Opus"},
"context_window": {"used_percentage": 42.5}
}"#;
let ctx = ClaudeCodeContext::parse(json).expect("should parse");
assert_eq!(ctx.current_dir, "/tmp/test");
assert_eq!(ctx.model_name, Some("Opus".to_string()));
assert_eq!(ctx.context_used_percentage, Some(42.5));
}
#[test]
fn test_claude_code_context_parse_missing_context_window() {
let json = r#"{
"workspace": {"current_dir": "/tmp/test"},
"model": {"display_name": "Opus"}
}"#;
let ctx = ClaudeCodeContext::parse(json).expect("should parse");
assert_eq!(ctx.context_used_percentage, None);
}
#[test]
fn test_claude_code_context_parse_context_window_missing_percentage() {
let json = r#"{
"workspace": {"current_dir": "/tmp/test"},
"context_window": {}
}"#;
let ctx = ClaudeCodeContext::parse(json).expect("should parse");
assert_eq!(ctx.context_used_percentage, None);
}
}