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