Skip to main content

callisto_cli/commands/
version.rs

1use std::process::ExitCode;
2
3use callisto_graph::apply::{apply_version_plan, ApplyOptions, ApplyOutcome};
4use callisto_graph::commands::VersionOptions;
5use callisto_model::{ApplyPermit, DiagnosticSeverity};
6
7use crate::cli::{GlobalArgs, OutputFormat, VersionArgs};
8use crate::error::CliError;
9use crate::output::write_json;
10use crate::render;
11use crate::runner::CliCommandRunner;
12use crate::workspace::{load_workspace, select_inference};
13
14pub fn handle(args: VersionArgs, global: &GlobalArgs) -> Result<ExitCode, CliError> {
15    let runner = CliCommandRunner;
16    let ws = load_workspace(global, &runner)?;
17
18    let inference = select_inference();
19    let opts = VersionOptions {
20        strict: args.strict,
21        strict_graph: args.strict_graph,
22        allow_empty_changesets: args.allow_empty_changesets,
23    };
24
25    let plan = callisto_graph::commands::plan_version(&ws, &inference, &opts)?;
26
27    let apply_opts = ApplyOptions {
28        refresh_lockfiles: args.refresh_lockfiles,
29        transient: false,
30    };
31
32    let outcome = match ApplyPermit::granted_unless_dry_run(global.dry_run) {
33        Some(permit) => apply_version_plan(&ws.root, &plan, &runner, &apply_opts, &permit)?,
34        None => ApplyOutcome::default(),
35    };
36    let report = plan.to_report(outcome.lockfile_refresh_results);
37
38    if global.dry_run && global.format == OutputFormat::Text {
39        println!("[DRY-RUN] Version Plan Calculated (no files modified):");
40    }
41
42    match global.format {
43        OutputFormat::Json => write_json(&mut std::io::stdout(), &report)?,
44        OutputFormat::Text => render::render_version(&report, &mut std::io::stdout())?,
45    }
46
47    // If any diagnostic was escalated to Error (e.g. by --strict), fail.
48    // Mirrors the pattern in status.rs: diagnostics ride in the report so the
49    // caller sees full detail before the non-zero exit.
50    let has_errors = report
51        .diagnostics
52        .iter()
53        .any(|d| d.severity == DiagnosticSeverity::Error);
54
55    if has_errors {
56        Ok(ExitCode::FAILURE)
57    } else {
58        Ok(ExitCode::SUCCESS)
59    }
60}
61
62#[cfg(test)]
63mod tests {
64    use std::path::Path;
65
66    use callisto_graph::commands::{plan_version, VersionOptions};
67    use callisto_graph::infer::NoInference;
68    use callisto_graph::locate::IgnoreWalkLocator;
69    use callisto_graph::Workspace;
70    use callisto_model::{CommandError, CommandOutput, CommandRunner, PackageId};
71
72    struct NoopRunner;
73
74    impl CommandRunner for NoopRunner {
75        fn run(&self, _program: &str, _args: &[&str], _cwd: &Path) -> Result<CommandOutput, CommandError> {
76            Ok(CommandOutput {
77                exit_code: Some(0),
78                stdout: String::new(),
79                stderr: String::new(),
80            })
81        }
82    }
83
84    fn git_init_with_commit(root: &Path) {
85        for args in [
86            vec!["init", "-q", "-b", "main"],
87            vec!["config", "user.email", "test@example.com"],
88            vec!["config", "user.name", "Test"],
89            vec!["config", "commit.gpgsign", "false"],
90            vec!["config", "tag.gpgsign", "false"],
91        ] {
92            std::process::Command::new("git")
93                .args(&args)
94                .current_dir(root)
95                .output()
96                .expect("git must be installed");
97        }
98        std::fs::write(root.join(".gitkeep"), "").unwrap();
99        for args in [vec!["add", "."], vec!["commit", "-q", "-m", "init"]] {
100            std::process::Command::new("git")
101                .args(&args)
102                .current_dir(root)
103                .output()
104                .expect("git must be installed");
105        }
106    }
107
108    #[cfg(feature = "inference")]
109    fn run_git(root: &Path, args: &[&str]) {
110        let status = std::process::Command::new("git")
111            .args(args)
112            .current_dir(root)
113            .status()
114            .expect("git must be installed");
115        assert!(status.success(), "git {args:?} failed");
116    }
117
118    fn make_fixture(root: &Path) {
119        git_init_with_commit(root);
120
121        std::fs::create_dir_all(root.join("pkg-alpha")).unwrap();
122        std::fs::write(
123            root.join("pkg-alpha/Cargo.toml"),
124            "[package]\nname = \"pkg-alpha\"\nversion = \"1.0.0\"\n",
125        )
126        .unwrap();
127
128        std::fs::create_dir_all(root.join(".changeset")).unwrap();
129        std::fs::write(
130            root.join(".changeset/bump-alpha.md"),
131            "---\n\"pkg-alpha\": minor\n---\n\nAdd a new feature.\n",
132        )
133        .unwrap();
134    }
135
136    /// Spec: the text rendering of a `VersionReport` produced from a workspace
137    /// with a pending changeset must include the package name and both the
138    /// `from` and `to` version strings. The `render_version` function writes
139    /// one line per bump in the format `<name> <from> → <to>`.
140    #[test]
141    fn version_text_output_includes_package_name_and_versions() {
142        let tmp = tempfile::tempdir().unwrap();
143        let root = tmp.path();
144        make_fixture(root);
145
146        let locator = IgnoreWalkLocator::new(root);
147        let runner = NoopRunner;
148        let ws = Workspace::load(root.to_path_buf(), &locator, &runner).expect("workspace must load");
149
150        let inference = NoInference;
151        let opts = VersionOptions {
152            strict: false,
153            strict_graph: false,
154            allow_empty_changesets: true,
155        };
156
157        let plan = plan_version(&ws, &inference, &opts).expect("plan_version must succeed");
158        let report = plan.to_report(None);
159
160        // The report must include a bump for pkg-alpha.
161        let pkg_alpha = PackageId::parse("pkg-alpha").unwrap();
162        let bump = report
163            .bumps
164            .iter()
165            .find(|b| b.package == pkg_alpha)
166            .expect("pkg-alpha must have a planned bump");
167
168        assert_eq!(bump.from.render(), "1.0.0", "from version must be the current version");
169        assert_eq!(
170            bump.to.render(),
171            "1.1.0",
172            "to version must be a minor bump (1.0.0 -> 1.1.0)"
173        );
174
175        // Render text output and verify it contains the key strings.
176        let mut text_out = Vec::new();
177        crate::render::render_version(&report, &mut text_out).unwrap();
178        let rendered = String::from_utf8(text_out).unwrap();
179
180        assert!(
181            rendered.contains("pkg-alpha"),
182            "text output must include package name; got:\n{rendered}"
183        );
184        assert!(
185            rendered.contains("1.0.0"),
186            "text output must include from version; got:\n{rendered}"
187        );
188        assert!(
189            rendered.contains("1.1.0"),
190            "text output must include to version; got:\n{rendered}"
191        );
192    }
193
194    /// Spec: the JSON output of `write_json(&report)` for a `VersionReport`
195    /// must be valid JSON. It must include the top-level keys `schemaVersion`
196    /// and `bumps`. The `diagnostics` key is omitted when empty
197    /// (`skip_serializing_if = "Vec::is_empty"`), so its presence is optional.
198    /// Each entry in `bumps` must have `package`, `from`, `to`, and `severity`.
199    #[test]
200    fn version_json_output_is_valid_json_with_expected_structure() {
201        let tmp = tempfile::tempdir().unwrap();
202        let root = tmp.path();
203        make_fixture(root);
204
205        let locator = IgnoreWalkLocator::new(root);
206        let runner = NoopRunner;
207        let ws = Workspace::load(root.to_path_buf(), &locator, &runner).expect("workspace must load");
208
209        let inference = NoInference;
210        let opts = VersionOptions {
211            strict: false,
212            strict_graph: false,
213            allow_empty_changesets: true,
214        };
215
216        let plan = plan_version(&ws, &inference, &opts).expect("plan_version must succeed");
217        let report = plan.to_report(None);
218
219        // Serialize to JSON via the same function the CLI uses.
220        let mut json_out = Vec::new();
221        crate::output::write_json(&mut json_out, &report).unwrap();
222        let json_str = String::from_utf8(json_out).unwrap();
223
224        // Must be parseable as valid JSON.
225        let parsed: serde_json::Value = serde_json::from_str(&json_str).expect("output must be valid JSON");
226
227        // Top-level structure checks: schemaVersion and bumps are always present.
228        assert!(
229            parsed.get("schemaVersion").is_some(),
230            "JSON must have schemaVersion key; got:\n{json_str}"
231        );
232        assert!(
233            parsed.get("bumps").is_some(),
234            "JSON must have bumps key; got:\n{json_str}"
235        );
236        // diagnostics is omitted when empty (skip_serializing_if); if present it
237        // must be an array.
238        if let Some(diags) = parsed.get("diagnostics") {
239            assert!(
240                diags.is_array(),
241                "diagnostics key must be an array when present; got:\n{json_str}"
242            );
243        }
244
245        // Each bump must have the required fields.
246        let bumps = parsed["bumps"].as_array().expect("bumps must be an array");
247        assert!(!bumps.is_empty(), "bumps array must be non-empty for the test fixture");
248
249        for bump in bumps {
250            assert!(
251                bump.get("package").is_some(),
252                "each bump must have a package field; bump: {bump}"
253            );
254            assert!(
255                bump.get("from").is_some(),
256                "each bump must have a from field; bump: {bump}"
257            );
258            assert!(bump.get("to").is_some(), "each bump must have a to field; bump: {bump}");
259            assert!(
260                bump.get("severity").is_some(),
261                "each bump must have a severity field; bump: {bump}"
262            );
263        }
264    }
265
266    /// Spec: with the `inference` feature enabled, the CLI must actually use commit-based
267    /// severity inference for a package with no changeset -- not silently behave as if
268    /// inference were off. Builds a real git repo (real `CliCommandRunner`, real gix/git,
269    /// no `NoopRunner`) with a release tag followed by a `feat:` commit and no changeset
270    /// file, then asserts `plan_version` -- driven by `crate::workspace::select_inference()`,
271    /// the same call `handle()` makes -- infers a `Minor` bump attributed to inference.
272    /// `NoInference` (or a `select_inference()` that ignores the feature flag) always
273    /// returns `Ok(None)`, which would leave the package unbumped, so this fails for exactly
274    /// the right reason if the feature-gated dispatch isn't wired up.
275    #[cfg(feature = "inference")]
276    #[test]
277    fn version_uses_real_commit_inference_when_feature_enabled() {
278        use crate::runner::CliCommandRunner;
279        use callisto_graph::locate::IgnoreWalkLocator;
280        use callisto_model::BumpReason;
281
282        let tmp = tempfile::tempdir().unwrap();
283        let root = tmp.path();
284
285        git_init_with_commit(root);
286        std::fs::write(
287            root.join("Cargo.toml"),
288            "[package]\nname = \"pkg-alpha\"\nversion = \"1.0.0\"\nedition = \"2021\"\n",
289        )
290        .unwrap();
291        run_git(root, &["add", "."]);
292        run_git(root, &["commit", "-q", "-m", "chore: add package"]);
293        run_git(
294            root,
295            &["-c", "tag.gpgSign=false", "tag", "-m", "release", "pkg-alpha@1.0.0"],
296        );
297
298        // A feat commit after the tag, with no changeset -- the only way this can produce a
299        // bump is via real commit inference. Inference's pathspecs are scoped to the package's
300        // source directory (aggregate.rs, via changed::package_paths); for this single-package
301        // repo that's the workspace root, so touching Cargo.toml itself is enough to be counted.
302        std::fs::write(
303            root.join("Cargo.toml"),
304            "[package]\nname = \"pkg-alpha\"\nversion = \"1.0.0\"\nedition = \"2021\"\n\
305             description = \"a new feature\"\n",
306        )
307        .unwrap();
308        run_git(root, &["add", "."]);
309        run_git(root, &["commit", "-q", "-m", "feat: add a new feature"]);
310
311        let locator = IgnoreWalkLocator::new(root);
312        let runner = CliCommandRunner;
313        let ws = callisto_graph::Workspace::load(root.to_path_buf(), &locator, &runner).expect("workspace must load");
314
315        let inference = crate::workspace::select_inference();
316        let opts = VersionOptions {
317            strict: false,
318            strict_graph: false,
319            allow_empty_changesets: true,
320        };
321
322        let plan = plan_version(&ws, &inference, &opts).expect("plan_version must succeed");
323
324        let pkg_alpha = PackageId::parse("pkg-alpha").unwrap();
325        let bump = plan.bumps.iter().find(|b| b.package == pkg_alpha).expect(
326            "pkg-alpha must have a planned bump from commit inference -- got none, meaning \
327             select_inference() is not actually dispatching to CommitInference despite the \
328             inference feature being enabled",
329        );
330
331        assert_eq!(bump.to.render(), "1.1.0", "a `feat:` commit must infer a minor bump");
332        assert!(
333            matches!(bump.reason, Some(BumpReason::Inference { .. })),
334            "the bump must be attributed to inference, not a changeset (there is none); got: \
335             {:?}",
336            bump.reason
337        );
338    }
339
340    /// `callisto version --dry-run --format text` must print the `[DRY-RUN]`
341    /// marker and not modify any manifest on disk.
342    #[test]
343    fn handle_dry_run_text_output_carries_the_dry_run_marker_and_writes_nothing() {
344        let tmp = tempfile::tempdir().unwrap();
345        let root = tmp.path();
346        make_fixture(root);
347        std::fs::write(
348            root.join("Cargo.toml"),
349            "[workspace]\nmembers = [\"pkg-alpha\"]\nresolver = \"2\"\n",
350        )
351        .unwrap();
352        std::fs::write(root.join("callisto.toml"), "").unwrap();
353
354        let global = crate::cli::GlobalArgs {
355            format: crate::cli::OutputFormat::Text,
356            cwd: root.to_path_buf(),
357            dry_run: true,
358        };
359
360        let manifest_before = std::fs::read_to_string(root.join("pkg-alpha/Cargo.toml")).unwrap();
361
362        let args = crate::cli::VersionArgs {
363            strict: false,
364            strict_graph: false,
365            allow_empty_changesets: true,
366            refresh_lockfiles: false,
367        };
368        let result = super::handle(args, &global);
369        assert!(result.is_ok(), "expected Ok, got: {result:?}");
370
371        let manifest_after = std::fs::read_to_string(root.join("pkg-alpha/Cargo.toml")).unwrap();
372        assert_eq!(
373            manifest_before, manifest_after,
374            "dry-run must not modify pkg-alpha's Cargo.toml"
375        );
376    }
377}