Skip to main content

wt/output/
render.rs

1//! Pure human renderers for worktree rows and status blocks (spec §7).
2
3use std::fmt::Write as _;
4use std::path::Path;
5
6use crate::git::status::StatusEntry;
7use crate::model::{Column, Worktree};
8use crate::time::{parse_iso8601, relative};
9
10/// Context for rendering a list cell.
11pub struct RenderCtx<'a> {
12    /// Whether untracked files show a `?` in the dirty column.
13    pub show_untracked: bool,
14    /// Reference time (Unix seconds) for relative timestamps.
15    pub now: i64,
16    /// Repository root, for relative path display.
17    pub repo_root: &'a Path,
18}
19
20/// The status marker for the Status column (spec §7).
21pub fn status_marker(worktree: &Worktree) -> char {
22    if worktree.is_current {
23        '*'
24    } else if worktree.is_missing {
25        '!'
26    } else if worktree.is_detached {
27        '~'
28    } else {
29        ' '
30    }
31}
32
33/// The dirty marker for the Dirty column (spec §7).
34pub fn dirty_marker(worktree: &Worktree, show_untracked: bool) -> char {
35    if worktree.dirty == Some(true) {
36        'M'
37    } else if show_untracked && worktree.has_untracked == Some(true) {
38        '?'
39    } else {
40        ' '
41    }
42}
43
44/// The branch display: the branch name, or `(HEAD detached @ <hash>)`.
45pub fn branch_display(worktree: &Worktree) -> String {
46    match &worktree.branch {
47        Some(branch) => branch.clone(),
48        None => {
49            let hash = worktree
50                .commit
51                .as_ref()
52                .map_or("unknown", |c| c.hash.as_str());
53            format!("(HEAD detached @ {hash})")
54        }
55    }
56}
57
58/// The marker appended to a locked worktree's Branch cell (issue #127).
59pub const LOCKED_MARKER: &str = "(locked)";
60
61/// The Branch column cell: [`branch_display`], suffixed with
62/// [`LOCKED_MARKER`] for a locked worktree. The lock reason is left to
63/// `wt status` and `--json`, since it can be arbitrarily long.
64pub fn branch_cell(worktree: &Worktree) -> String {
65    let branch = branch_display(worktree);
66    if worktree.is_locked {
67        format!("{branch} {LOCKED_MARKER}")
68    } else {
69        branch
70    }
71}
72
73/// The ahead/behind cell: `↑N ↓M`, or `–` when there is no upstream.
74pub fn ahead_behind_cell(worktree: &Worktree) -> String {
75    match (worktree.ahead, worktree.behind) {
76        (Some(ahead), Some(behind)) => format!("↑{ahead} ↓{behind}"),
77        _ => "–".to_string(),
78    }
79}
80
81/// The PR cell: `#N (state)`, or empty when no PR is recorded.
82pub fn pr_cell(worktree: &Worktree) -> String {
83    match &worktree.pr {
84        Some(pr) => format!("#{} ({})", pr.number, pr.state.as_str()),
85        None => String::new(),
86    }
87}
88
89/// The issue cell: `#<number>`, or empty when the branch has no linked issue.
90pub fn issue_cell(worktree: &Worktree) -> String {
91    match &worktree.issue {
92        Some(issue) => format!("#{}", issue.number),
93        None => String::new(),
94    }
95}
96
97/// The path cell: relative to the repo root, or absolute if outside it.
98pub fn path_cell(worktree: &Worktree, repo_root: &Path) -> String {
99    match worktree.path.strip_prefix(repo_root) {
100        Ok(rel) if rel.as_os_str().is_empty() => ".".to_string(),
101        Ok(rel) => rel.to_string_lossy().into_owned(),
102        Err(_) => worktree.path.to_string_lossy().into_owned(),
103    }
104}
105
106/// The commit cell: short hash + subject + relative time, or empty.
107pub fn commit_cell(worktree: &Worktree, now: i64) -> String {
108    match &worktree.commit {
109        Some(commit) => {
110            let rel = parse_iso8601(&commit.timestamp)
111                .map(|unix| relative(now, unix))
112                .unwrap_or_default();
113            format!("{} {} ({rel})", commit.hash, commit.subject)
114        }
115        None => String::new(),
116    }
117}
118
119/// Renders a single column's cell for a worktree.
120pub fn cell(worktree: &Worktree, column: Column, ctx: &RenderCtx) -> String {
121    match column {
122        Column::Status => status_marker(worktree).to_string(),
123        Column::Dirty => dirty_marker(worktree, ctx.show_untracked).to_string(),
124        Column::Branch => branch_cell(worktree),
125        Column::Path => path_cell(worktree, ctx.repo_root),
126        Column::AheadBehind => ahead_behind_cell(worktree),
127        Column::Commit => commit_cell(worktree, ctx.now),
128        Column::Pr => pr_cell(worktree),
129        Column::Issue => issue_cell(worktree),
130    }
131}
132
133/// Renders the detailed `wt status` block for one worktree (spec §7).
134#[cfg_attr(not(feature = "cli"), allow(dead_code))]
135pub(crate) fn status_block(worktree: &Worktree, entries: &[StatusEntry]) -> String {
136    let mut out = String::new();
137    let _ = writeln!(out, "worktree: {}", worktree.path.display());
138
139    let branch = branch_display(worktree);
140    match &worktree.upstream {
141        Some(upstream) => {
142            let _ = writeln!(out, "branch:   {branch} → {upstream}");
143        }
144        None => {
145            let _ = writeln!(out, "branch:   {branch} (no upstream)");
146        }
147    }
148    if let Some(base) = &worktree.base_ref {
149        let _ = writeln!(out, "base:     {base}");
150    }
151    if worktree.is_locked {
152        let reason = worktree
153            .lock_reason
154            .as_deref()
155            .unwrap_or("(no reason given)");
156        let _ = writeln!(out, "locked:   {reason}");
157    }
158
159    if worktree.is_missing {
160        let _ = writeln!(out, "(directory already deleted)");
161        return out;
162    }
163
164    if let (Some(ahead), Some(behind)) = (worktree.ahead, worktree.behind) {
165        let _ = writeln!(out, "ahead:    {ahead}  behind: {behind}");
166    }
167    if let Some(pr) = &worktree.pr {
168        let _ = writeln!(
169            out,
170            "pr:       #{} ({}) \"{}\"",
171            pr.number,
172            pr.state.as_str(),
173            pr.title
174        );
175    }
176    if let Some(issue) = &worktree.issue {
177        let _ = writeln!(out, "issue:    #{} \"{}\"", issue.number, issue.title);
178    }
179    if !entries.is_empty() {
180        let _ = writeln!(out, "dirty:");
181        for entry in entries {
182            let _ = writeln!(out, "  {}  {}", entry.marker, entry.path);
183        }
184    }
185    out
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191    use crate::model::{Commit, IssueLink, Pr, PrState};
192    use std::path::PathBuf;
193
194    fn base() -> Worktree {
195        let mut w = Worktree::new(PathBuf::from("/repo/main"));
196        w.branch = Some("main".into());
197        w.slug = Some("main".into());
198        w
199    }
200
201    #[test]
202    fn status_markers() {
203        let mut w = base();
204        assert_eq!(status_marker(&w), ' ');
205        w.is_detached = true;
206        assert_eq!(status_marker(&w), '~');
207        w.is_missing = true;
208        assert_eq!(status_marker(&w), '!');
209        w.is_current = true;
210        assert_eq!(status_marker(&w), '*'); // current wins
211    }
212
213    #[test]
214    fn dirty_markers_respect_show_untracked() {
215        let mut w = base();
216        assert_eq!(dirty_marker(&w, true), ' ');
217        w.has_untracked = Some(true);
218        assert_eq!(dirty_marker(&w, true), '?');
219        assert_eq!(dirty_marker(&w, false), ' '); // suppressed
220        w.dirty = Some(true);
221        assert_eq!(dirty_marker(&w, true), 'M'); // modified wins
222    }
223
224    #[test]
225    fn ahead_behind_and_no_upstream() {
226        let mut w = base();
227        assert_eq!(ahead_behind_cell(&w), "–");
228        w.ahead = Some(2);
229        w.behind = Some(1);
230        assert_eq!(ahead_behind_cell(&w), "↑2 ↓1");
231    }
232
233    #[test]
234    fn branch_display_detached() {
235        let mut w = base();
236        w.branch = None;
237        w.is_detached = true;
238        w.commit = Some(Commit {
239            hash: "abc1234".into(),
240            subject: "x".into(),
241            author: "a".into(),
242            timestamp: "2024-01-15T10:30:00Z".into(),
243        });
244        assert_eq!(branch_display(&w), "(HEAD detached @ abc1234)");
245    }
246
247    #[test]
248    fn branch_cell_marks_a_locked_worktree() {
249        let mut w = base();
250        assert_eq!(branch_cell(&w), "main");
251        w.is_locked = true;
252        w.lock_reason = Some("in use".into());
253        assert_eq!(branch_cell(&w), "main (locked)");
254        assert_eq!(
255            cell(
256                &w,
257                Column::Branch,
258                &RenderCtx {
259                    show_untracked: true,
260                    now: 0,
261                    repo_root: Path::new("/repo"),
262                }
263            ),
264            "main (locked)"
265        );
266        // The plain display (used by the TUI and `wt status`) is unchanged.
267        assert_eq!(branch_display(&w), "main");
268    }
269
270    #[test]
271    fn status_block_shows_lock_reason() {
272        let mut w = base();
273        assert!(!status_block(&w, &[]).contains("locked:"));
274        w.is_locked = true;
275        assert!(status_block(&w, &[]).contains("locked:   (no reason given)"));
276        w.lock_reason = Some("in use".into());
277        // Shown for a missing worktree too: a lock is what keeps it from pruning.
278        w.is_missing = true;
279        assert!(status_block(&w, &[]).contains("locked:   in use"));
280    }
281
282    #[test]
283    fn path_cell_relative_and_absolute() {
284        let root = Path::new("/repo");
285        let mut w = base();
286        w.path = PathBuf::from("/repo");
287        assert_eq!(path_cell(&w, root), ".");
288        w.path = PathBuf::from("/repo/.worktrees/x");
289        assert_eq!(path_cell(&w, root), ".worktrees/x");
290        w.path = PathBuf::from("/elsewhere/y");
291        assert_eq!(path_cell(&w, root), "/elsewhere/y");
292    }
293
294    #[test]
295    fn issue_cell_renders_the_linked_number() {
296        let mut w = base();
297        assert_eq!(issue_cell(&w), "");
298        w.issue = Some(IssueLink {
299            number: 7,
300            title: "Add login".into(),
301            url: "https://github.com/o/r/issues/7".into(),
302        });
303        assert_eq!(issue_cell(&w), "#7");
304    }
305
306    #[test]
307    fn pr_cell_renders_number_and_state() {
308        let mut w = base();
309        assert_eq!(pr_cell(&w), "");
310        w.pr = Some(Pr {
311            number: 42,
312            state: PrState::Open,
313            title: "t".into(),
314        });
315        assert_eq!(pr_cell(&w), "#42 (open)");
316    }
317
318    #[test]
319    fn commit_cell_includes_hash_subject_time() {
320        let mut w = base();
321        assert_eq!(commit_cell(&w, 0), "");
322        let ts = "2024-01-15T10:30:00Z";
323        w.commit = Some(Commit {
324            hash: "abc1234".into(),
325            subject: "Add login".into(),
326            author: "Alice".into(),
327            timestamp: ts.into(),
328        });
329        let now = parse_iso8601(ts).unwrap() + 3 * 3600;
330        assert_eq!(commit_cell(&w, now), "abc1234 Add login (3h ago)");
331    }
332
333    #[test]
334    fn status_block_full() {
335        let mut w = base();
336        w.upstream = Some("origin/main".into());
337        w.base_ref = Some("develop".into());
338        w.ahead = Some(3);
339        w.behind = Some(0);
340        w.pr = Some(Pr {
341            number: 42,
342            state: PrState::Open,
343            title: "Add login page".into(),
344        });
345        let entries = vec![
346            StatusEntry {
347                marker: 'M',
348                path: "src/main.rs".into(),
349            },
350            StatusEntry {
351                marker: '?',
352                path: "scratch.txt".into(),
353            },
354        ];
355        let block = status_block(&w, &entries);
356        assert!(block.contains("worktree: /repo/main"));
357        assert!(block.contains("branch:   main → origin/main"));
358        assert!(block.contains("base:     develop"));
359        assert!(block.contains("ahead:    3  behind: 0"));
360        assert!(block.contains("pr:       #42 (open) \"Add login page\""));
361        assert!(block.contains("dirty:\n  M  src/main.rs\n  ?  scratch.txt"));
362    }
363
364    #[test]
365    fn status_block_no_upstream_omits_ahead_behind() {
366        let w = base();
367        let block = status_block(&w, &[]);
368        assert!(block.contains("main (no upstream)"));
369        assert!(!block.contains("ahead:"));
370        assert!(!block.contains("dirty:"));
371    }
372
373    #[test]
374    fn status_block_missing_worktree() {
375        let mut w = base();
376        w.is_missing = true;
377        w.base_ref = Some("main".into());
378        let block = status_block(&w, &[]);
379        assert!(block.contains("(directory already deleted)"));
380        assert!(block.contains("base:     main"));
381        assert!(!block.contains("ahead:"));
382    }
383}