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};
14use crate::types::managed_cmd;
15
16/// Check if colored output should be used.
17///
18/// Respects `NO_COLOR` env var (https://no-color.org/).
19pub fn use_color() -> bool {
20    std::env::var_os("NO_COLOR").is_none()
21}
22
23fn color_choice() -> ColorChoice {
24    if use_color() {
25        ColorChoice::Auto
26    } else {
27        ColorChoice::Never
28    }
29}
30
31/// Entry in the list command output.
32#[derive(Debug, Serialize)]
33pub struct ListEntry {
34    pub source: String,
35    pub item: String,
36    pub kind: String,
37    pub version: String,
38    pub status: String,
39}
40
41/// Catalog entry — name + description for discovery.
42#[derive(Debug, Serialize)]
43pub struct CatalogEntry {
44    pub name: String,
45    pub description: String,
46    pub kind: String,
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub variants: Option<String>,
49}
50
51/// Print catalog view (name: description, grouped by kind).
52pub fn print_catalog(
53    agents: &[CatalogEntry],
54    skills: &[CatalogEntry],
55    bootstrap: &[CatalogEntry],
56    kind_filter: Option<&str>,
57) {
58    let show_agents =
59        kind_filter.is_none() || kind_filter == Some("agents") || kind_filter == Some("agent");
60    let show_skills =
61        kind_filter.is_none() || kind_filter == Some("skills") || kind_filter == Some("skill");
62    let show_bootstrap = kind_filter.is_none()
63        || kind_filter == Some("bootstrap")
64        || kind_filter == Some("bootstrap-doc");
65
66    if show_agents && !agents.is_empty() {
67        println!("AGENTS");
68        for entry in agents {
69            let variant_suffix = entry
70                .variants
71                .as_ref()
72                .map(|variants| format!(" [variants: {variants}]"))
73                .unwrap_or_default();
74            if entry.description.is_empty() {
75                println!("- {}{}", entry.name, variant_suffix);
76            } else {
77                println!("- {}{}: {}", entry.name, variant_suffix, entry.description);
78            }
79        }
80    }
81
82    if show_agents && !agents.is_empty() && show_skills && !skills.is_empty() {
83        println!();
84    }
85
86    if show_skills && !skills.is_empty() {
87        println!("SKILLS");
88        for entry in skills {
89            let variant_suffix = entry
90                .variants
91                .as_ref()
92                .map(|variants| format!(" [variants: {variants}]"))
93                .unwrap_or_default();
94            if entry.description.is_empty() {
95                println!("- {}{}", entry.name, variant_suffix);
96            } else {
97                println!("- {}{}: {}", entry.name, variant_suffix, entry.description);
98            }
99        }
100    }
101
102    if ((show_agents && !agents.is_empty()) || (show_skills && !skills.is_empty()))
103        && show_bootstrap
104        && !bootstrap.is_empty()
105    {
106        println!();
107    }
108
109    if show_bootstrap && !bootstrap.is_empty() {
110        println!("BOOTSTRAP");
111        for entry in bootstrap {
112            if entry.description.is_empty() {
113                println!("- {}", entry.name);
114            } else {
115                println!("- {}: {}", entry.name, entry.description);
116            }
117        }
118    }
119
120    if (show_agents
121        && agents.is_empty()
122        && show_skills
123        && skills.is_empty()
124        && show_bootstrap
125        && bootstrap.is_empty())
126        || (show_agents && !show_skills && agents.is_empty())
127        || (show_skills && !show_agents && !show_bootstrap && skills.is_empty())
128        || (show_bootstrap && !show_agents && !show_skills && bootstrap.is_empty())
129    {
130        println!("  no managed items");
131    }
132}
133
134/// Print sync report as human-readable text or JSON.
135pub fn print_sync_report(report: &SyncReport, json: bool, no_upgrade_hint: bool) {
136    if json {
137        print_sync_report_json(report);
138    } else {
139        print_sync_report_human(report, no_upgrade_hint);
140    }
141}
142
143/// Whether this report is from a dry run (`--diff`).
144/// Returns true when the report was produced without writing any files.
145fn is_dry_run(report: &SyncReport) -> bool {
146    report.dry_run
147}
148
149fn print_sync_report_json(report: &SyncReport) {
150    println!("{}", sync_report_json(report));
151}
152
153pub fn sync_report_json(report: &SyncReport) -> serde_json::Value {
154    #[derive(Serialize)]
155    struct JsonTargetOutcome {
156        name: String,
157        synced: usize,
158        removed: usize,
159        errors: Vec<String>,
160    }
161
162    #[derive(Serialize)]
163    struct JsonReport {
164        ok: bool,
165        dry_run: bool,
166        installed: usize,
167        updated: usize,
168        removed: usize,
169        conflicts: usize,
170        kept: usize,
171        skipped: usize,
172        upgrades_available: usize,
173        targets: Vec<JsonTargetOutcome>,
174        diagnostics: Vec<Diagnostic>,
175    }
176
177    let mut installed = 0;
178    let mut updated = 0;
179    let mut removed = 0;
180    let mut conflicts = 0;
181    let mut kept = 0;
182    let mut skipped = 0;
183
184    for outcome in &report.applied.outcomes {
185        match outcome.action {
186            ActionTaken::Installed => installed += 1,
187            ActionTaken::Updated => updated += 1,
188            ActionTaken::Merged => updated += 1,
189            ActionTaken::Conflicted => conflicts += 1,
190            ActionTaken::Removed => removed += 1,
191            ActionTaken::Kept => kept += 1,
192            ActionTaken::Skipped => skipped += 1,
193        }
194    }
195
196    for outcome in &report.pruned {
197        if matches!(outcome.action, ActionTaken::Removed) {
198            removed += 1;
199        }
200    }
201
202    let targets = report
203        .target_outcomes
204        .iter()
205        .map(|outcome| JsonTargetOutcome {
206            name: outcome.target.clone(),
207            synced: outcome.items_synced,
208            removed: outcome.items_removed,
209            errors: outcome.errors.clone(),
210        })
211        .collect();
212
213    serde_json::to_value(JsonReport {
214        ok: conflicts == 0,
215        dry_run: report.dry_run,
216        installed,
217        updated,
218        removed,
219        conflicts,
220        kept,
221        skipped,
222        upgrades_available: report.upgrades_available,
223        targets,
224        diagnostics: report.diagnostics.clone(),
225    })
226    .unwrap_or_else(|_| serde_json::json!({}))
227}
228
229fn print_sync_report_human(report: &SyncReport, no_upgrade_hint: bool) {
230    let mut stdout = StandardStream::stdout(color_choice());
231
232    let mut installed = 0usize;
233    let mut updated = 0usize;
234    let mut removed = 0usize;
235    let mut conflicts = 0usize;
236    let mut kept = 0usize;
237
238    // Print per-item actions
239    for outcome in &report.applied.outcomes {
240        match outcome.action {
241            ActionTaken::Installed => {
242                installed += 1;
243                print_action_line(&mut stdout, "+", Color::Green, outcome);
244            }
245            ActionTaken::Updated | ActionTaken::Merged => {
246                updated += 1;
247                print_action_line(&mut stdout, "~", Color::Yellow, outcome);
248            }
249            ActionTaken::Conflicted => {
250                conflicts += 1;
251                print_action_line(&mut stdout, "!", Color::Red, outcome);
252            }
253            ActionTaken::Removed => {
254                removed += 1;
255                print_action_line(&mut stdout, "-", Color::Red, outcome);
256            }
257            ActionTaken::Kept => {
258                kept += 1;
259            }
260            ActionTaken::Skipped => {}
261        }
262    }
263
264    for outcome in &report.pruned {
265        if matches!(outcome.action, ActionTaken::Removed) {
266            removed += 1;
267            print_action_line(&mut stdout, "-", Color::Red, outcome);
268        }
269    }
270
271    // Summary line — use "would ..." wording for dry runs
272    let _ = writeln!(stdout);
273    let dry = is_dry_run(report);
274    if installed > 0 {
275        if dry {
276            let _ = writeln!(stdout, "  would install {installed} new items");
277        } else {
278            let _ = writeln!(stdout, "  installed   {installed} new items");
279        }
280    }
281    if updated > 0 {
282        if dry {
283            let _ = writeln!(stdout, "  would update  {updated} items");
284        } else {
285            let _ = writeln!(stdout, "  updated     {updated} items");
286        }
287    }
288    if removed > 0 {
289        if dry {
290            let _ = writeln!(stdout, "  would remove  {removed} orphans");
291        } else {
292            let _ = writeln!(stdout, "  removed     {removed} orphans");
293        }
294    }
295    if kept > 0 {
296        let _ = writeln!(stdout, "  kept        {kept} locally modified");
297    }
298    if conflicts > 0 {
299        let _ = stdout.set_color(ColorSpec::new().set_fg(Some(Color::Red)));
300        let _ = writeln!(
301            stdout,
302            "  conflicts   {conflicts} files (run `{cmd}` after fixing)",
303            cmd = managed_cmd("mars resolve"),
304        );
305        let _ = stdout.reset();
306    }
307
308    if installed == 0 && updated == 0 && removed == 0 && conflicts == 0 && kept == 0 {
309        let _ = stdout.set_color(ColorSpec::new().set_fg(Some(Color::Green)));
310        let _ = writeln!(stdout, "  already up to date");
311        let _ = stdout.reset();
312    }
313
314    // Print diagnostics to stderr so machine-readable stdout remains stable.
315    let mut stderr = StandardStream::stderr(color_choice());
316    for diag in &report.diagnostics {
317        let color = match diag.level {
318            crate::diagnostic::DiagnosticLevel::Error => Color::Red,
319            crate::diagnostic::DiagnosticLevel::Warning => Color::Yellow,
320            crate::diagnostic::DiagnosticLevel::Info => Color::Cyan,
321        };
322        let _ = stderr.set_color(ColorSpec::new().set_fg(Some(color)));
323        let _ = writeln!(stderr, "  {diag}");
324        let _ = stderr.reset();
325    }
326
327    if report.upgrades_available > 0 && !report.dry_run && !no_upgrade_hint {
328        let noun = if report.upgrades_available == 1 {
329            "upgrade"
330        } else {
331            "upgrades"
332        };
333        let _ = stderr.set_color(ColorSpec::new().set_fg(Some(Color::Cyan)));
334        let _ = writeln!(
335            stderr,
336            "  ℹ {} {noun} available — run `{cmd}` to update",
337            report.upgrades_available,
338            cmd = managed_cmd("mars upgrade --bump"),
339        );
340        let _ = stderr.reset();
341    }
342}
343
344fn print_action_line(
345    stdout: &mut StandardStream,
346    prefix: &str,
347    color: Color,
348    outcome: &ActionOutcome,
349) {
350    let _ = stdout.set_color(ColorSpec::new().set_fg(Some(color)));
351    let _ = write!(stdout, "  {prefix} ");
352    let _ = stdout.reset();
353    let _ = writeln!(stdout, "{} ({})", outcome.dest_path, outcome.item_id.kind);
354}
355
356/// Print a list of items as a table or JSON.
357pub fn print_list(entries: &[ListEntry], json: bool) {
358    if json {
359        println!("{}", serde_json::to_string(entries).unwrap_or_default());
360    } else {
361        print_list_human(entries);
362    }
363}
364
365fn print_list_human(entries: &[ListEntry]) {
366    if entries.is_empty() {
367        println!("  no managed items");
368        return;
369    }
370
371    // Compute column widths
372    let source_w = entries
373        .iter()
374        .map(|e| e.source.len())
375        .max()
376        .unwrap_or(6)
377        .max(6);
378    let item_w = entries
379        .iter()
380        .map(|e| e.item.len())
381        .max()
382        .unwrap_or(4)
383        .max(4);
384    let version_w = entries
385        .iter()
386        .map(|e| e.version.len())
387        .max()
388        .unwrap_or(7)
389        .max(7);
390
391    // Header
392    println!(
393        "{:<source_w$}  {:<item_w$}  {:<version_w$}  STATUS",
394        "SOURCE", "ITEM", "VERSION"
395    );
396
397    let mut stdout = StandardStream::stdout(color_choice());
398    for entry in entries {
399        let _ = write!(
400            stdout,
401            "{:<source_w$}  {:<item_w$}  {:<version_w$}  ",
402            entry.source, entry.item, entry.version
403        );
404        let color = match entry.status.as_str() {
405            "ok" => Color::Green,
406            "modified" => Color::Yellow,
407            "conflicted" => Color::Red,
408            _ => Color::White,
409        };
410        let _ = stdout.set_color(ColorSpec::new().set_fg(Some(color)));
411        let _ = writeln!(stdout, "{}", entry.status);
412        let _ = stdout.reset();
413    }
414}
415
416/// Print doctor report.
417pub fn print_doctor(errors: &[String], warnings: &[String], json: bool) {
418    if json {
419        #[derive(Serialize)]
420        struct DoctorReport {
421            ok: bool,
422            errors: Vec<String>,
423            warnings: Vec<String>,
424        }
425        let report = DoctorReport {
426            ok: errors.is_empty(),
427            errors: errors.to_vec(),
428            warnings: warnings.to_vec(),
429        };
430        println!("{}", serde_json::to_string(&report).unwrap_or_default());
431    } else {
432        let mut stdout = StandardStream::stdout(color_choice());
433        if errors.is_empty() && warnings.is_empty() {
434            let _ = stdout.set_color(ColorSpec::new().set_fg(Some(Color::Green)));
435            let _ = writeln!(stdout, "  all checks passed");
436            let _ = stdout.reset();
437        } else {
438            for warning in warnings {
439                let _ = stdout.set_color(ColorSpec::new().set_fg(Some(Color::Yellow)));
440                let _ = write!(stdout, "  ⚠ ");
441                let _ = stdout.reset();
442                let _ = writeln!(stdout, "{warning}");
443            }
444
445            for error in errors {
446                let _ = stdout.set_color(ColorSpec::new().set_fg(Some(Color::Red)));
447                let _ = write!(stdout, "  ✗ ");
448                let _ = stdout.reset();
449                let _ = writeln!(stdout, "{error}");
450            }
451            let _ = writeln!(stdout);
452            if !warnings.is_empty() {
453                let _ = writeln!(stdout, "  {} warning(s)", warnings.len());
454            }
455            if !errors.is_empty() {
456                let _ = writeln!(stdout, "  {} error(s)", errors.len());
457            }
458        }
459    }
460}
461
462/// Print simple JSON value.
463pub fn print_json<T: Serialize>(value: &T) {
464    println!("{}", serde_json::to_string(value).unwrap_or_default());
465}
466
467/// Print a simple success message.
468pub fn print_success(msg: &str) {
469    let mut stdout = StandardStream::stdout(color_choice());
470    let _ = stdout.set_color(ColorSpec::new().set_fg(Some(Color::Green)));
471    let _ = write!(stdout, "  ✓ ");
472    let _ = stdout.reset();
473    let _ = writeln!(stdout, "{msg}");
474}
475
476/// Print a warning message (yellow).
477pub fn print_warn(msg: &str) {
478    let mut stdout = StandardStream::stdout(color_choice());
479    let _ = stdout.set_color(ColorSpec::new().set_fg(Some(Color::Yellow)));
480    let _ = write!(stdout, "  ⚠ ");
481    let _ = stdout.reset();
482    let _ = writeln!(stdout, "{msg}");
483}
484
485/// Print an error message (red).
486pub fn print_error(msg: &str) {
487    let mut stdout = StandardStream::stdout(color_choice());
488    let _ = stdout.set_color(ColorSpec::new().set_fg(Some(Color::Red)));
489    let _ = write!(stdout, "  ✗ ");
490    let _ = stdout.reset();
491    let _ = writeln!(stdout, "{msg}");
492}
493
494/// Print an info message.
495pub fn print_info(msg: &str) {
496    println!("  {msg}");
497}