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 ahead/behind cell: `↑N ↓M`, or `–` when there is no upstream.
59pub fn ahead_behind_cell(worktree: &Worktree) -> String {
60    match (worktree.ahead, worktree.behind) {
61        (Some(ahead), Some(behind)) => format!("↑{ahead} ↓{behind}"),
62        _ => "–".to_string(),
63    }
64}
65
66/// The PR cell: `#N (state)`, or empty when no PR is recorded.
67pub fn pr_cell(worktree: &Worktree) -> String {
68    match &worktree.pr {
69        Some(pr) => format!("#{} ({})", pr.number, pr.state.as_str()),
70        None => String::new(),
71    }
72}
73
74/// The issue cell: `#<number>`, or empty when the branch has no linked issue.
75pub fn issue_cell(worktree: &Worktree) -> String {
76    match &worktree.issue {
77        Some(issue) => format!("#{}", issue.number),
78        None => String::new(),
79    }
80}
81
82/// The path cell: relative to the repo root, or absolute if outside it.
83pub fn path_cell(worktree: &Worktree, repo_root: &Path) -> String {
84    match worktree.path.strip_prefix(repo_root) {
85        Ok(rel) if rel.as_os_str().is_empty() => ".".to_string(),
86        Ok(rel) => rel.to_string_lossy().into_owned(),
87        Err(_) => worktree.path.to_string_lossy().into_owned(),
88    }
89}
90
91/// The commit cell: short hash + subject + relative time, or empty.
92pub fn commit_cell(worktree: &Worktree, now: i64) -> String {
93    match &worktree.commit {
94        Some(commit) => {
95            let rel = parse_iso8601(&commit.timestamp)
96                .map(|unix| relative(now, unix))
97                .unwrap_or_default();
98            format!("{} {} ({rel})", commit.hash, commit.subject)
99        }
100        None => String::new(),
101    }
102}
103
104/// Renders a single column's cell for a worktree.
105pub fn cell(worktree: &Worktree, column: Column, ctx: &RenderCtx) -> String {
106    match column {
107        Column::Status => status_marker(worktree).to_string(),
108        Column::Dirty => dirty_marker(worktree, ctx.show_untracked).to_string(),
109        Column::Branch => branch_display(worktree),
110        Column::Path => path_cell(worktree, ctx.repo_root),
111        Column::AheadBehind => ahead_behind_cell(worktree),
112        Column::Commit => commit_cell(worktree, ctx.now),
113        Column::Pr => pr_cell(worktree),
114        Column::Issue => issue_cell(worktree),
115    }
116}
117
118/// Renders the detailed `wt status` block for one worktree (spec §7).
119#[cfg_attr(not(feature = "cli"), allow(dead_code))]
120pub(crate) fn status_block(worktree: &Worktree, entries: &[StatusEntry]) -> String {
121    let mut out = String::new();
122    let _ = writeln!(out, "worktree: {}", worktree.path.display());
123
124    let branch = branch_display(worktree);
125    match &worktree.upstream {
126        Some(upstream) => {
127            let _ = writeln!(out, "branch:   {branch} → {upstream}");
128        }
129        None => {
130            let _ = writeln!(out, "branch:   {branch} (no upstream)");
131        }
132    }
133    if let Some(base) = &worktree.base_ref {
134        let _ = writeln!(out, "base:     {base}");
135    }
136
137    if worktree.is_missing {
138        let _ = writeln!(out, "(directory already deleted)");
139        return out;
140    }
141
142    if let (Some(ahead), Some(behind)) = (worktree.ahead, worktree.behind) {
143        let _ = writeln!(out, "ahead:    {ahead}  behind: {behind}");
144    }
145    if let Some(pr) = &worktree.pr {
146        let _ = writeln!(
147            out,
148            "pr:       #{} ({}) \"{}\"",
149            pr.number,
150            pr.state.as_str(),
151            pr.title
152        );
153    }
154    if let Some(issue) = &worktree.issue {
155        let _ = writeln!(out, "issue:    #{} \"{}\"", issue.number, issue.title);
156    }
157    if !entries.is_empty() {
158        let _ = writeln!(out, "dirty:");
159        for entry in entries {
160            let _ = writeln!(out, "  {}  {}", entry.marker, entry.path);
161        }
162    }
163    out
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169    use crate::model::{Commit, IssueLink, Pr, PrState};
170    use std::path::PathBuf;
171
172    fn base() -> Worktree {
173        let mut w = Worktree::new(PathBuf::from("/repo/main"));
174        w.branch = Some("main".into());
175        w.slug = Some("main".into());
176        w
177    }
178
179    #[test]
180    fn status_markers() {
181        let mut w = base();
182        assert_eq!(status_marker(&w), ' ');
183        w.is_detached = true;
184        assert_eq!(status_marker(&w), '~');
185        w.is_missing = true;
186        assert_eq!(status_marker(&w), '!');
187        w.is_current = true;
188        assert_eq!(status_marker(&w), '*'); // current wins
189    }
190
191    #[test]
192    fn dirty_markers_respect_show_untracked() {
193        let mut w = base();
194        assert_eq!(dirty_marker(&w, true), ' ');
195        w.has_untracked = Some(true);
196        assert_eq!(dirty_marker(&w, true), '?');
197        assert_eq!(dirty_marker(&w, false), ' '); // suppressed
198        w.dirty = Some(true);
199        assert_eq!(dirty_marker(&w, true), 'M'); // modified wins
200    }
201
202    #[test]
203    fn ahead_behind_and_no_upstream() {
204        let mut w = base();
205        assert_eq!(ahead_behind_cell(&w), "–");
206        w.ahead = Some(2);
207        w.behind = Some(1);
208        assert_eq!(ahead_behind_cell(&w), "↑2 ↓1");
209    }
210
211    #[test]
212    fn branch_display_detached() {
213        let mut w = base();
214        w.branch = None;
215        w.is_detached = true;
216        w.commit = Some(Commit {
217            hash: "abc1234".into(),
218            subject: "x".into(),
219            author: "a".into(),
220            timestamp: "2024-01-15T10:30:00Z".into(),
221        });
222        assert_eq!(branch_display(&w), "(HEAD detached @ abc1234)");
223    }
224
225    #[test]
226    fn path_cell_relative_and_absolute() {
227        let root = Path::new("/repo");
228        let mut w = base();
229        w.path = PathBuf::from("/repo");
230        assert_eq!(path_cell(&w, root), ".");
231        w.path = PathBuf::from("/repo/.worktrees/x");
232        assert_eq!(path_cell(&w, root), ".worktrees/x");
233        w.path = PathBuf::from("/elsewhere/y");
234        assert_eq!(path_cell(&w, root), "/elsewhere/y");
235    }
236
237    #[test]
238    fn issue_cell_renders_the_linked_number() {
239        let mut w = base();
240        assert_eq!(issue_cell(&w), "");
241        w.issue = Some(IssueLink {
242            number: 7,
243            title: "Add login".into(),
244            url: "https://github.com/o/r/issues/7".into(),
245        });
246        assert_eq!(issue_cell(&w), "#7");
247    }
248
249    #[test]
250    fn pr_cell_renders_number_and_state() {
251        let mut w = base();
252        assert_eq!(pr_cell(&w), "");
253        w.pr = Some(Pr {
254            number: 42,
255            state: PrState::Open,
256            title: "t".into(),
257        });
258        assert_eq!(pr_cell(&w), "#42 (open)");
259    }
260
261    #[test]
262    fn commit_cell_includes_hash_subject_time() {
263        let mut w = base();
264        assert_eq!(commit_cell(&w, 0), "");
265        let ts = "2024-01-15T10:30:00Z";
266        w.commit = Some(Commit {
267            hash: "abc1234".into(),
268            subject: "Add login".into(),
269            author: "Alice".into(),
270            timestamp: ts.into(),
271        });
272        let now = parse_iso8601(ts).unwrap() + 3 * 3600;
273        assert_eq!(commit_cell(&w, now), "abc1234 Add login (3h ago)");
274    }
275
276    #[test]
277    fn status_block_full() {
278        let mut w = base();
279        w.upstream = Some("origin/main".into());
280        w.base_ref = Some("develop".into());
281        w.ahead = Some(3);
282        w.behind = Some(0);
283        w.pr = Some(Pr {
284            number: 42,
285            state: PrState::Open,
286            title: "Add login page".into(),
287        });
288        let entries = vec![
289            StatusEntry {
290                marker: 'M',
291                path: "src/main.rs".into(),
292            },
293            StatusEntry {
294                marker: '?',
295                path: "scratch.txt".into(),
296            },
297        ];
298        let block = status_block(&w, &entries);
299        assert!(block.contains("worktree: /repo/main"));
300        assert!(block.contains("branch:   main → origin/main"));
301        assert!(block.contains("base:     develop"));
302        assert!(block.contains("ahead:    3  behind: 0"));
303        assert!(block.contains("pr:       #42 (open) \"Add login page\""));
304        assert!(block.contains("dirty:\n  M  src/main.rs\n  ?  scratch.txt"));
305    }
306
307    #[test]
308    fn status_block_no_upstream_omits_ahead_behind() {
309        let w = base();
310        let block = status_block(&w, &[]);
311        assert!(block.contains("main (no upstream)"));
312        assert!(!block.contains("ahead:"));
313        assert!(!block.contains("dirty:"));
314    }
315
316    #[test]
317    fn status_block_missing_worktree() {
318        let mut w = base();
319        w.is_missing = true;
320        w.base_ref = Some("main".into());
321        let block = status_block(&w, &[]);
322        assert!(block.contains("(directory already deleted)"));
323        assert!(block.contains("base:     main"));
324        assert!(!block.contains("ahead:"));
325    }
326}