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