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 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 #[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 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 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 #[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 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 let parsed: serde_json::Value = serde_json::from_str(&json_str).expect("output must be valid JSON");
226
227 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 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 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 #[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 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 #[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}