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 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
66pub 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
74pub fn issue_cell(worktree: &Worktree) -> String {
76 match &worktree.issue {
77 Some(issue) => format!("#{}", issue.number),
78 None => String::new(),
79 }
80}
81
82pub 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
91pub 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
104pub 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#[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), '*'); }
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), ' '); w.dirty = Some(true);
199 assert_eq!(dirty_marker(&w, true), 'M'); }
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}