1use 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
16pub 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#[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#[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
51pub 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
134pub 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
143fn 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 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 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 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
401pub 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 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 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
461pub 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
507pub fn print_json<T: Serialize>(value: &T) {
509 println!("{}", serde_json::to_string(value).unwrap_or_default());
510}
511
512pub 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
521pub 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
536pub 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
545pub 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
554pub 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}