mirror of
https://github.com/max-sixty/worktrunk.git
synced 2026-09-14 20:00:38 +08:00
e1745db105
The Commit cell sliced `&head[..8]` while `--format=json`'s `short_sha` carried git's `%h`, so one commit read `1b9f1d96` in the table and `1b9f1d9` in JSON, and `core.abbrev` reached only the JSON. #3675 gave a detached row's Branch cell the same slice, so the disagreement showed up twice on one row. `ListItem::short_sha` becomes the only abbreviation of `head` anywhere: the Commit cell, a detached row's Branch cell, the statusline, and JSON all render it. `abbreviated_head()` is gone. ## Column widths `COMMIT_HASH_WIDTH = 8` is gone. The Commit column and the Branch column's detached budget both measure the SHAs they will render, so `core.abbrev = 12` no longer truncates mid-hash and the default 7 stops reserving a column nothing fills — the freed character goes to Message. ## Latency `collect()` folds `%h` onto the rows before layout instead of after the skeleton. The batch carrying it already gates the skeleton for `%ct` sort order, so this is a map lookup rather than new I/O, and both cells are identity columns with no placeholder — they still paint in the first frame. Measured on a 40-worktree / 400-branch fixture: git subprocess counts are identical (5 pre-skeleton, 108 for the full run). Pre-skeleton wall time is unchanged; running both binaries in each order, the sign of the difference follows run order rather than the binary (+1.5 ms with this branch second, −0.5 ms with it first), so the residual sits inside drift. ## Behavior change Where the commit-details batch fails, the Commit cell is now empty rather than a slice of a SHA git refused. Age and Message already report that failure the same way, under the same warning, and two snapshots show it. `render_text_cell` also stops styling empty text, so a blank cell no longer emits an escape pair around nothing. ## Reading the diff 160 files, but the hand-written part is +91/−79 in `src/commands/list/` plus a +71 test. The rest is generated. The docs mirrors and help snapshots are symmetric. Of the snapshot lines, content is +799/−799 — every changed line a 1-for-1 hash swap — while +1396 is insta `env:` metadata refreshing on the 128 snapshots this happens to touch. `test_list_abbreviated_sha_follows_git` pins the invariant: the table's hash equals JSON's `short_sha` at git's default and at `core.abbrev = 12`, and a longer prefix is ruled out. It fails against the old fixed slice. > _This was written by Claude Code on behalf of max-sixty_
1136 lines
40 KiB
Rust
1136 lines
40 KiB
Rust
//! Snapshot tests for `wt list statusline` command.
|
||
//!
|
||
//! Tests the statusline output for shell prompts and Claude Code integration.
|
||
|
||
use crate::common::{TestRepo, repo, wt_command};
|
||
use insta::assert_snapshot;
|
||
use rstest::rstest;
|
||
use serde_json::Value;
|
||
use std::io::Write;
|
||
use std::process::Stdio;
|
||
|
||
/// Run statusline command with optional JSON piped to stdin
|
||
fn run_statusline_from_dir(
|
||
repo: &TestRepo,
|
||
args: &[&str],
|
||
stdin_json: Option<&str>,
|
||
cwd: &std::path::Path,
|
||
) -> String {
|
||
let mut cmd = wt_command();
|
||
cmd.current_dir(cwd);
|
||
cmd.args(["list", "statusline"]);
|
||
cmd.args(args);
|
||
|
||
// Apply repo's git environment
|
||
repo.configure_wt_cmd(&mut cmd);
|
||
|
||
// Pin the timezone so any wall-clock segment (the claude-code statusline
|
||
// renders `chrono::Local`) is deterministic across machines — not only in
|
||
// the `run_statusline_with_locale` helper. `LC_ALL=C` is already pinned
|
||
// globally by the test harness.
|
||
cmd.env("TZ", "UTC");
|
||
|
||
if stdin_json.is_some() {
|
||
cmd.stdin(Stdio::piped());
|
||
}
|
||
cmd.stdout(Stdio::piped());
|
||
cmd.stderr(Stdio::piped());
|
||
|
||
let mut child = cmd.spawn().expect("failed to spawn command");
|
||
|
||
if let Some(json) = stdin_json {
|
||
// Take ownership of stdin so we can drop it after writing
|
||
let mut stdin = child.stdin.take().expect("failed to get stdin");
|
||
stdin
|
||
.write_all(json.as_bytes())
|
||
.expect("failed to write stdin");
|
||
// Explicitly close stdin by dropping it - this signals EOF to the child process.
|
||
// On Windows, not closing stdin can cause the child to hang waiting for more input.
|
||
drop(stdin);
|
||
}
|
||
|
||
let output = child.wait_with_output().expect("failed to wait for output");
|
||
|
||
// Statusline outputs to stdout in interactive mode
|
||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||
|
||
// Return whichever has content (stdout for interactive)
|
||
if !stdout.is_empty() {
|
||
stdout.to_string()
|
||
} else {
|
||
stderr.to_string()
|
||
}
|
||
}
|
||
|
||
fn run_statusline(repo: &TestRepo, args: &[&str], stdin_json: Option<&str>) -> String {
|
||
run_statusline_from_dir(repo, args, stdin_json, repo.root_path())
|
||
}
|
||
|
||
// --- Test Setup Helpers ---
|
||
|
||
fn add_uncommitted_changes(repo: &TestRepo) {
|
||
// Create uncommitted changes
|
||
std::fs::write(repo.root_path().join("modified.txt"), "modified content").unwrap();
|
||
}
|
||
|
||
fn add_commits_ahead(repo: &mut TestRepo) {
|
||
// Create feature branch with commits ahead
|
||
let feature_path = repo.add_worktree("feature");
|
||
|
||
// Add commits in the feature worktree
|
||
std::fs::write(feature_path.join("feature.txt"), "feature content").unwrap();
|
||
repo.git_command()
|
||
.args(["add", "."])
|
||
.current_dir(&feature_path)
|
||
.run()
|
||
.unwrap();
|
||
repo.git_command()
|
||
.args(["commit", "-m", "Feature commit 1"])
|
||
.current_dir(&feature_path)
|
||
.run()
|
||
.unwrap();
|
||
|
||
std::fs::write(feature_path.join("feature2.txt"), "more content").unwrap();
|
||
repo.git_command()
|
||
.args(["add", "."])
|
||
.current_dir(&feature_path)
|
||
.run()
|
||
.unwrap();
|
||
repo.git_command()
|
||
.args(["commit", "-m", "Feature commit 2"])
|
||
.current_dir(&feature_path)
|
||
.run()
|
||
.unwrap();
|
||
}
|
||
|
||
// --- Basic Tests ---
|
||
|
||
#[rstest]
|
||
fn test_statusline_basic(repo: TestRepo) {
|
||
let output = run_statusline(&repo, &[], None);
|
||
assert_snapshot!(output, @"[0m main [2m^[22m[2m|[22m");
|
||
}
|
||
|
||
#[rstest]
|
||
fn test_statusline_with_changes(repo: TestRepo) {
|
||
add_uncommitted_changes(&repo);
|
||
let output = run_statusline(&repo, &[], None);
|
||
assert_snapshot!(output, @"[0m main [36m?[0m[2m^[22m[2m|[22m @[32m+1[0m");
|
||
}
|
||
|
||
#[rstest]
|
||
fn test_statusline_ci_pr_number(repo: TestRepo) {
|
||
// Cached CI status with a PR number renders as the colored PR reference
|
||
super::list::mock_ci_status(&repo, "main", "passed", "pr", false, Some(3035));
|
||
let output = run_statusline(&repo, &[], None);
|
||
assert_snapshot!(output, @"[0m main [2m^[22m[2m|[22m [32m#3035[0m");
|
||
}
|
||
|
||
#[rstest]
|
||
fn test_statusline_commits_ahead(mut repo: TestRepo) {
|
||
add_commits_ahead(&mut repo);
|
||
// Run from the feature worktree to see commits ahead
|
||
let feature_path = repo.worktree_path("feature");
|
||
let output = run_statusline_from_dir(&repo, &[], None, feature_path);
|
||
assert_snapshot!(output, @"[0m feature [2m↑[22m [32m↑2[0m ^[32m+2[0m");
|
||
}
|
||
|
||
/// `-C` selects the worktree, not the process cwd.
|
||
///
|
||
/// Every other test here sets the child's cwd, which reaches the worktree by a
|
||
/// different route: `-C` sets wt's discovery path without chdir'ing, so a
|
||
/// statusline built from `std::env::current_dir()` would describe the cwd's
|
||
/// worktree (`main`) and pass those tests while ignoring the flag.
|
||
#[rstest]
|
||
fn test_statusline_directory_flag(mut repo: TestRepo) {
|
||
let feature_path = repo.add_worktree("feature");
|
||
|
||
// cwd is the main worktree; only `-C` points at `feature`
|
||
let output = run_statusline_from_dir(
|
||
&repo,
|
||
&["-C", feature_path.to_str().unwrap()],
|
||
None,
|
||
repo.root_path(),
|
||
);
|
||
assert_snapshot!(output, @"[0m feature [2m_[22m");
|
||
}
|
||
|
||
/// With no stdin JSON, claude-code mode falls back to the same worktree the
|
||
/// table format uses, so it honours `-C` too. A directory on stdin still wins:
|
||
/// it names the session's directory rather than the one wt was pointed at.
|
||
#[rstest]
|
||
fn test_statusline_claude_code_directory_flag(mut repo: TestRepo) {
|
||
let feature_path = repo.add_worktree("feature");
|
||
|
||
let output = run_statusline_from_dir(
|
||
&repo,
|
||
&["--format=claude-code", "-C", feature_path.to_str().unwrap()],
|
||
None,
|
||
repo.root_path(),
|
||
);
|
||
claude_code_snapshot_settings().bind(|| {
|
||
assert_snapshot!(output, @"[0m [PATH].feature [2m_[22m");
|
||
});
|
||
|
||
// stdin's directory outranks `-C`
|
||
let json = format!(
|
||
r#"{{"workspace": {{"current_dir": "{}"}}}}"#,
|
||
escape_path_for_json(repo.root_path())
|
||
);
|
||
let output = run_statusline_from_dir(
|
||
&repo,
|
||
&["--format=claude-code", "-C", feature_path.to_str().unwrap()],
|
||
Some(&json),
|
||
repo.root_path(),
|
||
);
|
||
claude_code_snapshot_settings().bind(|| {
|
||
assert_snapshot!(output, @"[0m [PATH] main [2m^[22m[2m|[22m");
|
||
});
|
||
}
|
||
|
||
/// A stdin directory in a *different* repository than the one wt discovered.
|
||
///
|
||
/// The worktree comes from stdin while `list_worktrees` comes from the
|
||
/// discovered repo, so the stdin directory matches none of them and only its
|
||
/// branch can be reported. That split is the known limit of taking the
|
||
/// directory from stdin: everything repo-wide still comes from wt's own
|
||
/// discovery path.
|
||
#[rstest]
|
||
fn test_statusline_claude_code_foreign_repo_on_stdin(repo: TestRepo) {
|
||
let foreign = repo.root_path().join("foreign");
|
||
std::fs::create_dir(&foreign).unwrap();
|
||
repo.git_command()
|
||
.args(["init", "--initial-branch=elsewhere"])
|
||
.current_dir(&foreign)
|
||
.run()
|
||
.unwrap();
|
||
repo.git_command()
|
||
.args(["commit", "-m", "init", "--allow-empty"])
|
||
.current_dir(&foreign)
|
||
.run()
|
||
.unwrap();
|
||
|
||
let json = format!(
|
||
r#"{{"workspace": {{"current_dir": "{}"}}}}"#,
|
||
escape_path_for_json(&foreign)
|
||
);
|
||
let output = run_statusline(&repo, &["--format=claude-code"], Some(&json));
|
||
let mut settings = claude_code_snapshot_settings();
|
||
// A nested directory renders home-relative, which the platform-specific
|
||
// `[PATH]` filters don't reach; the branch is this test's subject.
|
||
settings.add_filter(r"[^ ]*foreign", "[FOREIGN]");
|
||
settings.bind(|| {
|
||
assert_snapshot!(output, @"[0m [FOREIGN] elsewhere");
|
||
});
|
||
}
|
||
|
||
#[rstest]
|
||
fn test_statusline_json_directory_flag(mut repo: TestRepo) {
|
||
let feature_path = repo.add_worktree("feature");
|
||
|
||
// cwd is the main worktree; only `-C` points at `feature`
|
||
let output = run_statusline_from_dir(
|
||
&repo,
|
||
&["--format=json", "-C", feature_path.to_str().unwrap()],
|
||
None,
|
||
repo.root_path(),
|
||
);
|
||
let parsed: Value = serde_json::from_str(&output).expect("should be valid JSON");
|
||
let items = parsed.as_array().expect("should be an array");
|
||
assert_eq!(items.len(), 1, "should describe just the -C worktree");
|
||
assert_eq!(items[0]["branch"], "feature");
|
||
}
|
||
|
||
#[rstest]
|
||
fn test_statusline_does_not_run_repo_wide_ahead_behind_scan(repo: TestRepo) {
|
||
repo.create_branches(&(0..12).map(|i| format!("unused-{i}")).collect::<Vec<_>>());
|
||
|
||
let output = repo
|
||
.wt_command()
|
||
.args(["list", "statusline"])
|
||
.env("RUST_LOG", "debug")
|
||
.output()
|
||
.expect("statusline should run");
|
||
assert!(
|
||
output.status.success(),
|
||
"statusline failed:\nstdout:\n{}\nstderr:\n{}",
|
||
String::from_utf8_lossy(&output.stdout),
|
||
String::from_utf8_lossy(&output.stderr)
|
||
);
|
||
|
||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||
assert!(
|
||
stderr.contains("for-each-ref"),
|
||
"debug trace should include ref scans so this assertion is meaningful:\n{stderr}"
|
||
);
|
||
assert!(
|
||
!stderr.contains("%(ahead-behind:"),
|
||
"single-row statusline must not run repo-wide ahead/behind scans:\n{stderr}"
|
||
);
|
||
}
|
||
|
||
// --- Claude Code Mode Tests ---
|
||
|
||
/// Create snapshot settings that normalize path output for statusline tests.
|
||
///
|
||
/// The statusline output varies by platform:
|
||
/// - Linux: Raw path is filtered by auto-bound settings to `_REPO_`
|
||
/// - macOS: Fish-style abbreviation (e.g., `/p/v/f/.../repo`) bypasses auto-bound filters
|
||
///
|
||
/// This function normalizes both cases to a consistent `[PATH]` placeholder.
|
||
fn claude_code_snapshot_settings() -> insta::Settings {
|
||
let mut settings = insta::Settings::clone_current();
|
||
// Normalize _REPO_ (from auto-bound filters on Linux) to [PATH]
|
||
settings.add_filter(r"_REPO_", "[PATH]");
|
||
// Normalize fish-abbreviated paths (on macOS) to [PATH]
|
||
settings.add_filter(r"/[a-zA-Z0-9/._-]+/repo", "[PATH]");
|
||
settings
|
||
}
|
||
|
||
/// Escape a path for use in JSON strings.
|
||
/// On Windows, backslashes must be escaped as double backslashes.
|
||
fn escape_path_for_json(path: &std::path::Path) -> String {
|
||
path.display().to_string().replace('\\', r"\\")
|
||
}
|
||
|
||
#[rstest]
|
||
fn test_statusline_claude_code_full_context(repo: TestRepo) {
|
||
add_uncommitted_changes(&repo);
|
||
|
||
let escaped_path = escape_path_for_json(repo.root_path());
|
||
let json = format!(
|
||
r#"{{
|
||
"hook_event_name": "Status",
|
||
"session_id": "test-session",
|
||
"model": {{
|
||
"id": "claude-opus-4-1",
|
||
"display_name": "Opus"
|
||
}},
|
||
"workspace": {{
|
||
"current_dir": "{escaped_path}",
|
||
"project_dir": "{escaped_path}"
|
||
}},
|
||
"version": "1.0.80"
|
||
}}"#,
|
||
);
|
||
|
||
let output = run_statusline(&repo, &["--format=claude-code"], Some(&json));
|
||
claude_code_snapshot_settings().bind(|| {
|
||
assert_snapshot!(output, @"[0m [PATH] main [36m?[0m[2m^[22m[2m|[22m @[32m+1[0m Opus");
|
||
});
|
||
}
|
||
|
||
#[rstest]
|
||
fn test_statusline_claude_code_minimal(repo: TestRepo) {
|
||
let escaped_path = escape_path_for_json(repo.root_path());
|
||
let json = format!(r#"{{"workspace": {{"current_dir": "{escaped_path}"}}}}"#,);
|
||
|
||
let output = run_statusline(&repo, &["--format=claude-code"], Some(&json));
|
||
claude_code_snapshot_settings().bind(|| {
|
||
assert_snapshot!(output, @"[0m [PATH] main [2m^[22m[2m|[22m");
|
||
});
|
||
}
|
||
|
||
#[rstest]
|
||
fn test_statusline_claude_code_with_model(repo: TestRepo) {
|
||
let escaped_path = escape_path_for_json(repo.root_path());
|
||
let json = format!(
|
||
r#"{{
|
||
"workspace": {{"current_dir": "{escaped_path}"}},
|
||
"model": {{"display_name": "Haiku"}}
|
||
}}"#,
|
||
);
|
||
|
||
let output = run_statusline(&repo, &["--format=claude-code"], Some(&json));
|
||
claude_code_snapshot_settings().bind(|| {
|
||
assert_snapshot!(output, @"[0m [PATH] main [2m^[22m[2m|[22m Haiku");
|
||
});
|
||
}
|
||
|
||
// --- Context Gauge Tests ---
|
||
|
||
#[rstest]
|
||
fn test_statusline_claude_code_with_context_gauge(repo: TestRepo) {
|
||
let escaped_path = escape_path_for_json(repo.root_path());
|
||
let json = format!(
|
||
r#"{{
|
||
"workspace": {{"current_dir": "{escaped_path}"}},
|
||
"model": {{"display_name": "Opus"}},
|
||
"context_window": {{"used_percentage": 42}}
|
||
}}"#,
|
||
);
|
||
|
||
let output = run_statusline(&repo, &["--format=claude-code"], Some(&json));
|
||
claude_code_snapshot_settings().bind(|| {
|
||
assert_snapshot!(output, @"[0m [PATH] main [2m^[22m[2m|[22m Opus 🌕 42%");
|
||
});
|
||
}
|
||
|
||
#[rstest]
|
||
fn test_statusline_claude_code_context_gauge_low(repo: TestRepo) {
|
||
let escaped_path = escape_path_for_json(repo.root_path());
|
||
let json = format!(
|
||
r#"{{
|
||
"workspace": {{"current_dir": "{escaped_path}"}},
|
||
"model": {{"display_name": "Opus"}},
|
||
"context_window": {{"used_percentage": 5}}
|
||
}}"#,
|
||
);
|
||
|
||
let output = run_statusline(&repo, &["--format=claude-code"], Some(&json));
|
||
claude_code_snapshot_settings().bind(|| {
|
||
assert_snapshot!(output, @"[0m [PATH] main [2m^[22m[2m|[22m Opus 🌕 5%");
|
||
});
|
||
}
|
||
|
||
#[rstest]
|
||
fn test_statusline_claude_code_context_gauge_high(repo: TestRepo) {
|
||
let escaped_path = escape_path_for_json(repo.root_path());
|
||
let json = format!(
|
||
r#"{{
|
||
"workspace": {{"current_dir": "{escaped_path}"}},
|
||
"model": {{"display_name": "Opus"}},
|
||
"context_window": {{"used_percentage": 98}}
|
||
}}"#,
|
||
);
|
||
|
||
let output = run_statusline(&repo, &["--format=claude-code"], Some(&json));
|
||
claude_code_snapshot_settings().bind(|| {
|
||
assert_snapshot!(output, @"[0m [PATH] main [2m^[22m[2m|[22m Opus 🌑 98%");
|
||
});
|
||
}
|
||
|
||
#[rstest]
|
||
fn test_statusline_claude_code_missing_context_window(repo: TestRepo) {
|
||
// When context_window is missing, no gauge should be displayed
|
||
let escaped_path = escape_path_for_json(repo.root_path());
|
||
let json = format!(
|
||
r#"{{
|
||
"workspace": {{"current_dir": "{escaped_path}"}},
|
||
"model": {{"display_name": "Opus"}}
|
||
}}"#,
|
||
);
|
||
|
||
let output = run_statusline(&repo, &["--format=claude-code"], Some(&json));
|
||
claude_code_snapshot_settings().bind(|| {
|
||
assert_snapshot!(output, @"[0m [PATH] main [2m^[22m[2m|[22m Opus");
|
||
});
|
||
}
|
||
|
||
// --- Directive Mode Tests ---
|
||
// Note: With the split directive file architecture, data output (like statusline)
|
||
// still goes to stdout. The directive files are only used for cd paths and exec
|
||
// commands. So this test is no longer needed - statusline behavior is the same
|
||
// regardless of whether directive env vars are set.
|
||
|
||
// --- Branch Display Tests ---
|
||
|
||
///
|
||
/// Git updates worktree metadata (`branch` field in `git worktree list`) when
|
||
/// you checkout a different branch. This test verifies that statusline correctly
|
||
/// shows the new branch name after such a checkout.
|
||
#[rstest]
|
||
fn test_statusline_reflects_checked_out_branch(mut repo: TestRepo) {
|
||
// Create a feature worktree
|
||
let feature_path = repo.add_worktree("feature");
|
||
|
||
// Verify statusline shows "feature" initially
|
||
let output = run_statusline_from_dir(&repo, &[], None, &feature_path);
|
||
assert!(
|
||
output.contains("feature"),
|
||
"statusline should show 'feature' for feature worktree, got: {output}"
|
||
);
|
||
|
||
// Create and checkout a different branch "other" in the feature worktree
|
||
repo.git_command().args(["branch", "other"]).run().unwrap();
|
||
let checkout_output = repo
|
||
.git_command()
|
||
.args(["checkout", "other"])
|
||
.current_dir(&feature_path)
|
||
.run()
|
||
.unwrap();
|
||
assert!(
|
||
checkout_output.status.success(),
|
||
"checkout should succeed: {}",
|
||
String::from_utf8_lossy(&checkout_output.stderr)
|
||
);
|
||
|
||
// Verify statusline now shows "other"
|
||
let output = run_statusline_from_dir(&repo, &[], None, &feature_path);
|
||
assert!(
|
||
output.contains("other"),
|
||
"statusline should show 'other' after checkout, got: {output}"
|
||
);
|
||
assert!(
|
||
!output.contains("feature"),
|
||
"statusline should not show 'feature' after checkout, got: {output}"
|
||
);
|
||
}
|
||
|
||
#[rstest]
|
||
fn test_statusline_detached_head(mut repo: TestRepo) {
|
||
// Create a feature worktree
|
||
let feature_path = repo.add_worktree("feature");
|
||
|
||
// Detach HEAD
|
||
repo.git_command()
|
||
.args(["checkout", "--detach"])
|
||
.current_dir(&feature_path)
|
||
.run()
|
||
.unwrap();
|
||
|
||
// Verify the statusline names a detached worktree the way its `wt list`
|
||
// row does — by the abbreviated HEAD — and not by the prior branch name.
|
||
// The SHA also tells two detached worktrees apart, which one shared
|
||
// "(detached)" label cannot.
|
||
let output = run_statusline_from_dir(&repo, &[], None, &feature_path);
|
||
// Ask git for the abbreviation instead of slicing a fixed width: the
|
||
// statusline prints git's own `%h`, so the length follows `core.abbrev` and
|
||
// stretches further still when a prefix is ambiguous.
|
||
let head = repo.git_output(&["rev-parse", "--short", "HEAD"]);
|
||
let short_head = head.trim();
|
||
assert!(
|
||
output.contains(short_head),
|
||
"statusline should show the abbreviated HEAD ({short_head}) for detached HEAD, got: {output}"
|
||
);
|
||
assert!(
|
||
!output.contains("feature"),
|
||
"statusline should not show the prior branch name, got: {output}"
|
||
);
|
||
}
|
||
|
||
/// With no default branch to compare against, the statusline is the branch
|
||
/// name alone — every other segment describes a relation to that branch.
|
||
///
|
||
/// Two branches, neither named main/master/develop/trunk, and no remote:
|
||
/// nothing is left for `default_branch()` to infer from.
|
||
#[rstest]
|
||
fn test_statusline_without_default_branch() {
|
||
let repo = TestRepo::with_initial_commit();
|
||
repo.run_git(&["branch", "-m", "main", "alpha"]);
|
||
repo.run_git(&["branch", "beta"]);
|
||
|
||
let output = run_statusline(&repo, &[], None);
|
||
assert_snapshot!(output, @"[0m alpha");
|
||
}
|
||
|
||
// --- URL Display Tests ---
|
||
|
||
#[rstest]
|
||
fn test_statusline_with_url(repo: TestRepo) {
|
||
// Configure URL template with simple branch variable (no hash_port for deterministic output)
|
||
repo.write_project_config(
|
||
r#"[list]
|
||
url = "http://{{ branch }}.localhost:3000"
|
||
"#,
|
||
);
|
||
|
||
let output = run_statusline(&repo, &[], None);
|
||
// Shows `?` because writing project config creates uncommitted file
|
||
assert_snapshot!(output, @r"[0m main [36m?[0m[2m^[22m[2m|[22m @[32m+2[0m [2m[4m]8;;http://main.localhost:3000\:3000]8;;\[24m[22m");
|
||
}
|
||
|
||
#[rstest]
|
||
fn test_statusline_url_in_feature_worktree(mut repo: TestRepo) {
|
||
// Configure URL template with simple branch variable
|
||
repo.write_project_config(
|
||
r#"[list]
|
||
url = "http://{{ branch }}.localhost:3000"
|
||
"#,
|
||
);
|
||
|
||
// Commit the project config so it's visible in worktrees
|
||
repo.run_git(&["add", ".config/wt.toml"]);
|
||
repo.run_git(&["commit", "-m", "Add project config"]);
|
||
|
||
// Create feature worktree
|
||
let feature_path = repo.add_worktree("feature");
|
||
|
||
// Run statusline from feature worktree
|
||
let output = run_statusline_from_dir(&repo, &[], None, &feature_path);
|
||
assert_snapshot!(output, @r"[0m feature [2m_[22m [2m[4m]8;;http://feature.localhost:3000\:3000]8;;\[24m[22m");
|
||
}
|
||
|
||
/// The URL segment lands after the CI reference and before the model in Claude
|
||
/// Code mode, where the directory and model segments share the line with it.
|
||
#[rstest]
|
||
fn test_statusline_claude_code_url_placement(repo: TestRepo) {
|
||
repo.write_project_config(
|
||
r#"[list]
|
||
url = "http://{{ branch }}.localhost:3000"
|
||
"#,
|
||
);
|
||
|
||
let escaped_path = escape_path_for_json(repo.root_path());
|
||
let json = format!(
|
||
r#"{{"model": {{"display_name": "Opus"}}, "workspace": {{"current_dir": "{escaped_path}"}}}}"#
|
||
);
|
||
|
||
let output = run_statusline(&repo, &["--format=claude-code"], Some(&json));
|
||
claude_code_snapshot_settings().bind(|| {
|
||
assert_snapshot!(output, @r"[0m [PATH] main [36m?[0m[2m^[22m[2m|[22m @[32m+2[0m [2m[4m]8;;http://main.localhost:3000\:3000]8;;\[24m[22m Opus");
|
||
});
|
||
}
|
||
|
||
// --- JSON Format Tests ---
|
||
|
||
#[rstest]
|
||
fn test_statusline_json_basic(repo: TestRepo) {
|
||
let output = run_statusline(&repo, &["--format=json"], None);
|
||
let parsed: Value = serde_json::from_str(&output).expect("should be valid JSON");
|
||
|
||
// Should be an array with one item
|
||
let items = parsed.as_array().expect("should be an array");
|
||
assert_eq!(
|
||
items.len(),
|
||
1,
|
||
"should have exactly one item (current worktree)"
|
||
);
|
||
|
||
let item = &items[0];
|
||
|
||
// Check essential fields
|
||
assert_eq!(item["branch"], "main");
|
||
assert_eq!(item["kind"], "worktree");
|
||
assert!(item["is_current"].as_bool().unwrap());
|
||
assert!(item["is_main"].as_bool().unwrap());
|
||
|
||
// commit object should exist with sha, message, and non-zero timestamp
|
||
assert!(item["commit"]["sha"].is_string());
|
||
assert!(item["commit"]["short_sha"].is_string());
|
||
assert!(
|
||
!item["commit"]["message"].as_str().unwrap().is_empty(),
|
||
"commit.message should be populated from git log"
|
||
);
|
||
assert!(
|
||
item["commit"]["timestamp"].as_i64().unwrap() > 0,
|
||
"commit.timestamp should be populated from git log"
|
||
);
|
||
}
|
||
|
||
/// `[list] json-schema = 2` switches the statusline JSON to the envelope,
|
||
/// with the same single-item contract as the schema-1 array. An unset key
|
||
/// stays silent here — the statusline surface suppresses warnings.
|
||
#[rstest]
|
||
fn test_statusline_json_schema_2(repo: TestRepo) {
|
||
repo.write_test_config("[list]\njson-schema = 2\n");
|
||
|
||
let output = run_statusline(&repo, &["--format=json"], None);
|
||
let parsed: Value = serde_json::from_str(&output).expect("should be valid JSON");
|
||
|
||
assert_eq!(parsed["schema"], 2);
|
||
assert_eq!(
|
||
parsed["collected"],
|
||
serde_json::json!({"ci": true, "summary": false}),
|
||
"statusline collects with full gates, no summaries"
|
||
);
|
||
let items = parsed["items"].as_array().expect("envelope items");
|
||
assert_eq!(items.len(), 1, "one item: the current worktree");
|
||
|
||
let item = &items[0];
|
||
assert_eq!(item["branch"], "main");
|
||
assert!(item["worktree"]["current"].as_bool().unwrap());
|
||
assert!(item["worktree"]["main"].as_bool().unwrap());
|
||
assert!(
|
||
item.get("default_branch").is_none(),
|
||
"the default branch row carries no relation to itself"
|
||
);
|
||
assert!(item["head"]["sha"].is_string());
|
||
assert!(
|
||
item["display"]["statusline"].is_string(),
|
||
"pre-rendered statusline lives under display"
|
||
);
|
||
}
|
||
|
||
/// Outside a worktree the JSON surface emits an empty result in the
|
||
/// selected schema, and the schema-2 empty envelope skips default-branch
|
||
/// detection (no `default_branch` key — nothing to relate to).
|
||
#[rstest]
|
||
fn test_statusline_json_outside_worktree(repo: TestRepo) {
|
||
let git_dir = repo.root_path().join(".git");
|
||
|
||
let output = run_statusline_from_dir(&repo, &["--format=json"], None, &git_dir);
|
||
assert_eq!(output.trim(), "[]", "schema 1 empty result is a bare array");
|
||
|
||
repo.write_test_config("[list]\njson-schema = 2\n");
|
||
let output = run_statusline_from_dir(&repo, &["--format=json"], None, &git_dir);
|
||
let parsed: Value = serde_json::from_str(&output).expect("should be valid JSON");
|
||
assert_eq!(parsed["schema"], 2);
|
||
assert_eq!(parsed["items"], serde_json::json!([]));
|
||
assert!(
|
||
parsed["repo"].get("default_branch").is_none(),
|
||
"item-less path must not attempt default-branch detection"
|
||
);
|
||
}
|
||
|
||
/// An invalid `[list] json-schema` degrades to schema 1 silently here —
|
||
/// the statusline surface suppresses warnings, so a config typo must not
|
||
/// corrupt the prompt.
|
||
#[rstest]
|
||
fn test_statusline_json_invalid_schema_degrades_silently(repo: TestRepo) {
|
||
repo.write_test_config("[list]\njson-schema = 7\n");
|
||
let output = run_statusline(&repo, &["--format=json"], None);
|
||
let parsed: Value = serde_json::from_str(&output).expect("should be valid JSON");
|
||
assert!(parsed.is_array(), "degrades to the schema 1 array");
|
||
}
|
||
|
||
#[rstest]
|
||
fn test_statusline_json_with_changes(repo: TestRepo) {
|
||
// Create uncommitted changes
|
||
std::fs::write(repo.root_path().join("modified.txt"), "modified content").unwrap();
|
||
|
||
let output = run_statusline(&repo, &["--format=json"], None);
|
||
let parsed: Value = serde_json::from_str(&output).expect("should be valid JSON");
|
||
|
||
let item = &parsed[0];
|
||
assert_eq!(item["branch"], "main");
|
||
|
||
// Should have working_tree status
|
||
let working_tree = &item["working_tree"];
|
||
assert!(
|
||
working_tree["untracked"].as_bool().unwrap(),
|
||
"should show untracked file"
|
||
);
|
||
}
|
||
|
||
#[rstest]
|
||
fn test_statusline_json_feature_branch(mut repo: TestRepo) {
|
||
// Create feature worktree with commits
|
||
let feature_path = repo.add_worktree("feature");
|
||
|
||
std::fs::write(feature_path.join("feature.txt"), "content").unwrap();
|
||
repo.git_command()
|
||
.args(["add", "."])
|
||
.current_dir(&feature_path)
|
||
.run()
|
||
.unwrap();
|
||
repo.git_command()
|
||
.args(["commit", "-m", "Feature commit"])
|
||
.current_dir(&feature_path)
|
||
.run()
|
||
.unwrap();
|
||
|
||
let output = run_statusline_from_dir(&repo, &["--format=json"], None, &feature_path);
|
||
let parsed: Value = serde_json::from_str(&output).expect("should be valid JSON");
|
||
|
||
let item = &parsed[0];
|
||
assert_eq!(item["branch"], "feature");
|
||
assert!(item["is_current"].as_bool().unwrap());
|
||
assert!(!item["is_main"].as_bool().unwrap());
|
||
|
||
// Should have ahead/behind counts (commits ahead of main)
|
||
assert!(
|
||
item["main"]["ahead"].as_u64().unwrap() >= 1,
|
||
"should be ahead of main"
|
||
);
|
||
}
|
||
|
||
#[rstest]
|
||
fn test_statusline_json_ignores_claude_code(repo: TestRepo) {
|
||
// When --format=json is used, --claude-code should be ignored
|
||
let escaped_path = escape_path_for_json(repo.root_path());
|
||
let json = format!(
|
||
r#"{{
|
||
"workspace": {{"current_dir": "{escaped_path}"}},
|
||
"model": {{"display_name": "Opus"}}
|
||
}}"#,
|
||
);
|
||
|
||
let output = run_statusline(&repo, &["--format=json", "--claude-code"], Some(&json));
|
||
let parsed: Value = serde_json::from_str(&output).expect("should be valid JSON");
|
||
|
||
// Should still produce JSON output (not statusline format)
|
||
assert!(parsed.is_array(), "should produce JSON array output");
|
||
let item = &parsed[0];
|
||
assert_eq!(item["branch"], "main");
|
||
}
|
||
|
||
// --- Deprecated flags ---
|
||
|
||
#[rstest]
|
||
fn test_statusline_claude_code_flag_deprecated(repo: TestRepo) {
|
||
// The hidden `--claude-code` flag still maps to `--format=claude-code`,
|
||
// but emits a deprecation warning per invocation.
|
||
let escaped_path = escape_path_for_json(repo.root_path());
|
||
let json = format!(r#"{{"workspace": {{"current_dir": "{escaped_path}"}}}}"#);
|
||
|
||
let mut cmd = wt_command();
|
||
cmd.current_dir(repo.root_path());
|
||
cmd.args(["list", "statusline", "--claude-code"]);
|
||
repo.configure_wt_cmd(&mut cmd);
|
||
cmd.stdin(Stdio::piped());
|
||
cmd.stdout(Stdio::piped());
|
||
cmd.stderr(Stdio::piped());
|
||
|
||
let mut child = cmd.spawn().expect("spawn");
|
||
child
|
||
.stdin
|
||
.as_mut()
|
||
.unwrap()
|
||
.write_all(json.as_bytes())
|
||
.unwrap();
|
||
let output = child.wait_with_output().expect("wait");
|
||
|
||
assert!(output.status.success());
|
||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||
assert!(
|
||
stderr.contains("--claude-code is deprecated"),
|
||
"expected deprecation warning in stderr, got: {stderr}"
|
||
);
|
||
assert!(
|
||
stderr.contains("--format=claude-code"),
|
||
"expected migration hint in stderr, got: {stderr}"
|
||
);
|
||
}
|
||
|
||
/// Tests that statusline correctly identifies nested worktrees.
|
||
///
|
||
/// When worktrees are placed inside other worktrees (e.g., `.worktrees/` layout),
|
||
/// the detection must use git rev-parse --show-toplevel rather than prefix matching,
|
||
/// which would incorrectly match the parent worktree.
|
||
///
|
||
/// Regression test for: prefix matching with starts_with would incorrectly identify
|
||
/// the main worktree when running from a nested worktree.
|
||
#[rstest]
|
||
fn test_statusline_nested_worktree(mut repo: TestRepo) {
|
||
// Create a worktree nested inside the main repo (like .worktrees/ layout)
|
||
let nested_path = repo.root_path().join(".worktrees").join("feature");
|
||
let nested_worktree = repo.add_worktree_at_path("feature", &nested_path);
|
||
|
||
// Run statusline from inside the nested worktree - should show "feature", not "main"
|
||
let output = repo
|
||
.wt_command()
|
||
.current_dir(&nested_worktree)
|
||
.args(["list", "statusline"])
|
||
.output()
|
||
.expect("statusline should succeed");
|
||
|
||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||
assert!(
|
||
stdout.contains("feature"),
|
||
"Nested worktree should show 'feature' branch, got: {stdout}"
|
||
);
|
||
assert!(
|
||
!stdout.contains("main"),
|
||
"Nested worktree should NOT show 'main' branch, got: {stdout}"
|
||
);
|
||
}
|
||
|
||
/// Tests that JSON output correctly identifies nested worktrees.
|
||
#[rstest]
|
||
fn test_statusline_json_nested_worktree(mut repo: TestRepo) {
|
||
// Create a worktree nested inside the main repo
|
||
let nested_path = repo.root_path().join(".worktrees").join("feature");
|
||
let nested_worktree = repo.add_worktree_at_path("feature", &nested_path);
|
||
|
||
// Run statusline --format=json from inside the nested worktree
|
||
let output = repo
|
||
.wt_command()
|
||
.current_dir(&nested_worktree)
|
||
.args(["list", "statusline", "--format=json"])
|
||
.output()
|
||
.expect("statusline should succeed");
|
||
|
||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||
let parsed: Value = serde_json::from_str(&stdout).expect("should be valid JSON");
|
||
|
||
assert!(parsed.is_array(), "should produce JSON array");
|
||
let items = parsed.as_array().unwrap();
|
||
assert_eq!(items.len(), 1, "should have exactly one item");
|
||
assert_eq!(
|
||
items[0]["branch"], "feature",
|
||
"Nested worktree should report 'feature' branch, not parent"
|
||
);
|
||
}
|
||
|
||
// --- Rate-limit segment snapshots ---
|
||
//
|
||
// These tests pin the "now" used by the rate-limit predictor (via
|
||
// `WORKTRUNK_TEST_EPOCH`), force `TZ=UTC` so the deadline string is
|
||
// deterministic across machines and timezones, and pin `LC_TIME=en_US.UTF-8`
|
||
// so the clock segment renders 12h (`3pm`) — matching the docs example and
|
||
// the US/macOS default. The 24h path has its own test that overrides
|
||
// `LC_TIME` to a non-US locale.
|
||
//
|
||
// All cases use Thursday 2025-01-02 13:00 UTC as the reference "now", and
|
||
// resets_at values relative to it.
|
||
|
||
use crate::common::TEST_EPOCH;
|
||
|
||
const TEST_NOW_THU_1PM: u64 = TEST_EPOCH + 13 * 3600;
|
||
|
||
/// Run statusline with a pinned "now" and timezone; optional COLUMNS override.
|
||
///
|
||
/// Defaults to `LC_TIME=en_US.UTF-8` (12h clock); pass a different locale
|
||
/// string to exercise the 24h path.
|
||
fn run_statusline_at_time(
|
||
repo: &TestRepo,
|
||
stdin_json: &str,
|
||
now_epoch: u64,
|
||
columns: Option<u32>,
|
||
) -> String {
|
||
run_statusline_with_locale(repo, stdin_json, now_epoch, columns, "en_US.UTF-8")
|
||
}
|
||
|
||
fn run_statusline_with_locale(
|
||
repo: &TestRepo,
|
||
stdin_json: &str,
|
||
now_epoch: u64,
|
||
columns: Option<u32>,
|
||
lc_time: &str,
|
||
) -> String {
|
||
let mut cmd = wt_command();
|
||
cmd.current_dir(repo.root_path());
|
||
cmd.args(["list", "statusline", "--format=claude-code"]);
|
||
repo.configure_wt_cmd(&mut cmd);
|
||
cmd.env("WORKTRUNK_TEST_EPOCH", now_epoch.to_string());
|
||
cmd.env("TZ", "UTC");
|
||
// The test harness pins `LC_ALL=C` globally for determinism; clear it
|
||
// so `LC_TIME` actually takes effect (POSIX precedence: LC_ALL beats
|
||
// LC_TIME). Then set `LC_TIME` per-test.
|
||
cmd.env_remove("LC_ALL");
|
||
cmd.env("LC_TIME", lc_time);
|
||
if let Some(c) = columns {
|
||
cmd.env("COLUMNS", c.to_string());
|
||
}
|
||
cmd.stdin(Stdio::piped());
|
||
cmd.stdout(Stdio::piped());
|
||
cmd.stderr(Stdio::piped());
|
||
|
||
let mut child = cmd.spawn().expect("failed to spawn command");
|
||
let mut stdin = child.stdin.take().expect("failed to get stdin");
|
||
stdin
|
||
.write_all(stdin_json.as_bytes())
|
||
.expect("failed to write stdin");
|
||
drop(stdin);
|
||
|
||
let output = child.wait_with_output().expect("failed to wait for output");
|
||
String::from_utf8_lossy(&output.stdout).to_string()
|
||
}
|
||
|
||
/// Build a minimal Claude Code JSON with the given rate-limit windows.
|
||
///
|
||
/// `windows`: `[(key, used_percentage, seconds_until_reset_from_now)]`.
|
||
fn build_claude_code_json(
|
||
repo_path: &std::path::Path,
|
||
model: Option<&str>,
|
||
context_pct: Option<f64>,
|
||
now_epoch: u64,
|
||
windows: &[(&str, f64, i64)],
|
||
) -> String {
|
||
use std::fmt::Write;
|
||
let escaped = escape_path_for_json(repo_path);
|
||
let mut json = format!(r#"{{"workspace":{{"current_dir":"{escaped}"}}"#);
|
||
if let Some(m) = model {
|
||
write!(json, r#","model":{{"display_name":"{m}"}}"#).unwrap();
|
||
}
|
||
if let Some(p) = context_pct {
|
||
write!(json, r#","context_window":{{"used_percentage":{p}}}"#).unwrap();
|
||
}
|
||
if !windows.is_empty() {
|
||
json.push_str(r#","rate_limits":{"#);
|
||
for (i, (key, used, secs_to_reset)) in windows.iter().enumerate() {
|
||
if i > 0 {
|
||
json.push(',');
|
||
}
|
||
let resets_at = now_epoch as i64 + secs_to_reset;
|
||
write!(
|
||
json,
|
||
r#""{key}":{{"used_percentage":{used},"resets_at":{resets_at}}}"#
|
||
)
|
||
.unwrap();
|
||
}
|
||
json.push('}');
|
||
}
|
||
json.push('}');
|
||
json
|
||
}
|
||
|
||
#[rstest]
|
||
fn test_statusline_rate_limit_hidden_when_safe(repo: TestRepo) {
|
||
// Both windows well under threshold → segment absent.
|
||
let json = build_claude_code_json(
|
||
repo.root_path(),
|
||
Some("Opus"),
|
||
Some(42.0),
|
||
TEST_NOW_THU_1PM,
|
||
&[
|
||
("five_hour", 30.0, 2 * 3600 + 1800), // 50% elapsed, 30% used
|
||
("seven_day", 20.0, 4 * 86400), // ~43% elapsed, 20% used
|
||
],
|
||
);
|
||
let out = run_statusline_at_time(&repo, &json, TEST_NOW_THU_1PM, None);
|
||
claude_code_snapshot_settings().bind(|| {
|
||
assert_snapshot!("rate_limit_hidden_when_safe", out);
|
||
});
|
||
}
|
||
|
||
#[rstest]
|
||
fn test_statusline_rate_limit_5h_clock_time(repo: TestRepo) {
|
||
// 5h burning mid-window, reset in 2h → 3pm.
|
||
let json = build_claude_code_json(
|
||
repo.root_path(),
|
||
Some("Opus"),
|
||
Some(42.0),
|
||
TEST_NOW_THU_1PM,
|
||
&[("five_hour", 80.0, 2 * 3600)],
|
||
);
|
||
let out = run_statusline_at_time(&repo, &json, TEST_NOW_THU_1PM, None);
|
||
claude_code_snapshot_settings().bind(|| {
|
||
assert_snapshot!("rate_limit_5h_clock_time", out);
|
||
});
|
||
}
|
||
|
||
#[rstest]
|
||
fn test_statusline_rate_limit_5h_with_minutes(repo: TestRepo) {
|
||
// Reset 30 minutes from now → 1:30pm — exercises the `:mm` time spec.
|
||
let json = build_claude_code_json(
|
||
repo.root_path(),
|
||
Some("Opus"),
|
||
Some(42.0),
|
||
TEST_NOW_THU_1PM,
|
||
&[("five_hour", 95.0, 1800)],
|
||
);
|
||
let out = run_statusline_at_time(&repo, &json, TEST_NOW_THU_1PM, None);
|
||
claude_code_snapshot_settings().bind(|| {
|
||
assert_snapshot!("rate_limit_5h_with_minutes", out);
|
||
});
|
||
}
|
||
|
||
#[rstest]
|
||
fn test_statusline_rate_limit_5h_at_limit(repo: TestRepo) {
|
||
// 100% used — the throttled edge.
|
||
let json = build_claude_code_json(
|
||
repo.root_path(),
|
||
Some("Opus"),
|
||
Some(42.0),
|
||
TEST_NOW_THU_1PM,
|
||
&[("five_hour", 100.0, 2 * 3600)],
|
||
);
|
||
let out = run_statusline_at_time(&repo, &json, TEST_NOW_THU_1PM, None);
|
||
claude_code_snapshot_settings().bind(|| {
|
||
assert_snapshot!("rate_limit_5h_at_limit", out);
|
||
});
|
||
}
|
||
|
||
#[rstest]
|
||
fn test_statusline_rate_limit_7d_with_day_and_time(repo: TestRepo) {
|
||
// 7d binding, reset 4d 2h from Thu 1pm → Mon 3pm.
|
||
let json = build_claude_code_json(
|
||
repo.root_path(),
|
||
Some("Opus"),
|
||
Some(42.0),
|
||
TEST_NOW_THU_1PM,
|
||
&[
|
||
("five_hour", 15.0, 4 * 3600), // safe
|
||
("seven_day", 80.0, 4 * 86400 + 2 * 3600),
|
||
],
|
||
);
|
||
let out = run_statusline_at_time(&repo, &json, TEST_NOW_THU_1PM, None);
|
||
claude_code_snapshot_settings().bind(|| {
|
||
assert_snapshot!("rate_limit_7d_with_day_and_time", out);
|
||
});
|
||
}
|
||
|
||
#[rstest]
|
||
fn test_statusline_rate_limit_both_picks_worse(repo: TestRepo) {
|
||
// Both windows flagged; 7d projects worse, so 7d is the segment shown.
|
||
let json = build_claude_code_json(
|
||
repo.root_path(),
|
||
Some("Opus"),
|
||
Some(42.0),
|
||
TEST_NOW_THU_1PM,
|
||
&[
|
||
("five_hour", 85.0, 90 * 60), // 70% elapsed, 85% used → visible
|
||
("seven_day", 80.0, 4 * 86400 + 2 * 3600), // ~40% elapsed, 80% used → worse
|
||
],
|
||
);
|
||
let out = run_statusline_at_time(&repo, &json, TEST_NOW_THU_1PM, None);
|
||
claude_code_snapshot_settings().bind(|| {
|
||
assert_snapshot!("rate_limit_both_picks_worse", out);
|
||
});
|
||
}
|
||
|
||
#[rstest]
|
||
fn test_statusline_rate_limit_with_dirty_worktree_and_context(repo: TestRepo) {
|
||
// Uncommitted change + 95% context + 5h binding — checks segment
|
||
// composition with other state.
|
||
add_uncommitted_changes(&repo);
|
||
let json = build_claude_code_json(
|
||
repo.root_path(),
|
||
Some("Opus"),
|
||
Some(95.0),
|
||
TEST_NOW_THU_1PM,
|
||
&[("five_hour", 80.0, 2 * 3600)],
|
||
);
|
||
let out = run_statusline_at_time(&repo, &json, TEST_NOW_THU_1PM, None);
|
||
claude_code_snapshot_settings().bind(|| {
|
||
assert_snapshot!("rate_limit_with_dirty_worktree_and_context", out);
|
||
});
|
||
}
|
||
|
||
#[rstest]
|
||
fn test_statusline_rate_limit_5h_24h_locale(repo: TestRepo) {
|
||
// Same input as `5h_clock_time` but `LC_TIME=en_GB.UTF-8` → 24h clock.
|
||
// 12h `10am–3pm` becomes 24h `10:00–15:00`. Weekday in `Mon–Mon 15:00`
|
||
// is still English (out of scope for the clock-format follow-up).
|
||
let json = build_claude_code_json(
|
||
repo.root_path(),
|
||
Some("Opus"),
|
||
Some(42.0),
|
||
TEST_NOW_THU_1PM,
|
||
&[("five_hour", 80.0, 2 * 3600)],
|
||
);
|
||
let out = run_statusline_with_locale(&repo, &json, TEST_NOW_THU_1PM, None, "en_GB.UTF-8");
|
||
claude_code_snapshot_settings().bind(|| {
|
||
assert_snapshot!("rate_limit_5h_24h_locale", out);
|
||
});
|
||
}
|
||
|
||
#[rstest]
|
||
fn test_statusline_rate_limit_drops_at_narrow_width(repo: TestRepo) {
|
||
// Same input as `5h_clock_time` but COLUMNS=40 — the rate-limit
|
||
// segment is the lowest-priority Claude-Code segment, so it drops first.
|
||
let json = build_claude_code_json(
|
||
repo.root_path(),
|
||
Some("Opus"),
|
||
Some(42.0),
|
||
TEST_NOW_THU_1PM,
|
||
&[("five_hour", 80.0, 2 * 3600)],
|
||
);
|
||
// COLUMNS=40 is narrower than the rendered line with rate-limit, so
|
||
// the lowest-priority segments drop. The full line is ~60 chars wide
|
||
// even on the short test repo path.
|
||
let out = run_statusline_at_time(&repo, &json, TEST_NOW_THU_1PM, Some(40));
|
||
claude_code_snapshot_settings().bind(|| {
|
||
assert_snapshot!("rate_limit_drops_at_narrow_width", out);
|
||
});
|
||
}
|
||
|
||
#[rstest]
|
||
fn test_statusline_zero_columns_renders_untruncated(repo: TestRepo) {
|
||
// Regression: `COLUMNS=0` is an absurd width. It must be treated as "no
|
||
// detectable width" and fall through to the untruncated render — not
|
||
// collapse the content budget to zero (after the statusline subtracts its
|
||
// UI margin) and drop every segment. Compare against a wide terminal,
|
||
// which also renders untruncated: the two must be identical and non-empty.
|
||
let json = build_claude_code_json(
|
||
repo.root_path(),
|
||
Some("Opus"),
|
||
Some(42.0),
|
||
TEST_NOW_THU_1PM,
|
||
&[("five_hour", 80.0, 2 * 3600)],
|
||
);
|
||
let zero = run_statusline_at_time(&repo, &json, TEST_NOW_THU_1PM, Some(0));
|
||
let wide = run_statusline_at_time(&repo, &json, TEST_NOW_THU_1PM, Some(500));
|
||
assert!(
|
||
!zero.trim().is_empty(),
|
||
"COLUMNS=0 must not collapse the statusline to empty"
|
||
);
|
||
assert_eq!(
|
||
zero, wide,
|
||
"COLUMNS=0 should render untruncated, identical to a wide terminal"
|
||
);
|
||
}
|