Skip to main content

dotm/
status.rs

1use crate::git::GitSummary;
2use crate::state::{DeployEntry, FileStatus};
3use crossterm::style::Stylize;
4use std::collections::BTreeMap;
5use std::io::IsTerminal;
6use std::path::Path;
7
8pub struct PackageStatus {
9    pub name: String,
10    pub total: usize,
11    pub ok: usize,
12    pub modified: usize,
13    pub missing: usize,
14    pub metadata_drift: usize,
15    pub files: Vec<FileEntry>,
16}
17
18pub struct FileEntry {
19    pub display_path: String,
20    pub status: FileStatus,
21}
22
23pub fn group_by_package(entries: &[DeployEntry], statuses: &[FileStatus]) -> Vec<PackageStatus> {
24    let mut groups: BTreeMap<&str, Vec<(String, FileStatus)>> = BTreeMap::new();
25
26    for (entry, status) in entries.iter().zip(statuses.iter()) {
27        groups
28            .entry(&entry.package)
29            .or_default()
30            .push((display_path(&entry.target), status.clone()));
31    }
32
33    groups
34        .into_iter()
35        .map(|(name, files)| {
36            let total = files.len();
37            let ok = files.iter().filter(|(_, s)| s.is_ok()).count();
38            let modified = files.iter().filter(|(_, s)| s.is_modified()).count();
39            let missing = files.iter().filter(|(_, s)| s.is_missing()).count();
40            let metadata_drift = files
41                .iter()
42                .filter(|(_, s)| s.has_metadata_drift() && !s.is_modified())
43                .count();
44            let file_entries = files
45                .into_iter()
46                .map(|(display_path, status)| FileEntry {
47                    display_path,
48                    status,
49                })
50                .collect();
51
52            PackageStatus {
53                name: name.to_string(),
54                total,
55                ok,
56                modified,
57                missing,
58                metadata_drift,
59                files: file_entries,
60            }
61        })
62        .collect()
63}
64
65fn display_path(path: &Path) -> String {
66    if let Some(home) = std::env::var_os("HOME") {
67        let home = Path::new(&home);
68        if let Ok(rest) = path.strip_prefix(home) {
69            return format!("~/{}", rest.display());
70        }
71    }
72    path.display().to_string()
73}
74
75pub fn render_default(groups: &[PackageStatus]) -> String {
76    let mut out = String::new();
77
78    for pkg in groups {
79        out.push_str(&format!(
80            "{} ({}, {})\n",
81            pkg.name,
82            files_label(pkg.total),
83            status_summary(pkg),
84        ));
85
86        for file in &pkg.files {
87            if file.status.is_missing() {
88                out.push_str(&format!("  ! {}\n", file.display_path));
89            } else if file.status.is_modified() {
90                out.push_str(&format!("  M {}\n", file.display_path));
91            } else if file.status.has_metadata_drift() {
92                out.push_str(&format!("  P {}\n", file.display_path));
93            }
94        }
95    }
96
97    out
98}
99
100pub fn render_verbose(groups: &[PackageStatus]) -> String {
101    let mut out = String::new();
102
103    for pkg in groups {
104        out.push_str(&format!(
105            "{} ({}, {})\n",
106            pkg.name,
107            files_label(pkg.total),
108            status_summary(pkg),
109        ));
110
111        for file in &pkg.files {
112            let marker = if file.status.is_missing() {
113                "!"
114            } else if file.status.is_modified() {
115                "M"
116            } else if file.status.has_metadata_drift() {
117                "P"
118            } else {
119                "~"
120            };
121            out.push_str(&format!("  {} {}\n", marker, file.display_path));
122        }
123    }
124
125    out
126}
127
128pub fn render_short(total: usize, modified: usize, missing: usize) -> String {
129    let _ = total;
130    if modified == 0 && missing == 0 {
131        return String::new();
132    }
133
134    let mut parts = Vec::new();
135    if modified > 0 {
136        parts.push(format!("{modified} modified"));
137    }
138    if missing > 0 {
139        parts.push(format!("{missing} missing"));
140    }
141    format!("dotm: {}\n", parts.join(", "))
142}
143
144pub fn render_footer(total: usize, modified: usize, missing: usize) -> String {
145    if modified == 0 && missing == 0 {
146        return format!("{total} managed, all ok.\n");
147    }
148
149    let mut parts = vec![format!("{total} managed")];
150    if modified > 0 {
151        parts.push(format!("{modified} modified"));
152    }
153    if missing > 0 {
154        parts.push(format!("{missing} missing"));
155    }
156    format!("{}.\n", parts.join(", "))
157}
158
159fn files_label(count: usize) -> String {
160    if count == 1 {
161        "1 file".to_string()
162    } else {
163        format!("{count} files")
164    }
165}
166
167fn status_summary(pkg: &PackageStatus) -> String {
168    if pkg.modified == 0 && pkg.missing == 0 && pkg.metadata_drift == 0 {
169        return "ok".to_string();
170    }
171
172    let mut parts = Vec::new();
173    if pkg.modified > 0 {
174        parts.push(format!("{} modified", pkg.modified));
175    }
176    if pkg.missing > 0 {
177        parts.push(format!("{} missing", pkg.missing));
178    }
179    if pkg.metadata_drift > 0 {
180        parts.push(format!("{} metadata", pkg.metadata_drift));
181    }
182    parts.join(", ")
183}
184
185pub fn use_color() -> bool {
186    std::env::var("NO_COLOR").is_err() && std::io::stdout().is_terminal()
187}
188
189pub fn print_status_default(groups: &[PackageStatus], color: bool) {
190    for pkg in groups {
191        let summary = format!("({}, {})", files_label(pkg.total), status_summary(pkg));
192
193        if color {
194            if pkg.modified == 0 && pkg.missing == 0 {
195                println!("{} {}", pkg.name, summary.green());
196            } else if pkg.missing > 0 {
197                println!("{} {}", pkg.name, summary.red());
198            } else {
199                println!("{} {}", pkg.name, summary.yellow());
200            }
201        } else {
202            println!("{} {}", pkg.name, summary);
203        }
204
205        for file in &pkg.files {
206            if file.status.is_missing() {
207                if color {
208                    println!("  {} {}", "!".red(), file.display_path);
209                } else {
210                    println!("  ! {}", file.display_path);
211                }
212            } else if file.status.is_modified() {
213                if color {
214                    println!("  {} {}", "M".yellow(), file.display_path);
215                } else {
216                    println!("  M {}", file.display_path);
217                }
218            } else if file.status.has_metadata_drift() {
219                if color {
220                    println!("  {} {}", "P".yellow(), file.display_path);
221                } else {
222                    println!("  P {}", file.display_path);
223                }
224            }
225        }
226    }
227}
228
229pub fn print_status_verbose(groups: &[PackageStatus], color: bool) {
230    for pkg in groups {
231        let summary = format!("({}, {})", files_label(pkg.total), status_summary(pkg));
232
233        if color {
234            if pkg.modified == 0 && pkg.missing == 0 {
235                println!("{} {}", pkg.name, summary.green());
236            } else if pkg.missing > 0 {
237                println!("{} {}", pkg.name, summary.red());
238            } else {
239                println!("{} {}", pkg.name, summary.yellow());
240            }
241        } else {
242            println!("{} {}", pkg.name, summary);
243        }
244
245        for file in &pkg.files {
246            if file.status.is_missing() {
247                if color {
248                    println!("  {} {}", "!".red(), file.display_path);
249                } else {
250                    println!("  ! {}", file.display_path);
251                }
252            } else if file.status.is_modified() {
253                if color {
254                    println!("  {} {}", "M".yellow(), file.display_path);
255                } else {
256                    println!("  M {}", file.display_path);
257                }
258            } else if file.status.has_metadata_drift() {
259                if color {
260                    println!("  {} {}", "P".yellow(), file.display_path);
261                } else {
262                    println!("  P {}", file.display_path);
263                }
264            } else if color {
265                println!("  {} {}", "~".green(), file.display_path);
266            } else {
267                println!("  ~ {}", file.display_path);
268            }
269        }
270    }
271}
272
273pub fn print_short(total: usize, modified: usize, missing: usize, color: bool) {
274    let text = render_short(total, modified, missing);
275    if text.is_empty() {
276        return;
277    }
278    if color {
279        if missing > 0 {
280            print!("{}", text.red());
281        } else {
282            print!("{}", text.yellow());
283        }
284    } else {
285        print!("{}", text);
286    }
287}
288
289pub fn print_footer(total: usize, modified: usize, missing: usize, color: bool) {
290    let text = render_footer(total, modified, missing);
291    if color && modified == 0 && missing == 0 {
292        print!("{}", text.green());
293    } else {
294        print!("{}", text);
295    }
296}
297
298pub fn render_git_summary(summary: &GitSummary) -> String {
299    let mut parts = Vec::new();
300
301    let branch = summary.branch.as_deref().unwrap_or("(detached)");
302    parts.push(format!("git: {branch}"));
303
304    let mut dirty_parts = Vec::new();
305    if summary.modified_count > 0 {
306        dirty_parts.push(format!("{} modified", summary.modified_count));
307    }
308    if summary.untracked_count > 0 {
309        dirty_parts.push(format!("{} untracked", summary.untracked_count));
310    }
311    if dirty_parts.is_empty() {
312        dirty_parts.push("clean".to_string());
313    }
314    parts.push(dirty_parts.join(", "));
315
316    if let Some((ahead, behind)) = summary.ahead_behind {
317        parts.push(format!("{ahead} ahead, {behind} behind"));
318    }
319
320    parts.join(" | ")
321}
322
323pub fn print_git_summary(summary: &GitSummary, color: bool) {
324    let text = render_git_summary(summary);
325    if color {
326        if summary.dirty_count > 0 {
327            println!("{}", text.yellow());
328        } else {
329            println!("{}", text.green());
330        }
331    } else {
332        println!("{text}");
333    }
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339    use crate::scanner::EntryKind;
340    use crate::state::DeployEntry;
341    use std::path::PathBuf;
342
343    fn make_entry(target: &str, package: &str, hash: &str) -> DeployEntry {
344        DeployEntry {
345            target: PathBuf::from(target),
346            staged: PathBuf::from(format!("/staged{target}")),
347            source: PathBuf::from(format!("/source{target}")),
348            content_hash: hash.to_string(),
349            original_hash: None,
350            kind: EntryKind::Base,
351            package: package.to_string(),
352            owner: None,
353            group: None,
354            mode: None,
355            original_owner: None,
356            original_group: None,
357            original_mode: None,
358        }
359    }
360
361    #[test]
362    fn group_entries_by_package() {
363        let entries = vec![
364            make_entry("/home/user/.bashrc", "shell", "h1"),
365            make_entry("/home/user/.zshrc", "shell", "h2"),
366            make_entry("/home/user/.config/app.conf", "desktop", "h3"),
367        ];
368        let statuses = vec![
369            FileStatus::ok(),
370            FileStatus::ok(),
371            FileStatus {
372                content_modified: true,
373                ..FileStatus::ok()
374            },
375        ];
376        let grouped = group_by_package(&entries, &statuses);
377
378        assert_eq!(grouped.len(), 2);
379        let desktop = grouped.iter().find(|g| g.name == "desktop").unwrap();
380        assert_eq!(desktop.total, 1);
381        assert_eq!(desktop.modified, 1);
382        let shell = grouped.iter().find(|g| g.name == "shell").unwrap();
383        assert_eq!(shell.total, 2);
384        assert_eq!(shell.ok, 2);
385    }
386
387    #[test]
388    fn packages_sorted_alphabetically() {
389        let entries = vec![
390            make_entry("/a", "zsh", "h1"),
391            make_entry("/b", "bin", "h2"),
392            make_entry("/c", "gaming", "h3"),
393        ];
394        let statuses = vec![FileStatus::ok(), FileStatus::ok(), FileStatus::ok()];
395        let grouped = group_by_package(&entries, &statuses);
396        let names: Vec<&str> = grouped.iter().map(|g| g.name.as_str()).collect();
397        assert_eq!(names, vec!["bin", "gaming", "zsh"]);
398    }
399
400    #[test]
401    fn render_default_shows_package_headers() {
402        let entries = vec![
403            make_entry("/home/user/.bashrc", "shell", "h1"),
404            make_entry("/home/user/.config/app.conf", "desktop", "h2"),
405        ];
406        let statuses = vec![
407            FileStatus::ok(),
408            FileStatus {
409                content_modified: true,
410                ..FileStatus::ok()
411            },
412        ];
413        let grouped = group_by_package(&entries, &statuses);
414        let output = render_default(&grouped);
415        assert!(output.contains("shell"));
416        assert!(output.contains("desktop"));
417        assert!(output.contains("1 modified"));
418        assert!(output.contains("M "));
419        assert!(output.contains("app.conf"));
420    }
421
422    #[test]
423    fn render_default_hides_ok_files() {
424        let entries = vec![make_entry("/home/user/.bashrc", "shell", "h1")];
425        let statuses = vec![FileStatus::ok()];
426        let grouped = group_by_package(&entries, &statuses);
427        let output = render_default(&grouped);
428        assert!(output.contains("shell"));
429        assert!(output.contains("ok"));
430        assert!(!output.contains(".bashrc"));
431    }
432
433    #[test]
434    fn render_verbose_shows_all_files() {
435        let entries = vec![
436            make_entry("/home/user/.bashrc", "shell", "h1"),
437            make_entry("/home/user/.zshrc", "shell", "h2"),
438        ];
439        let statuses = vec![FileStatus::ok(), FileStatus::ok()];
440        let grouped = group_by_package(&entries, &statuses);
441        let output = render_verbose(&grouped);
442        assert!(output.contains(".bashrc"));
443        assert!(output.contains(".zshrc"));
444    }
445
446    #[test]
447    fn render_short_empty_when_clean() {
448        let output = render_short(5, 0, 0);
449        assert!(output.is_empty());
450    }
451
452    #[test]
453    fn render_short_shows_problems() {
454        let output = render_short(10, 2, 1);
455        assert!(output.contains("dotm:"));
456        assert!(output.contains("2 modified"));
457        assert!(output.contains("1 missing"));
458    }
459
460    #[test]
461    fn render_footer_all_ok() {
462        let output = render_footer(10, 0, 0);
463        assert!(output.contains("10 managed"));
464        assert!(output.contains("all ok"));
465    }
466
467    #[test]
468    fn render_footer_with_problems() {
469        let output = render_footer(10, 2, 1);
470        assert!(output.contains("10 managed"));
471        assert!(output.contains("2 modified"));
472        assert!(output.contains("1 missing"));
473    }
474
475    #[test]
476    fn render_git_summary_clean() {
477        let summary = crate::git::GitSummary {
478            branch: Some("main".to_string()),
479            dirty_count: 0,
480            untracked_count: 0,
481            modified_count: 0,
482            ahead_behind: None,
483        };
484        let output = render_git_summary(&summary);
485        assert!(output.contains("git: main"));
486        assert!(output.contains("clean"));
487    }
488
489    #[test]
490    fn render_git_summary_dirty_with_remote() {
491        let summary = crate::git::GitSummary {
492            branch: Some("feature/test".to_string()),
493            dirty_count: 3,
494            untracked_count: 1,
495            modified_count: 2,
496            ahead_behind: Some((3, 0)),
497        };
498        let output = render_git_summary(&summary);
499        assert!(output.contains("git: feature/test"));
500        assert!(output.contains("2 modified"));
501        assert!(output.contains("1 untracked"));
502        assert!(output.contains("3 ahead, 0 behind"));
503    }
504}