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