use gwm::cli::{format_prune_plan, format_remove_plan, format_sync_report};
use gwm::sync::{SyncAction, SyncReport, SyncStrategy};
use gwm::worktree::PrunableEntry;
use std::path::{Path, PathBuf};
#[test]
fn remove_plan_with_branch_and_no_delete_omits_deletion_marker() {
let out = format_remove_plan(
"feat-31-preview",
Path::new("/tmp/wt/feat-31-preview"),
Some("feat/#31-preview"),
false,
);
assert!(out.contains("would remove"), "header missing: {out}");
assert!(out.contains("name: feat-31-preview"), "name row missing: {out}");
assert!(
out.contains("path: /tmp/wt/feat-31-preview"),
"path row missing: {out}"
);
assert!(out.contains("branch: feat/#31-preview"), "branch row missing: {out}");
assert!(
!out.contains("would be deleted"),
"must not advertise branch deletion when --delete-branch is off: {out}"
);
assert!(
!out.contains("no branch"),
"must not advertise 'no branch' when a branch is resolved: {out}"
);
}
#[test]
fn remove_plan_with_branch_and_delete_flag_marks_branch_deletion() {
let out = format_remove_plan(
"feat-31-preview",
Path::new("/tmp/wt/feat-31-preview"),
Some("feat/#31-preview"),
true,
);
assert!(
out.contains("branch: feat/#31-preview (would be deleted)"),
"delete marker missing: {out}"
);
}
#[test]
fn remove_plan_without_branch_and_no_delete_renders_dash_only() {
let out = format_remove_plan("detached", Path::new("/tmp/wt/detached"), None, false);
assert!(out.contains("branch: -"), "dash row missing: {out}");
assert!(
!out.contains("would be deleted"),
"no delete marker without --delete-branch: {out}"
);
assert!(
!out.contains("no branch"),
"no 'no branch' rider without --delete-branch (avoid noise): {out}"
);
}
#[test]
fn remove_plan_without_branch_but_with_delete_flag_does_not_claim_deletion() {
let out = format_remove_plan("detached", Path::new("/tmp/wt/detached"), None, true);
assert!(
!out.contains("would be deleted"),
"must not claim a deletion that worktree::remove will not perform: {out}"
);
assert!(
out.contains("no branch to delete"),
"must clarify why --delete-branch is a no-op here: {out}"
);
}
#[test]
fn prune_plan_empty_emits_zero_count_marker() {
let out = format_prune_plan(&[]);
assert!(out.contains("0 worktree"), "stable zero-count marker missing: {out}");
}
#[test]
fn prune_plan_lists_entries_with_header_and_rows() {
let entries = vec![
PrunableEntry {
name: "feat-1-alpha".into(),
path: PathBuf::from("/tmp/wt/feat-1-alpha"),
reason: "working dir missing".into(),
},
PrunableEntry {
name: "feat-2-bravo".into(),
path: PathBuf::from("/tmp/wt/feat-2-bravo"),
reason: "working dir missing".into(),
},
];
let out = format_prune_plan(&entries);
assert!(out.contains("would prune 2 worktree(s):"), "header missing: {out}");
assert!(out.contains("feat-1-alpha"), "first row missing: {out}");
assert!(out.contains("feat-2-bravo"), "second row missing: {out}");
assert!(out.contains("working dir missing"), "reason missing: {out}");
}
#[test]
fn prune_plan_aligns_columns_in_unicode_chars_not_bytes() {
let entries = vec![
PrunableEntry {
name: "féat-α-1".into(),
path: PathBuf::from("/tmp/wt/féat-α-1"),
reason: "working dir missing".into(),
},
PrunableEntry {
name: "feat-b-22".into(),
path: PathBuf::from("/tmp/wt/feat-b-22"),
reason: "working dir missing".into(),
},
];
let out = format_prune_plan(&entries);
let mut reason_offsets: Vec<usize> = Vec::new();
for line in out.lines() {
if let Some(idx) = line.find("(working dir missing)") {
let char_idx = line[..idx].chars().count();
reason_offsets.push(char_idx);
}
}
assert_eq!(
reason_offsets.len(),
2,
"expected two reason cells, got {reason_offsets:?} (output: {out})"
);
assert_eq!(
reason_offsets[0], reason_offsets[1],
"reason column drifted across rows ({reason_offsets:?}) — width must be in chars, not bytes:\n{out}"
);
}
#[test]
fn sync_report_up_to_date_renders_ok_sigil_and_upstream() {
let report = SyncReport {
branch: "main".into(),
upstream: "origin/main".into(),
strategy: SyncStrategy::Rebase,
ahead_before: 0,
behind_before: 0,
action: SyncAction::UpToDate,
};
let out = format_sync_report("feat-24-sync", &report);
assert!(out.starts_with('✓'), "up-to-date should use the success sigil: {out}");
assert!(out.contains("feat-24-sync"), "worktree name missing: {out}");
assert!(out.contains("origin/main"), "upstream label missing: {out}");
assert!(
out.contains("up to date") || out.contains("up-to-date"),
"should state the branch is up to date: {out}"
);
}
#[test]
fn sync_report_rebased_states_commit_count_and_strategy() {
let report = SyncReport {
branch: "feat/#24-sync".into(),
upstream: "origin/main".into(),
strategy: SyncStrategy::Rebase,
ahead_before: 1,
behind_before: 3,
action: SyncAction::Integrated,
};
let out = format_sync_report("feat-24-sync", &report);
assert!(
out.starts_with('✓'),
"successful integration uses the success sigil: {out}"
);
assert!(out.contains("rebased"), "rebase strategy should be named: {out}");
assert!(out.contains('3'), "the integrated commit count should appear: {out}");
assert!(out.contains("origin/main"), "upstream label missing: {out}");
}
#[test]
fn sync_report_merged_names_the_merge_strategy() {
let report = SyncReport {
branch: "feat/#24-sync".into(),
upstream: "origin/main".into(),
strategy: SyncStrategy::Merge,
ahead_before: 0,
behind_before: 2,
action: SyncAction::Integrated,
};
let out = format_sync_report("feat-24-sync", &report);
assert!(out.contains("merged"), "merge strategy should be named: {out}");
assert!(!out.contains("rebased"), "must not claim a rebase when merging: {out}");
assert!(out.contains('2'), "the integrated commit count should appear: {out}");
}