1use crate::scanner::OpenLoop;
3use crate::worktrees::{Verdict, Worktree};
4use chrono::{DateTime, Utc};
5use std::collections::HashSet;
6use std::path::PathBuf;
7
8pub fn human_age(now: DateTime<Utc>, then: DateTime<Utc>) -> String {
14 let mins = (now - then).num_minutes().max(0);
15 if mins < 60 {
16 format!("{mins}min")
17 } else if mins < 48 * 60 {
18 format!("{}h", mins / 60)
19 } else {
20 format!("{}d", mins / (60 * 24))
21 }
22}
23
24pub fn render_table(loops: &[OpenLoop], now: DateTime<Utc>) -> String {
28 if loops.is_empty() {
29 return "No open loops. All finished or ignored.\n".into();
30 }
31 let mut sorted: Vec<&OpenLoop> = loops.iter().collect();
32 sorted.sort_by_key(|l| l.last_commit);
33 let key_w = sorted
34 .iter()
35 .map(|l| l.key().len())
36 .max()
37 .unwrap_or(4)
38 .max(4);
39 let mut out = format!(
40 "{:<key_w$} {:>9} {:>5} {:>6}\n",
41 "LOOP", "IDLE", "AHEAD", "BEHIND"
42 );
43 for l in sorted {
44 out.push_str(&format!(
45 "{:<key_w$} {:>9} {:>5} {:>6}\n",
46 l.key(),
47 human_age(now, l.last_commit),
48 l.ahead,
49 l.behind
50 ));
51 }
52 out
53}
54
55fn verdict_rank(v: &Verdict) -> u8 {
56 match v {
57 Verdict::Deletable | Verdict::Prunable => 0,
58 Verdict::Cold => 1,
59 Verdict::Active => 2,
60 Verdict::Home => 3,
61 }
62}
63
64fn branch_label(w: &Worktree) -> String {
65 w.branch.clone().unwrap_or_else(|| "(detached)".into())
66}
67
68pub fn render_worktrees(wts: &[Worktree], now: DateTime<Utc>) -> String {
72 if wts.is_empty() {
73 return "No worktrees found.\n".into();
74 }
75 let epoch = DateTime::from_timestamp(0, 0).unwrap();
76 let mut sorted: Vec<&Worktree> = wts.iter().collect();
77 sorted.sort_by_key(|w| (verdict_rank(&w.verdict()), w.last_commit.unwrap_or(epoch)));
78
79 let name_w = sorted
80 .iter()
81 .map(|w| w.short_name().len())
82 .max()
83 .unwrap_or(8)
84 .max(8);
85 let branch_w = sorted
86 .iter()
87 .map(|w| branch_label(w).len())
88 .max()
89 .unwrap_or(6)
90 .max(6);
91
92 let mut out = format!(
93 "{:<name_w$} {:<branch_w$} {:>5} {:>6} {:>5} {}\n",
94 "WORKTREE", "BRANCH", "IDLE", "MERGED", "STATE", "VERDICT"
95 );
96 for w in &sorted {
97 out.push_str(&format!(
98 "{:<name_w$} {:<branch_w$} {:>5} {:>6} {:>5} {}\n",
99 w.short_name(),
100 branch_label(w),
101 w.last_commit
102 .map(|t| human_age(now, t))
103 .unwrap_or_else(|| "?".into()),
104 if w.merged { "yes" } else { "no" },
105 if w.dirty { "dirty" } else { "clean" },
106 w.verdict().label()
107 ));
108 }
109
110 let mut cmds: Vec<String> = Vec::new();
111 let mut pruned: HashSet<PathBuf> = HashSet::new();
112 for w in &sorted {
113 match w.verdict() {
114 Verdict::Deletable => {
115 if let Some(b) = &w.branch {
116 cmds.push(format!(
117 "git -C {repo} worktree remove {wt} && git -C {repo} branch -d {b}",
118 repo = w.repo_path.display(),
119 wt = w.worktree_path.display(),
120 ));
121 }
122 }
123 Verdict::Prunable => {
124 if pruned.insert(w.repo_path.clone()) {
125 cmds.push(format!("git -C {} worktree prune", w.repo_path.display()));
126 }
127 }
128 _ => {}
129 }
130 }
131 if cmds.is_empty() {
132 out.push_str("\n# nothing to clean up.\n");
133 } else {
134 out.push_str(&format!(
135 "\n# {} worktree(s) to clean up. Copy to run:\n",
136 cmds.len()
137 ));
138 for c in &cmds {
139 out.push_str(c);
140 out.push('\n');
141 }
142 }
143 out
144}
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149 use crate::scanner::OpenLoop;
150 use crate::worktrees::Worktree;
151 use chrono::{Duration, Utc};
152 use std::path::PathBuf;
153
154 fn lp(branch: &str, idle_days: i64) -> OpenLoop {
155 OpenLoop {
156 root_label: "app".into(),
157 repo_name: "app".into(),
158 repo_path: PathBuf::from("/tmp/app"),
159 branch: branch.into(),
160 head_sha: "abc".into(),
161 last_commit: Utc::now() - Duration::days(idle_days),
162 ahead: 1,
163 behind: 0,
164 }
165 }
166
167 #[test]
168 fn human_age_minutes_hours_days() {
169 let now = Utc::now();
170 assert_eq!(human_age(now, now - Duration::minutes(5)), "5min");
171 assert_eq!(human_age(now, now - Duration::hours(3)), "3h");
172 assert_eq!(human_age(now, now - Duration::days(12)), "12d");
173 }
174
175 #[test]
176 fn render_table_sorts_most_idle_first() {
177 let t = render_table(&[lp("recente", 1), lp("antiga", 30)], Utc::now());
178 let pos_antiga = t.find("antiga").unwrap();
179 let pos_recente = t.find("recente").unwrap();
180 assert!(pos_antiga < pos_recente);
181 assert!(t.contains("LOOP"));
182 assert!(t.contains("30d"));
183 }
184
185 #[test]
186 fn render_table_empty_celebrates() {
187 assert!(render_table(&[], Utc::now()).contains("No open loops"));
188 }
189
190 fn wt(branch: &str, merged: bool, dirty: bool, idade_dias: i64) -> Worktree {
191 Worktree {
192 repo_name: "app".into(),
193 repo_path: std::path::PathBuf::from("/tmp/app"),
194 worktree_path: std::path::PathBuf::from(format!("/tmp/app/{branch}")),
195 branch: Some(branch.into()),
196 last_commit: Some(Utc::now() - Duration::days(idade_dias)),
197 merged,
198 dirty,
199 prunable: false,
200 is_main: false,
201 }
202 }
203
204 #[test]
205 fn render_worktrees_sorts_deletable_first_and_shows_command() {
206 let out = render_worktrees(
207 &[
208 wt("feat/cold", false, false, 40),
209 wt("fix/done", true, false, 8),
210 ],
211 Utc::now(),
212 );
213 assert!(out.contains("WORKTREE"));
215 assert!(out.contains("VERDICT"));
216 let pos_done = out.find("fix/done").unwrap();
218 let pos_cold = out.find("feat/cold").unwrap();
219 assert!(pos_done < pos_cold);
220 assert!(out.contains("worktree remove"));
222 assert!(out.contains("branch -d fix/done"));
223 assert!(out.is_ascii());
225 }
226
227 #[test]
228 fn render_worktrees_no_action_says_nothing() {
229 let out = render_worktrees(&[wt("feat/cold", false, false, 3)], Utc::now());
230 assert!(out.contains("nothing to clean up"));
231 }
232
233 #[test]
234 fn render_worktrees_empty() {
235 assert!(render_worktrees(&[], Utc::now()).contains("No worktrees found"));
236 }
237}