1use std::io::Write;
7
8use serde::Serialize;
9use termcolor::{Color, ColorChoice, ColorSpec, StandardStream, WriteColor};
10
11use crate::sync::SyncReport;
12use crate::sync::apply::{ActionOutcome, ActionTaken};
13use crate::validate::ValidationWarning;
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) {
91 if json {
92 print_sync_report_json(report);
93 } else {
94 print_sync_report_human(report);
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 JsonReport {
107 ok: bool,
108 dry_run: bool,
109 installed: usize,
110 updated: usize,
111 removed: usize,
112 conflicts: usize,
113 kept: usize,
114 skipped: usize,
115 warnings: Vec<String>,
116 }
117
118 let mut installed = 0;
119 let mut updated = 0;
120 let mut removed = 0;
121 let mut conflicts = 0;
122 let mut kept = 0;
123 let mut skipped = 0;
124
125 for outcome in &report.applied.outcomes {
126 match outcome.action {
127 ActionTaken::Installed => installed += 1,
128 ActionTaken::Updated => updated += 1,
129 ActionTaken::Merged => updated += 1,
130 ActionTaken::Conflicted => conflicts += 1,
131 ActionTaken::Removed => removed += 1,
132 ActionTaken::Kept => kept += 1,
133 ActionTaken::Skipped => skipped += 1,
134 }
135 }
136
137 for outcome in &report.pruned {
138 if matches!(outcome.action, ActionTaken::Removed) {
139 removed += 1;
140 }
141 }
142
143 let warnings: Vec<String> = report.warnings.iter().map(format_warning).collect();
144
145 let json_report = JsonReport {
146 ok: conflicts == 0,
147 dry_run: report.dry_run,
148 installed,
149 updated,
150 removed,
151 conflicts,
152 kept,
153 skipped,
154 warnings,
155 };
156
157 println!(
158 "{}",
159 serde_json::to_string(&json_report).unwrap_or_default()
160 );
161}
162
163fn print_sync_report_human(report: &SyncReport) {
164 let mut stdout = StandardStream::stdout(color_choice());
165
166 let mut installed = 0usize;
167 let mut updated = 0usize;
168 let mut removed = 0usize;
169 let mut conflicts = 0usize;
170 let mut kept = 0usize;
171
172 for outcome in &report.applied.outcomes {
174 match outcome.action {
175 ActionTaken::Installed => {
176 installed += 1;
177 print_action_line(&mut stdout, "+", Color::Green, outcome);
178 }
179 ActionTaken::Updated | ActionTaken::Merged => {
180 updated += 1;
181 print_action_line(&mut stdout, "~", Color::Yellow, outcome);
182 }
183 ActionTaken::Conflicted => {
184 conflicts += 1;
185 print_action_line(&mut stdout, "!", Color::Red, outcome);
186 }
187 ActionTaken::Removed => {
188 removed += 1;
189 print_action_line(&mut stdout, "-", Color::Red, outcome);
190 }
191 ActionTaken::Kept => {
192 kept += 1;
193 }
194 ActionTaken::Skipped => {}
195 }
196 }
197
198 for outcome in &report.pruned {
199 if matches!(outcome.action, ActionTaken::Removed) {
200 removed += 1;
201 print_action_line(&mut stdout, "-", Color::Red, outcome);
202 }
203 }
204
205 let _ = writeln!(stdout);
207 let dry = is_dry_run(report);
208 if installed > 0 {
209 if dry {
210 let _ = writeln!(stdout, " would install {installed} new items");
211 } else {
212 let _ = writeln!(stdout, " installed {installed} new items");
213 }
214 }
215 if updated > 0 {
216 if dry {
217 let _ = writeln!(stdout, " would update {updated} items");
218 } else {
219 let _ = writeln!(stdout, " updated {updated} items");
220 }
221 }
222 if removed > 0 {
223 if dry {
224 let _ = writeln!(stdout, " would remove {removed} orphans");
225 } else {
226 let _ = writeln!(stdout, " removed {removed} orphans");
227 }
228 }
229 if kept > 0 {
230 let _ = writeln!(stdout, " kept {kept} locally modified");
231 }
232 if conflicts > 0 {
233 let _ = stdout.set_color(ColorSpec::new().set_fg(Some(Color::Red)));
234 let _ = writeln!(
235 stdout,
236 " conflicts {conflicts} files (run `mars resolve` after fixing)"
237 );
238 let _ = stdout.reset();
239 }
240
241 if installed == 0 && updated == 0 && removed == 0 && conflicts == 0 && kept == 0 {
242 let _ = stdout.set_color(ColorSpec::new().set_fg(Some(Color::Green)));
243 let _ = writeln!(stdout, " already up to date");
244 let _ = stdout.reset();
245 }
246
247 for warning in &report.warnings {
249 let _ = stdout.set_color(ColorSpec::new().set_fg(Some(Color::Yellow)));
250 let _ = writeln!(stdout, " warning: {}", format_warning(warning));
251 let _ = stdout.reset();
252 }
253}
254
255fn print_action_line(
256 stdout: &mut StandardStream,
257 prefix: &str,
258 color: Color,
259 outcome: &ActionOutcome,
260) {
261 let _ = stdout.set_color(ColorSpec::new().set_fg(Some(color)));
262 let _ = write!(stdout, " {prefix} ");
263 let _ = stdout.reset();
264 let _ = writeln!(
265 stdout,
266 "{} ({})",
267 outcome.dest_path.display(),
268 outcome.item_id.kind
269 );
270}
271
272fn format_warning(w: &ValidationWarning) -> String {
273 match w {
274 ValidationWarning::MissingSkill {
275 agent,
276 skill_name,
277 suggestion,
278 } => {
279 let base = format!(
280 "agent `{}` references missing skill `{}`",
281 agent.name, skill_name
282 );
283 match suggestion {
284 Some(s) => format!("{base} (did you mean `{s}`?)"),
285 None => base,
286 }
287 }
288 }
289}
290
291pub fn print_list(entries: &[ListEntry], json: bool) {
293 if json {
294 println!("{}", serde_json::to_string(entries).unwrap_or_default());
295 } else {
296 print_list_human(entries);
297 }
298}
299
300fn print_list_human(entries: &[ListEntry]) {
301 if entries.is_empty() {
302 println!(" no managed items");
303 return;
304 }
305
306 let source_w = entries
308 .iter()
309 .map(|e| e.source.len())
310 .max()
311 .unwrap_or(6)
312 .max(6);
313 let item_w = entries
314 .iter()
315 .map(|e| e.item.len())
316 .max()
317 .unwrap_or(4)
318 .max(4);
319 let version_w = entries
320 .iter()
321 .map(|e| e.version.len())
322 .max()
323 .unwrap_or(7)
324 .max(7);
325
326 println!(
328 "{:<source_w$} {:<item_w$} {:<version_w$} STATUS",
329 "SOURCE", "ITEM", "VERSION"
330 );
331
332 let mut stdout = StandardStream::stdout(color_choice());
333 for entry in entries {
334 let _ = write!(
335 stdout,
336 "{:<source_w$} {:<item_w$} {:<version_w$} ",
337 entry.source, entry.item, entry.version
338 );
339 let color = match entry.status.as_str() {
340 "ok" => Color::Green,
341 "modified" => Color::Yellow,
342 "conflicted" => Color::Red,
343 _ => Color::White,
344 };
345 let _ = stdout.set_color(ColorSpec::new().set_fg(Some(color)));
346 let _ = writeln!(stdout, "{}", entry.status);
347 let _ = stdout.reset();
348 }
349}
350
351pub fn print_doctor(issues: &[String], json: bool) {
353 if json {
354 #[derive(Serialize)]
355 struct DoctorReport {
356 ok: bool,
357 issues: Vec<String>,
358 }
359 let report = DoctorReport {
360 ok: issues.is_empty(),
361 issues: issues.to_vec(),
362 };
363 println!("{}", serde_json::to_string(&report).unwrap_or_default());
364 } else {
365 let mut stdout = StandardStream::stdout(color_choice());
366 if issues.is_empty() {
367 let _ = stdout.set_color(ColorSpec::new().set_fg(Some(Color::Green)));
368 let _ = writeln!(stdout, " all checks passed");
369 let _ = stdout.reset();
370 } else {
371 for issue in issues {
372 let _ = stdout.set_color(ColorSpec::new().set_fg(Some(Color::Red)));
373 let _ = write!(stdout, " ✗ ");
374 let _ = stdout.reset();
375 let _ = writeln!(stdout, "{issue}");
376 }
377 let _ = writeln!(stdout);
378 let _ = writeln!(stdout, " {} issue(s) found", issues.len());
379 }
380 }
381}
382
383pub fn print_json<T: Serialize>(value: &T) {
385 println!("{}", serde_json::to_string(value).unwrap_or_default());
386}
387
388pub fn print_success(msg: &str) {
390 let mut stdout = StandardStream::stdout(color_choice());
391 let _ = stdout.set_color(ColorSpec::new().set_fg(Some(Color::Green)));
392 let _ = write!(stdout, " ✓ ");
393 let _ = stdout.reset();
394 let _ = writeln!(stdout, "{msg}");
395}
396
397pub fn print_warn(msg: &str) {
399 let mut stdout = StandardStream::stdout(color_choice());
400 let _ = stdout.set_color(ColorSpec::new().set_fg(Some(Color::Yellow)));
401 let _ = write!(stdout, " ⚠ ");
402 let _ = stdout.reset();
403 let _ = writeln!(stdout, "{msg}");
404}
405
406pub fn print_error(msg: &str) {
408 let mut stdout = StandardStream::stdout(color_choice());
409 let _ = stdout.set_color(ColorSpec::new().set_fg(Some(Color::Red)));
410 let _ = write!(stdout, " ✗ ");
411 let _ = stdout.reset();
412 let _ = writeln!(stdout, "{msg}");
413}
414
415pub fn print_info(msg: &str) {
417 println!(" {msg}");
418}