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        BumpRecord, CreatedTag, Ecosystem, PackageId, PublishAttempt, ReleaseTrigger, Severity, StatusPackageRecord,
231        Version, VersionGrammar,
232    };
233
234    fn v1() -> Version {
235        Version::parse("1.0.0", VersionGrammar::SemVer).unwrap()
236    }
237
238    fn pkg(name: &str) -> PackageId {
239        PackageId::Prefixed {
240            ecosystem: Ecosystem::Cargo,
241            name: name.to_string(),
242        }
243    }
244
245    fn mixed_report() -> PublishReport {
246        PublishReport {
247            schema_version: callisto_model::SCHEMA_VERSION,
248            attempts: vec![
249                PublishAttempt {
250                    package: pkg("crate-a"),
251                    version: v1(),
252                    result: PublishAttemptResult::Published,
253                },
254                PublishAttempt {
255                    package: pkg("crate-b"),
256                    version: v1(),
257                    result: PublishAttemptResult::AlreadyPublished,
258                },
259                PublishAttempt {
260                    package: pkg("crate-c"),
261                    version: v1(),
262                    result: PublishAttemptResult::Failed {
263                        kind: "authFailed".to_string(),
264                        error: "auth failed: bad token".to_string(),
265                    },
266                },
267            ],
268            diagnostics: vec![],
269        }
270    }
271
272    fn status_pkg(name: &str, severity: Option<Severity>, changesets: Vec<&str>) -> StatusPackageRecord {
273        StatusPackageRecord {
274            package: pkg(name),
275            current_version: v1(),
276            last_tag: None,
277            last_released_version: None,
278            pending_severity: severity,
279            changed_since_last_tag: false,
280            release_trigger: ReleaseTrigger::Changeset,
281            pending_changesets: changesets.into_iter().map(|s| s.to_string()).collect(),
282        }
283    }
284
285    // QW-2: render_status must not produce "Some(" in output.
286    #[test]
287    fn render_status_no_some_wrapper_in_output() {
288        let report = StatusReport {
289            schema_version: callisto_model::SCHEMA_VERSION,
290            has_changesets: true,
291            packages: vec![status_pkg("crate-a", Some(Severity::Minor), vec!["cs-001"])],
292            diagnostics: vec![],
293        };
294        let mut out = Vec::new();
295        render_status(&report, &mut out).unwrap();
296        let text = String::from_utf8(out).unwrap();
297        assert!(
298            !text.contains("Some("),
299            "render_status output must not contain 'Some('; got: {text}"
300        );
301        assert!(
302            text.contains("minor"),
303            "render_status output should contain severity 'minor'; got: {text}"
304        );
305    }
306
307    fn full_plan() -> PublishPlan {
308        use callisto_model::{
309            CratePublish, NpmMainPublish, NpmPublish, PypiPublish, RegistryKey, Version, SCHEMA_VERSION,
310        };
311        let v = Version::parse("1.0.0", callisto_model::VersionGrammar::SemVer).unwrap();
312        PublishPlan {
313            schema_version: SCHEMA_VERSION,
314            rust_crates: vec![CratePublish {
315                name: "my-crate".to_string(),
316                version: v.clone(),
317                publish_to: RegistryKey(RegistryKey::CRATES_IO.to_string()),
318                registry: None,
319                package_dir: None,
320            }],
321            npm_main_packages: vec![NpmMainPublish {
322                name: "@scope/main-pkg".to_string(),
323                version: v.clone(),
324                publish_to: RegistryKey(RegistryKey::NPM.to_string()),
325                registry: None,
326                tag: None,
327                access: None,
328                depends_on_platforms: vec![],
329                package_dir: std::path::PathBuf::new(),
330            }],
331            npm_platform_packages: vec![NpmPublish {
332                name: "@scope/main-pkg-linux-x64-gnu".to_string(),
333                version: v.clone(),
334                publish_to: RegistryKey(RegistryKey::NPM.to_string()),
335                registry: None,
336                tag: None,
337                access: None,
338                package_dir: std::path::PathBuf::new(),
339            }],
340            pypi_packages: vec![PypiPublish {
341                name: "my-pypi-pkg".to_string(),
342                version: v,
343                publish_to: RegistryKey(RegistryKey::PYPI.to_string()),
344                index: None,
345                package_dir: std::path::PathBuf::new(),
346            }],
347            releases: vec![],
348            diagnostics: vec![],
349        }
350    }
351
352    #[test]
353    fn render_publish_lists_all_four_package_types() {
354        let plan = full_plan();
355        let mut out = Vec::new();
356        render_publish(&plan, &mut out).unwrap();
357        let text = String::from_utf8(out).unwrap();
358        assert!(
359            text.contains("my-crate"),
360            "render_publish must list rust crates; got:\n{text}"
361        );
362        assert!(
363            text.contains("@scope/main-pkg"),
364            "render_publish must list npm main packages; got:\n{text}"
365        );
366        assert!(
367            text.contains("@scope/main-pkg-linux-x64-gnu"),
368            "render_publish must list npm platform packages; got:\n{text}"
369        );
370        assert!(
371            text.contains("my-pypi-pkg"),
372            "render_publish must list pypi packages; got:\n{text}"
373        );
374    }
375
376    // QW-9: render_publish with empty plan should say "nothing to publish".
377    #[test]
378    fn render_publish_empty_plan_shows_nothing_to_publish() {
379        let plan = PublishPlan {
380            schema_version: callisto_model::SCHEMA_VERSION,
381            rust_crates: vec![],
382            npm_platform_packages: vec![],
383            npm_main_packages: vec![],
384            pypi_packages: vec![],
385            releases: vec![],
386            diagnostics: vec![],
387        };
388        let mut out = Vec::new();
389        render_publish(&plan, &mut out).unwrap();
390        let text = String::from_utf8(out).unwrap().to_lowercase();
391        assert!(
392            text.contains("no packages") || text.contains("nothing to publish"),
393            "render_publish empty plan must mention 'no packages' or 'nothing to publish'; got: {text}"
394        );
395    }
396
397    /// Diagnostics emitted during plan computation (e.g. GitDiscoveryFailed,
398    /// ChangesetReadError) must appear in the text output of render_publish.
399    /// Without this, an operator using `plan-publish --format text` gets no
400    /// explanation when releases are silently omitted due to a git error.
401    #[test]
402    fn render_publish_surfaces_plan_diagnostics() {
403        use callisto_model::{Diagnostic, DiagnosticCode, DiagnosticSeverity};
404
405        let mut plan = PublishPlan {
406            schema_version: callisto_model::SCHEMA_VERSION,
407            rust_crates: vec![],
408            npm_platform_packages: vec![],
409            npm_main_packages: vec![],
410            pypi_packages: vec![],
411            releases: vec![],
412            diagnostics: vec![Diagnostic {
413                code: DiagnosticCode::GitDiscoveryFailed,
414                severity: DiagnosticSeverity::Warning,
415                message: "could not discover git repository: not a git repo".to_string(),
416                package: None,
417                path: None,
418                escalated_by: None,
419                governed_by: None,
420            }],
421        };
422
423        use callisto_model::{CratePublish, RegistryKey};
424
425        // Non-empty plan case: diagnostic must appear alongside package list.
426        plan.rust_crates.push(CratePublish {
427            name: "my-crate".to_string(),
428            version: v1(),
429            publish_to: RegistryKey(RegistryKey::CRATES_IO.to_string()),
430            registry: None,
431            package_dir: None,
432        });
433        let mut out = Vec::new();
434        render_publish(&plan, &mut out).unwrap();
435        let text = String::from_utf8(out).unwrap();
436        assert!(
437            text.to_ascii_lowercase().contains("git") || text.contains("GitDiscoveryFailed"),
438            "render_publish must include diagnostic text; got:\n{text}"
439        );
440
441        // Empty-plan case: diagnostic must appear even when no packages are listed.
442        plan.rust_crates.clear();
443        let mut out2 = Vec::new();
444        render_publish(&plan, &mut out2).unwrap();
445        let text2 = String::from_utf8(out2).unwrap();
446        assert!(
447            text2.to_ascii_lowercase().contains("git") || text2.contains("GitDiscoveryFailed"),
448            "render_publish must include diagnostic text even for empty plan; got:\n{text2}"
449        );
450    }
451
452    #[test]
453    fn render_publish_report_text_distinguishes_per_package_outcomes() {
454        let mut out = Vec::new();
455        render_publish_report(&mixed_report(), &mut out).unwrap();
456        let text = String::from_utf8(out).unwrap();
457
458        assert!(text.contains("crate-a") && text.contains("published"));
459        assert!(text.contains("crate-b") && text.contains("already published"));
460        assert!(text.contains("crate-c") && text.contains("FAILED [authFailed]: auth failed: bad token"));
461    }
462
463    /// The text renderer must surface the `kind` discriminator so operators can
464    /// distinguish "authFailed" (permanent — rotate credentials) from
465    /// "rateLimited" (transient — safe to retry) without parsing the human-
466    /// readable error string.
467    #[test]
468    fn render_publish_report_failed_includes_error_kind() {
469        use callisto_model::{PublishAttempt, PublishReport, SCHEMA_VERSION};
470
471        let report = PublishReport {
472            schema_version: SCHEMA_VERSION,
473            attempts: vec![
474                PublishAttempt {
475                    package: pkg("pkg-a"),
476                    version: v1(),
477                    result: callisto_model::PublishAttemptResult::Failed {
478                        kind: "authFailed".to_string(),
479                        error: "invalid token".to_string(),
480                    },
481                },
482                PublishAttempt {
483                    package: pkg("pkg-b"),
484                    version: v1(),
485                    result: callisto_model::PublishAttemptResult::Failed {
486                        kind: "rateLimited".to_string(),
487                        error: "try again in 60s".to_string(),
488                    },
489                },
490            ],
491            diagnostics: vec![],
492        };
493
494        let mut out = Vec::new();
495        render_publish_report(&report, &mut out).unwrap();
496        let text = String::from_utf8(out).unwrap();
497
498        assert!(
499            text.contains("authFailed"),
500            "text output must include the error kind 'authFailed' so operators \
501             can distinguish it from transient failures; got:\n{text}"
502        );
503        assert!(
504            text.contains("rateLimited"),
505            "text output must include the error kind 'rateLimited'; got:\n{text}"
506        );
507    }
508
509    #[test]
510    fn publish_report_json_distinguishes_per_package_outcomes() {
511        let json = serde_json::to_string(&mixed_report()).unwrap();
512
513        assert!(json.contains("\"status\":\"published\""));
514        assert!(json.contains("\"status\":\"alreadyPublished\""));
515        assert!(json.contains("\"status\":\"failed\"") && json.contains("auth failed: bad token"));
516    }
517
518    /// AC-008: text-format output for a report with at least one platform
519    /// target and one runtime-version entry must be non-empty and must not
520    /// parse as JSON.
521    #[test]
522    fn render_matrix_produces_non_json_non_empty_output() {
523        use callisto_model::{
524            MatrixReport, PlatformTarget, PlatformTargetGroup, PlatformTargetKind, RuntimeEcosystem,
525            RuntimeVersionEntry,
526        };
527        use std::collections::BTreeMap;
528
529        let mut platform_targets = BTreeMap::new();
530        platform_targets.insert(
531            "native-mod".to_string(),
532            PlatformTargetGroup {
533                kind: PlatformTargetKind::Napi,
534                source: "napi.targets".to_string(),
535                targets: vec![PlatformTarget {
536                    triple: "aarch64-apple-darwin".to_string(),
537                    platform: "darwin".to_string(),
538                    arch: "arm64".to_string(),
539                    abi: None,
540                    host_runner: "macos-latest".to_string(),
541                    use_cross: false,
542                    artifact_name: "native-mod-darwin-arm64".to_string(),
543                    package_dir: "native-mod".to_string(),
544                    package_name: "native-mod".to_string(),
545                }],
546            },
547        );
548        let mut runtime_versions = BTreeMap::new();
549        runtime_versions.insert(
550            "native-mod".to_string(),
551            vec![RuntimeVersionEntry {
552                ecosystem: RuntimeEcosystem::Npm,
553                field: "engines.node".to_string(),
554                range: ">=20.0.0".to_string(),
555            }],
556        );
557        let report = MatrixReport {
558            schema_version: 1,
559            platform_targets,
560            runtime_versions,
561            diagnostics: vec![],
562        };
563
564        let mut buf = Vec::new();
565        render_matrix(&report, &mut buf).unwrap();
566        let text = String::from_utf8(buf).unwrap();
567
568        assert!(!text.is_empty(), "table output must not be empty");
569        assert!(
570            serde_json::from_str::<serde_json::Value>(&text).is_err(),
571            "table output must not itself parse as JSON: {text}"
572        );
573        assert!(
574            text.contains("native-mod"),
575            "table must mention the package name: {text}"
576        );
577        assert!(
578            text.contains("aarch64-apple-darwin"),
579            "table must mention the triple: {text}"
580        );
581    }
582
583    /// AC-011: render_matrix must surface a diagnostic's triple/message in
584    /// the human-readable table output, not just its presence.
585    #[test]
586    fn render_matrix_renders_diagnostics_for_unrecognised_triple() {
587        use callisto_model::{Diagnostic, DiagnosticCode, DiagnosticSeverity, MatrixReport, PackageId};
588        use std::collections::BTreeMap;
589
590        let report = MatrixReport {
591            schema_version: 1,
592            platform_targets: BTreeMap::new(),
593            runtime_versions: BTreeMap::new(),
594            diagnostics: vec![Diagnostic {
595                code: DiagnosticCode::UnrecognisedPlatformTriple,
596                severity: DiagnosticSeverity::Warning,
597                message: "package `native-mod` declares unrecognised platform triple `sparc64-unknown-linux-gnu` in `napi.targets`".to_string(),
598                package: Some(PackageId::Bare("native-mod".to_string())),
599                path: None,
600                escalated_by: None,
601                governed_by: None,
602            }],
603        };
604
605        let mut buf = Vec::new();
606        render_matrix(&report, &mut buf).unwrap();
607        let text = String::from_utf8(buf).unwrap();
608
609        assert!(
610            text.contains("sparc64-unknown-linux-gnu"),
611            "table output must mention the unrecognised triple: {text}"
612        );
613        assert!(
614            text.contains("native-mod"),
615            "table output must mention the offending package: {text}"
616        );
617    }
618
619    #[test]
620    fn render_snapshot_lists_snapshot_tag_and_bumps() {
621        let report = SnapshotReport {
622            schema_version: callisto_model::SCHEMA_VERSION,
623            snapshot_tag: "0.0.0-canary-abc1234".to_string(),
624            bumps: vec![BumpRecord {
625                package: pkg("crate-a"),
626                from: v1(),
627                to: Version::parse("0.0.0-canary-abc1234", VersionGrammar::SemVer).unwrap(),
628                severity: Severity::Patch,
629                governed_by: None,
630                reason: None,
631            }],
632            diagnostics: vec![],
633        };
634        let mut out = Vec::new();
635        render_snapshot(&report, &mut out).unwrap();
636        let text = String::from_utf8(out).unwrap();
637        assert!(text.contains("0.0.0-canary-abc1234"), "got: {text}");
638        assert!(text.contains("crate-a"), "got: {text}");
639    }
640
641    #[test]
642    fn render_validate_ok_reports_pass() {
643        let report = ValidateReport {
644            schema_version: callisto_model::SCHEMA_VERSION,
645            ok: true,
646            diagnostics: vec![],
647        };
648        let mut out = Vec::new();
649        render_validate(&report, &mut out).unwrap();
650        let text = String::from_utf8(out).unwrap();
651        assert!(text.contains("Validation passed"), "got: {text}");
652    }
653
654    #[test]
655    fn render_validate_failure_lists_diagnostics() {
656        use callisto_model::{Diagnostic, DiagnosticCode, DiagnosticSeverity};
657
658        let report = ValidateReport {
659            schema_version: callisto_model::SCHEMA_VERSION,
660            ok: false,
661            diagnostics: vec![Diagnostic {
662                code: DiagnosticCode::UnrecognisedPlatformTriple,
663                severity: DiagnosticSeverity::Error,
664                message: "something is wrong".to_string(),
665                package: None,
666                path: None,
667                escalated_by: None,
668                governed_by: None,
669            }],
670        };
671        let mut out = Vec::new();
672        render_validate(&report, &mut out).unwrap();
673        let text = String::from_utf8(out).unwrap();
674        assert!(text.contains("Validation failed"), "got: {text}");
675        assert!(text.contains("something is wrong"), "got: {text}");
676    }
677
678    #[test]
679    fn render_tag_dry_run_vs_real_use_distinct_headers() {
680        use callisto_model::{CommitSha, TagName};
681
682        let report = TagReport {
683            schema_version: callisto_model::SCHEMA_VERSION,
684            tags: vec![CreatedTag {
685                package: pkg("crate-a"),
686                tag_name: TagName("crate-a@1.0.0".to_string()),
687                sha: CommitSha::parse(&"a".repeat(40)).unwrap(),
688                already_existed: false,
689            }],
690            diagnostics: vec![],
691        };
692
693        let mut dry_run_out = Vec::new();
694        render_tag(&report, true, &mut dry_run_out).unwrap();
695        let dry_run_text = String::from_utf8(dry_run_out).unwrap();
696        assert!(dry_run_text.contains("Would create tags"), "got: {dry_run_text}");
697        assert!(dry_run_text.contains("crate-a@1.0.0"), "got: {dry_run_text}");
698
699        let mut real_out = Vec::new();
700        render_tag(&report, false, &mut real_out).unwrap();
701        let real_text = String::from_utf8(real_out).unwrap();
702        assert!(real_text.contains("Created Tags"), "got: {real_text}");
703        assert!(!real_text.contains("Would create"), "got: {real_text}");
704    }
705
706    #[test]
707    fn render_init_up_to_date_reports_nothing_to_reconcile() {
708        let report = InitReport {
709            schema_version: callisto_model::SCHEMA_VERSION,
710            initialized: false,
711            config_path: std::path::PathBuf::from("callisto.toml"),
712            diff: callisto_model::InitDiff {
713                new_ecosystems: vec![],
714                applied: false,
715            },
716            diagnostics: vec![],
717        };
718        let mut out = Vec::new();
719        render_init(&report, &mut out).unwrap();
720        let text = String::from_utf8(out).unwrap();
721        assert!(text.contains("up to date"), "got: {text}");
722    }
723
724    #[test]
725    fn render_init_applied_drift_reports_reconciled() {
726        let report = InitReport {
727            schema_version: callisto_model::SCHEMA_VERSION,
728            initialized: false,
729            config_path: std::path::PathBuf::from("callisto.toml"),
730            diff: callisto_model::InitDiff {
731                new_ecosystems: vec![Ecosystem::Npm],
732                applied: true,
733            },
734            diagnostics: vec![],
735        };
736        let mut out = Vec::new();
737        render_init(&report, &mut out).unwrap();
738        let text = String::from_utf8(out).unwrap();
739        assert!(text.contains("Reconciled"), "got: {text}");
740        assert!(text.contains("npm"), "got: {text}");
741    }
742
743    #[test]
744    fn render_init_unapplied_drift_reports_needs_yes_flag() {
745        let report = InitReport {
746            schema_version: callisto_model::SCHEMA_VERSION,
747            initialized: false,
748            config_path: std::path::PathBuf::from("callisto.toml"),
749            diff: callisto_model::InitDiff {
750                new_ecosystems: vec![Ecosystem::Npm],
751                applied: false,
752            },
753            diagnostics: vec![],
754        };
755        let mut out = Vec::new();
756        render_init(&report, &mut out).unwrap();
757        let text = String::from_utf8(out).unwrap();
758        assert!(text.contains("Drift detected"), "got: {text}");
759        assert!(text.contains("--yes"), "got: {text}");
760    }
761}