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_for_statusline, 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>,
rate_limits: Vec<RateLimitReading>,
}
#[derive(Clone, Debug)]
struct RateLimitReading {
used_percentage: f64,
resets_at: i64,
window_secs: i64,
priors: &'static WindowPriors,
}
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());
let rate_limits = parse_rate_limits(&v);
Some(Self {
current_dir,
model_name,
context_used_percentage,
rate_limits,
})
}
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;
const PRIORITY_RATE_LIMITS: u8 = 3;
#[derive(Debug)]
struct WindowPriors {
sigma: f64,
m0: f64,
s0: f64,
}
const FIVE_HOUR_PRIORS: WindowPriors = WindowPriors {
sigma: 0.35,
m0: 0.8,
s0: 0.5,
};
const SEVEN_DAY_PRIORS: WindowPriors = WindowPriors {
sigma: 0.30,
m0: 0.8,
s0: 0.5,
};
const FIVE_HOUR_SECS: i64 = 5 * 3600;
const SEVEN_DAY_SECS: i64 = 7 * 86400;
const RATE_LIMIT_P_THRESHOLD: f64 = 0.50;
fn parse_rate_limits(v: &serde_json::Value) -> Vec<RateLimitReading> {
let mut out = Vec::new();
for (key, window_secs, priors) in [
("five_hour", FIVE_HOUR_SECS, &FIVE_HOUR_PRIORS),
("seven_day", SEVEN_DAY_SECS, &SEVEN_DAY_PRIORS),
] {
let used = v
.pointer(&format!("/rate_limits/{key}/used_percentage"))
.and_then(|x| x.as_f64());
let resets = v
.pointer(&format!("/rate_limits/{key}/resets_at"))
.and_then(|x| x.as_i64());
if let (Some(u), Some(r)) = (used, resets) {
out.push(RateLimitReading {
used_percentage: u,
resets_at: r,
window_secs,
priors,
});
}
}
out
}
fn standard_normal_cdf(z: f64) -> f64 {
0.5 * (1.0 + erf(z / std::f64::consts::SQRT_2))
}
fn erf(x: f64) -> f64 {
const P: f64 = 0.3275911;
const COEFFS: [f64; 5] = [
0.254829592,
-0.284496736,
1.421413741,
-1.453152027,
1.061405429,
];
let sign = x.signum();
let x = x.abs();
let t = 1.0 / (1.0 + P * x);
let poly = COEFFS.iter().rev().fold(0.0, |acc, &c| acc * t + c) * t;
sign * (1.0 - poly * (-x * x).exp())
}
fn p_over(u: f64, t: f64, p: &WindowPriors) -> f64 {
if t >= 1.0 {
return if u >= 1.0 { 1.0 } else { 0.0 };
}
let (mean, var) = if t <= 0.0 {
(p.m0, p.s0 * p.s0 + p.sigma * p.sigma)
} else {
let sigma2 = p.sigma * p.sigma;
let prior_prec = 1.0 / (p.s0 * p.s0);
let data_prec = t / sigma2;
let post_var = 1.0 / (prior_prec + data_prec);
let post_mean = post_var * (p.m0 * prior_prec + u / sigma2);
let mean = u + post_mean * (1.0 - t);
let var = post_var * (1.0 - t).powi(2) + sigma2 * (1.0 - t);
(mean, var)
};
standard_normal_cdf((mean - 1.0) / var.sqrt())
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
enum ClockFormat {
H12,
H24,
}
fn classify_locale(locale: &str) -> ClockFormat {
let lang = locale.split(['.', '@']).next().unwrap_or("");
if matches!(lang, "en_US" | "en_PH" | "en_CA") {
ClockFormat::H12
} else {
ClockFormat::H24
}
}
fn detect_clock_format() -> ClockFormat {
let locale = std::env::var("LC_ALL")
.or_else(|_| std::env::var("LC_TIME"))
.or_else(|_| std::env::var("LANG"))
.unwrap_or_default();
classify_locale(&locale)
}
fn format_clock<Tz: chrono::TimeZone>(dt: &chrono::DateTime<Tz>, fmt: ClockFormat) -> String
where
Tz::Offset: std::fmt::Display,
{
use chrono::Timelike as _;
match fmt {
ClockFormat::H12 if dt.minute() == 0 => dt.format("%-I%P").to_string(),
ClockFormat::H12 => dt.format("%-I:%M%P").to_string(),
ClockFormat::H24 => dt.format("%H:%M").to_string(),
}
}
fn format_window_bounds<Tz: chrono::TimeZone>(
resets_at: i64,
window_secs: i64,
tz: &Tz,
fmt: ClockFormat,
) -> String
where
Tz::Offset: std::fmt::Display,
{
let to_tz = |unix| {
chrono::DateTime::from_timestamp(unix, 0)
.map(|t| t.with_timezone(tz))
.unwrap_or_else(|| chrono::Utc::now().with_timezone(tz))
};
let start = to_tz(resets_at - window_secs);
let end = to_tz(resets_at);
if window_secs <= 12 * 3600 {
format!("{}–{}", format_clock(&start, fmt), format_clock(&end, fmt))
} else {
format!(
"{}–{} {}",
start.format("%a"),
end.format("%a"),
format_clock(&end, fmt)
)
}
}
fn select_binding_window(
readings: &[RateLimitReading],
now_unix: i64,
) -> Option<&RateLimitReading> {
readings
.iter()
.filter_map(|r| {
let u = (r.used_percentage / 100.0).clamp(0.0, 1.0);
let elapsed = (now_unix - (r.resets_at - r.window_secs)) as f64 / r.window_secs as f64;
let t = elapsed.clamp(0.0, 1.0);
let p = p_over(u, t, r.priors);
(p >= RATE_LIMIT_P_THRESHOLD).then_some((p, r))
})
.max_by(|(a, _), (b, _)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
.map(|(_, r)| r)
}
fn format_rate_limit_segment(readings: &[RateLimitReading], now_unix: i64) -> Option<String> {
let r = select_binding_window(readings, now_unix)?;
let u = r.used_percentage / 100.0;
let elapsed = (now_unix - (r.resets_at - r.window_secs)) as f64 / r.window_secs as f64;
let t = elapsed.clamp(0.001, 1.0);
let pace = u / t;
let bounds = format_window_bounds(
r.resets_at,
r.window_secs,
&chrono::Local,
detect_clock_format(),
);
Some(color_print::cformat!("<yellow>{pace:.1}×pace({bounds})</>"))
}
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, rate_limits) = 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.as_ref().and_then(|c| c.context_used_percentage);
let limits = ctx.map(|c| c.rate_limits).unwrap_or_default();
(
Path::new(¤t_dir).to_path_buf(),
model,
context_pct,
limits,
)
} else {
(
env::current_dir().context("Failed to get current directory")?,
None,
None,
Vec::new(),
)
};
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(model, PRIORITY_MODEL));
}
if let Some(pct) = context_used_percentage {
segments.push(StatuslineSegment::new(
format_context_gauge(pct),
PRIORITY_CONTEXT,
));
}
if !rate_limits.is_empty()
&& let Some(content) =
format_rate_limit_segment(&rate_limits, worktrunk::utils::epoch_now() as i64)
{
segments.push(StatuslineSegment::new(content, PRIORITY_RATE_LIMITS));
}
if segments.is_empty() {
return Ok(());
}
let max_width = terminal_width_for_statusline();
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,
include_untracked_in_working_diff: true,
..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,
include_untracked_in_working_diff: true,
..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);
}
#[test]
fn test_erf_matches_reference_values() {
let cases = [
(0.0, 0.0),
(0.5, 0.520_499_877_8),
(1.0, 0.842_700_792_9),
(2.0, 0.995_322_265_0),
(-1.0, -0.842_700_792_9),
];
for (x, expected) in cases {
assert!(
(erf(x) - expected).abs() < 1e-6,
"erf({x}): expected {expected}, got {}",
erf(x)
);
}
assert!((erf(5.0) - 1.0).abs() < 1e-7);
assert!((erf(-5.0) + 1.0).abs() < 1e-7);
}
#[test]
fn test_standard_normal_cdf_known_points() {
let cases = [
(0.0, 0.5),
(1.0, 0.841_344_746),
(-1.0, 0.158_655_254),
(1.96, 0.975_002_105),
(-1.96, 0.024_997_895),
];
for (z, expected) in cases {
assert!(
(standard_normal_cdf(z) - expected).abs() < 1e-5,
"phi({z}): expected {expected}, got {}",
standard_normal_cdf(z)
);
}
}
#[test]
fn test_p_over_boundary_cases() {
assert_eq!(p_over(0.5, 1.0, &FIVE_HOUR_PRIORS), 0.0);
assert_eq!(p_over(1.0, 1.0, &FIVE_HOUR_PRIORS), 1.0);
assert_eq!(p_over(1.5, 1.0, &FIVE_HOUR_PRIORS), 1.0);
let p0 = p_over(0.0, 0.0, &FIVE_HOUR_PRIORS);
let p1 = p_over(0.9, 0.0, &FIVE_HOUR_PRIORS);
assert!((p0 - p1).abs() < 1e-9);
assert!(p0 < 0.5);
}
#[test]
fn test_p_over_matches_prototype_values() {
let cases: &[(f64, f64, &WindowPriors, f64, &str)] = &[
(0.05, 0.03, &FIVE_HOUR_PRIORS, 0.415, "5%@3% on 5h"),
(0.30, 0.20, &FIVE_HOUR_PRIORS, 0.59, "30%@20% on 5h"),
(0.50, 0.40, &FIVE_HOUR_PRIORS, 0.61, "50%@40% on 5h"),
(0.80, 0.60, &FIVE_HOUR_PRIORS, 0.82, "80%@60% on 5h"),
(0.50, 0.30, &SEVEN_DAY_PRIORS, 0.83, "50%@30% on 7d"),
(0.30, 0.20, &SEVEN_DAY_PRIORS, 0.63, "30%@20% on 7d"),
];
for &(u, t, p, expected, label) in cases {
let got = p_over(u, t, p);
assert!(
(got - expected).abs() < 0.02,
"{label}: expected ~{expected:.3}, got {got:.3}"
);
}
}
#[test]
fn test_p_over_monotonic_in_u() {
let mut prev = 0.0;
for u_pct in 0..=100 {
let u = u_pct as f64 / 100.0;
let p = p_over(u, 0.5, &FIVE_HOUR_PRIORS);
assert!(
p >= prev - 1e-9,
"non-monotone at u={u}: prev={prev}, p={p}"
);
prev = p;
}
}
#[test]
fn test_format_clock_renders_12h_and_elides_zero_minutes() {
use chrono::TimeZone;
let cases: &[(u32, u32, &str)] = &[
(15, 0, "3pm"),
(17, 45, "5:45pm"),
(0, 0, "12am"),
(12, 0, "12pm"),
(1, 5, "1:05am"),
(23, 59, "11:59pm"),
];
for &(h, m, expected) in cases {
let d = chrono::Utc.with_ymd_and_hms(2026, 5, 23, h, m, 0).unwrap();
assert_eq!(format_clock(&d, ClockFormat::H12), expected, "h={h} m={m}");
}
}
#[test]
fn test_format_clock_renders_24h_always_with_minutes_and_zero_pad() {
use chrono::TimeZone;
let cases: &[(u32, u32, &str)] = &[
(15, 0, "15:00"),
(17, 45, "17:45"),
(0, 0, "00:00"),
(9, 5, "09:05"),
(23, 59, "23:59"),
];
for &(h, m, expected) in cases {
let d = chrono::Utc.with_ymd_and_hms(2026, 5, 23, h, m, 0).unwrap();
assert_eq!(format_clock(&d, ClockFormat::H24), expected, "h={h} m={m}");
}
}
#[test]
fn test_classify_locale_picks_12h_for_us_canada_philippines_only() {
for loc in [
"en_US",
"en_US.UTF-8",
"en_PH.UTF-8",
"en_CA.UTF-8",
"en_US.UTF-8@posix",
] {
assert_eq!(classify_locale(loc), ClockFormat::H12, "{loc}");
}
for loc in [
"en_GB",
"en_GB.UTF-8",
"en_AU.UTF-8",
"fr_FR.UTF-8",
"de_DE.UTF-8",
"ja_JP.UTF-8",
"C",
"POSIX",
"",
] {
assert_eq!(classify_locale(loc), ClockFormat::H24, "{loc}");
}
}
#[test]
fn test_format_window_bounds_five_hour_is_clock_only() {
use chrono::TimeZone;
let resets_at = chrono::Utc
.with_ymd_and_hms(2026, 5, 23, 15, 0, 0)
.unwrap()
.timestamp();
assert_eq!(
format_window_bounds(resets_at, FIVE_HOUR_SECS, &chrono::Utc, ClockFormat::H12),
"10am\u{2013}3pm"
);
assert_eq!(
format_window_bounds(resets_at, FIVE_HOUR_SECS, &chrono::Utc, ClockFormat::H24),
"10:00\u{2013}15:00"
);
}
#[test]
fn test_format_window_bounds_seven_day_is_weekday_with_end_time() {
use chrono::TimeZone;
let resets_at = chrono::Utc
.with_ymd_and_hms(2026, 1, 5, 15, 0, 0)
.unwrap()
.timestamp();
assert_eq!(
format_window_bounds(resets_at, SEVEN_DAY_SECS, &chrono::Utc, ClockFormat::H12),
"Mon\u{2013}Mon 3pm"
);
assert_eq!(
format_window_bounds(resets_at, SEVEN_DAY_SECS, &chrono::Utc, ClockFormat::H24),
"Mon\u{2013}Mon 15:00"
);
}
fn make_reading(
used_pct: f64,
t_elapsed: f64,
priors: &'static WindowPriors,
now: i64,
window_secs: i64,
) -> RateLimitReading {
let resets_at = now + ((1.0 - t_elapsed) * window_secs as f64).round() as i64;
RateLimitReading {
used_percentage: used_pct,
resets_at,
window_secs,
priors,
}
}
#[test]
fn test_select_binding_window_empty() {
assert!(select_binding_window(&[], 1_700_000_000).is_none());
}
#[test]
fn test_select_binding_window_below_threshold_hides() {
let now = 1_700_000_000_i64;
let r = make_reading(5.0, 0.03, &FIVE_HOUR_PRIORS, now, FIVE_HOUR_SECS);
assert!(select_binding_window(&[r], now).is_none());
}
#[test]
fn test_select_binding_window_visible_single() {
let now = 1_700_000_000_i64;
let r = make_reading(80.0, 0.60, &FIVE_HOUR_PRIORS, now, FIVE_HOUR_SECS);
let sel = select_binding_window(std::slice::from_ref(&r), now);
assert!(sel.is_some());
assert_eq!(sel.unwrap().used_percentage, 80.0);
}
#[test]
fn test_select_binding_window_picks_worse_of_two() {
let now = 1_700_000_000_i64;
let readings = [
make_reading(50.0, 0.30, &FIVE_HOUR_PRIORS, now, FIVE_HOUR_SECS),
make_reading(80.0, 0.40, &SEVEN_DAY_PRIORS, now, SEVEN_DAY_SECS),
];
let sel = select_binding_window(&readings, now);
assert!(sel.is_some());
assert_eq!(sel.unwrap().used_percentage, 80.0);
}
#[test]
fn test_select_binding_window_filters_below_threshold() {
let now = 1_700_000_000_i64;
let readings = [
make_reading(5.0, 0.03, &FIVE_HOUR_PRIORS, now, FIVE_HOUR_SECS),
make_reading(50.0, 0.30, &SEVEN_DAY_PRIORS, now, SEVEN_DAY_SECS),
];
let sel = select_binding_window(&readings, now);
assert!(sel.is_some());
assert_eq!(sel.unwrap().used_percentage, 50.0);
}
#[test]
fn test_format_rate_limit_segment_format_string() {
let now = 1_700_000_000_i64;
let r = make_reading(80.0, 0.60, &FIVE_HOUR_PRIORS, now, FIVE_HOUR_SECS);
let out =
format_rate_limit_segment(std::slice::from_ref(&r), now).expect("should be visible");
let visible = out.ansi_strip();
assert!(
visible.starts_with("1.3×pace(") && visible.ends_with(')'),
"unexpected format: {visible:?}"
);
}
#[test]
fn test_format_rate_limit_segment_hidden() {
let now = 1_700_000_000_i64;
let r = make_reading(5.0, 0.03, &FIVE_HOUR_PRIORS, now, FIVE_HOUR_SECS);
assert!(format_rate_limit_segment(&[r], now).is_none());
}
#[test]
fn test_parse_rate_limits_both_windows() {
let v: serde_json::Value = serde_json::from_str(
r#"{
"rate_limits": {
"five_hour": {"used_percentage": 23.5, "resets_at": 1738425600},
"seven_day": {"used_percentage": 41.2, "resets_at": 1738857600}
}
}"#,
)
.unwrap();
let limits = parse_rate_limits(&v);
assert_eq!(limits.len(), 2);
assert_eq!(limits[0].used_percentage, 23.5);
assert_eq!(limits[0].resets_at, 1738425600);
assert_eq!(limits[0].window_secs, FIVE_HOUR_SECS);
assert_eq!(limits[1].used_percentage, 41.2);
assert_eq!(limits[1].resets_at, 1738857600);
assert_eq!(limits[1].window_secs, SEVEN_DAY_SECS);
}
#[test]
fn test_parse_rate_limits_one_window_present() {
let v: serde_json::Value = serde_json::from_str(
r#"{
"rate_limits": {
"five_hour": {"used_percentage": 23.5, "resets_at": 1738425600}
}
}"#,
)
.unwrap();
let limits = parse_rate_limits(&v);
assert_eq!(limits.len(), 1);
assert_eq!(limits[0].window_secs, FIVE_HOUR_SECS);
}
#[test]
fn test_parse_rate_limits_absent() {
let v: serde_json::Value = serde_json::from_str(r#"{}"#).unwrap();
assert!(parse_rate_limits(&v).is_empty());
}
#[test]
fn test_parse_rate_limits_partial_window_dropped() {
let v: serde_json::Value = serde_json::from_str(
r#"{
"rate_limits": {
"five_hour": {"used_percentage": 23.5},
"seven_day": {"resets_at": 1738857600}
}
}"#,
)
.unwrap();
assert!(parse_rate_limits(&v).is_empty());
}
#[test]
fn test_claude_code_context_parse_with_rate_limits() {
let json = r#"{
"workspace": {"current_dir": "/tmp/test"},
"rate_limits": {
"five_hour": {"used_percentage": 23.5, "resets_at": 1738425600},
"seven_day": {"used_percentage": 41.2, "resets_at": 1738857600}
}
}"#;
let ctx = ClaudeCodeContext::parse(json).expect("should parse");
assert_eq!(ctx.rate_limits.len(), 2);
}
#[test]
fn test_claude_code_context_parse_no_rate_limits() {
let json = r#"{"workspace": {"current_dir": "/tmp/test"}}"#;
let ctx = ClaudeCodeContext::parse(json).expect("should parse");
assert!(ctx.rate_limits.is_empty());
}
}