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
24/// Renders a sorted loop inventory table, most idle first (staleness is the attention criterion).
25///
26/// Returns a celebratory message when the list is empty.
27pub 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
68/// Renders the worktree table + ASCII cleanup-command block.
69///
70/// Order: deletable/prunable first, then oldest idle first.
71pub 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        // ASCII header
214        assert!(out.contains("WORKTREE"));
215        assert!(out.contains("VERDICT"));
216        // deletable appears before cold
217        let pos_done = out.find("fix/done").unwrap();
218        let pos_cold = out.find("feat/cold").unwrap();
219        assert!(pos_done < pos_cold);
220        // command block for the deletable entry
221        assert!(out.contains("worktree remove"));
222        assert!(out.contains("branch -d fix/done"));
223        // ASCII-only
224        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}