1use 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
10pub struct RenderCtx<'a> {
12 pub show_untracked: bool,
14 pub now: i64,
16 pub repo_root: &'a Path,
18}
19
20pub 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
33pub 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
44pub 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
58pub const LOCKED_MARKER: &str = "(locked)";
60
61pub 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
73pub 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
81pub 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
89pub fn issue_cell(worktree: &Worktree) -> String {
91 match &worktree.issue {
92 Some(issue) => format!("#{}", issue.number),
93 None => String::new(),
94 }
95}
96
97pub 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
106pub 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
119pub 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#[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), '*'); }
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), ' '); w.dirty = Some(true);
221 assert_eq!(dirty_marker(&w, true), 'M'); }
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 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 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}