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