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}