Skip to main content

mars_agents/cli/
output.rs

1//! Shared output formatting for CLI commands.
2//!
3//! Supports two modes: human-readable tables and JSON.
4//! Respects `NO_COLOR` env var for colored output.
5
6use std::io::Write;
7
8use serde::Serialize;
9use termcolor::{Color, ColorChoice, ColorSpec, StandardStream, WriteColor};
10
11use crate::sync::SyncReport;
12use crate::sync::apply::{ActionOutcome, ActionTaken};
13use crate::validate::ValidationWarning;
14
15/// Check if colored output should be used.
16///
17/// Respects `NO_COLOR` env var (https://no-color.org/).
18pub fn use_color() -> bool {
19    std::env::var_os("NO_COLOR").is_none()
20}
21
22fn color_choice() -> ColorChoice {
23    if use_color() {
24        ColorChoice::Auto
25    } else {
26        ColorChoice::Never
27    }
28}
29
30/// Entry in the list command output.
31#[derive(Debug, Serialize)]
32pub struct ListEntry {
33    pub source: String,
34    pub item: String,
35    pub kind: String,
36    pub version: String,
37    pub status: String,
38}
39
40/// Catalog entry — name + description for discovery.
41#[derive(Debug, Serialize)]
42pub struct CatalogEntry {
43    pub name: String,
44    pub description: String,
45    pub kind: String,
46}
47
48/// Print catalog view (name: description, grouped by kind).
49pub fn print_catalog(agents: &[CatalogEntry], skills: &[CatalogEntry], kind_filter: Option<&str>) {
50    let show_agents =
51        kind_filter.is_none() || kind_filter == Some("agents") || kind_filter == Some("agent");
52    let show_skills =
53        kind_filter.is_none() || kind_filter == Some("skills") || kind_filter == Some("skill");
54
55    if show_agents && !agents.is_empty() {
56        println!("AGENTS");
57        for entry in agents {
58            if entry.description.is_empty() {
59                println!("- {}", entry.name);
60            } else {
61                println!("- {}: {}", entry.name, entry.description);
62            }
63        }
64    }
65
66    if show_agents && !agents.is_empty() && show_skills && !skills.is_empty() {
67        println!();
68    }
69
70    if show_skills && !skills.is_empty() {
71        println!("SKILLS");
72        for entry in skills {
73            if entry.description.is_empty() {
74                println!("- {}", entry.name);
75            } else {
76                println!("- {}: {}", entry.name, entry.description);
77            }
78        }
79    }
80
81    if (show_agents && agents.is_empty() && show_skills && skills.is_empty())
82        || (show_agents && !show_skills && agents.is_empty())
83        || (show_skills && !show_agents && skills.is_empty())
84    {
85        println!("  no managed items");
86    }
87}
88
89/// Print sync report as human-readable text or JSON.
90pub fn print_sync_report(report: &SyncReport, json: bool) {
91    if json {
92        print_sync_report_json(report);
93    } else {
94        print_sync_report_human(report);
95    }
96}
97
98/// Whether this report is from a dry run (`--diff`).
99/// Returns true when the report was produced without writing any files.
100fn is_dry_run(report: &SyncReport) -> bool {
101    report.dry_run
102}
103
104fn print_sync_report_json(report: &SyncReport) {
105    #[derive(Serialize)]
106    struct JsonReport {
107        ok: bool,
108        dry_run: bool,
109        installed: usize,
110        updated: usize,
111        removed: usize,
112        conflicts: usize,
113        kept: usize,
114        skipped: usize,
115        warnings: Vec<String>,
116    }
117
118    let mut installed = 0;
119    let mut updated = 0;
120    let mut removed = 0;
121    let mut conflicts = 0;
122    let mut kept = 0;
123    let mut skipped = 0;
124
125    for outcome in &report.applied.outcomes {
126        match outcome.action {
127            ActionTaken::Installed => installed += 1,
128            ActionTaken::Updated => updated += 1,
129            ActionTaken::Merged => updated += 1,
130            ActionTaken::Conflicted => conflicts += 1,
131            ActionTaken::Removed => removed += 1,
132            ActionTaken::Kept => kept += 1,
133            ActionTaken::Skipped => skipped += 1,
134        }
135    }
136
137    for outcome in &report.pruned {
138        if matches!(outcome.action, ActionTaken::Removed) {
139            removed += 1;
140        }
141    }
142
143    let warnings: Vec<String> = report.warnings.iter().map(format_warning).collect();
144
145    let json_report = JsonReport {
146        ok: conflicts == 0,
147        dry_run: report.dry_run,
148        installed,
149        updated,
150        removed,
151        conflicts,
152        kept,
153        skipped,
154        warnings,
155    };
156
157    println!(
158        "{}",
159        serde_json::to_string(&json_report).unwrap_or_default()
160    );
161}
162
163fn print_sync_report_human(report: &SyncReport) {
164    let mut stdout = StandardStream::stdout(color_choice());
165
166    let mut installed = 0usize;
167    let mut updated = 0usize;
168    let mut removed = 0usize;
169    let mut conflicts = 0usize;
170    let mut kept = 0usize;
171
172    // Print per-item actions
173    for outcome in &report.applied.outcomes {
174        match outcome.action {
175            ActionTaken::Installed => {
176                installed += 1;
177                print_action_line(&mut stdout, "+", Color::Green, outcome);
178            }
179            ActionTaken::Updated | ActionTaken::Merged => {
180                updated += 1;
181                print_action_line(&mut stdout, "~", Color::Yellow, outcome);
182            }
183            ActionTaken::Conflicted => {
184                conflicts += 1;
185                print_action_line(&mut stdout, "!", Color::Red, outcome);
186            }
187            ActionTaken::Removed => {
188                removed += 1;
189                print_action_line(&mut stdout, "-", Color::Red, outcome);
190            }
191            ActionTaken::Kept => {
192                kept += 1;
193            }
194            ActionTaken::Skipped => {}
195        }
196    }
197
198    for outcome in &report.pruned {
199        if matches!(outcome.action, ActionTaken::Removed) {
200            removed += 1;
201            print_action_line(&mut stdout, "-", Color::Red, outcome);
202        }
203    }
204
205    // Summary line — use "would ..." wording for dry runs
206    let _ = writeln!(stdout);
207    let dry = is_dry_run(report);
208    if installed > 0 {
209        if dry {
210            let _ = writeln!(stdout, "  would install {installed} new items");
211        } else {
212            let _ = writeln!(stdout, "  installed   {installed} new items");
213        }
214    }
215    if updated > 0 {
216        if dry {
217            let _ = writeln!(stdout, "  would update  {updated} items");
218        } else {
219            let _ = writeln!(stdout, "  updated     {updated} items");
220        }
221    }
222    if removed > 0 {
223        if dry {
224            let _ = writeln!(stdout, "  would remove  {removed} orphans");
225        } else {
226            let _ = writeln!(stdout, "  removed     {removed} orphans");
227        }
228    }
229    if kept > 0 {
230        let _ = writeln!(stdout, "  kept        {kept} locally modified");
231    }
232    if conflicts > 0 {
233        let _ = stdout.set_color(ColorSpec::new().set_fg(Some(Color::Red)));
234        let _ = writeln!(
235            stdout,
236            "  conflicts   {conflicts} files (run `mars resolve` after fixing)"
237        );
238        let _ = stdout.reset();
239    }
240
241    if installed == 0 && updated == 0 && removed == 0 && conflicts == 0 && kept == 0 {
242        let _ = stdout.set_color(ColorSpec::new().set_fg(Some(Color::Green)));
243        let _ = writeln!(stdout, "  already up to date");
244        let _ = stdout.reset();
245    }
246
247    // Print warnings
248    for warning in &report.warnings {
249        let _ = stdout.set_color(ColorSpec::new().set_fg(Some(Color::Yellow)));
250        let _ = writeln!(stdout, "  warning: {}", format_warning(warning));
251        let _ = stdout.reset();
252    }
253}
254
255fn print_action_line(
256    stdout: &mut StandardStream,
257    prefix: &str,
258    color: Color,
259    outcome: &ActionOutcome,
260) {
261    let _ = stdout.set_color(ColorSpec::new().set_fg(Some(color)));
262    let _ = write!(stdout, "  {prefix} ");
263    let _ = stdout.reset();
264    let _ = writeln!(
265        stdout,
266        "{} ({})",
267        outcome.dest_path.display(),
268        outcome.item_id.kind
269    );
270}
271
272fn format_warning(w: &ValidationWarning) -> String {
273    match w {
274        ValidationWarning::MissingSkill {
275            agent,
276            skill_name,
277            suggestion,
278        } => {
279            let base = format!(
280                "agent `{}` references missing skill `{}`",
281                agent.name, skill_name
282            );
283            match suggestion {
284                Some(s) => format!("{base} (did you mean `{s}`?)"),
285                None => base,
286            }
287        }
288    }
289}
290
291/// Print a list of items as a table or JSON.
292pub fn print_list(entries: &[ListEntry], json: bool) {
293    if json {
294        println!("{}", serde_json::to_string(entries).unwrap_or_default());
295    } else {
296        print_list_human(entries);
297    }
298}
299
300fn print_list_human(entries: &[ListEntry]) {
301    if entries.is_empty() {
302        println!("  no managed items");
303        return;
304    }
305
306    // Compute column widths
307    let source_w = entries
308        .iter()
309        .map(|e| e.source.len())
310        .max()
311        .unwrap_or(6)
312        .max(6);
313    let item_w = entries
314        .iter()
315        .map(|e| e.item.len())
316        .max()
317        .unwrap_or(4)
318        .max(4);
319    let version_w = entries
320        .iter()
321        .map(|e| e.version.len())
322        .max()
323        .unwrap_or(7)
324        .max(7);
325
326    // Header
327    println!(
328        "{:<source_w$}  {:<item_w$}  {:<version_w$}  STATUS",
329        "SOURCE", "ITEM", "VERSION"
330    );
331
332    let mut stdout = StandardStream::stdout(color_choice());
333    for entry in entries {
334        let _ = write!(
335            stdout,
336            "{:<source_w$}  {:<item_w$}  {:<version_w$}  ",
337            entry.source, entry.item, entry.version
338        );
339        let color = match entry.status.as_str() {
340            "ok" => Color::Green,
341            "modified" => Color::Yellow,
342            "conflicted" => Color::Red,
343            _ => Color::White,
344        };
345        let _ = stdout.set_color(ColorSpec::new().set_fg(Some(color)));
346        let _ = writeln!(stdout, "{}", entry.status);
347        let _ = stdout.reset();
348    }
349}
350
351/// Print doctor report.
352pub fn print_doctor(issues: &[String], json: bool) {
353    if json {
354        #[derive(Serialize)]
355        struct DoctorReport {
356            ok: bool,
357            issues: Vec<String>,
358        }
359        let report = DoctorReport {
360            ok: issues.is_empty(),
361            issues: issues.to_vec(),
362        };
363        println!("{}", serde_json::to_string(&report).unwrap_or_default());
364    } else {
365        let mut stdout = StandardStream::stdout(color_choice());
366        if issues.is_empty() {
367            let _ = stdout.set_color(ColorSpec::new().set_fg(Some(Color::Green)));
368            let _ = writeln!(stdout, "  all checks passed");
369            let _ = stdout.reset();
370        } else {
371            for issue in issues {
372                let _ = stdout.set_color(ColorSpec::new().set_fg(Some(Color::Red)));
373                let _ = write!(stdout, "  ✗ ");
374                let _ = stdout.reset();
375                let _ = writeln!(stdout, "{issue}");
376            }
377            let _ = writeln!(stdout);
378            let _ = writeln!(stdout, "  {} issue(s) found", issues.len());
379        }
380    }
381}
382
383/// Print simple JSON value.
384pub fn print_json<T: Serialize>(value: &T) {
385    println!("{}", serde_json::to_string(value).unwrap_or_default());
386}
387
388/// Print a simple success message.
389pub fn print_success(msg: &str) {
390    let mut stdout = StandardStream::stdout(color_choice());
391    let _ = stdout.set_color(ColorSpec::new().set_fg(Some(Color::Green)));
392    let _ = write!(stdout, "  ✓ ");
393    let _ = stdout.reset();
394    let _ = writeln!(stdout, "{msg}");
395}
396
397/// Print a warning message (yellow).
398pub fn print_warn(msg: &str) {
399    let mut stdout = StandardStream::stdout(color_choice());
400    let _ = stdout.set_color(ColorSpec::new().set_fg(Some(Color::Yellow)));
401    let _ = write!(stdout, "  ⚠ ");
402    let _ = stdout.reset();
403    let _ = writeln!(stdout, "{msg}");
404}
405
406/// Print an error message (red).
407pub fn print_error(msg: &str) {
408    let mut stdout = StandardStream::stdout(color_choice());
409    let _ = stdout.set_color(ColorSpec::new().set_fg(Some(Color::Red)));
410    let _ = write!(stdout, "  ✗ ");
411    let _ = stdout.reset();
412    let _ = writeln!(stdout, "{msg}");
413}
414
415/// Print an info message.
416pub fn print_info(msg: &str) {
417    println!("  {msg}");
418}