diff --git a/src/commands/status.rs b/src/commands/status.rs index 2a6606a5..20bbc78a 100644 --- a/src/commands/status.rs +++ b/src/commands/status.rs @@ -1,9 +1,9 @@ +use crate::output::{HumanFormatter, LabelKind, OutputMode}; use agentsync::config::{SyncType, TargetConfig}; use agentsync::skills_layout::detect_skills_mode_mismatch; use agentsync::{Linker, linker::SymlinkContentsChildExpectation}; use anyhow::Result; use clap::Args; -use colored::Colorize; use serde::Serialize; use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -156,24 +156,30 @@ pub fn run_status(json: bool, project_root: PathBuf) -> Result<()> { return Ok(()); } + let use_color = match crate::output::output_mode(json) { + OutputMode::Json => false, + OutputMode::Human { use_color } => use_color, + }; + let formatter = HumanFormatter::new(use_color); + for entry in &entries { - for line in render_status_entry(entry) { + for line in render_status_entry(entry, &formatter) { println!("{line}"); } if !entry_is_problematic(entry) && let Some(hint) = hints.get(&entry.destination) { - println!(" {} {}", "↳".blue(), hint); + println!(" {}", render_status_hint(hint, &formatter)); } } if problems > 0 { - println!("\nStatus: {} problems found", problems); + println!("\n{}", render_status_summary(problems, &formatter)); std::process::exit(1); } - println!("\nStatus: All good"); + println!("\n{}", render_status_summary(0, &formatter)); Ok(()) } @@ -214,116 +220,180 @@ pub fn entry_is_problematic(e: &StatusEntry) -> bool { !e.issues.is_empty() } -pub(crate) fn render_status_entry(entry: &StatusEntry) -> Vec { +pub(crate) fn render_status_entry(entry: &StatusEntry, formatter: &HumanFormatter) -> Vec { if entry.issues.is_empty() { - return vec![render_ok_line(entry)]; + return render_ok_line(entry, formatter); } entry .issues .iter() - .map(|issue| render_issue_line(entry, issue)) + .flat_map(|issue| render_issue_line(entry, issue, formatter)) .collect() } -fn render_ok_line(entry: &StatusEntry) -> String { +fn render_ok_line(entry: &StatusEntry, formatter: &HumanFormatter) -> Vec { match entry.sync_type.as_str() { "symlink-contents" => { let managed_count = entry.managed_children.as_ref().map_or(0, Vec::len); if entry.destination_kind == DestinationKind::Directory { - format!( - "{} OK: {} (symlink-contents container, {} managed entries expected)", - "✔".green(), - entry.destination, - managed_count - ) + vec![ + format!( + "{}: {}", + formatter.format_label("✔", "OK", LabelKind::Success), + entry.destination + ), + format!( + " {}", + formatter.format_key_value("type", "symlink-contents container") + ), + format!( + " {}", + formatter.format_key_value( + "managed entries expected", + &managed_count.to_string() + ) + ), + ] } else { - format!("{} OK: {}", "✔".green(), entry.destination) + vec![format!( + "{}: {}", + formatter.format_label("✔", "OK", LabelKind::Success), + entry.destination + )] } } - _ => format!( - "{} OK: {} -> {}", - "✔".green(), + _ => vec![format!( + "{}: {} -> {}", + formatter.format_label("✔", "OK", LabelKind::Success), entry.destination, entry.points_to.as_deref().unwrap_or("") - ), + )], } } -fn render_issue_line(entry: &StatusEntry, issue: &StatusIssue) -> String { - match issue.kind { +fn render_issue_line( + entry: &StatusEntry, + issue: &StatusIssue, + formatter: &HumanFormatter, +) -> Vec { + let mut lines = match issue.kind { StatusIssueKind::MissingDestination => match entry.sync_type.as_str() { - "symlink-contents" => format!( - "{} Drift: {} missing managed container directory", - "✗".red(), + "symlink-contents" => vec![format!( + "{}: {} missing managed container directory", + formatter.format_label("✗", "Drift", LabelKind::Failure), + entry.destination + )], + _ => vec![format!( + "{}: {}", + formatter.format_label("!", "Missing", LabelKind::Warning), entry.destination - ), - _ => format!("{} Missing: {}", "!".yellow(), entry.destination), + )], }, StatusIssueKind::InvalidDestinationType => { if entry.sync_type.as_str() == "symlink-contents" { - format!( - "{} Drift: {} exists as {} but symlink-contents expects a directory container", - "✗".red(), + vec![format!( + "{}: {} exists as {} but symlink-contents expects a directory container", + formatter.format_label("✗", "Drift", LabelKind::Failure), entry.destination, issue.actual.as_deref().unwrap_or("an invalid path type") - ) + )] } else { - format!( - "{} Exists but not a symlink: {}", - "·".dimmed(), + vec![format!( + "{}: {}", + formatter.format_label("·", "Exists but not a symlink", LabelKind::Muted), entry.destination - ) + )] } } - StatusIssueKind::MissingExpectedChild => format!( - "{} Drift: {} missing managed child {}", - "✗".red(), + StatusIssueKind::MissingExpectedChild => vec![format!( + "{}: {} missing managed child {}", + formatter.format_label("✗", "Drift", LabelKind::Failure), entry.destination, Path::new(&issue.path) .file_name() .and_then(|name| name.to_str()) .unwrap_or(&issue.path) - ), - StatusIssueKind::ChildNotSymlink => format!( - "{} Drift: {} exists but is not a symlink", - "✗".red(), + )], + StatusIssueKind::ChildNotSymlink => vec![format!( + "{}: {} exists but is not a symlink", + formatter.format_label("✗", "Drift", LabelKind::Failure), issue.path - ), + )], StatusIssueKind::IncorrectLinkTarget => { if issue.path == entry.destination { - format!( - "{} Incorrect link: {} -> {} (expected: {})", - "✗".red(), - entry.destination, - issue.actual.as_deref().unwrap_or(""), - issue.expected.as_deref().unwrap_or("") - ) + vec![format!( + "{}: {}", + formatter.format_label("✗", "Incorrect link", LabelKind::Failure), + entry.destination + )] } else { - format!( - "{} Drift: {} points to {} (expected: {})", - "✗".red(), - issue.path, - issue.actual.as_deref().unwrap_or(""), - issue.expected.as_deref().unwrap_or("") - ) + vec![format!( + "{}: {}", + formatter.format_label("✗", "Drift", LabelKind::Failure), + issue.path + )] } } StatusIssueKind::MissingExpectedSource => { if entry.sync_type.as_str() == "symlink-contents" { - format!( - "{} Missing source container directory: {}", - "!".yellow(), + vec![format!( + "{}: {}", + formatter.format_label( + "!", + "Missing source container directory", + LabelKind::Warning + ), entry.destination - ) + )] } else { - format!( - "{} Link points to missing source: {}", - "!".yellow(), + vec![format!( + "{}: {}", + formatter.format_label( + "!", + "Link points to missing source", + LabelKind::Warning + ), issue.actual.as_deref().unwrap_or(&entry.destination) - ) + )] } } + }; + + lines.extend(issue_detail_lines(issue, formatter)); + lines +} + +fn issue_detail_lines(issue: &StatusIssue, formatter: &HumanFormatter) -> Vec { + let mut lines = Vec::new(); + if let Some(actual) = issue.actual.as_deref() { + lines.push(format!( + " {}", + formatter.format_key_value("actual", actual) + )); + } + if let Some(expected) = issue.expected.as_deref() { + lines.push(format!( + " {}", + formatter.format_key_value("expected", expected) + )); + } + lines +} + +pub(crate) fn render_status_hint(hint: &str, formatter: &HumanFormatter) -> String { + formatter.format_hint(hint) +} + +pub(crate) fn render_status_summary(problems: usize, formatter: &HumanFormatter) -> String { + if problems == 0 { + formatter.format_summary_line("Status", "All good", LabelKind::Success) + } else { + formatter.format_summary_line( + "Status", + &format!("{problems} problems found"), + LabelKind::Failure, + ) } } diff --git a/src/commands/status_tests.rs b/src/commands/status_tests.rs index 5e5f05ba..0303d6a1 100644 --- a/src/commands/status_tests.rs +++ b/src/commands/status_tests.rs @@ -2,13 +2,18 @@ mod tests { use crate::commands::status::{ StatusEntry, collect_status_entries, collect_status_hints, entry_is_problematic, - render_status_entry, + render_status_entry, render_status_hint, render_status_summary, }; + use crate::output::HumanFormatter; use agentsync::{Linker, config::Config, linker::SyncOptions}; use std::fs; use std::path::PathBuf; use tempfile::TempDir; + fn plain_formatter() -> HumanFormatter { + HumanFormatter::new(false) + } + fn load_linker(temp_dir: &TempDir, config_content: &str) -> (Linker, PathBuf) { let agents_dir = temp_dir.path().join(".agents"); fs::create_dir_all(&agents_dir).unwrap(); @@ -47,6 +52,41 @@ mod tests { ) } + fn status_entry( + destination: &str, + sync_type: &str, + destination_kind: crate::commands::status::DestinationKind, + points_to: Option<&str>, + expected_source: Option<&str>, + issues: Vec, + ) -> StatusEntry { + StatusEntry { + destination: destination.into(), + sync_type: sync_type.into(), + destination_kind, + exists: destination_kind != crate::commands::status::DestinationKind::Missing, + is_symlink: destination_kind == crate::commands::status::DestinationKind::Symlink, + points_to: points_to.map(str::to_string), + expected_source: expected_source.map(str::to_string), + issues, + managed_children: None, + } + } + + fn status_issue( + kind: crate::commands::status::StatusIssueKind, + path: &str, + expected: Option<&str>, + actual: Option<&str>, + ) -> crate::commands::status::StatusIssue { + crate::commands::status::StatusIssue { + kind, + path: path.into(), + expected: expected.map(str::to_string), + actual: actual.map(str::to_string), + } + } + fn single_symlink_config(source: &str, destination: &str) -> String { format!( r#" @@ -307,11 +347,11 @@ mod tests { assert_eq!(entry["issues"], serde_json::json!([])); assert_eq!(entry["managed_children"], serde_json::json!([])); - let rendered = render_status_entry(&entries[0]); - assert_eq!(rendered.len(), 1); - assert!(rendered[0].contains("OK:")); - assert!(rendered[0].contains("symlink-contents container")); - assert!(rendered[0].contains("0 managed entries expected")); + let rendered = render_status_entry(&entries[0], &plain_formatter()); + assert_eq!(rendered.len(), 3); + assert!(rendered[0].contains("✔ OK:")); + assert!(rendered[1].contains("type: symlink-contents container")); + assert!(rendered[2].contains("managed entries expected: 0")); } #[test] @@ -430,11 +470,13 @@ mod tests { && issue["actual"].as_str().unwrap().contains("wrong.md") })); - let rendered = render_status_entry(&entries[0]); - assert_eq!(rendered.len(), 1); - assert!(rendered[0].contains("Drift:")); - assert!(rendered[0].contains("review.md points to")); - assert!(rendered[0].contains("wrong.md")); + let rendered = render_status_entry(&entries[0], &plain_formatter()); + assert_eq!(rendered.len(), 3); + assert!(rendered[0].contains("✗ Drift:")); + assert!(rendered[0].contains("review.md")); + assert!(rendered.iter().any(|line| line.contains("actual:"))); + assert!(rendered.iter().any(|line| line.contains("expected:"))); + assert!(rendered.iter().any(|line| line.contains("wrong.md"))); } #[test] @@ -457,9 +499,10 @@ mod tests { crate::commands::status::StatusIssueKind::MissingDestination ); - let rendered = render_status_entry(&entries[0]); - assert_eq!(rendered.len(), 1); - assert!(rendered[0].contains("Missing:")); + let rendered = render_status_entry(&entries[0], &plain_formatter()); + assert_eq!(rendered.len(), 2); + assert!(rendered[0].contains("! Missing:")); + assert!(rendered.iter().any(|line| line.contains("expected:"))); assert!( rendered[0].contains( &temp_dir @@ -493,9 +536,11 @@ mod tests { crate::commands::status::StatusIssueKind::InvalidDestinationType ); - let rendered = render_status_entry(&entries[0]); - assert_eq!(rendered.len(), 1); + let rendered = render_status_entry(&entries[0], &plain_formatter()); + assert_eq!(rendered.len(), 3); assert!(rendered[0].contains("Exists but not a symlink:")); + assert!(rendered.iter().any(|line| line.contains("actual:"))); + assert!(rendered.iter().any(|line| line.contains("expected:"))); assert!( rendered[0].contains( &temp_dir @@ -528,9 +573,10 @@ mod tests { crate::commands::status::StatusIssueKind::MissingDestination ); - let rendered = render_status_entry(&entries[0]); - assert_eq!(rendered.len(), 1); + let rendered = render_status_entry(&entries[0], &plain_formatter()); + assert_eq!(rendered.len(), 2); assert!(rendered[0].contains("missing managed container directory")); + assert!(rendered.iter().any(|line| line.contains("expected:"))); } #[test] @@ -551,9 +597,10 @@ mod tests { crate::commands::status::StatusIssueKind::MissingExpectedSource ); - let rendered = render_status_entry(&entries[0]); - assert_eq!(rendered.len(), 1); + let rendered = render_status_entry(&entries[0], &plain_formatter()); + assert_eq!(rendered.len(), 2); assert!(rendered[0].contains("Missing source container directory")); + assert!(rendered.iter().any(|line| line.contains("actual:"))); assert!( rendered[0].contains( &temp_dir @@ -565,6 +612,174 @@ mod tests { ); } + #[test] + fn test_render_healthy_symlink_entry_uses_structured_ok_line() { + let entry = status_entry( + "/tmp/dest", + "symlink", + crate::commands::status::DestinationKind::Symlink, + Some("/tmp/source"), + Some("/tmp/source"), + Vec::new(), + ); + + let rendered = render_status_entry(&entry, &plain_formatter()); + + assert_eq!(rendered, vec!["✔ OK: /tmp/dest -> /tmp/source"]); + } + + #[test] + fn test_render_symlink_contents_invalid_container_type_shows_actual_and_expected() { + let entry = status_entry( + "/tmp/.claude/commands", + "symlink-contents", + crate::commands::status::DestinationKind::File, + None, + Some("/tmp/.agents/commands"), + vec![status_issue( + crate::commands::status::StatusIssueKind::InvalidDestinationType, + "/tmp/.claude/commands", + Some("directory"), + Some("file"), + )], + ); + + let rendered = render_status_entry(&entry, &plain_formatter()); + + assert!(rendered[0].contains("✗ Drift:")); + assert!(rendered[0].contains("exists as file")); + assert!(rendered.iter().any(|line| line == " actual: file")); + assert!(rendered.iter().any(|line| line == " expected: directory")); + } + + #[test] + fn test_render_missing_expected_child_uses_child_file_name_and_expected_detail() { + let entry = status_entry( + "/tmp/.claude/commands", + "symlink-contents", + crate::commands::status::DestinationKind::Directory, + None, + Some("/tmp/.agents/commands"), + vec![status_issue( + crate::commands::status::StatusIssueKind::MissingExpectedChild, + "/tmp/.claude/commands/review.md", + Some("/tmp/.agents/commands/review.md"), + None, + )], + ); + + let rendered = render_status_entry(&entry, &plain_formatter()); + + assert!(rendered[0].contains("✗ Drift:")); + assert!(rendered[0].contains("missing managed child review.md")); + assert!( + rendered + .iter() + .any(|line| line == " expected: /tmp/.agents/commands/review.md") + ); + } + + #[test] + fn test_render_child_not_symlink_shows_actual_detail() { + let entry = status_entry( + "/tmp/.claude/commands", + "symlink-contents", + crate::commands::status::DestinationKind::Directory, + None, + Some("/tmp/.agents/commands"), + vec![status_issue( + crate::commands::status::StatusIssueKind::ChildNotSymlink, + "/tmp/.claude/commands/review.md", + Some("symlink"), + Some("file"), + )], + ); + + let rendered = render_status_entry(&entry, &plain_formatter()); + + assert!(rendered[0].contains("✗ Drift:")); + assert!(rendered[0].contains("exists but is not a symlink")); + assert!(rendered.iter().any(|line| line == " actual: file")); + assert!(rendered.iter().any(|line| line == " expected: symlink")); + } + + #[test] + fn test_render_top_level_incorrect_link_shows_actual_and_expected_details() { + let entry = status_entry( + "/tmp/dest", + "symlink", + crate::commands::status::DestinationKind::Symlink, + Some("/tmp/old-source"), + Some("/tmp/source"), + vec![status_issue( + crate::commands::status::StatusIssueKind::IncorrectLinkTarget, + "/tmp/dest", + Some("/tmp/source"), + Some("/tmp/old-source"), + )], + ); + + let rendered = render_status_entry(&entry, &plain_formatter()); + + assert!(rendered[0].contains("✗ Incorrect link:")); + assert!(rendered[0].contains("/tmp/dest")); + assert!( + rendered + .iter() + .any(|line| line == " actual: /tmp/old-source") + ); + assert!( + rendered + .iter() + .any(|line| line == " expected: /tmp/source") + ); + } + + #[test] + fn test_render_symlink_missing_expected_source_shows_actual_detail() { + let entry = status_entry( + "/tmp/dest", + "symlink", + crate::commands::status::DestinationKind::Symlink, + Some("/tmp/missing-source"), + Some("/tmp/missing-source"), + vec![status_issue( + crate::commands::status::StatusIssueKind::MissingExpectedSource, + "/tmp/dest", + None, + Some("/tmp/missing-source"), + )], + ); + + let rendered = render_status_entry(&entry, &plain_formatter()); + + assert!(rendered[0].contains("! Link points to missing source:")); + assert!(rendered[0].contains("/tmp/missing-source")); + assert!( + rendered + .iter() + .any(|line| line == " actual: /tmp/missing-source") + ); + } + + #[test] + fn test_render_status_hint_uses_hint_style() { + let rendered = render_status_hint("Run agentsync apply", &plain_formatter()); + assert_eq!(rendered, "↳ Run agentsync apply"); + } + + #[test] + fn test_render_status_summary_all_good() { + let rendered = render_status_summary(0, &plain_formatter()); + assert_eq!(rendered, "Status: All good"); + } + + #[test] + fn test_render_status_summary_problem_count() { + let rendered = render_status_summary(3, &plain_formatter()); + assert_eq!(rendered, "Status: 3 problems found"); + } + #[test] #[cfg(unix)] fn test_collect_status_entries_reports_healthy_populated_symlink_contents_container() {