Skip to main content

callisto_cli/render/
mod.rs

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    // QW-2: render_status must not produce "Some(" in output.
285    #[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    // QW-9: render_publish with empty plan should say "nothing to publish".
376    #[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    /// Diagnostics emitted during plan computation (e.g. GitDiscoveryFailed,
397    /// ChangesetReadError) must appear in the text output of render_publish.
398    /// Without this, an operator using `plan-publish --format text` gets no
399    /// explanation when releases are silently omitted due to a git error.
400    #[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        // Non-empty plan case: diagnostic must appear alongside package list.
425        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        // Empty-plan case: diagnostic must appear even when no packages are listed.
441        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    /// The text renderer must surface the `kind` discriminator so operators can
463    /// distinguish "authFailed" (permanent — rotate credentials) from
464    /// "rateLimited" (transient — safe to retry) without parsing the human-
465    /// readable error string.
466    #[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    /// AC-008: text-format output for a report with at least one platform
518    /// target and one runtime-version entry must be non-empty and must not
519    /// parse as JSON.
520    #[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    /// AC-011: render_matrix must surface a diagnostic's triple/message in
583    /// the human-readable table output, not just its presence.
584    #[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}