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