Skip to main content

open_loops/
output.rs

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