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        kept: usize,
170        skipped: usize,
171        native_emitted: usize,
172        native_removed: usize,
173        upgrades_available: usize,
174        targets: Vec<JsonTargetOutcome>,
175        diagnostics: Vec<Diagnostic>,
176        #[serde(skip_serializing_if = "Option::is_none")]
177        recovery_halt: Option<crate::sync::RecoveryHalt>,
178    }
179
180    let mut installed = 0;
181    let mut updated = 0;
182    let mut removed = 0;
183    let mut kept = 0;
184    let mut skipped = 0;
185
186    for outcome in &report.applied.outcomes {
187        match outcome.action {
188            ActionTaken::Installed => installed += 1,
189            ActionTaken::Updated => updated += 1,
190            ActionTaken::Removed => removed += 1,
191            ActionTaken::Kept => kept += 1,
192            ActionTaken::Skipped => skipped += 1,
193        }
194    }
195
196    let targets = report
197        .target_outcomes
198        .iter()
199        .map(|outcome| JsonTargetOutcome {
200            name: outcome.target.clone(),
201            synced: outcome.items_synced,
202            removed: outcome.items_removed,
203            errors: outcome.errors.clone(),
204        })
205        .collect();
206
207    serde_json::to_value(JsonReport {
208        ok: report.recovery_halt.is_none(),
209        dry_run: report.dry_run,
210        installed,
211        updated,
212        removed,
213        kept,
214        skipped,
215        native_emitted: report.native_emitted.len(),
216        native_removed: report.native_removed.len(),
217        upgrades_available: report.upgrades_available,
218        targets,
219        diagnostics: report.diagnostics.clone(),
220        recovery_halt: report.recovery_halt.clone(),
221    })
222    .unwrap_or_else(|_| serde_json::json!({}))
223}
224
225fn print_sync_report_human(report: &SyncReport, no_upgrade_hint: bool) {
226    if let Some(halt) = &report.recovery_halt {
227        let mut stderr = StandardStream::stderr(color_choice());
228        let _ = stderr.set_color(ColorSpec::new().set_fg(Some(Color::Yellow)));
229        let _ = writeln!(stderr, "  recovery halted before materialization");
230        let _ = stderr.reset();
231        for persisted in &halt.persisted {
232            let _ = writeln!(stderr, "  {persisted}");
233        }
234        for blocker in &halt.blockers {
235            let _ = writeln!(
236                stderr,
237                "  blocked by {}@{} (hooks: {})",
238                blocker.package,
239                blocker.version,
240                blocker.hook_names.join(", ")
241            );
242            let _ = writeln!(stderr, "  {}", blocker.guidance);
243            let _ = writeln!(stderr, "  suggested: `{}`", blocker.suggested_command);
244        }
245        let _ = writeln!(stderr, "  {}", halt.next_step);
246        for diagnostic in &report.diagnostics {
247            let _ = writeln!(stderr, "  {diagnostic}");
248        }
249        return;
250    }
251
252    let mut stdout = StandardStream::stdout(color_choice());
253
254    let mut installed = 0usize;
255    let mut updated = 0usize;
256    let mut removed = 0usize;
257    let mut kept = 0usize;
258
259    // Print per-item actions
260    for outcome in &report.applied.outcomes {
261        match outcome.action {
262            ActionTaken::Installed => {
263                installed += 1;
264                print_action_line(&mut stdout, "+", Color::Green, outcome);
265            }
266            ActionTaken::Updated => {
267                updated += 1;
268                print_action_line(&mut stdout, "~", Color::Yellow, outcome);
269            }
270            ActionTaken::Removed => {
271                removed += 1;
272                print_action_line(&mut stdout, "-", Color::Red, outcome);
273            }
274            ActionTaken::Kept => {
275                kept += 1;
276            }
277            ActionTaken::Skipped => {}
278        }
279    }
280
281    let native_emitted = report.native_emitted.len();
282    let native_removed = report.native_removed.len();
283    for (target_root, dest_path) in &report.native_removed {
284        print_native_line(&mut stdout, "-", Color::Red, target_root, dest_path);
285    }
286    for (target_root, dest_path) in &report.native_emitted {
287        print_native_line(&mut stdout, "+", Color::Green, target_root, dest_path);
288    }
289
290    // Summary line — use "would ..." wording for dry runs
291    let _ = writeln!(stdout);
292    let dry = is_dry_run(report);
293    if installed > 0 {
294        if dry {
295            let _ = writeln!(stdout, "  would install {installed} new items");
296        } else {
297            let _ = writeln!(stdout, "  installed   {installed} new items");
298        }
299    }
300    if updated > 0 {
301        if dry {
302            let _ = writeln!(stdout, "  would update  {updated} items");
303        } else {
304            let _ = writeln!(stdout, "  updated     {updated} items");
305        }
306    }
307    if removed > 0 {
308        if dry {
309            let _ = writeln!(stdout, "  would remove  {removed} orphans");
310        } else {
311            let _ = writeln!(stdout, "  removed     {removed} orphans");
312        }
313    }
314    if native_emitted > 0 {
315        if dry {
316            let _ = writeln!(stdout, "  would emit   {native_emitted} native agents");
317        } else {
318            let _ = writeln!(stdout, "  emitted     {native_emitted} native agents");
319        }
320    }
321    if native_removed > 0 {
322        if dry {
323            let _ = writeln!(stdout, "  would remove {native_removed} native agents");
324        } else {
325            let _ = writeln!(stdout, "  removed     {native_removed} native agents");
326        }
327    }
328    if kept > 0 {
329        let _ = writeln!(stdout, "  kept        {kept} locally modified");
330    }
331
332    if installed == 0
333        && updated == 0
334        && removed == 0
335        && kept == 0
336        && native_emitted == 0
337        && native_removed == 0
338    {
339        let _ = stdout.set_color(ColorSpec::new().set_fg(Some(Color::Green)));
340        let _ = writeln!(stdout, "  already up to date");
341        let _ = stdout.reset();
342    }
343
344    // Print diagnostics to stderr so machine-readable stdout remains stable.
345    let mut stderr = StandardStream::stderr(color_choice());
346    for diag in &report.diagnostics {
347        let color = match diag.level {
348            crate::diagnostic::DiagnosticLevel::Error => Color::Red,
349            crate::diagnostic::DiagnosticLevel::Warning => Color::Yellow,
350            crate::diagnostic::DiagnosticLevel::Info => Color::Cyan,
351        };
352        let _ = stderr.set_color(ColorSpec::new().set_fg(Some(color)));
353        let _ = writeln!(stderr, "  {diag}");
354        let _ = stderr.reset();
355    }
356
357    if report.upgrades_available > 0 && !report.dry_run && !no_upgrade_hint {
358        let noun = if report.upgrades_available == 1 {
359            "upgrade"
360        } else {
361            "upgrades"
362        };
363        let _ = stderr.set_color(ColorSpec::new().set_fg(Some(Color::Cyan)));
364        let _ = writeln!(
365            stderr,
366            "  ℹ {} {noun} available — run `{cmd}` to update",
367            report.upgrades_available,
368            cmd = managed_cmd("mars upgrade"),
369        );
370        let _ = stderr.reset();
371    }
372}
373
374fn print_action_line(
375    stdout: &mut StandardStream,
376    prefix: &str,
377    color: Color,
378    outcome: &ActionOutcome,
379) {
380    let _ = stdout.set_color(ColorSpec::new().set_fg(Some(color)));
381    let _ = write!(stdout, "  {prefix} ");
382    let _ = stdout.reset();
383    let _ = writeln!(stdout, "{} ({})", outcome.dest_path, outcome.item_id.kind);
384}
385
386fn print_native_line(
387    stdout: &mut StandardStream,
388    prefix: &str,
389    color: Color,
390    target_root: &str,
391    dest_path: &str,
392) {
393    let _ = stdout.set_color(ColorSpec::new().set_fg(Some(color)));
394    let _ = write!(stdout, "  {prefix} ");
395    let _ = stdout.reset();
396    let _ = writeln!(stdout, "{target_root}/{dest_path} (native agent)");
397}
398
399/// Print a list of items as a table or JSON.
400pub fn print_list(entries: &[ListEntry], json: bool) {
401    if json {
402        println!("{}", serde_json::to_string(entries).unwrap_or_default());
403    } else {
404        print_list_human(entries);
405    }
406}
407
408fn print_list_human(entries: &[ListEntry]) {
409    if entries.is_empty() {
410        println!("  no managed items");
411        return;
412    }
413
414    // Compute column widths
415    let source_w = entries
416        .iter()
417        .map(|e| e.source.len())
418        .max()
419        .unwrap_or(6)
420        .max(6);
421    let item_w = entries
422        .iter()
423        .map(|e| e.item.len())
424        .max()
425        .unwrap_or(4)
426        .max(4);
427    let version_w = entries
428        .iter()
429        .map(|e| e.version.len())
430        .max()
431        .unwrap_or(7)
432        .max(7);
433
434    // Header
435    println!(
436        "{:<source_w$}  {:<item_w$}  {:<version_w$}  STATUS",
437        "SOURCE", "ITEM", "VERSION"
438    );
439
440    let mut stdout = StandardStream::stdout(color_choice());
441    for entry in entries {
442        let _ = write!(
443            stdout,
444            "{:<source_w$}  {:<item_w$}  {:<version_w$}  ",
445            entry.source, entry.item, entry.version
446        );
447        let color = match entry.status.as_str() {
448            "ok" => Color::Green,
449            "modified" => Color::Yellow,
450            "conflicted" => Color::Red,
451            _ => Color::White,
452        };
453        let _ = stdout.set_color(ColorSpec::new().set_fg(Some(color)));
454        let _ = writeln!(stdout, "{}", entry.status);
455        let _ = stdout.reset();
456    }
457}
458
459/// Print doctor report.
460pub fn print_doctor(errors: &[String], warnings: &[String], json: bool) {
461    if json {
462        #[derive(Serialize)]
463        struct DoctorReport {
464            ok: bool,
465            errors: Vec<String>,
466            warnings: Vec<String>,
467        }
468        let report = DoctorReport {
469            ok: errors.is_empty(),
470            errors: errors.to_vec(),
471            warnings: warnings.to_vec(),
472        };
473        println!("{}", serde_json::to_string(&report).unwrap_or_default());
474    } else {
475        let mut stdout = StandardStream::stdout(color_choice());
476        if errors.is_empty() && warnings.is_empty() {
477            let _ = stdout.set_color(ColorSpec::new().set_fg(Some(Color::Green)));
478            let _ = writeln!(stdout, "  all checks passed");
479            let _ = stdout.reset();
480        } else {
481            for warning in warnings {
482                let _ = stdout.set_color(ColorSpec::new().set_fg(Some(Color::Yellow)));
483                let _ = write!(stdout, "  ⚠ ");
484                let _ = stdout.reset();
485                let _ = writeln!(stdout, "{warning}");
486            }
487
488            for error in errors {
489                let _ = stdout.set_color(ColorSpec::new().set_fg(Some(Color::Red)));
490                let _ = write!(stdout, "  ✗ ");
491                let _ = stdout.reset();
492                let _ = writeln!(stdout, "{error}");
493            }
494            let _ = writeln!(stdout);
495            if !warnings.is_empty() {
496                let _ = writeln!(stdout, "  {} warning(s)", warnings.len());
497            }
498            if !errors.is_empty() {
499                let _ = writeln!(stdout, "  {} error(s)", errors.len());
500            }
501        }
502    }
503}
504
505/// Print simple JSON value.
506pub fn print_json<T: Serialize>(value: &T) {
507    println!("{}", serde_json::to_string(value).unwrap_or_default());
508}
509
510/// Print a simple success message.
511pub fn print_success(msg: &str) {
512    let mut stdout = StandardStream::stdout(color_choice());
513    let _ = stdout.set_color(ColorSpec::new().set_fg(Some(Color::Green)));
514    let _ = write!(stdout, "  ✓ ");
515    let _ = stdout.reset();
516    let _ = writeln!(stdout, "{msg}");
517}
518
519/// Print pipeline diagnostics to stderr (same format as sync report output).
520pub fn print_diagnostics(diagnostics: &[Diagnostic]) {
521    let mut stderr = StandardStream::stderr(color_choice());
522    for diag in diagnostics {
523        let color = match diag.level {
524            crate::diagnostic::DiagnosticLevel::Error => Color::Red,
525            crate::diagnostic::DiagnosticLevel::Warning => Color::Yellow,
526            crate::diagnostic::DiagnosticLevel::Info => Color::Cyan,
527        };
528        let _ = stderr.set_color(ColorSpec::new().set_fg(Some(color)));
529        let _ = writeln!(stderr, "  {diag}");
530        let _ = stderr.reset();
531    }
532}
533
534/// Print a warning message (yellow).
535pub fn print_warn(msg: &str) {
536    let mut stdout = StandardStream::stdout(color_choice());
537    let _ = stdout.set_color(ColorSpec::new().set_fg(Some(Color::Yellow)));
538    let _ = write!(stdout, "  ⚠ ");
539    let _ = stdout.reset();
540    let _ = writeln!(stdout, "{msg}");
541}
542
543/// Print an error message (red).
544pub fn print_error(msg: &str) {
545    let mut stdout = StandardStream::stdout(color_choice());
546    let _ = stdout.set_color(ColorSpec::new().set_fg(Some(Color::Red)));
547    let _ = write!(stdout, "  ✗ ");
548    let _ = stdout.reset();
549    let _ = writeln!(stdout, "{msg}");
550}
551
552/// Print an info message.
553pub fn print_info(msg: &str) {
554    println!("  {msg}");
555}