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_short(total: usize, modified: usize, missing: usize) -> String {
76    let _ = total;
77    if modified == 0 && missing == 0 {
78        return String::new();
79    }
80
81    let mut parts = Vec::new();
82    if modified > 0 {
83        parts.push(format!("{modified} modified"));
84    }
85    if missing > 0 {
86        parts.push(format!("{missing} missing"));
87    }
88    format!("dotm: {}\n", parts.join(", "))
89}
90
91pub fn render_footer(total: usize, modified: usize, missing: usize) -> String {
92    if modified == 0 && missing == 0 {
93        return format!("{total} managed, all ok.\n");
94    }
95
96    let mut parts = vec![format!("{total} managed")];
97    if modified > 0 {
98        parts.push(format!("{modified} modified"));
99    }
100    if missing > 0 {
101        parts.push(format!("{missing} missing"));
102    }
103    format!("{}.\n", parts.join(", "))
104}
105
106fn files_label(count: usize) -> String {
107    if count == 1 {
108        "1 file".to_string()
109    } else {
110        format!("{count} files")
111    }
112}
113
114fn status_summary(pkg: &PackageStatus) -> String {
115    if pkg.modified == 0 && pkg.missing == 0 && pkg.metadata_drift == 0 {
116        return "ok".to_string();
117    }
118
119    let mut parts = Vec::new();
120    if pkg.modified > 0 {
121        parts.push(format!("{} modified", pkg.modified));
122    }
123    if pkg.missing > 0 {
124        parts.push(format!("{} missing", pkg.missing));
125    }
126    if pkg.metadata_drift > 0 {
127        parts.push(format!("{} metadata", pkg.metadata_drift));
128    }
129    parts.join(", ")
130}
131
132pub fn use_color() -> bool {
133    std::env::var("NO_COLOR").is_err() && std::io::stdout().is_terminal()
134}
135
136pub fn print_status(groups: &[PackageStatus], color: bool, verbose: bool) {
137    for pkg in groups {
138        let summary = format!("({}, {})", files_label(pkg.total), status_summary(pkg));
139
140        if color {
141            if pkg.modified == 0 && pkg.missing == 0 {
142                println!("{} {}", pkg.name, summary.green());
143            } else if pkg.missing > 0 {
144                println!("{} {}", pkg.name, summary.red());
145            } else {
146                println!("{} {}", pkg.name, summary.yellow());
147            }
148        } else {
149            println!("{} {}", pkg.name, summary);
150        }
151
152        for file in &pkg.files {
153            if file.status.is_missing() {
154                if color {
155                    println!("  {} {}", "!".red(), file.display_path);
156                } else {
157                    println!("  ! {}", file.display_path);
158                }
159            } else if file.status.is_modified() {
160                if color {
161                    println!("  {} {}", "M".yellow(), file.display_path);
162                } else {
163                    println!("  M {}", file.display_path);
164                }
165            } else if file.status.has_metadata_drift() {
166                if color {
167                    println!("  {} {}", "P".yellow(), file.display_path);
168                } else {
169                    println!("  P {}", file.display_path);
170                }
171            } else if verbose {
172                if color {
173                    println!("  {} {}", "~".green(), file.display_path);
174                } else {
175                    println!("  ~ {}", file.display_path);
176                }
177            }
178        }
179    }
180}
181
182pub fn print_short(total: usize, modified: usize, missing: usize, color: bool) {
183    let text = render_short(total, modified, missing);
184    if text.is_empty() {
185        return;
186    }
187    if color {
188        if missing > 0 {
189            print!("{}", text.red());
190        } else {
191            print!("{}", text.yellow());
192        }
193    } else {
194        print!("{}", text);
195    }
196}
197
198pub fn print_footer(total: usize, modified: usize, missing: usize, color: bool) {
199    let text = render_footer(total, modified, missing);
200    if color && modified == 0 && missing == 0 {
201        print!("{}", text.green());
202    } else {
203        print!("{}", text);
204    }
205}
206
207pub fn render_git_summary(summary: &GitSummary) -> String {
208    let mut parts = Vec::new();
209
210    let branch = summary.branch.as_deref().unwrap_or("(detached)");
211    parts.push(format!("git: {branch}"));
212
213    let mut dirty_parts = Vec::new();
214    if summary.modified_count > 0 {
215        dirty_parts.push(format!("{} modified", summary.modified_count));
216    }
217    if summary.untracked_count > 0 {
218        dirty_parts.push(format!("{} untracked", summary.untracked_count));
219    }
220    if dirty_parts.is_empty() {
221        dirty_parts.push("clean".to_string());
222    }
223    parts.push(dirty_parts.join(", "));
224
225    if let Some((ahead, behind)) = summary.ahead_behind {
226        parts.push(format!("{ahead} ahead, {behind} behind"));
227    }
228
229    parts.join(" | ")
230}
231
232pub fn print_git_summary(summary: &GitSummary, color: bool) {
233    let text = render_git_summary(summary);
234    if color {
235        if summary.dirty_count > 0 {
236            println!("{}", text.yellow());
237        } else {
238            println!("{}", text.green());
239        }
240    } else {
241        println!("{text}");
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248    use crate::scanner::EntryKind;
249    use crate::state::DeployEntry;
250    use std::path::PathBuf;
251
252    fn make_entry(target: &str, package: &str, hash: &str) -> DeployEntry {
253        DeployEntry {
254            target: PathBuf::from(target),
255            staged: None,
256            source: PathBuf::from(format!("/source{target}")),
257            content_hash: hash.to_string(),
258            original_hash: None,
259            kind: EntryKind::Base,
260            package: package.to_string(),
261            owner: None,
262            group: None,
263            mode: None,
264            original_owner: None,
265            original_group: None,
266            original_mode: None,
267        }
268    }
269
270    #[test]
271    fn group_entries_by_package() {
272        let entries = vec![
273            make_entry("/home/user/.bashrc", "shell", "h1"),
274            make_entry("/home/user/.zshrc", "shell", "h2"),
275            make_entry("/home/user/.config/app.conf", "desktop", "h3"),
276        ];
277        let statuses = vec![
278            FileStatus::ok(),
279            FileStatus::ok(),
280            FileStatus {
281                content_modified: true,
282                ..FileStatus::ok()
283            },
284        ];
285        let grouped = group_by_package(&entries, &statuses);
286
287        assert_eq!(grouped.len(), 2);
288        let desktop = grouped.iter().find(|g| g.name == "desktop").unwrap();
289        assert_eq!(desktop.total, 1);
290        assert_eq!(desktop.modified, 1);
291        let shell = grouped.iter().find(|g| g.name == "shell").unwrap();
292        assert_eq!(shell.total, 2);
293        assert_eq!(shell.ok, 2);
294    }
295
296    #[test]
297    fn packages_sorted_alphabetically() {
298        let entries = vec![
299            make_entry("/a", "zsh", "h1"),
300            make_entry("/b", "bin", "h2"),
301            make_entry("/c", "gaming", "h3"),
302        ];
303        let statuses = vec![FileStatus::ok(), FileStatus::ok(), FileStatus::ok()];
304        let grouped = group_by_package(&entries, &statuses);
305        let names: Vec<&str> = grouped.iter().map(|g| g.name.as_str()).collect();
306        assert_eq!(names, vec!["bin", "gaming", "zsh"]);
307    }
308
309    #[test]
310    fn render_short_empty_when_clean() {
311        let output = render_short(5, 0, 0);
312        assert!(output.is_empty());
313    }
314
315    #[test]
316    fn render_short_shows_problems() {
317        let output = render_short(10, 2, 1);
318        assert!(output.contains("dotm:"));
319        assert!(output.contains("2 modified"));
320        assert!(output.contains("1 missing"));
321    }
322
323    #[test]
324    fn render_footer_all_ok() {
325        let output = render_footer(10, 0, 0);
326        assert!(output.contains("10 managed"));
327        assert!(output.contains("all ok"));
328    }
329
330    #[test]
331    fn render_footer_with_problems() {
332        let output = render_footer(10, 2, 1);
333        assert!(output.contains("10 managed"));
334        assert!(output.contains("2 modified"));
335        assert!(output.contains("1 missing"));
336    }
337
338    #[test]
339    fn render_git_summary_clean() {
340        let summary = crate::git::GitSummary {
341            branch: Some("main".to_string()),
342            dirty_count: 0,
343            untracked_count: 0,
344            modified_count: 0,
345            ahead_behind: None,
346        };
347        let output = render_git_summary(&summary);
348        assert!(output.contains("git: main"));
349        assert!(output.contains("clean"));
350    }
351
352    #[test]
353    fn render_git_summary_dirty_with_remote() {
354        let summary = crate::git::GitSummary {
355            branch: Some("feature/test".to_string()),
356            dirty_count: 3,
357            untracked_count: 1,
358            modified_count: 2,
359            ahead_behind: Some((3, 0)),
360        };
361        let output = render_git_summary(&summary);
362        assert!(output.contains("git: feature/test"));
363        assert!(output.contains("2 modified"));
364        assert!(output.contains("1 untracked"));
365        assert!(output.contains("3 ahead, 0 behind"));
366    }
367}