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}