1use std::io;
2
3use callisto_model::{
4 ComposePrBodyReport, InitReport, PublishAttemptResult, PublishPlan, PublishReport, SnapshotReport, StatusReport,
5 TagReport, ValidateReport, VersionReport,
6};
7
8pub mod attribution;
9pub mod diff;
10
11pub fn render_diagnostics<W: io::Write>(diagnostics: &[callisto_model::Diagnostic], w: &mut W) -> io::Result<()> {
12 if !diagnostics.is_empty() {
13 writeln!(w, "\nDiagnostics:")?;
14 for d in diagnostics {
15 writeln!(w, " [{:?}] {}", d.severity, d.message)?;
16 }
17 }
18 Ok(())
19}
20
21pub fn render_status<W: io::Write>(report: &StatusReport, w: &mut W) -> io::Result<()> {
22 writeln!(w, "Status (schema v{}):", report.schema_version)?;
23 for pkg in &report.packages {
24 let severity = pkg
25 .pending_severity
26 .map(|s| s.to_string())
27 .unwrap_or_else(|| "none".to_string());
28 writeln!(
29 w,
30 " {} {} (pending: {})",
31 pkg.package.display_name(),
32 pkg.current_version.raw(),
33 severity
34 )?;
35 }
36 render_diagnostics(&report.diagnostics, w)
37}
38
39pub fn render_version<W: io::Write>(report: &VersionReport, w: &mut W) -> io::Result<()> {
40 writeln!(w, "Version Plan (schema v{}):", report.schema_version)?;
41 for bump in &report.bumps {
42 writeln!(
43 w,
44 " {} {} → {}",
45 bump.package.display_name(),
46 bump.from.raw(),
47 bump.to.raw()
48 )?;
49 }
50 render_diagnostics(&report.diagnostics, w)
51}
52
53pub fn render_publish<W: io::Write>(report: &PublishPlan, w: &mut W) -> io::Result<()> {
54 let total_packages = report.rust_crates.len()
55 + report.npm_platform_packages.len()
56 + report.npm_main_packages.len()
57 + report.pypi_packages.len();
58 writeln!(w, "Publish Plan (schema v{}):", report.schema_version)?;
59 for rel in &report.releases {
60 writeln!(w, " Tag: {} (sha: {})", rel.tag_name, rel.sha.as_str())?;
61 }
62 if total_packages == 0 && report.releases.is_empty() {
63 writeln!(w, " No packages to publish.")?;
64 render_diagnostics(&report.diagnostics, w)?;
65 return Ok(());
66 }
67 if total_packages == 0 {
68 render_diagnostics(&report.diagnostics, w)?;
69 return Ok(());
70 }
71 if !report.rust_crates.is_empty() {
72 writeln!(w, " Crates ({}):", report.rust_crates.len())?;
73 for pkg in &report.rust_crates {
74 writeln!(w, " {} {}", pkg.name, pkg.version.raw())?;
75 }
76 }
77 if !report.npm_main_packages.is_empty() {
78 writeln!(w, " npm packages ({}):", report.npm_main_packages.len())?;
79 for pkg in &report.npm_main_packages {
80 writeln!(w, " {} {}", pkg.name, pkg.version.raw())?;
81 }
82 }
83 if !report.npm_platform_packages.is_empty() {
84 writeln!(w, " npm platform packages ({}):", report.npm_platform_packages.len())?;
85 for pkg in &report.npm_platform_packages {
86 writeln!(w, " {} {}", pkg.name, pkg.version.raw())?;
87 }
88 }
89 if !report.pypi_packages.is_empty() {
90 writeln!(w, " PyPI packages ({}):", report.pypi_packages.len())?;
91 for pkg in &report.pypi_packages {
92 writeln!(w, " {} {}", pkg.name, pkg.version.raw())?;
93 }
94 }
95 render_diagnostics(&report.diagnostics, w)?;
96 Ok(())
97}
98
99pub fn render_publish_report<W: io::Write>(report: &PublishReport, w: &mut W) -> io::Result<()> {
100 writeln!(w, "Publish Report (schema v{}):", report.schema_version)?;
101 for attempt in &report.attempts {
102 let status = match &attempt.result {
103 PublishAttemptResult::Published => "published".to_string(),
104 PublishAttemptResult::AlreadyPublished => "already published".to_string(),
105 PublishAttemptResult::Failed { kind, error } => format!("FAILED [{kind}]: {error}"),
106 };
107 writeln!(
108 w,
109 " {} {} — {}",
110 attempt.package.display_name(),
111 attempt.version.raw(),
112 status
113 )?;
114 }
115 render_diagnostics(&report.diagnostics, w)
116}
117
118pub fn render_snapshot<W: io::Write>(report: &SnapshotReport, w: &mut W) -> io::Result<()> {
119 writeln!(w, "Snapshot Tag: {}", report.snapshot_tag)?;
120 for bump in &report.bumps {
121 writeln!(
122 w,
123 " {} {} → {}",
124 bump.package.display_name(),
125 bump.from.raw(),
126 bump.to.raw()
127 )?;
128 }
129 Ok(())
130}
131
132pub fn render_validate<W: io::Write>(report: &ValidateReport, w: &mut W) -> io::Result<()> {
133 if report.ok {
134 writeln!(w, "Validation passed.")?;
135 } else {
136 writeln!(w, "Validation failed with diagnostics:")?;
137 for diag in &report.diagnostics {
138 writeln!(w, " [{:?}] {}", diag.severity, diag.message)?;
139 }
140 }
141 Ok(())
142}
143
144pub fn render_tag<W: io::Write>(report: &TagReport, dry_run: bool, w: &mut W) -> io::Result<()> {
145 if dry_run {
146 writeln!(w, "Would create tags:")?;
147 } else {
148 writeln!(w, "Created Tags:")?;
149 }
150 for tag in &report.tags {
151 writeln!(w, " {} ({})", tag.tag_name, tag.sha.as_str())?;
152 }
153 Ok(())
154}
155
156pub fn render_compose_pr_body<W: io::Write>(report: &ComposePrBodyReport, w: &mut W) -> io::Result<()> {
157 write!(w, "{}", report.body)?;
158 Ok(())
159}
160
161pub fn render_init<W: io::Write>(report: &InitReport, w: &mut W) -> io::Result<()> {
162 if report.initialized {
163 writeln!(
164 w,
165 "Initialized callisto configuration at {}",
166 report.config_path.display()
167 )?;
168 } else if report.diff.new_ecosystems.is_empty() {
169 writeln!(
170 w,
171 "callisto configuration at {} is up to date; nothing to reconcile",
172 report.config_path.display()
173 )?;
174 } else {
175 let names: Vec<&str> = report.diff.new_ecosystems.iter().map(|e| e.prefix()).collect();
176 if report.diff.applied {
177 writeln!(
178 w,
179 "Reconciled {}: added newly-detected ecosystem(s) {}",
180 report.config_path.display(),
181 names.join(", ")
182 )?;
183 } else {
184 writeln!(
185 w,
186 "Drift detected in {}: newly-detected ecosystem(s) {} — re-run with --yes to apply",
187 report.config_path.display(),
188 names.join(", ")
189 )?;
190 }
191 }
192 Ok(())
193}
194
195pub fn render_matrix<W: io::Write>(report: &callisto_model::MatrixReport, w: &mut W) -> io::Result<()> {
196 writeln!(w, "Matrix (schema v{}):", report.schema_version)?;
197
198 if report.platform_targets.is_empty() && report.runtime_versions.is_empty() {
199 writeln!(w, " (no platform targets or runtime-version constraints declared)")?;
200 }
201
202 for (pkg, group) in &report.platform_targets {
203 writeln!(w, " {pkg} [{:?} <- {}]:", group.kind, group.source)?;
204 for t in &group.targets {
205 writeln!(
206 w,
207 " {:<32} abi={:<8} runner={:<14} cross={:<5} artifact={}",
208 t.triple,
209 t.abi.as_deref().unwrap_or("-"),
210 t.host_runner,
211 t.use_cross,
212 t.artifact_name
213 )?;
214 }
215 }
216
217 for (pkg, entries) in &report.runtime_versions {
218 for e in entries {
219 writeln!(w, " {pkg} [{:?}] {} = {}", e.ecosystem, e.field, e.range)?;
220 }
221 }
222
223 render_diagnostics(&report.diagnostics, w)
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229 use callisto_model::{
230 Ecosystem, PackageId, PublishAttempt, ReleaseTrigger, Severity, StatusPackageRecord, Version, VersionGrammar,
231 };
232
233 fn v1() -> Version {
234 Version::parse("1.0.0", VersionGrammar::SemVer).unwrap()
235 }
236
237 fn pkg(name: &str) -> PackageId {
238 PackageId::Prefixed {
239 ecosystem: Ecosystem::Cargo,
240 name: name.to_string(),
241 }
242 }
243
244 fn mixed_report() -> PublishReport {
245 PublishReport {
246 schema_version: callisto_model::SCHEMA_VERSION,
247 attempts: vec![
248 PublishAttempt {
249 package: pkg("crate-a"),
250 version: v1(),
251 result: PublishAttemptResult::Published,
252 },
253 PublishAttempt {
254 package: pkg("crate-b"),
255 version: v1(),
256 result: PublishAttemptResult::AlreadyPublished,
257 },
258 PublishAttempt {
259 package: pkg("crate-c"),
260 version: v1(),
261 result: PublishAttemptResult::Failed {
262 kind: "authFailed".to_string(),
263 error: "auth failed: bad token".to_string(),
264 },
265 },
266 ],
267 diagnostics: vec![],
268 }
269 }
270
271 fn status_pkg(name: &str, severity: Option<Severity>, changesets: Vec<&str>) -> StatusPackageRecord {
272 StatusPackageRecord {
273 package: pkg(name),
274 current_version: v1(),
275 last_tag: None,
276 last_released_version: None,
277 pending_severity: severity,
278 changed_since_last_tag: false,
279 release_trigger: ReleaseTrigger::Changeset,
280 pending_changesets: changesets.into_iter().map(|s| s.to_string()).collect(),
281 }
282 }
283
284 #[test]
286 fn render_status_no_some_wrapper_in_output() {
287 let report = StatusReport {
288 schema_version: callisto_model::SCHEMA_VERSION,
289 has_changesets: true,
290 packages: vec![status_pkg("crate-a", Some(Severity::Minor), vec!["cs-001"])],
291 diagnostics: vec![],
292 };
293 let mut out = Vec::new();
294 render_status(&report, &mut out).unwrap();
295 let text = String::from_utf8(out).unwrap();
296 assert!(
297 !text.contains("Some("),
298 "render_status output must not contain 'Some('; got: {text}"
299 );
300 assert!(
301 text.contains("minor"),
302 "render_status output should contain severity 'minor'; got: {text}"
303 );
304 }
305
306 fn full_plan() -> PublishPlan {
307 use callisto_model::{
308 CratePublish, NpmMainPublish, NpmPublish, PypiPublish, RegistryKey, Version, SCHEMA_VERSION,
309 };
310 let v = Version::parse("1.0.0", callisto_model::VersionGrammar::SemVer).unwrap();
311 PublishPlan {
312 schema_version: SCHEMA_VERSION,
313 rust_crates: vec![CratePublish {
314 name: "my-crate".to_string(),
315 version: v.clone(),
316 publish_to: RegistryKey(RegistryKey::CRATES_IO.to_string()),
317 registry: None,
318 package_dir: None,
319 }],
320 npm_main_packages: vec![NpmMainPublish {
321 name: "@scope/main-pkg".to_string(),
322 version: v.clone(),
323 publish_to: RegistryKey(RegistryKey::NPM.to_string()),
324 registry: None,
325 tag: None,
326 access: None,
327 depends_on_platforms: vec![],
328 package_dir: std::path::PathBuf::new(),
329 }],
330 npm_platform_packages: vec![NpmPublish {
331 name: "@scope/main-pkg-linux-x64-gnu".to_string(),
332 version: v.clone(),
333 publish_to: RegistryKey(RegistryKey::NPM.to_string()),
334 registry: None,
335 tag: None,
336 access: None,
337 package_dir: std::path::PathBuf::new(),
338 }],
339 pypi_packages: vec![PypiPublish {
340 name: "my-pypi-pkg".to_string(),
341 version: v,
342 publish_to: RegistryKey(RegistryKey::PYPI.to_string()),
343 index: None,
344 package_dir: std::path::PathBuf::new(),
345 }],
346 releases: vec![],
347 diagnostics: vec![],
348 }
349 }
350
351 #[test]
352 fn render_publish_lists_all_four_package_types() {
353 let plan = full_plan();
354 let mut out = Vec::new();
355 render_publish(&plan, &mut out).unwrap();
356 let text = String::from_utf8(out).unwrap();
357 assert!(
358 text.contains("my-crate"),
359 "render_publish must list rust crates; got:\n{text}"
360 );
361 assert!(
362 text.contains("@scope/main-pkg"),
363 "render_publish must list npm main packages; got:\n{text}"
364 );
365 assert!(
366 text.contains("@scope/main-pkg-linux-x64-gnu"),
367 "render_publish must list npm platform packages; got:\n{text}"
368 );
369 assert!(
370 text.contains("my-pypi-pkg"),
371 "render_publish must list pypi packages; got:\n{text}"
372 );
373 }
374
375 #[test]
377 fn render_publish_empty_plan_shows_nothing_to_publish() {
378 let plan = PublishPlan {
379 schema_version: callisto_model::SCHEMA_VERSION,
380 rust_crates: vec![],
381 npm_platform_packages: vec![],
382 npm_main_packages: vec![],
383 pypi_packages: vec![],
384 releases: vec![],
385 diagnostics: vec![],
386 };
387 let mut out = Vec::new();
388 render_publish(&plan, &mut out).unwrap();
389 let text = String::from_utf8(out).unwrap().to_lowercase();
390 assert!(
391 text.contains("no packages") || text.contains("nothing to publish"),
392 "render_publish empty plan must mention 'no packages' or 'nothing to publish'; got: {text}"
393 );
394 }
395
396 #[test]
401 fn render_publish_surfaces_plan_diagnostics() {
402 use callisto_model::{Diagnostic, DiagnosticCode, DiagnosticSeverity};
403
404 let mut plan = PublishPlan {
405 schema_version: callisto_model::SCHEMA_VERSION,
406 rust_crates: vec![],
407 npm_platform_packages: vec![],
408 npm_main_packages: vec![],
409 pypi_packages: vec![],
410 releases: vec![],
411 diagnostics: vec![Diagnostic {
412 code: DiagnosticCode::GitDiscoveryFailed,
413 severity: DiagnosticSeverity::Warning,
414 message: "could not discover git repository: not a git repo".to_string(),
415 package: None,
416 path: None,
417 escalated_by: None,
418 governed_by: None,
419 }],
420 };
421
422 use callisto_model::{CratePublish, RegistryKey};
423
424 plan.rust_crates.push(CratePublish {
426 name: "my-crate".to_string(),
427 version: v1(),
428 publish_to: RegistryKey(RegistryKey::CRATES_IO.to_string()),
429 registry: None,
430 package_dir: None,
431 });
432 let mut out = Vec::new();
433 render_publish(&plan, &mut out).unwrap();
434 let text = String::from_utf8(out).unwrap();
435 assert!(
436 text.to_ascii_lowercase().contains("git") || text.contains("GitDiscoveryFailed"),
437 "render_publish must include diagnostic text; got:\n{text}"
438 );
439
440 plan.rust_crates.clear();
442 let mut out2 = Vec::new();
443 render_publish(&plan, &mut out2).unwrap();
444 let text2 = String::from_utf8(out2).unwrap();
445 assert!(
446 text2.to_ascii_lowercase().contains("git") || text2.contains("GitDiscoveryFailed"),
447 "render_publish must include diagnostic text even for empty plan; got:\n{text2}"
448 );
449 }
450
451 #[test]
452 fn render_publish_report_text_distinguishes_per_package_outcomes() {
453 let mut out = Vec::new();
454 render_publish_report(&mixed_report(), &mut out).unwrap();
455 let text = String::from_utf8(out).unwrap();
456
457 assert!(text.contains("crate-a") && text.contains("published"));
458 assert!(text.contains("crate-b") && text.contains("already published"));
459 assert!(text.contains("crate-c") && text.contains("FAILED [authFailed]: auth failed: bad token"));
460 }
461
462 #[test]
467 fn render_publish_report_failed_includes_error_kind() {
468 use callisto_model::{PublishAttempt, PublishReport, SCHEMA_VERSION};
469
470 let report = PublishReport {
471 schema_version: SCHEMA_VERSION,
472 attempts: vec![
473 PublishAttempt {
474 package: pkg("pkg-a"),
475 version: v1(),
476 result: callisto_model::PublishAttemptResult::Failed {
477 kind: "authFailed".to_string(),
478 error: "invalid token".to_string(),
479 },
480 },
481 PublishAttempt {
482 package: pkg("pkg-b"),
483 version: v1(),
484 result: callisto_model::PublishAttemptResult::Failed {
485 kind: "rateLimited".to_string(),
486 error: "try again in 60s".to_string(),
487 },
488 },
489 ],
490 diagnostics: vec![],
491 };
492
493 let mut out = Vec::new();
494 render_publish_report(&report, &mut out).unwrap();
495 let text = String::from_utf8(out).unwrap();
496
497 assert!(
498 text.contains("authFailed"),
499 "text output must include the error kind 'authFailed' so operators \
500 can distinguish it from transient failures; got:\n{text}"
501 );
502 assert!(
503 text.contains("rateLimited"),
504 "text output must include the error kind 'rateLimited'; got:\n{text}"
505 );
506 }
507
508 #[test]
509 fn publish_report_json_distinguishes_per_package_outcomes() {
510 let json = serde_json::to_string(&mixed_report()).unwrap();
511
512 assert!(json.contains("\"status\":\"published\""));
513 assert!(json.contains("\"status\":\"alreadyPublished\""));
514 assert!(json.contains("\"status\":\"failed\"") && json.contains("auth failed: bad token"));
515 }
516
517 #[test]
521 fn render_matrix_produces_non_json_non_empty_output() {
522 use callisto_model::{
523 MatrixReport, PlatformTarget, PlatformTargetGroup, PlatformTargetKind, RuntimeEcosystem,
524 RuntimeVersionEntry,
525 };
526 use std::collections::BTreeMap;
527
528 let mut platform_targets = BTreeMap::new();
529 platform_targets.insert(
530 "native-mod".to_string(),
531 PlatformTargetGroup {
532 kind: PlatformTargetKind::Napi,
533 source: "napi.targets".to_string(),
534 targets: vec![PlatformTarget {
535 triple: "aarch64-apple-darwin".to_string(),
536 platform: "darwin".to_string(),
537 arch: "arm64".to_string(),
538 abi: None,
539 host_runner: "macos-latest".to_string(),
540 use_cross: false,
541 artifact_name: "native-aarch64-apple-darwin".to_string(),
542 package_dir: "native-mod".to_string(),
543 package_name: "native-mod".to_string(),
544 }],
545 },
546 );
547 let mut runtime_versions = BTreeMap::new();
548 runtime_versions.insert(
549 "native-mod".to_string(),
550 vec![RuntimeVersionEntry {
551 ecosystem: RuntimeEcosystem::Npm,
552 field: "engines.node".to_string(),
553 range: ">=20.0.0".to_string(),
554 }],
555 );
556 let report = MatrixReport {
557 schema_version: 1,
558 platform_targets,
559 runtime_versions,
560 diagnostics: vec![],
561 };
562
563 let mut buf = Vec::new();
564 render_matrix(&report, &mut buf).unwrap();
565 let text = String::from_utf8(buf).unwrap();
566
567 assert!(!text.is_empty(), "table output must not be empty");
568 assert!(
569 serde_json::from_str::<serde_json::Value>(&text).is_err(),
570 "table output must not itself parse as JSON: {text}"
571 );
572 assert!(
573 text.contains("native-mod"),
574 "table must mention the package name: {text}"
575 );
576 assert!(
577 text.contains("aarch64-apple-darwin"),
578 "table must mention the triple: {text}"
579 );
580 }
581
582 #[test]
585 fn render_matrix_renders_diagnostics_for_unrecognised_triple() {
586 use callisto_model::{Diagnostic, DiagnosticCode, DiagnosticSeverity, MatrixReport, PackageId};
587 use std::collections::BTreeMap;
588
589 let report = MatrixReport {
590 schema_version: 1,
591 platform_targets: BTreeMap::new(),
592 runtime_versions: BTreeMap::new(),
593 diagnostics: vec![Diagnostic {
594 code: DiagnosticCode::UnrecognisedPlatformTriple,
595 severity: DiagnosticSeverity::Warning,
596 message: "package `native-mod` declares unrecognised platform triple `sparc64-unknown-linux-gnu` in `napi.targets`".to_string(),
597 package: Some(PackageId::Bare("native-mod".to_string())),
598 path: None,
599 escalated_by: None,
600 governed_by: None,
601 }],
602 };
603
604 let mut buf = Vec::new();
605 render_matrix(&report, &mut buf).unwrap();
606 let text = String::from_utf8(buf).unwrap();
607
608 assert!(
609 text.contains("sparc64-unknown-linux-gnu"),
610 "table output must mention the unrecognised triple: {text}"
611 );
612 assert!(
613 text.contains("native-mod"),
614 "table output must mention the offending package: {text}"
615 );
616 }
617}