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::diagnostic::Diagnostic;
12use crate::sync::SyncReport;
13use crate::sync::apply::{ActionOutcome, ActionTaken};
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 JsonTargetOutcome {
107        name: String,
108        synced: usize,
109        removed: usize,
110        errors: Vec<String>,
111    }
112
113    #[derive(Serialize)]
114    struct JsonReport {
115        ok: bool,
116        dry_run: bool,
117        installed: usize,
118        updated: usize,
119        removed: usize,
120        conflicts: usize,
121        kept: usize,
122        skipped: usize,
123        targets: Vec<JsonTargetOutcome>,
124        diagnostics: Vec<Diagnostic>,
125    }
126
127    let mut installed = 0;
128    let mut updated = 0;
129    let mut removed = 0;
130    let mut conflicts = 0;
131    let mut kept = 0;
132    let mut skipped = 0;
133
134    for outcome in &report.applied.outcomes {
135        match outcome.action {
136            ActionTaken::Installed | ActionTaken::Symlinked => installed += 1,
137            ActionTaken::Updated => updated += 1,
138            ActionTaken::Merged => updated += 1,
139            ActionTaken::Conflicted => conflicts += 1,
140            ActionTaken::Removed => removed += 1,
141            ActionTaken::Kept => kept += 1,
142            ActionTaken::Skipped => skipped += 1,
143        }
144    }
145
146    for outcome in &report.pruned {
147        if matches!(outcome.action, ActionTaken::Removed) {
148            removed += 1;
149        }
150    }
151
152    let targets = report
153        .target_outcomes
154        .iter()
155        .map(|outcome| JsonTargetOutcome {
156            name: outcome.target.clone(),
157            synced: outcome.items_synced,
158            removed: outcome.items_removed,
159            errors: outcome.errors.clone(),
160        })
161        .collect();
162
163    let json_report = JsonReport {
164        ok: conflicts == 0,
165        dry_run: report.dry_run,
166        installed,
167        updated,
168        removed,
169        conflicts,
170        kept,
171        skipped,
172        targets,
173        diagnostics: report.diagnostics.clone(),
174    };
175
176    println!(
177        "{}",
178        serde_json::to_string(&json_report).unwrap_or_default()
179    );
180}
181
182fn print_sync_report_human(report: &SyncReport) {
183    let mut stdout = StandardStream::stdout(color_choice());
184
185    let mut installed = 0usize;
186    let mut updated = 0usize;
187    let mut removed = 0usize;
188    let mut conflicts = 0usize;
189    let mut kept = 0usize;
190
191    // Print per-item actions
192    for outcome in &report.applied.outcomes {
193        match outcome.action {
194            ActionTaken::Installed | ActionTaken::Symlinked => {
195                installed += 1;
196                print_action_line(&mut stdout, "+", Color::Green, outcome);
197            }
198            ActionTaken::Updated | ActionTaken::Merged => {
199                updated += 1;
200                print_action_line(&mut stdout, "~", Color::Yellow, outcome);
201            }
202            ActionTaken::Conflicted => {
203                conflicts += 1;
204                print_action_line(&mut stdout, "!", Color::Red, outcome);
205            }
206            ActionTaken::Removed => {
207                removed += 1;
208                print_action_line(&mut stdout, "-", Color::Red, outcome);
209            }
210            ActionTaken::Kept => {
211                kept += 1;
212            }
213            ActionTaken::Skipped => {}
214        }
215    }
216
217    for outcome in &report.pruned {
218        if matches!(outcome.action, ActionTaken::Removed) {
219            removed += 1;
220            print_action_line(&mut stdout, "-", Color::Red, outcome);
221        }
222    }
223
224    // Summary line — use "would ..." wording for dry runs
225    let _ = writeln!(stdout);
226    let dry = is_dry_run(report);
227    if installed > 0 {
228        if dry {
229            let _ = writeln!(stdout, "  would install {installed} new items");
230        } else {
231            let _ = writeln!(stdout, "  installed   {installed} new items");
232        }
233    }
234    if updated > 0 {
235        if dry {
236            let _ = writeln!(stdout, "  would update  {updated} items");
237        } else {
238            let _ = writeln!(stdout, "  updated     {updated} items");
239        }
240    }
241    if removed > 0 {
242        if dry {
243            let _ = writeln!(stdout, "  would remove  {removed} orphans");
244        } else {
245            let _ = writeln!(stdout, "  removed     {removed} orphans");
246        }
247    }
248    if kept > 0 {
249        let _ = writeln!(stdout, "  kept        {kept} locally modified");
250    }
251    if conflicts > 0 {
252        let _ = stdout.set_color(ColorSpec::new().set_fg(Some(Color::Red)));
253        let _ = writeln!(
254            stdout,
255            "  conflicts   {conflicts} files (run `mars resolve` after fixing)"
256        );
257        let _ = stdout.reset();
258    }
259
260    if installed == 0 && updated == 0 && removed == 0 && conflicts == 0 && kept == 0 {
261        let _ = stdout.set_color(ColorSpec::new().set_fg(Some(Color::Green)));
262        let _ = writeln!(stdout, "  already up to date");
263        let _ = stdout.reset();
264    }
265
266    // Print diagnostics to stderr so machine-readable stdout remains stable.
267    let mut stderr = StandardStream::stderr(color_choice());
268    for diag in &report.diagnostics {
269        let color = match diag.level {
270            crate::diagnostic::DiagnosticLevel::Warning => Color::Yellow,
271            crate::diagnostic::DiagnosticLevel::Info => Color::Cyan,
272        };
273        let _ = stderr.set_color(ColorSpec::new().set_fg(Some(color)));
274        let _ = writeln!(stderr, "  {diag}");
275        let _ = stderr.reset();
276    }
277}
278
279fn print_action_line(
280    stdout: &mut StandardStream,
281    prefix: &str,
282    color: Color,
283    outcome: &ActionOutcome,
284) {
285    let _ = stdout.set_color(ColorSpec::new().set_fg(Some(color)));
286    let _ = write!(stdout, "  {prefix} ");
287    let _ = stdout.reset();
288    let _ = writeln!(
289        stdout,
290        "{} ({})",
291        outcome.dest_path.display(),
292        outcome.item_id.kind
293    );
294}
295
296/// Print a list of items as a table or JSON.
297pub fn print_list(entries: &[ListEntry], json: bool) {
298    if json {
299        println!("{}", serde_json::to_string(entries).unwrap_or_default());
300    } else {
301        print_list_human(entries);
302    }
303}
304
305fn print_list_human(entries: &[ListEntry]) {
306    if entries.is_empty() {
307        println!("  no managed items");
308        return;
309    }
310
311    // Compute column widths
312    let source_w = entries
313        .iter()
314        .map(|e| e.source.len())
315        .max()
316        .unwrap_or(6)
317        .max(6);
318    let item_w = entries
319        .iter()
320        .map(|e| e.item.len())
321        .max()
322        .unwrap_or(4)
323        .max(4);
324    let version_w = entries
325        .iter()
326        .map(|e| e.version.len())
327        .max()
328        .unwrap_or(7)
329        .max(7);
330
331    // Header
332    println!(
333        "{:<source_w$}  {:<item_w$}  {:<version_w$}  STATUS",
334        "SOURCE", "ITEM", "VERSION"
335    );
336
337    let mut stdout = StandardStream::stdout(color_choice());
338    for entry in entries {
339        let _ = write!(
340            stdout,
341            "{:<source_w$}  {:<item_w$}  {:<version_w$}  ",
342            entry.source, entry.item, entry.version
343        );
344        let color = match entry.status.as_str() {
345            "ok" => Color::Green,
346            "modified" => Color::Yellow,
347            "conflicted" => Color::Red,
348            _ => Color::White,
349        };
350        let _ = stdout.set_color(ColorSpec::new().set_fg(Some(color)));
351        let _ = writeln!(stdout, "{}", entry.status);
352        let _ = stdout.reset();
353    }
354}
355
356/// Print doctor report.
357pub fn print_doctor(errors: &[String], warnings: &[String], json: bool) {
358    if json {
359        #[derive(Serialize)]
360        struct DoctorReport {
361            ok: bool,
362            errors: Vec<String>,
363            warnings: Vec<String>,
364        }
365        let report = DoctorReport {
366            ok: errors.is_empty(),
367            errors: errors.to_vec(),
368            warnings: warnings.to_vec(),
369        };
370        println!("{}", serde_json::to_string(&report).unwrap_or_default());
371    } else {
372        let mut stdout = StandardStream::stdout(color_choice());
373        if errors.is_empty() && warnings.is_empty() {
374            let _ = stdout.set_color(ColorSpec::new().set_fg(Some(Color::Green)));
375            let _ = writeln!(stdout, "  all checks passed");
376            let _ = stdout.reset();
377        } else {
378            for warning in warnings {
379                let _ = stdout.set_color(ColorSpec::new().set_fg(Some(Color::Yellow)));
380                let _ = write!(stdout, "  ⚠ ");
381                let _ = stdout.reset();
382                let _ = writeln!(stdout, "{warning}");
383            }
384
385            for error in errors {
386                let _ = stdout.set_color(ColorSpec::new().set_fg(Some(Color::Red)));
387                let _ = write!(stdout, "  ✗ ");
388                let _ = stdout.reset();
389                let _ = writeln!(stdout, "{error}");
390            }
391            let _ = writeln!(stdout);
392            if !warnings.is_empty() {
393                let _ = writeln!(stdout, "  {} warning(s)", warnings.len());
394            }
395            if !errors.is_empty() {
396                let _ = writeln!(stdout, "  {} error(s)", errors.len());
397            }
398        }
399    }
400}
401
402/// Print simple JSON value.
403pub fn print_json<T: Serialize>(value: &T) {
404    println!("{}", serde_json::to_string(value).unwrap_or_default());
405}
406
407/// Print a simple success message.
408pub fn print_success(msg: &str) {
409    let mut stdout = StandardStream::stdout(color_choice());
410    let _ = stdout.set_color(ColorSpec::new().set_fg(Some(Color::Green)));
411    let _ = write!(stdout, "  ✓ ");
412    let _ = stdout.reset();
413    let _ = writeln!(stdout, "{msg}");
414}
415
416/// Print a warning message (yellow).
417pub fn print_warn(msg: &str) {
418    let mut stdout = StandardStream::stdout(color_choice());
419    let _ = stdout.set_color(ColorSpec::new().set_fg(Some(Color::Yellow)));
420    let _ = write!(stdout, "  ⚠ ");
421    let _ = stdout.reset();
422    let _ = writeln!(stdout, "{msg}");
423}
424
425/// Print an error message (red).
426pub fn print_error(msg: &str) {
427    let mut stdout = StandardStream::stdout(color_choice());
428    let _ = stdout.set_color(ColorSpec::new().set_fg(Some(Color::Red)));
429    let _ = write!(stdout, "  ✗ ");
430    let _ = stdout.reset();
431    let _ = writeln!(stdout, "{msg}");
432}
433
434/// Print an info message.
435pub fn print_info(msg: &str) {
436    println!("  {msg}");
437}