use crate::git::GitSummary;
use crate::state::{DeployEntry, FileStatus};
use crossterm::style::Stylize;
use std::collections::BTreeMap;
use std::io::IsTerminal;
use std::path::Path;
pub struct PackageStatus {
pub name: String,
pub total: usize,
pub ok: usize,
pub modified: usize,
pub missing: usize,
pub metadata_drift: usize,
pub files: Vec<FileEntry>,
}
pub struct FileEntry {
pub display_path: String,
pub status: FileStatus,
}
pub fn group_by_package(entries: &[DeployEntry], statuses: &[FileStatus]) -> Vec<PackageStatus> {
let mut groups: BTreeMap<&str, Vec<(String, FileStatus)>> = BTreeMap::new();
for (entry, status) in entries.iter().zip(statuses.iter()) {
groups
.entry(&entry.package)
.or_default()
.push((display_path(&entry.target), status.clone()));
}
groups
.into_iter()
.map(|(name, files)| {
let total = files.len();
let ok = files.iter().filter(|(_, s)| s.is_ok()).count();
let modified = files.iter().filter(|(_, s)| s.is_modified()).count();
let missing = files.iter().filter(|(_, s)| s.is_missing()).count();
let metadata_drift = files
.iter()
.filter(|(_, s)| s.has_metadata_drift() && !s.is_modified())
.count();
let file_entries = files
.into_iter()
.map(|(display_path, status)| FileEntry {
display_path,
status,
})
.collect();
PackageStatus {
name: name.to_string(),
total,
ok,
modified,
missing,
metadata_drift,
files: file_entries,
}
})
.collect()
}
fn display_path(path: &Path) -> String {
if let Some(home) = std::env::var_os("HOME") {
let home = Path::new(&home);
if let Ok(rest) = path.strip_prefix(home) {
return format!("~/{}", rest.display());
}
}
path.display().to_string()
}
pub fn render_default(groups: &[PackageStatus]) -> String {
let mut out = String::new();
for pkg in groups {
out.push_str(&format!(
"{} ({}, {})\n",
pkg.name,
files_label(pkg.total),
status_summary(pkg),
));
for file in &pkg.files {
if file.status.is_missing() {
out.push_str(&format!(" ! {}\n", file.display_path));
} else if file.status.is_modified() {
out.push_str(&format!(" M {}\n", file.display_path));
} else if file.status.has_metadata_drift() {
out.push_str(&format!(" P {}\n", file.display_path));
}
}
}
out
}
pub fn render_verbose(groups: &[PackageStatus]) -> String {
let mut out = String::new();
for pkg in groups {
out.push_str(&format!(
"{} ({}, {})\n",
pkg.name,
files_label(pkg.total),
status_summary(pkg),
));
for file in &pkg.files {
let marker = if file.status.is_missing() {
"!"
} else if file.status.is_modified() {
"M"
} else if file.status.has_metadata_drift() {
"P"
} else {
"~"
};
out.push_str(&format!(" {} {}\n", marker, file.display_path));
}
}
out
}
pub fn render_short(total: usize, modified: usize, missing: usize) -> String {
let _ = total;
if modified == 0 && missing == 0 {
return String::new();
}
let mut parts = Vec::new();
if modified > 0 {
parts.push(format!("{modified} modified"));
}
if missing > 0 {
parts.push(format!("{missing} missing"));
}
format!("dotm: {}\n", parts.join(", "))
}
pub fn render_footer(total: usize, modified: usize, missing: usize) -> String {
if modified == 0 && missing == 0 {
return format!("{total} managed, all ok.\n");
}
let mut parts = vec![format!("{total} managed")];
if modified > 0 {
parts.push(format!("{modified} modified"));
}
if missing > 0 {
parts.push(format!("{missing} missing"));
}
format!("{}.\n", parts.join(", "))
}
fn files_label(count: usize) -> String {
if count == 1 {
"1 file".to_string()
} else {
format!("{count} files")
}
}
fn status_summary(pkg: &PackageStatus) -> String {
if pkg.modified == 0 && pkg.missing == 0 && pkg.metadata_drift == 0 {
return "ok".to_string();
}
let mut parts = Vec::new();
if pkg.modified > 0 {
parts.push(format!("{} modified", pkg.modified));
}
if pkg.missing > 0 {
parts.push(format!("{} missing", pkg.missing));
}
if pkg.metadata_drift > 0 {
parts.push(format!("{} metadata", pkg.metadata_drift));
}
parts.join(", ")
}
pub fn use_color() -> bool {
std::env::var("NO_COLOR").is_err() && std::io::stdout().is_terminal()
}
pub fn print_status_default(groups: &[PackageStatus], color: bool) {
for pkg in groups {
let summary = format!("({}, {})", files_label(pkg.total), status_summary(pkg));
if color {
if pkg.modified == 0 && pkg.missing == 0 {
println!("{} {}", pkg.name, summary.green());
} else if pkg.missing > 0 {
println!("{} {}", pkg.name, summary.red());
} else {
println!("{} {}", pkg.name, summary.yellow());
}
} else {
println!("{} {}", pkg.name, summary);
}
for file in &pkg.files {
if file.status.is_missing() {
if color {
println!(" {} {}", "!".red(), file.display_path);
} else {
println!(" ! {}", file.display_path);
}
} else if file.status.is_modified() {
if color {
println!(" {} {}", "M".yellow(), file.display_path);
} else {
println!(" M {}", file.display_path);
}
} else if file.status.has_metadata_drift() {
if color {
println!(" {} {}", "P".yellow(), file.display_path);
} else {
println!(" P {}", file.display_path);
}
}
}
}
}
pub fn print_status_verbose(groups: &[PackageStatus], color: bool) {
for pkg in groups {
let summary = format!("({}, {})", files_label(pkg.total), status_summary(pkg));
if color {
if pkg.modified == 0 && pkg.missing == 0 {
println!("{} {}", pkg.name, summary.green());
} else if pkg.missing > 0 {
println!("{} {}", pkg.name, summary.red());
} else {
println!("{} {}", pkg.name, summary.yellow());
}
} else {
println!("{} {}", pkg.name, summary);
}
for file in &pkg.files {
if file.status.is_missing() {
if color {
println!(" {} {}", "!".red(), file.display_path);
} else {
println!(" ! {}", file.display_path);
}
} else if file.status.is_modified() {
if color {
println!(" {} {}", "M".yellow(), file.display_path);
} else {
println!(" M {}", file.display_path);
}
} else if file.status.has_metadata_drift() {
if color {
println!(" {} {}", "P".yellow(), file.display_path);
} else {
println!(" P {}", file.display_path);
}
} else if color {
println!(" {} {}", "~".green(), file.display_path);
} else {
println!(" ~ {}", file.display_path);
}
}
}
}
pub fn print_short(total: usize, modified: usize, missing: usize, color: bool) {
let text = render_short(total, modified, missing);
if text.is_empty() {
return;
}
if color {
if missing > 0 {
print!("{}", text.red());
} else {
print!("{}", text.yellow());
}
} else {
print!("{}", text);
}
}
pub fn print_footer(total: usize, modified: usize, missing: usize, color: bool) {
let text = render_footer(total, modified, missing);
if color && modified == 0 && missing == 0 {
print!("{}", text.green());
} else {
print!("{}", text);
}
}
pub fn render_git_summary(summary: &GitSummary) -> String {
let mut parts = Vec::new();
let branch = summary.branch.as_deref().unwrap_or("(detached)");
parts.push(format!("git: {branch}"));
let mut dirty_parts = Vec::new();
if summary.modified_count > 0 {
dirty_parts.push(format!("{} modified", summary.modified_count));
}
if summary.untracked_count > 0 {
dirty_parts.push(format!("{} untracked", summary.untracked_count));
}
if dirty_parts.is_empty() {
dirty_parts.push("clean".to_string());
}
parts.push(dirty_parts.join(", "));
if let Some((ahead, behind)) = summary.ahead_behind {
parts.push(format!("{ahead} ahead, {behind} behind"));
}
parts.join(" | ")
}
pub fn print_git_summary(summary: &GitSummary, color: bool) {
let text = render_git_summary(summary);
if color {
if summary.dirty_count > 0 {
println!("{}", text.yellow());
} else {
println!("{}", text.green());
}
} else {
println!("{text}");
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scanner::EntryKind;
use crate::state::DeployEntry;
use std::path::PathBuf;
fn make_entry(target: &str, package: &str, hash: &str) -> DeployEntry {
DeployEntry {
target: PathBuf::from(target),
staged: None,
source: PathBuf::from(format!("/source{target}")),
content_hash: hash.to_string(),
original_hash: None,
kind: EntryKind::Base,
package: package.to_string(),
owner: None,
group: None,
mode: None,
original_owner: None,
original_group: None,
original_mode: None,
}
}
#[test]
fn group_entries_by_package() {
let entries = vec![
make_entry("/home/user/.bashrc", "shell", "h1"),
make_entry("/home/user/.zshrc", "shell", "h2"),
make_entry("/home/user/.config/app.conf", "desktop", "h3"),
];
let statuses = vec![
FileStatus::ok(),
FileStatus::ok(),
FileStatus {
content_modified: true,
..FileStatus::ok()
},
];
let grouped = group_by_package(&entries, &statuses);
assert_eq!(grouped.len(), 2);
let desktop = grouped.iter().find(|g| g.name == "desktop").unwrap();
assert_eq!(desktop.total, 1);
assert_eq!(desktop.modified, 1);
let shell = grouped.iter().find(|g| g.name == "shell").unwrap();
assert_eq!(shell.total, 2);
assert_eq!(shell.ok, 2);
}
#[test]
fn packages_sorted_alphabetically() {
let entries = vec![
make_entry("/a", "zsh", "h1"),
make_entry("/b", "bin", "h2"),
make_entry("/c", "gaming", "h3"),
];
let statuses = vec![FileStatus::ok(), FileStatus::ok(), FileStatus::ok()];
let grouped = group_by_package(&entries, &statuses);
let names: Vec<&str> = grouped.iter().map(|g| g.name.as_str()).collect();
assert_eq!(names, vec!["bin", "gaming", "zsh"]);
}
#[test]
fn render_default_shows_package_headers() {
let entries = vec![
make_entry("/home/user/.bashrc", "shell", "h1"),
make_entry("/home/user/.config/app.conf", "desktop", "h2"),
];
let statuses = vec![
FileStatus::ok(),
FileStatus {
content_modified: true,
..FileStatus::ok()
},
];
let grouped = group_by_package(&entries, &statuses);
let output = render_default(&grouped);
assert!(output.contains("shell"));
assert!(output.contains("desktop"));
assert!(output.contains("1 modified"));
assert!(output.contains("M "));
assert!(output.contains("app.conf"));
}
#[test]
fn render_default_hides_ok_files() {
let entries = vec![make_entry("/home/user/.bashrc", "shell", "h1")];
let statuses = vec![FileStatus::ok()];
let grouped = group_by_package(&entries, &statuses);
let output = render_default(&grouped);
assert!(output.contains("shell"));
assert!(output.contains("ok"));
assert!(!output.contains(".bashrc"));
}
#[test]
fn render_verbose_shows_all_files() {
let entries = vec![
make_entry("/home/user/.bashrc", "shell", "h1"),
make_entry("/home/user/.zshrc", "shell", "h2"),
];
let statuses = vec![FileStatus::ok(), FileStatus::ok()];
let grouped = group_by_package(&entries, &statuses);
let output = render_verbose(&grouped);
assert!(output.contains(".bashrc"));
assert!(output.contains(".zshrc"));
}
#[test]
fn render_short_empty_when_clean() {
let output = render_short(5, 0, 0);
assert!(output.is_empty());
}
#[test]
fn render_short_shows_problems() {
let output = render_short(10, 2, 1);
assert!(output.contains("dotm:"));
assert!(output.contains("2 modified"));
assert!(output.contains("1 missing"));
}
#[test]
fn render_footer_all_ok() {
let output = render_footer(10, 0, 0);
assert!(output.contains("10 managed"));
assert!(output.contains("all ok"));
}
#[test]
fn render_footer_with_problems() {
let output = render_footer(10, 2, 1);
assert!(output.contains("10 managed"));
assert!(output.contains("2 modified"));
assert!(output.contains("1 missing"));
}
#[test]
fn render_git_summary_clean() {
let summary = crate::git::GitSummary {
branch: Some("main".to_string()),
dirty_count: 0,
untracked_count: 0,
modified_count: 0,
ahead_behind: None,
};
let output = render_git_summary(&summary);
assert!(output.contains("git: main"));
assert!(output.contains("clean"));
}
#[test]
fn render_git_summary_dirty_with_remote() {
let summary = crate::git::GitSummary {
branch: Some("feature/test".to_string()),
dirty_count: 3,
untracked_count: 1,
modified_count: 2,
ahead_behind: Some((3, 0)),
};
let output = render_git_summary(&summary);
assert!(output.contains("git: feature/test"));
assert!(output.contains("2 modified"));
assert!(output.contains("1 untracked"));
assert!(output.contains("3 ahead, 0 behind"));
}
}