1use std::collections::{BTreeMap, BTreeSet};
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use callisto_manifests::{open, OpenContext, WorkspaceCargoResolver};
6use callisto_model::{ApplyPermit, CommandError, CommandOutput, CommandRunner, LockfileRefreshResult, ManifestRole};
7
8use crate::cascade::DepWriteTarget;
9use crate::error::GraphError;
10use crate::plan::{VersionPlan, VersionWriteTarget};
11
12#[derive(Clone, Debug, Default)]
14pub struct ApplyOptions {
15 pub refresh_lockfiles: bool,
18 pub transient: bool,
22}
23
24#[derive(Clone, Debug, Default)]
26pub struct ApplyOutcome {
27 pub lockfile_refresh_results: Option<Vec<LockfileRefreshResult>>,
29 pub staged: Vec<PathBuf>,
31}
32
33#[derive(Debug, Default)]
34pub(crate) struct ManifestWriteGroup {
35 pub(crate) bump: Option<(usize, callisto_model::Version)>,
36 pub(crate) rewrite_indices: Vec<usize>,
37}
38
39#[derive(Debug, Default)]
40pub(crate) struct ManifestWriteClassification {
41 pub(crate) batched: BTreeMap<PathBuf, ManifestWriteGroup>,
42 pub(crate) excluded: BTreeSet<PathBuf>,
43}
44
45pub(crate) fn classify_manifest_writes(plan: &VersionPlan) -> ManifestWriteClassification {
46 let mut resolver_routed: BTreeSet<PathBuf> = BTreeSet::new();
47 for bump in &plan.bumps {
48 for write in &bump.writes {
49 if let VersionWriteTarget::CargoWorkspacePackage { root_manifest } = write {
50 resolver_routed.insert(root_manifest.clone());
51 }
52 }
53 }
54 for rewrite in &plan.rewrites {
55 if let DepWriteTarget::CargoWorkspaceDependency { root_manifest } = &rewrite.key.target {
56 resolver_routed.insert(root_manifest.clone());
57 }
58 }
59
60 let mut by_path: BTreeMap<PathBuf, ManifestWriteGroup> = BTreeMap::new();
61 for (idx, bump) in plan.bumps.iter().enumerate() {
62 for write in &bump.writes {
63 if let VersionWriteTarget::Manifest(p) = write {
64 by_path.entry(p.clone()).or_default().bump = Some((idx, bump.to.clone()));
65 }
66 }
67 }
68 for (idx, rewrite) in plan.rewrites.iter().enumerate() {
69 if let DepWriteTarget::Manifest(p) = &rewrite.key.target {
70 by_path.entry(p.clone()).or_default().rewrite_indices.push(idx);
71 }
72 }
73
74 let mut batched = BTreeMap::new();
75 let mut excluded = BTreeSet::new();
76 for (path, group) in by_path {
77 if resolver_routed.contains(&path) {
78 excluded.insert(path);
79 } else {
80 batched.insert(path, group);
81 }
82 }
83
84 ManifestWriteClassification { batched, excluded }
85}
86
87pub fn apply_version_plan<R: CommandRunner>(
101 root: &Path,
102 plan: &VersionPlan,
103 runner: &R,
104 opts: &ApplyOptions,
105 permit: &ApplyPermit,
106) -> Result<ApplyOutcome, GraphError> {
107 let mut outcome = ApplyOutcome::default();
108 let mut modified_paths = Vec::new();
109
110 let cargo_workspace = if root.join("Cargo.toml").exists() {
111 if let Ok(resolver) = WorkspaceCargoResolver::load(&root.join("Cargo.toml")) {
112 resolver.inheritance().ok().map(std::sync::Arc::new)
113 } else {
114 None
115 }
116 } else {
117 None
118 };
119
120 let npm_workspace_kind = callisto_manifests::detect_npm_workspace_kind(root).ok().flatten();
121
122 let ctx = OpenContext {
123 workspace_root: root,
124 cargo_workspace,
125 npm_workspace_kind,
126 };
127
128 let classification = classify_manifest_writes(plan);
129
130 for (path, group) in &classification.batched {
131 let fmt = callisto_model::ManifestFormat::from_path(path)?;
132 let decl = callisto_model::ManifestDecl::new(path.clone(), ManifestRole::Canonical, fmt)?;
133 let mut handle = open(&decl, &ctx)?;
134 let mut mutated = false;
135
136 if let Some((bump_idx, target_version)) = &group.bump {
137 let bump = &plan.bumps[*bump_idx];
138 let current = handle.current_version()?;
139 if current == *target_version {
140 } else if current == bump.from {
142 handle.write_version(target_version, permit)?;
143 mutated = true;
144 } else {
145 return Err(GraphError::UnexpectedManifestVersion {
146 path: path.clone(),
147 expected_from: bump.from.clone(),
148 expected_to: bump.to.clone(),
149 found: current,
150 });
151 }
152 }
153
154 for rewrite_idx in &group.rewrite_indices {
155 let rewrite = &plan.rewrites[*rewrite_idx];
156 handle.update_dependency_spec(
157 &rewrite.key.name,
158 rewrite.key.kind.unwrap_or(callisto_model::DepKind::Runtime),
159 rewrite.to.clone(),
160 permit,
161 )?;
162 mutated = true;
163 }
164
165 if mutated {
166 handle.persist(permit)?;
167 }
168 modified_paths.push(path.clone());
169 }
170
171 for bump in &plan.bumps {
172 for write in &bump.writes {
173 match write {
174 VersionWriteTarget::Manifest(p) => {
175 if !classification.excluded.contains(p) {
176 continue;
177 }
178 let fmt = callisto_model::ManifestFormat::from_path(p)?;
179 let decl = callisto_model::ManifestDecl::new(p.clone(), ManifestRole::Canonical, fmt)?;
180 let mut handle = open(&decl, &ctx)?;
181 let current = handle.current_version()?;
182 if current == bump.to {
183 } else if current == bump.from {
185 handle.write_version(&bump.to, permit)?;
186 handle.persist(permit)?;
187 } else {
188 return Err(GraphError::UnexpectedManifestVersion {
189 path: p.clone(),
190 expected_from: bump.from.clone(),
191 expected_to: bump.to.clone(),
192 found: current,
193 });
194 }
195 modified_paths.push(p.clone());
196 }
197 VersionWriteTarget::CargoWorkspacePackage { root_manifest } => {
198 let mut ws_res = WorkspaceCargoResolver::load(&root.join(root_manifest))?;
199 ws_res.write_version(&bump.to, permit)?;
200 modified_paths.push(root_manifest.clone());
201 }
202 }
203 }
204 }
205
206 for rewrite in &plan.rewrites {
207 match &rewrite.key.target {
208 DepWriteTarget::Manifest(p) => {
209 if !classification.excluded.contains(p) {
210 continue;
211 }
212 let fmt = callisto_model::ManifestFormat::from_path(p)?;
213 let decl = callisto_model::ManifestDecl::new(p.clone(), ManifestRole::Canonical, fmt)?;
214 let mut handle = open(&decl, &ctx)?;
215 handle.update_dependency_spec(
216 &rewrite.key.name,
217 rewrite.key.kind.unwrap_or(callisto_model::DepKind::Runtime),
218 rewrite.to.clone(),
219 permit,
220 )?;
221 handle.persist(permit)?;
222 modified_paths.push(p.clone());
223 }
224 DepWriteTarget::CargoWorkspaceDependency { root_manifest } => {
225 let mut ws_res = WorkspaceCargoResolver::load(&root.join(root_manifest))?;
226 ws_res.write_dependency(&rewrite.key.name, rewrite.to.clone(), permit)?;
227 modified_paths.push(root_manifest.clone());
228 }
229 }
230 }
231
232 for pw in &plan.platform_writes {
233 let fmt = callisto_model::ManifestFormat::from_path(&pw.manifest)?;
234 let decl = callisto_model::ManifestDecl::new(pw.manifest.clone(), ManifestRole::Canonical, fmt)?;
235 let mut handle = open(&decl, &ctx)?;
236 let current = handle.current_version()?;
237 if current == pw.version {
238 } else if current == pw.from {
240 handle.write_version(&pw.version, permit)?;
241 handle.persist(permit)?;
242 } else {
243 return Err(GraphError::UnexpectedManifestVersion {
244 path: pw.manifest.clone(),
245 expected_from: pw.from.clone(),
246 expected_to: pw.version.clone(),
247 found: current,
248 });
249 }
250 modified_paths.push(pw.manifest.clone());
251 }
252
253 for opt in &plan.optional_dep_updates {
254 let fmt = callisto_model::ManifestFormat::from_path(&opt.manifest)?;
255 let decl = callisto_model::ManifestDecl::new(opt.manifest.clone(), ManifestRole::Canonical, fmt)?;
256 let mut handle = open(&decl, &ctx)?;
257 handle.update_optional_dependencies(&opt.updates, permit)?;
258 handle.persist(permit)?;
259 modified_paths.push(opt.manifest.clone());
260 }
261
262 if !opts.transient {
263 for cl in &plan.changelog_writes {
264 let rendered = callisto_changelog::render_section(&cl.input)?;
265 callisto_changelog::prepend(
266 root,
267 &cl.changelog_path,
268 &cl.input.package.display_name(),
269 &rendered,
270 permit,
271 )?;
272 modified_paths.push(cl.changelog_path.clone());
273 }
274
275 for cs_path in &plan.consumed_changesets {
276 let full = root.join(cs_path);
277 if full.exists() {
278 fs::remove_file(&full).map_err(|e| {
279 GraphError::Command(CommandError::Io {
280 program: "fs".to_string(),
281 message: e.to_string(),
282 })
283 })?;
284 }
285 modified_paths.push(cs_path.clone());
286 }
287
288 if let Some(ref pre_state) = plan.pre_state_update {
289 let default_dir = PathBuf::from(".changeset");
290 let pre_dir = plan
291 .consumed_changesets
292 .first()
293 .and_then(|p| p.parent())
294 .unwrap_or(&default_dir);
295 let rel_pre_path = pre_dir.join("pre.json");
296 let pre_path = root.join(&rel_pre_path);
297 let text = callisto_format::write_pre_json(pre_state);
298 callisto_manifests::atomic::atomic_write(&pre_path, &text, permit).map_err(|e| {
299 GraphError::Command(CommandError::Io {
300 program: "fs".to_string(),
301 message: e.to_string(),
302 })
303 })?;
304 modified_paths.push(rel_pre_path);
305 } else if let Some(rel_pre_path) = &plan.delete_pre_json {
306 let pre_path = root.join(rel_pre_path);
307 if pre_path.exists() {
308 fs::remove_file(&pre_path).map_err(|e| {
309 GraphError::Command(CommandError::Io {
310 program: "fs".to_string(),
311 message: e.to_string(),
312 })
313 })?;
314 modified_paths.push(rel_pre_path.clone());
315 }
316 }
317 }
318
319 use callisto_model::Ecosystem;
323 let active_ecosystems: std::collections::HashSet<Ecosystem> = plan
324 .bumps
325 .iter()
326 .filter_map(|b| {
327 if let Some(eco) = b.package.ecosystem() {
330 return Some(eco);
331 }
332 for write in &b.writes {
334 let eco = match write {
335 VersionWriteTarget::CargoWorkspacePackage { .. } => Ecosystem::Cargo,
336 VersionWriteTarget::Manifest(p) => match callisto_model::ManifestFormat::from_path(p) {
337 Ok(fmt) => fmt.ecosystem(),
338 Err(_) => continue,
339 },
340 };
341 return Some(eco);
342 }
343 None
344 })
345 .chain(
346 plan.platform_writes
347 .iter()
348 .filter_map(|pw| callisto_model::ManifestFormat::from_path(&pw.manifest).ok())
349 .map(|fmt| fmt.ecosystem()),
350 )
351 .chain(
352 plan.optional_dep_updates
353 .iter()
354 .filter_map(|opt| callisto_model::ManifestFormat::from_path(&opt.manifest).ok())
355 .map(|fmt| fmt.ecosystem()),
356 )
357 .collect();
358
359 if !opts.transient && opts.refresh_lockfiles {
363 let mut refresh_results: Vec<LockfileRefreshResult> = Vec::new();
364
365 if active_ecosystems.contains(&Ecosystem::Cargo) {
366 let out = runner
367 .run("cargo", &["update", "--workspace"], root)
368 .unwrap_or_else(|e| CommandOutput {
369 exit_code: None,
370 stdout: String::new(),
371 stderr: e.to_string(),
372 });
373 refresh_results.push(LockfileRefreshResult {
374 filename: PathBuf::from("Cargo.lock"),
375 refresh_command: "cargo update --workspace".to_string(),
376 success: out.success(),
377 exit_code: out.exit_code,
378 });
379 }
380
381 if active_ecosystems.contains(&Ecosystem::Pypi) {
382 if root.join("uv.lock").exists() {
383 let out = runner.run("uv", &["lock"], root).unwrap_or_else(|e| CommandOutput {
384 exit_code: None,
385 stdout: String::new(),
386 stderr: e.to_string(),
387 });
388 refresh_results.push(LockfileRefreshResult {
389 filename: PathBuf::from("uv.lock"),
390 refresh_command: "uv lock".to_string(),
391 success: out.success(),
392 exit_code: out.exit_code,
393 });
394 } else if root.join("poetry.lock").exists() {
395 let out = runner
396 .run("poetry", &["lock", "--no-update"], root)
397 .unwrap_or_else(|e| CommandOutput {
398 exit_code: None,
399 stdout: String::new(),
400 stderr: e.to_string(),
401 });
402 refresh_results.push(LockfileRefreshResult {
403 filename: PathBuf::from("poetry.lock"),
404 refresh_command: "poetry lock --no-update".to_string(),
405 success: out.success(),
406 exit_code: out.exit_code,
407 });
408 }
409 }
410
411 if !refresh_results.is_empty() {
412 outcome.lockfile_refresh_results = Some(refresh_results);
413 }
414 }
415
416 let lockfile_ecosystems: &[(&str, Ecosystem)] = &[
419 ("Cargo.lock", Ecosystem::Cargo),
420 ("package-lock.json", Ecosystem::Npm),
421 ("pnpm-lock.yaml", Ecosystem::Npm),
422 ("yarn.lock", Ecosystem::Npm),
423 ("bun.lockb", Ecosystem::Npm),
424 ("uv.lock", Ecosystem::Pypi),
425 ("poetry.lock", Ecosystem::Pypi),
426 ("pdm.lock", Ecosystem::Pypi),
427 ("Pipfile.lock", Ecosystem::Pypi),
428 ];
429 for (lockfile, ecosystem) in lockfile_ecosystems {
430 if !active_ecosystems.contains(ecosystem) {
431 continue;
432 }
433 let p = PathBuf::from(lockfile);
434 if root.join(&p).exists() && !modified_paths.contains(&p) {
435 modified_paths.push(p);
436 }
437 }
438
439 if !opts.transient && !modified_paths.is_empty() {
440 let (existing, deleted): (Vec<_>, Vec<_>) = modified_paths.iter().partition(|p| root.join(p).exists());
441
442 if !existing.is_empty() {
443 let mut args = vec!["add", "--"];
444 let strs: Vec<String> = existing.iter().map(|p| p.display().to_string()).collect();
445 for s in &strs {
446 args.push(s);
447 }
448 let output = runner.run("git", &args, root)?;
449 if !output.success() {
450 return Err(GraphError::Command(CommandError::Failed {
451 program: "git".to_string(),
452 exit_code: output.exit_code,
453 stderr: redact_git_stderr(&output.stderr),
454 }));
455 }
456 }
457
458 if !deleted.is_empty() {
459 let mut args = vec!["rm", "--cached", "--ignore-unmatch", "--"];
460 let strs: Vec<String> = deleted.iter().map(|p| p.display().to_string()).collect();
461 for s in &strs {
462 args.push(s);
463 }
464 let output = runner.run("git", &args, root)?;
465 if !output.success() {
466 return Err(GraphError::Command(CommandError::Failed {
467 program: "git".to_string(),
468 exit_code: output.exit_code,
469 stderr: redact_git_stderr(&output.stderr),
470 }));
471 }
472 }
473
474 outcome.staged = modified_paths;
475 }
476
477 Ok(outcome)
478}
479
480pub(crate) fn redact_git_stderr(text: &str) -> String {
491 callisto_model::redact_known_secrets(text, &callisto_model::known_credential_env_values(std::env::vars()))
492}
493
494#[cfg(test)]
499mod redact_git_stderr_tests {
500 use super::redact_git_stderr;
501
502 #[test]
503 fn strips_authenticated_remote_url_userinfo() {
504 let stderr = "fatal: unable to access 'https://x-access-token:ghs_supersecret123@github.com/org/repo.git/': The requested URL returned error: 403";
505 let redacted = redact_git_stderr(stderr);
506 assert!(
507 !redacted.contains("ghs_supersecret123"),
508 "token must not survive redaction, got: {redacted}"
509 );
510 assert!(redacted.contains("[REDACTED]"));
511 }
512}
513
514#[cfg(test)]
515mod tests {
516 use std::path::Path;
517
518 use callisto_model::{
519 ApplyPermit, CommandError, CommandOutput, CommandRunner, ManifestDecl, ManifestFormat, PackageId, Severity,
520 Version, VersionGrammar,
521 };
522
523 use super::*;
524 use crate::plan::{PlannedBump, VersionPlan};
525
526 struct NoopRunner;
528
529 impl CommandRunner for NoopRunner {
530 fn run(&self, _program: &str, _args: &[&str], _cwd: &Path) -> Result<CommandOutput, CommandError> {
531 Ok(CommandOutput {
532 exit_code: Some(0),
533 stdout: String::new(),
534 stderr: String::new(),
535 })
536 }
537 }
538
539 type CallLog = std::sync::Arc<std::sync::Mutex<Vec<(String, Vec<String>)>>>;
540
541 struct RecordingRunner {
543 #[allow(clippy::type_complexity)]
544 calls: CallLog,
545 }
546
547 impl CommandRunner for RecordingRunner {
548 fn run(&self, program: &str, args: &[&str], _cwd: &Path) -> Result<CommandOutput, CommandError> {
549 self.calls
550 .lock()
551 .unwrap()
552 .push((program.to_string(), args.iter().map(|s| s.to_string()).collect()));
553 Ok(CommandOutput {
554 exit_code: Some(0),
555 stdout: String::new(),
556 stderr: String::new(),
557 })
558 }
559 }
560
561 fn cargo_version(v: &str) -> Version {
562 Version::parse(v, VersionGrammar::SemVer).expect("valid semver")
563 }
564
565 #[test]
569 fn batched_group_bump_precondition_failure_skips_all_rewrites_in_group() {
570 let dir = tempfile::tempdir().unwrap();
571 let root = dir.path();
572 let cargo_toml_path = root.join("Cargo.toml");
573 let original = "[package]\nname = \"my-crate\"\nversion = \"2.0.0\"\nedition = \"2021\"\n\n[dependencies]\nhelper = \"1.0.0\"\nother = \"1.0.0\"\n";
574 std::fs::write(&cargo_toml_path, original).unwrap();
575
576 let manifest_rel = PathBuf::from("Cargo.toml");
577 let plan = VersionPlan {
578 bumps: vec![PlannedBump {
579 package: PackageId::parse("cargo:my-crate").unwrap(),
580 from: cargo_version("1.0.0"),
581 to: cargo_version("1.1.0"),
582 severity: Severity::Minor,
583 governed_by: None,
584 reason: None,
585 writes: vec![VersionWriteTarget::Manifest(manifest_rel.clone())],
586 }],
587 rewrites: vec![
588 crate::cascade::SpecRewrite {
589 key: crate::cascade::RewriteKey {
590 target: DepWriteTarget::Manifest(manifest_rel.clone()),
591 name: "helper".to_string(),
592 kind: Some(callisto_model::DepKind::Runtime),
593 },
594 dependency: PackageId::parse("cargo:helper").unwrap(),
595 from: callisto_model::DepSpec::Range(
596 callisto_model::VersionReq::parse("^1.0.0", callisto_model::Ecosystem::Cargo).unwrap(),
597 "^1.0.0".to_string(),
598 ),
599 to: callisto_model::DepSpec::Range(
600 callisto_model::VersionReq::parse("^1.1.0", callisto_model::Ecosystem::Cargo).unwrap(),
601 "^1.1.0".to_string(),
602 ),
603 },
604 crate::cascade::SpecRewrite {
605 key: crate::cascade::RewriteKey {
606 target: DepWriteTarget::Manifest(manifest_rel.clone()),
607 name: "other".to_string(),
608 kind: Some(callisto_model::DepKind::Runtime),
609 },
610 dependency: PackageId::parse("cargo:other").unwrap(),
611 from: callisto_model::DepSpec::Range(
612 callisto_model::VersionReq::parse("^1.0.0", callisto_model::Ecosystem::Cargo).unwrap(),
613 "^1.0.0".to_string(),
614 ),
615 to: callisto_model::DepSpec::Range(
616 callisto_model::VersionReq::parse("^1.1.0", callisto_model::Ecosystem::Cargo).unwrap(),
617 "^1.1.0".to_string(),
618 ),
619 },
620 ],
621 ..Default::default()
622 };
623
624 let permit = ApplyPermit::force_for_tests();
625 let opts = ApplyOptions::default();
626 let result = apply_version_plan(root, &plan, &NoopRunner, &opts, &permit);
627
628 assert!(
629 matches!(result, Err(GraphError::UnexpectedManifestVersion { .. })),
630 "bump precondition failure must return UnexpectedManifestVersion; got: {result:?}"
631 );
632
633 let on_disk = std::fs::read_to_string(&cargo_toml_path).unwrap();
634 assert_eq!(
635 on_disk, original,
636 "manifest must be byte-for-byte unchanged: no rewrite in the group may be applied and persist must never be called when the bump precondition fails"
637 );
638 }
639
640 #[test]
644 fn batched_group_skipped_bump_still_writes_a_successful_rewrite_to_disk() {
645 let dir = tempfile::tempdir().unwrap();
646 let root = dir.path();
647 let cargo_toml_path = root.join("Cargo.toml");
648 let original = "[package]\nname = \"my-crate\"\nversion = \"1.1.0\"\nedition = \"2021\"\n\n[dependencies]\nhelper = \"1.0.0\"\n";
649 std::fs::write(&cargo_toml_path, original).unwrap();
650
651 let manifest_rel = PathBuf::from("Cargo.toml");
652 let plan = VersionPlan {
653 bumps: vec![PlannedBump {
654 package: PackageId::parse("cargo:my-crate").unwrap(),
655 from: cargo_version("1.0.0"),
656 to: cargo_version("1.1.0"),
657 severity: Severity::Minor,
658 governed_by: None,
659 reason: None,
660 writes: vec![VersionWriteTarget::Manifest(manifest_rel.clone())],
661 }],
662 rewrites: vec![crate::cascade::SpecRewrite {
663 key: crate::cascade::RewriteKey {
664 target: DepWriteTarget::Manifest(manifest_rel.clone()),
665 name: "helper".to_string(),
666 kind: Some(callisto_model::DepKind::Runtime),
667 },
668 dependency: PackageId::parse("cargo:helper").unwrap(),
669 from: callisto_model::DepSpec::Range(
670 callisto_model::VersionReq::parse("^1.0.0", callisto_model::Ecosystem::Cargo).unwrap(),
671 "^1.0.0".to_string(),
672 ),
673 to: callisto_model::DepSpec::Range(
674 callisto_model::VersionReq::parse("^1.2.0", callisto_model::Ecosystem::Cargo).unwrap(),
675 "^1.2.0".to_string(),
676 ),
677 }],
678 ..Default::default()
679 };
680
681 let permit = ApplyPermit::force_for_tests();
682 let opts = ApplyOptions::default();
683 let result = apply_version_plan(root, &plan, &NoopRunner, &opts, &permit);
684 assert!(result.is_ok(), "apply_version_plan should succeed: {result:?}");
685
686 let on_disk = std::fs::read_to_string(&cargo_toml_path).unwrap();
687 assert!(
688 on_disk.contains("version = \"1.1.0\""),
689 "version must remain at the already-correct target"
690 );
691 assert!(
692 on_disk.contains("helper = \"^1.2.0\""),
693 "the rewrite's mutation must not be silently dropped"
694 );
695 }
696
697 #[test]
701 fn cargo_only_bump_does_not_stage_python_lockfile() {
702 let dir = tempfile::tempdir().expect("create tempdir");
703 let root = dir.path();
704
705 std::fs::write(root.join("Cargo.lock"), "# fake Cargo.lock").unwrap();
707 std::fs::write(root.join("uv.lock"), "# fake uv.lock").unwrap();
708
709 let plan = VersionPlan {
710 bumps: vec![PlannedBump {
711 package: PackageId::parse("cargo:my-crate").expect("valid package id"),
712 from: cargo_version("1.0.0"),
713 to: cargo_version("1.1.0"),
714 severity: Severity::Minor,
715 governed_by: None,
716 reason: None,
717 writes: vec![], }],
719 ..Default::default()
720 };
721
722 let permit = ApplyPermit::force_for_tests();
723 let opts = ApplyOptions::default();
724 let outcome =
725 apply_version_plan(root, &plan, &NoopRunner, &opts, &permit).expect("apply_version_plan should succeed");
726
727 let staged_names: Vec<&str> = outcome.staged.iter().filter_map(|p| p.to_str()).collect();
728
729 assert!(
730 staged_names.contains(&"Cargo.lock"),
731 "Cargo.lock should be staged when a Cargo package is bumped, got: {staged_names:?}"
732 );
733 assert!(
734 !staged_names.contains(&"uv.lock"),
735 "uv.lock must NOT be staged when no Python package is bumped, got: {staged_names:?}"
736 );
737 }
738
739 struct LeakyGitRunner;
743
744 impl CommandRunner for LeakyGitRunner {
745 fn run(&self, _program: &str, _args: &[&str], _cwd: &Path) -> Result<CommandOutput, CommandError> {
746 Ok(CommandOutput {
747 exit_code: Some(128),
748 stdout: String::new(),
749 stderr: "fatal: unable to access 'https://x-access-token:ghs_leaked_secret@github.com/org/repo.git/': The requested URL returned error: 403".to_string(),
750 })
751 }
752 }
753
754 #[test]
757 fn git_add_staging_failure_redacts_credential_from_error() {
758 let dir = tempfile::tempdir().expect("create tempdir");
759 let root = dir.path();
760 std::fs::write(root.join("Cargo.lock"), "# fake Cargo.lock").unwrap();
761
762 let plan = VersionPlan {
763 bumps: vec![PlannedBump {
764 package: PackageId::parse("cargo:my-crate").expect("valid package id"),
765 from: cargo_version("1.0.0"),
766 to: cargo_version("1.1.0"),
767 severity: Severity::Minor,
768 governed_by: None,
769 reason: None,
770 writes: vec![],
771 }],
772 ..Default::default()
773 };
774
775 let permit = ApplyPermit::force_for_tests();
776 let opts = ApplyOptions::default();
777 let err = apply_version_plan(root, &plan, &LeakyGitRunner, &opts, &permit)
778 .expect_err("git add failure must surface as an Err");
779
780 let rendered = format!("{err}");
781 assert!(
782 !rendered.contains("ghs_leaked_secret"),
783 "credential must not survive redaction, got: {rendered}"
784 );
785 assert!(rendered.contains("[REDACTED]"), "got: {rendered}");
786 }
787
788 #[test]
795 fn refresh_lockfiles_calls_cargo_update_workspace_when_cargo_bumped() {
796 let dir = tempfile::tempdir().expect("create tempdir");
797 let root = dir.path();
798 std::fs::write(root.join("Cargo.lock"), "# stale lock").unwrap();
799
800 let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
801 let runner = RecordingRunner {
802 calls: std::sync::Arc::clone(&calls),
803 };
804
805 let plan = VersionPlan {
806 bumps: vec![PlannedBump {
807 package: PackageId::parse("cargo:my-crate").expect("valid package id"),
808 from: cargo_version("1.0.0"),
809 to: cargo_version("1.1.0"),
810 severity: Severity::Minor,
811 governed_by: None,
812 reason: None,
813 writes: vec![],
814 }],
815 ..Default::default()
816 };
817
818 let permit = ApplyPermit::force_for_tests();
819 let opts = ApplyOptions {
820 refresh_lockfiles: true,
821 transient: false,
822 };
823 let outcome =
824 apply_version_plan(root, &plan, &runner, &opts, &permit).expect("apply_version_plan should succeed");
825
826 let recorded = calls.lock().unwrap().clone();
827 let cargo_update_called = recorded
828 .iter()
829 .any(|(prog, args)| prog == "cargo" && args.iter().any(|a| a == "update"));
830 assert!(
831 cargo_update_called,
832 "cargo update must be called when refresh_lockfiles=true and Cargo package is bumped; calls: {recorded:?}"
833 );
834
835 let refresh_results = outcome
836 .lockfile_refresh_results
837 .expect("lockfile_refresh_results must be Some when refresh ran");
838 assert!(
839 refresh_results.iter().any(|r| r.filename.as_os_str() == "Cargo.lock"),
840 "Cargo.lock must appear in lockfile_refresh_results; got: {refresh_results:?}"
841 );
842 }
843
844 #[test]
848 fn refresh_lockfiles_false_does_not_call_cargo_update() {
849 let dir = tempfile::tempdir().expect("create tempdir");
850 let root = dir.path();
851 std::fs::write(root.join("Cargo.lock"), "# lock").unwrap();
852
853 let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
854 let runner = RecordingRunner {
855 calls: std::sync::Arc::clone(&calls),
856 };
857
858 let plan = VersionPlan {
859 bumps: vec![PlannedBump {
860 package: PackageId::parse("cargo:my-crate").expect("valid package id"),
861 from: cargo_version("1.0.0"),
862 to: cargo_version("1.1.0"),
863 severity: Severity::Minor,
864 governed_by: None,
865 reason: None,
866 writes: vec![],
867 }],
868 ..Default::default()
869 };
870
871 let permit = ApplyPermit::force_for_tests();
872 let opts = ApplyOptions::default(); let outcome =
874 apply_version_plan(root, &plan, &runner, &opts, &permit).expect("apply_version_plan should succeed");
875
876 let recorded = calls.lock().unwrap().clone();
877 let cargo_update_called = recorded
878 .iter()
879 .any(|(prog, args)| prog == "cargo" && args.iter().any(|a| a == "update"));
880 assert!(
881 !cargo_update_called,
882 "cargo update must NOT be called when refresh_lockfiles=false; calls: {recorded:?}"
883 );
884 assert!(
885 outcome.lockfile_refresh_results.is_none(),
886 "lockfile_refresh_results must be None when refresh_lockfiles=false"
887 );
888 }
889
890 #[test]
898 fn apply_is_idempotent_when_manifest_already_at_target_version() {
899 let dir = tempfile::tempdir().expect("create tempdir");
900 let root = dir.path();
901
902 let cargo_toml_path = root.join("Cargo.toml");
904 std::fs::write(
905 &cargo_toml_path,
906 "[package]\nname = \"my-crate\"\nversion = \"1.1.0\"\nedition = \"2021\"\n",
907 )
908 .unwrap();
909
910 let changeset_dir = root.join(".changeset");
912 std::fs::create_dir_all(&changeset_dir).unwrap();
913 std::fs::write(changeset_dir.join("my-change.md"), "---\nmy-crate: minor\n---\n").unwrap();
914
915 let manifest_rel = PathBuf::from("Cargo.toml");
916 let cs_rel = PathBuf::from(".changeset/my-change.md");
917
918 let plan = VersionPlan {
919 bumps: vec![PlannedBump {
920 package: PackageId::parse("cargo:my-crate").expect("valid id"),
921 from: cargo_version("1.0.0"),
922 to: cargo_version("1.1.0"),
923 severity: Severity::Minor,
924 governed_by: None,
925 reason: None,
926 writes: vec![VersionWriteTarget::Manifest(manifest_rel.clone())],
927 }],
928 consumed_changesets: vec![cs_rel.clone()],
929 ..Default::default()
930 };
931
932 let permit = ApplyPermit::force_for_tests();
933 let opts = ApplyOptions::default();
934 let outcome = apply_version_plan(root, &plan, &NoopRunner, &opts, &permit)
935 .expect("apply_version_plan must succeed when manifest is already at target version");
936
937 let content = std::fs::read_to_string(&cargo_toml_path).unwrap();
939 assert!(
940 content.contains("version = \"1.1.0\""),
941 "manifest must remain at 1.1.0 after idempotent apply; content: {content}"
942 );
943
944 assert!(
946 !root.join(&cs_rel).exists(),
947 "changeset file must be deleted even on an idempotent apply"
948 );
949
950 assert!(
952 outcome.staged.contains(&manifest_rel),
953 "Cargo.toml must be in staged even when the write was skipped; staged: {:?}",
954 outcome.staged
955 );
956
957 assert!(
959 outcome.staged.contains(&cs_rel),
960 "changeset path must be in staged so git rm --cached runs; staged: {:?}",
961 outcome.staged
962 );
963 }
964
965 #[test]
969 fn apply_returns_error_when_manifest_has_unexpected_version() {
970 let dir = tempfile::tempdir().expect("create tempdir");
971 let root = dir.path();
972
973 let cargo_toml_path = root.join("Cargo.toml");
975 std::fs::write(
976 &cargo_toml_path,
977 "[package]\nname = \"my-crate\"\nversion = \"2.0.0\"\nedition = \"2021\"\n",
978 )
979 .unwrap();
980
981 let manifest_rel = PathBuf::from("Cargo.toml");
982 let plan = VersionPlan {
983 bumps: vec![PlannedBump {
984 package: PackageId::parse("cargo:my-crate").expect("valid id"),
985 from: cargo_version("1.0.0"),
986 to: cargo_version("1.1.0"),
987 severity: Severity::Minor,
988 governed_by: None,
989 reason: None,
990 writes: vec![VersionWriteTarget::Manifest(manifest_rel.clone())],
991 }],
992 ..Default::default()
993 };
994
995 let permit = ApplyPermit::force_for_tests();
996 let opts = ApplyOptions::default();
997 let result = apply_version_plan(root, &plan, &NoopRunner, &opts, &permit);
998
999 assert!(
1000 matches!(result, Err(GraphError::UnexpectedManifestVersion { .. })),
1001 "apply must fail with UnexpectedManifestVersion when manifest is at an unexpected \
1002 version; got: {result:?}"
1003 );
1004
1005 let content = std::fs::read_to_string(&cargo_toml_path).unwrap();
1007 assert!(
1008 content.contains("version = \"2.0.0\""),
1009 "manifest must not be modified when apply fails; content: {content}"
1010 );
1011 }
1012
1013 #[test]
1018 fn apply_stages_changeset_path_even_when_file_already_deleted() {
1019 let dir = tempfile::tempdir().expect("create tempdir");
1020 let root = dir.path();
1021
1022 let cargo_toml_path = root.join("Cargo.toml");
1024 std::fs::write(
1025 &cargo_toml_path,
1026 "[package]\nname = \"my-crate\"\nversion = \"1.1.0\"\nedition = \"2021\"\n",
1027 )
1028 .unwrap();
1029
1030 let cs_rel = PathBuf::from(".changeset/deleted-change.md");
1032 assert!(
1033 !root.join(&cs_rel).exists(),
1034 "changeset file must not exist at test start"
1035 );
1036
1037 let manifest_rel = PathBuf::from("Cargo.toml");
1038 let plan = VersionPlan {
1039 bumps: vec![PlannedBump {
1040 package: PackageId::parse("cargo:my-crate").expect("valid id"),
1041 from: cargo_version("1.0.0"),
1042 to: cargo_version("1.1.0"),
1043 severity: Severity::Minor,
1044 governed_by: None,
1045 reason: None,
1046 writes: vec![VersionWriteTarget::Manifest(manifest_rel.clone())],
1047 }],
1048 consumed_changesets: vec![cs_rel.clone()],
1049 ..Default::default()
1050 };
1051
1052 let permit = ApplyPermit::force_for_tests();
1053 let opts = ApplyOptions::default();
1054 let outcome =
1055 apply_version_plan(root, &plan, &NoopRunner, &opts, &permit).expect("apply_version_plan must succeed");
1056
1057 assert!(
1059 outcome.staged.contains(&cs_rel),
1060 "changeset path must be in staged even when file is already deleted; staged: {:?}",
1061 outcome.staged
1062 );
1063 }
1064
1065 #[test]
1066 fn apply_persists_bumps_loop_write_version_to_disk() {
1067 let dir = tempfile::tempdir().expect("create tempdir");
1068 let root = dir.path();
1069 let cargo_toml_path = root.join("Cargo.toml");
1070 std::fs::write(
1071 &cargo_toml_path,
1072 "[package]\nname = \"my-crate\"\nversion = \"1.0.0\"\nedition = \"2021\"\n",
1073 )
1074 .unwrap();
1075
1076 let manifest_rel = PathBuf::from("Cargo.toml");
1077 let plan = VersionPlan {
1078 bumps: vec![PlannedBump {
1079 package: PackageId::parse("cargo:my-crate").expect("valid id"),
1080 from: cargo_version("1.0.0"),
1081 to: cargo_version("1.1.0"),
1082 severity: Severity::Minor,
1083 governed_by: None,
1084 reason: None,
1085 writes: vec![VersionWriteTarget::Manifest(manifest_rel.clone())],
1086 }],
1087 ..Default::default()
1088 };
1089
1090 let permit = ApplyPermit::force_for_tests();
1091 let opts = ApplyOptions::default();
1092 let result = apply_version_plan(root, &plan, &NoopRunner, &opts, &permit);
1093 assert!(result.is_ok(), "apply_version_plan should succeed: {result:?}");
1094
1095 let on_disk = std::fs::read_to_string(&cargo_toml_path).unwrap();
1096 assert!(
1097 on_disk.contains("version = \"1.1.0\""),
1098 "bumps loop must persist write_version's mutation to disk; got:\n{on_disk}"
1099 );
1100 }
1101
1102 #[test]
1103 fn apply_persists_rewrites_loop_update_dependency_spec_to_disk() {
1104 let dir = tempfile::tempdir().expect("create tempdir");
1105 let root = dir.path();
1106 let cargo_toml_path = root.join("Cargo.toml");
1107 std::fs::write(
1108 &cargo_toml_path,
1109 "[package]\nname = \"my-crate\"\nversion = \"1.0.0\"\nedition = \"2021\"\n\n[dependencies]\nhelper = \"1.0.0\"\n",
1110 )
1111 .unwrap();
1112
1113 let manifest_rel = PathBuf::from("Cargo.toml");
1114 let key = crate::cascade::RewriteKey {
1115 target: DepWriteTarget::Manifest(manifest_rel.clone()),
1116 name: "helper".to_string(),
1117 kind: Some(callisto_model::DepKind::Runtime),
1118 };
1119 let plan = VersionPlan {
1120 rewrites: vec![crate::cascade::SpecRewrite {
1121 key: key.clone(),
1122 dependency: PackageId::parse("cargo:helper").expect("valid id"),
1123 from: callisto_model::DepSpec::Range(
1124 callisto_model::VersionReq::parse("^1.0.0", callisto_model::Ecosystem::Cargo).unwrap(),
1125 "^1.0.0".to_string(),
1126 ),
1127 to: callisto_model::DepSpec::Range(
1128 callisto_model::VersionReq::parse("^1.1.0", callisto_model::Ecosystem::Cargo).unwrap(),
1129 "^1.1.0".to_string(),
1130 ),
1131 }],
1132 ..Default::default()
1133 };
1134
1135 let permit = ApplyPermit::force_for_tests();
1136 let opts = ApplyOptions::default();
1137 let result = apply_version_plan(root, &plan, &NoopRunner, &opts, &permit);
1138 assert!(result.is_ok(), "apply_version_plan should succeed: {result:?}");
1139
1140 let on_disk = std::fs::read_to_string(&cargo_toml_path).unwrap();
1141 assert!(
1142 on_disk.contains("helper = \"^1.1.0\""),
1143 "rewrites loop must persist update_dependency_spec's mutation to disk; got:\n{on_disk}"
1144 );
1145 }
1146
1147 #[test]
1148 fn rewrites_loop_update_dependency_spec_error_leaves_manifest_untouched_and_skips_persist() {
1149 let dir = tempfile::tempdir().expect("create tempdir");
1150 let root = dir.path();
1151 let cargo_toml_path = root.join("Cargo.toml");
1152 let original = "[package]\nname = \"my-crate\"\nversion = \"1.0.0\"\nedition = \"2021\"\n";
1153 std::fs::write(&cargo_toml_path, original).unwrap();
1154
1155 let manifest_rel = PathBuf::from("Cargo.toml");
1156 let key = crate::cascade::RewriteKey {
1157 target: DepWriteTarget::Manifest(manifest_rel.clone()),
1158 name: "nonexistent-dep".to_string(),
1159 kind: Some(callisto_model::DepKind::Runtime),
1160 };
1161 let plan = VersionPlan {
1162 rewrites: vec![crate::cascade::SpecRewrite {
1163 key: key.clone(),
1164 dependency: PackageId::parse("cargo:nonexistent-dep").expect("valid id"),
1165 from: callisto_model::DepSpec::Range(
1166 callisto_model::VersionReq::parse("^1.0.0", callisto_model::Ecosystem::Cargo).unwrap(),
1167 "^1.0.0".to_string(),
1168 ),
1169 to: callisto_model::DepSpec::Range(
1170 callisto_model::VersionReq::parse("^1.1.0", callisto_model::Ecosystem::Cargo).unwrap(),
1171 "^1.1.0".to_string(),
1172 ),
1173 }],
1174 ..Default::default()
1175 };
1176
1177 let permit = ApplyPermit::force_for_tests();
1178 let opts = ApplyOptions::default();
1179 let result = apply_version_plan(root, &plan, &NoopRunner, &opts, &permit);
1180
1181 assert!(
1182 matches!(
1183 result,
1184 Err(GraphError::Manifest(
1185 callisto_model::ManifestError::DependencyNotFound { .. }
1186 ))
1187 ),
1188 "missing dependency must propagate as DependencyNotFound; got: {result:?}"
1189 );
1190
1191 let on_disk = std::fs::read_to_string(&cargo_toml_path).unwrap();
1192 assert_eq!(
1193 on_disk, original,
1194 "manifest must be byte-for-byte unchanged when update_dependency_spec errors before persist"
1195 );
1196 }
1197
1198 #[test]
1203 fn apply_version_plan_cargo_bump_produces_byte_identical_output_to_direct_mutate_then_persist() {
1204 let fixture = "[package]\nname = \"my-crate\"\nversion = \"1.0.0\"\nedition = \"2021\"\n";
1205 let manifest_rel = PathBuf::from("Cargo.toml");
1206 let permit = ApplyPermit::force_for_tests();
1207
1208 let dir_a = tempfile::tempdir().expect("create tempdir");
1209 std::fs::write(dir_a.path().join("Cargo.toml"), fixture).unwrap();
1210 let plan = VersionPlan {
1211 bumps: vec![PlannedBump {
1212 package: PackageId::parse("cargo:my-crate").expect("valid id"),
1213 from: cargo_version("1.0.0"),
1214 to: cargo_version("1.1.0"),
1215 severity: Severity::Minor,
1216 governed_by: None,
1217 reason: None,
1218 writes: vec![VersionWriteTarget::Manifest(manifest_rel.clone())],
1219 }],
1220 ..Default::default()
1221 };
1222 let opts = ApplyOptions::default();
1223 let result = apply_version_plan(dir_a.path(), &plan, &NoopRunner, &opts, &permit);
1224 assert!(result.is_ok(), "apply_version_plan should succeed: {result:?}");
1225 let via_apply = std::fs::read_to_string(dir_a.path().join("Cargo.toml")).unwrap();
1226
1227 let dir_b = tempfile::tempdir().expect("create tempdir");
1228 std::fs::write(dir_b.path().join("Cargo.toml"), fixture).unwrap();
1229 let decl = ManifestDecl::new(manifest_rel.clone(), ManifestRole::Canonical, ManifestFormat::CargoToml).unwrap();
1230 let ctx = OpenContext {
1231 workspace_root: dir_b.path(),
1232 cargo_workspace: None,
1233 npm_workspace_kind: None,
1234 };
1235 let mut handle = open(&decl, &ctx).unwrap();
1236 handle.write_version(&cargo_version("1.1.0"), &permit).unwrap();
1237 handle.persist(&permit).unwrap();
1238 let via_direct = std::fs::read_to_string(dir_b.path().join("Cargo.toml")).unwrap();
1239
1240 assert_eq!(
1241 via_apply, via_direct,
1242 "apply_version_plan's on-disk bytes must be byte-identical to a direct open->write_version->persist sequence"
1243 );
1244 }
1245
1246 #[test]
1249 fn apply_version_plan_npm_bump_produces_byte_identical_output_to_direct_mutate_then_persist() {
1250 let fixture = "{\n \"name\": \"@myorg/pkg\",\n \"version\": \"1.0.0\"\n}\n";
1251 let manifest_rel = PathBuf::from("package.json");
1252 let permit = ApplyPermit::force_for_tests();
1253
1254 let dir_a = tempfile::tempdir().expect("create tempdir");
1255 std::fs::write(dir_a.path().join("package.json"), fixture).unwrap();
1256 let plan = VersionPlan {
1257 bumps: vec![PlannedBump {
1258 package: PackageId::parse("npm:@myorg/pkg").expect("valid id"),
1259 from: cargo_version("1.0.0"),
1260 to: cargo_version("1.1.0"),
1261 severity: Severity::Minor,
1262 governed_by: None,
1263 reason: None,
1264 writes: vec![VersionWriteTarget::Manifest(manifest_rel.clone())],
1265 }],
1266 ..Default::default()
1267 };
1268 let opts = ApplyOptions::default();
1269 let result = apply_version_plan(dir_a.path(), &plan, &NoopRunner, &opts, &permit);
1270 assert!(result.is_ok(), "apply_version_plan should succeed: {result:?}");
1271 let via_apply = std::fs::read_to_string(dir_a.path().join("package.json")).unwrap();
1272
1273 let dir_b = tempfile::tempdir().expect("create tempdir");
1274 std::fs::write(dir_b.path().join("package.json"), fixture).unwrap();
1275 let decl = ManifestDecl::new(
1276 manifest_rel.clone(),
1277 ManifestRole::Canonical,
1278 ManifestFormat::PackageJson,
1279 )
1280 .unwrap();
1281 let ctx = OpenContext {
1282 workspace_root: dir_b.path(),
1283 cargo_workspace: None,
1284 npm_workspace_kind: None,
1285 };
1286 let mut handle = open(&decl, &ctx).unwrap();
1287 handle.write_version(&cargo_version("1.1.0"), &permit).unwrap();
1288 handle.persist(&permit).unwrap();
1289 let via_direct = std::fs::read_to_string(dir_b.path().join("package.json")).unwrap();
1290
1291 assert_eq!(
1292 via_apply, via_direct,
1293 "apply_version_plan's on-disk bytes must be byte-identical to a direct open->write_version->persist sequence"
1294 );
1295 }
1296
1297 #[test]
1298 fn bumps_loop_write_version_error_leaves_manifest_untouched_and_skips_persist() {
1299 let dir = tempfile::tempdir().expect("create tempdir");
1300 let root = dir.path();
1301 let cargo_toml_path = root.join("Cargo.toml");
1302 let original = "package = { name = \"my-crate\", version = \"1.0.0\", edition = \"2021\" }\n";
1303 std::fs::write(&cargo_toml_path, original).unwrap();
1304
1305 let decl = ManifestDecl::new("Cargo.toml", ManifestRole::Canonical, ManifestFormat::CargoToml).unwrap();
1311 let ctx = OpenContext {
1312 workspace_root: root,
1313 cargo_workspace: None,
1314 npm_workspace_kind: None,
1315 };
1316 let precondition_handle = open(&decl, &ctx).unwrap();
1317 assert_eq!(
1318 precondition_handle.current_version().unwrap().render(),
1319 "1.0.0",
1320 "fixture must open successfully and current_version() must succeed via the inline table"
1321 );
1322
1323 let manifest_rel = PathBuf::from("Cargo.toml");
1324 let plan = VersionPlan {
1325 bumps: vec![PlannedBump {
1326 package: PackageId::parse("cargo:my-crate").expect("valid id"),
1327 from: cargo_version("1.0.0"),
1328 to: cargo_version("1.1.0"),
1329 severity: Severity::Minor,
1330 governed_by: None,
1331 reason: None,
1332 writes: vec![VersionWriteTarget::Manifest(manifest_rel.clone())],
1333 }],
1334 ..Default::default()
1335 };
1336
1337 let permit = ApplyPermit::force_for_tests();
1338 let opts = ApplyOptions::default();
1339 let result = apply_version_plan(root, &plan, &NoopRunner, &opts, &permit);
1340
1341 assert!(
1342 matches!(
1343 result,
1344 Err(GraphError::Manifest(callisto_model::ManifestError::MissingField { field, .. })) if field == "package"
1345 ),
1346 "write_version must fail at the [package] as_table_mut() step, not earlier in open()/current_version(); got: {result:?}"
1347 );
1348
1349 let on_disk = std::fs::read_to_string(&cargo_toml_path).unwrap();
1350 assert_eq!(
1351 on_disk, original,
1352 "manifest must be byte-for-byte unchanged when write_version errors before persist"
1353 );
1354 }
1355
1356 #[test]
1357 #[cfg(unix)]
1358 fn bumps_loop_persist_failure_leaves_earlier_successful_write_intact_and_later_manifest_unchanged() {
1359 use std::os::unix::fs::PermissionsExt;
1360
1361 let dir = tempfile::tempdir().expect("create tempdir");
1362 let root = dir.path();
1363
1364 let crate_a_path = root.join("Cargo.toml");
1365 let crate_a_original = "[package]\nname = \"crate-a\"\nversion = \"1.0.0\"\nedition = \"2021\"\n";
1366 std::fs::write(&crate_a_path, crate_a_original).unwrap();
1367
1368 let pkg_b_dir = root.join("pkg-b");
1369 std::fs::create_dir_all(&pkg_b_dir).unwrap();
1370 let crate_b_path = pkg_b_dir.join("Cargo.toml");
1371 let crate_b_original = "[package]\nname = \"crate-b\"\nversion = \"1.0.0\"\nedition = \"2021\"\n";
1372 std::fs::write(&crate_b_path, crate_b_original).unwrap();
1373
1374 let crate_a_rel = PathBuf::from("Cargo.toml");
1375 let crate_b_rel = PathBuf::from("pkg-b/Cargo.toml");
1376
1377 let plan = VersionPlan {
1378 bumps: vec![
1379 PlannedBump {
1380 package: PackageId::parse("cargo:crate-a").expect("valid id"),
1381 from: cargo_version("1.0.0"),
1382 to: cargo_version("1.1.0"),
1383 severity: Severity::Minor,
1384 governed_by: None,
1385 reason: None,
1386 writes: vec![VersionWriteTarget::Manifest(crate_a_rel.clone())],
1387 },
1388 PlannedBump {
1389 package: PackageId::parse("cargo:crate-b").expect("valid id"),
1390 from: cargo_version("1.0.0"),
1391 to: cargo_version("1.1.0"),
1392 severity: Severity::Minor,
1393 governed_by: None,
1394 reason: None,
1395 writes: vec![VersionWriteTarget::Manifest(crate_b_rel.clone())],
1396 },
1397 ],
1398 ..Default::default()
1399 };
1400
1401 let permit = ApplyPermit::force_for_tests();
1402 let opts = ApplyOptions::default();
1403
1404 let original_mode = std::fs::metadata(&pkg_b_dir).unwrap().permissions().mode();
1405 std::fs::set_permissions(&pkg_b_dir, std::fs::Permissions::from_mode(0o555)).unwrap();
1406
1407 let probe_path = pkg_b_dir.join(".rtk-write-probe");
1413 let probe_write_succeeded = std::fs::write(&probe_path, b"probe").is_ok();
1414 if probe_write_succeeded {
1415 std::fs::remove_file(&probe_path).ok();
1416 std::fs::set_permissions(&pkg_b_dir, std::fs::Permissions::from_mode(original_mode)).unwrap();
1417 eprintln!(
1418 "skipping bumps_loop_persist_failure_leaves_earlier_successful_write_intact_and_later_manifest_unchanged: \
1419 process can write into a 0o555 directory (likely running as root); chmod-based failure injection is a no-op here"
1420 );
1421 return;
1422 }
1423
1424 let result = apply_version_plan(root, &plan, &NoopRunner, &opts, &permit);
1425
1426 std::fs::set_permissions(&pkg_b_dir, std::fs::Permissions::from_mode(original_mode)).unwrap();
1427
1428 assert!(
1429 matches!(
1430 result,
1431 Err(GraphError::Manifest(callisto_model::ManifestError::Write { .. }))
1432 ),
1433 "persist failure on the second bump must propagate as GraphError::Manifest(ManifestError::Write); got: {result:?}"
1434 );
1435
1436 let crate_a_on_disk = std::fs::read_to_string(&crate_a_path).unwrap();
1437 assert!(
1438 crate_a_on_disk.contains("version = \"1.1.0\""),
1439 "the first bump's successful mutate-then-persist must remain on disk even though the second bump later failed; got:\n{crate_a_on_disk}"
1440 );
1441
1442 let crate_b_on_disk = std::fs::read_to_string(&crate_b_path).unwrap();
1443 assert_eq!(
1444 crate_b_on_disk, crate_b_original,
1445 "the second bump's manifest must be byte-for-byte unchanged when its own persist() fails"
1446 );
1447 }
1448
1449 #[test]
1450 #[cfg(unix)]
1451 fn rewrites_loop_persist_failure_leaves_earlier_successful_write_intact_and_later_manifest_unchanged() {
1452 use std::os::unix::fs::PermissionsExt;
1453
1454 let dir = tempfile::tempdir().expect("create tempdir");
1455 let root = dir.path();
1456
1457 let crate_a_path = root.join("Cargo.toml");
1458 let crate_a_original = "[package]\nname = \"crate-a\"\nversion = \"1.0.0\"\nedition = \"2021\"\n\n[dependencies]\nhelper = \"1.0.0\"\n";
1459 std::fs::write(&crate_a_path, crate_a_original).unwrap();
1460
1461 let pkg_b_dir = root.join("pkg-b");
1462 std::fs::create_dir_all(&pkg_b_dir).unwrap();
1463 let crate_b_path = pkg_b_dir.join("Cargo.toml");
1464 let crate_b_original = "[package]\nname = \"crate-b\"\nversion = \"1.0.0\"\nedition = \"2021\"\n\n[dependencies]\nhelper = \"1.0.0\"\n";
1465 std::fs::write(&crate_b_path, crate_b_original).unwrap();
1466
1467 let crate_a_rel = PathBuf::from("Cargo.toml");
1468 let crate_b_rel = PathBuf::from("pkg-b/Cargo.toml");
1469
1470 let plan = VersionPlan {
1471 rewrites: vec![
1472 crate::cascade::SpecRewrite {
1473 key: crate::cascade::RewriteKey {
1474 target: DepWriteTarget::Manifest(crate_a_rel.clone()),
1475 name: "helper".to_string(),
1476 kind: Some(callisto_model::DepKind::Runtime),
1477 },
1478 dependency: PackageId::parse("cargo:helper").expect("valid id"),
1479 from: callisto_model::DepSpec::Range(
1480 callisto_model::VersionReq::parse("^1.0.0", callisto_model::Ecosystem::Cargo).unwrap(),
1481 "^1.0.0".to_string(),
1482 ),
1483 to: callisto_model::DepSpec::Range(
1484 callisto_model::VersionReq::parse("^1.1.0", callisto_model::Ecosystem::Cargo).unwrap(),
1485 "^1.1.0".to_string(),
1486 ),
1487 },
1488 crate::cascade::SpecRewrite {
1489 key: crate::cascade::RewriteKey {
1490 target: DepWriteTarget::Manifest(crate_b_rel.clone()),
1491 name: "helper".to_string(),
1492 kind: Some(callisto_model::DepKind::Runtime),
1493 },
1494 dependency: PackageId::parse("cargo:helper").expect("valid id"),
1495 from: callisto_model::DepSpec::Range(
1496 callisto_model::VersionReq::parse("^1.0.0", callisto_model::Ecosystem::Cargo).unwrap(),
1497 "^1.0.0".to_string(),
1498 ),
1499 to: callisto_model::DepSpec::Range(
1500 callisto_model::VersionReq::parse("^1.1.0", callisto_model::Ecosystem::Cargo).unwrap(),
1501 "^1.1.0".to_string(),
1502 ),
1503 },
1504 ],
1505 ..Default::default()
1506 };
1507
1508 let permit = ApplyPermit::force_for_tests();
1509 let opts = ApplyOptions::default();
1510
1511 let original_mode = std::fs::metadata(&pkg_b_dir).unwrap().permissions().mode();
1512 std::fs::set_permissions(&pkg_b_dir, std::fs::Permissions::from_mode(0o555)).unwrap();
1513
1514 let probe_path = pkg_b_dir.join(".rtk-write-probe");
1517 let probe_write_succeeded = std::fs::write(&probe_path, b"probe").is_ok();
1518 if probe_write_succeeded {
1519 std::fs::remove_file(&probe_path).ok();
1520 std::fs::set_permissions(&pkg_b_dir, std::fs::Permissions::from_mode(original_mode)).unwrap();
1521 eprintln!(
1522 "skipping rewrites_loop_persist_failure_leaves_earlier_successful_write_intact_and_later_manifest_unchanged: \
1523 process can write into a 0o555 directory (likely running as root); chmod-based failure injection is a no-op here"
1524 );
1525 return;
1526 }
1527
1528 let result = apply_version_plan(root, &plan, &NoopRunner, &opts, &permit);
1529
1530 std::fs::set_permissions(&pkg_b_dir, std::fs::Permissions::from_mode(original_mode)).unwrap();
1531
1532 assert!(
1533 matches!(
1534 result,
1535 Err(GraphError::Manifest(callisto_model::ManifestError::Write { .. }))
1536 ),
1537 "persist failure on the second rewrite must propagate as GraphError::Manifest(ManifestError::Write); got: {result:?}"
1538 );
1539
1540 let crate_a_on_disk = std::fs::read_to_string(&crate_a_path).unwrap();
1541 assert!(
1542 crate_a_on_disk.contains("helper = \"^1.1.0\""),
1543 "the first rewrite's successful mutate-then-persist must remain on disk even though the second rewrite later failed; got:\n{crate_a_on_disk}"
1544 );
1545
1546 let crate_b_on_disk = std::fs::read_to_string(&crate_b_path).unwrap();
1547 assert_eq!(
1548 crate_b_on_disk, crate_b_original,
1549 "the second rewrite's manifest must be byte-for-byte unchanged when its own persist() fails"
1550 );
1551 }
1552
1553 #[test]
1554 fn classify_manifest_writes_partitions_by_path_with_correct_groups() {
1555 let bump_only = PathBuf::from("bump-only/Cargo.toml");
1556 let rewrites_only = PathBuf::from("rewrites-only/Cargo.toml");
1557 let both = PathBuf::from("both/Cargo.toml");
1558
1559 fn spec(v: &str) -> callisto_model::DepSpec {
1560 callisto_model::DepSpec::Range(
1561 callisto_model::VersionReq::parse(v, callisto_model::Ecosystem::Cargo).unwrap(),
1562 v.to_string(),
1563 )
1564 }
1565
1566 let plan = VersionPlan {
1567 bumps: vec![
1568 PlannedBump {
1569 package: PackageId::parse("cargo:bump-only").unwrap(),
1570 from: cargo_version("1.0.0"),
1571 to: cargo_version("1.1.0"),
1572 severity: Severity::Minor,
1573 governed_by: None,
1574 reason: None,
1575 writes: vec![VersionWriteTarget::Manifest(bump_only.clone())],
1576 },
1577 PlannedBump {
1578 package: PackageId::parse("cargo:both").unwrap(),
1579 from: cargo_version("2.0.0"),
1580 to: cargo_version("2.1.0"),
1581 severity: Severity::Minor,
1582 governed_by: None,
1583 reason: None,
1584 writes: vec![VersionWriteTarget::Manifest(both.clone())],
1585 },
1586 ],
1587 rewrites: vec![
1588 crate::cascade::SpecRewrite {
1589 key: crate::cascade::RewriteKey {
1590 target: DepWriteTarget::Manifest(rewrites_only.clone()),
1591 name: "helper".to_string(),
1592 kind: Some(callisto_model::DepKind::Runtime),
1593 },
1594 dependency: PackageId::parse("cargo:helper").unwrap(),
1595 from: spec("^1.0.0"),
1596 to: spec("^1.1.0"),
1597 },
1598 crate::cascade::SpecRewrite {
1599 key: crate::cascade::RewriteKey {
1600 target: DepWriteTarget::Manifest(both.clone()),
1601 name: "other".to_string(),
1602 kind: Some(callisto_model::DepKind::Runtime),
1603 },
1604 dependency: PackageId::parse("cargo:other").unwrap(),
1605 from: spec("^1.0.0"),
1606 to: spec("^1.1.0"),
1607 },
1608 ],
1609 ..Default::default()
1610 };
1611
1612 let classification = classify_manifest_writes(&plan);
1613
1614 assert!(classification.excluded.is_empty());
1615 assert_eq!(classification.batched.len(), 3);
1616
1617 let g = classification.batched.get(&bump_only).unwrap();
1618 assert_eq!(g.bump.as_ref().unwrap().1, cargo_version("1.1.0"));
1619 assert!(g.rewrite_indices.is_empty());
1620
1621 let g = classification.batched.get(&rewrites_only).unwrap();
1622 assert!(g.bump.is_none());
1623 assert_eq!(g.rewrite_indices, vec![0]);
1624
1625 let g = classification.batched.get(&both).unwrap();
1626 assert_eq!(g.bump.as_ref().unwrap().1, cargo_version("2.1.0"));
1627 assert_eq!(g.rewrite_indices, vec![1]);
1628 }
1629
1630 #[test]
1631 fn classify_manifest_writes_excludes_cargo_workspace_package_mixed_path() {
1632 let p = PathBuf::from("Cargo.toml");
1633 let plan = VersionPlan {
1634 bumps: vec![PlannedBump {
1635 package: PackageId::parse("cargo:root-pkg").unwrap(),
1636 from: cargo_version("1.0.0"),
1637 to: cargo_version("1.1.0"),
1638 severity: Severity::Minor,
1639 governed_by: None,
1640 reason: None,
1641 writes: vec![VersionWriteTarget::CargoWorkspacePackage {
1642 root_manifest: p.clone(),
1643 }],
1644 }],
1645 rewrites: vec![crate::cascade::SpecRewrite {
1646 key: crate::cascade::RewriteKey {
1647 target: DepWriteTarget::Manifest(p.clone()),
1648 name: "helper".to_string(),
1649 kind: Some(callisto_model::DepKind::Runtime),
1650 },
1651 dependency: PackageId::parse("cargo:helper").unwrap(),
1652 from: callisto_model::DepSpec::Range(
1653 callisto_model::VersionReq::parse("^1.0.0", callisto_model::Ecosystem::Cargo).unwrap(),
1654 "^1.0.0".to_string(),
1655 ),
1656 to: callisto_model::DepSpec::Range(
1657 callisto_model::VersionReq::parse("^1.1.0", callisto_model::Ecosystem::Cargo).unwrap(),
1658 "^1.1.0".to_string(),
1659 ),
1660 }],
1661 ..Default::default()
1662 };
1663
1664 let classification = classify_manifest_writes(&plan);
1665 assert!(classification.excluded.contains(&p));
1666 assert!(!classification.batched.contains_key(&p));
1667 }
1668
1669 #[test]
1681 fn batched_group_rewrite_failure_leaves_bump_and_first_rewrite_unpersisted() {
1682 let dir = tempfile::tempdir().unwrap();
1683 let root = dir.path();
1684 let cargo_toml_path = root.join("Cargo.toml");
1685 let original = "[package]\nname = \"my-crate\"\nversion = \"1.0.0\"\nedition = \"2021\"\n\n[dependencies]\nhelper = \"1.0.0\"\n";
1686 std::fs::write(&cargo_toml_path, original).unwrap();
1687
1688 let manifest_rel = PathBuf::from("Cargo.toml");
1689 let plan = VersionPlan {
1690 bumps: vec![PlannedBump {
1691 package: PackageId::parse("cargo:my-crate").unwrap(),
1692 from: cargo_version("1.0.0"),
1693 to: cargo_version("1.1.0"),
1694 severity: Severity::Minor,
1695 governed_by: None,
1696 reason: None,
1697 writes: vec![VersionWriteTarget::Manifest(manifest_rel.clone())],
1698 }],
1699 rewrites: vec![
1700 crate::cascade::SpecRewrite {
1701 key: crate::cascade::RewriteKey {
1702 target: DepWriteTarget::Manifest(manifest_rel.clone()),
1703 name: "helper".to_string(),
1704 kind: Some(callisto_model::DepKind::Runtime),
1705 },
1706 dependency: PackageId::parse("cargo:helper").unwrap(),
1707 from: callisto_model::DepSpec::Range(
1708 callisto_model::VersionReq::parse("^1.0.0", callisto_model::Ecosystem::Cargo).unwrap(),
1709 "^1.0.0".to_string(),
1710 ),
1711 to: callisto_model::DepSpec::Range(
1712 callisto_model::VersionReq::parse("^1.1.0", callisto_model::Ecosystem::Cargo).unwrap(),
1713 "^1.1.0".to_string(),
1714 ),
1715 },
1716 crate::cascade::SpecRewrite {
1717 key: crate::cascade::RewriteKey {
1718 target: DepWriteTarget::Manifest(manifest_rel.clone()),
1719 name: "nonexistent-dep".to_string(),
1720 kind: Some(callisto_model::DepKind::Runtime),
1721 },
1722 dependency: PackageId::parse("cargo:nonexistent-dep").unwrap(),
1723 from: callisto_model::DepSpec::Range(
1724 callisto_model::VersionReq::parse("^1.0.0", callisto_model::Ecosystem::Cargo).unwrap(),
1725 "^1.0.0".to_string(),
1726 ),
1727 to: callisto_model::DepSpec::Range(
1728 callisto_model::VersionReq::parse("^1.1.0", callisto_model::Ecosystem::Cargo).unwrap(),
1729 "^1.1.0".to_string(),
1730 ),
1731 },
1732 ],
1733 ..Default::default()
1734 };
1735
1736 let permit = ApplyPermit::force_for_tests();
1737 let opts = ApplyOptions::default();
1738 let result = apply_version_plan(root, &plan, &NoopRunner, &opts, &permit);
1739
1740 assert!(
1741 matches!(
1742 result,
1743 Err(GraphError::Manifest(
1744 callisto_model::ManifestError::DependencyNotFound { .. }
1745 ))
1746 ),
1747 "second rewrite's missing dependency must propagate; got: {result:?}"
1748 );
1749
1750 let on_disk = std::fs::read_to_string(&cargo_toml_path).unwrap();
1751 assert_eq!(
1752 on_disk, original,
1753 "all-or-nothing: neither the bump nor the first rewrite may be persisted when a later rewrite in the same group fails"
1754 );
1755 }
1756
1757 #[test]
1764 #[cfg(unix)]
1765 fn batched_groups_process_strictly_sequentially_in_btreemap_path_order() {
1766 use std::os::unix::fs::PermissionsExt;
1767
1768 let dir = tempfile::tempdir().unwrap();
1769 let root = dir.path();
1770
1771 let a_dir = root.join("a-crate");
1772 std::fs::create_dir_all(&a_dir).unwrap();
1773 let a_path = a_dir.join("Cargo.toml");
1774 let a_original = "[package]\nname = \"crate-a\"\nversion = \"1.0.0\"\nedition = \"2021\"\n";
1775 std::fs::write(&a_path, a_original).unwrap();
1776
1777 let b_dir = root.join("b-crate");
1778 std::fs::create_dir_all(&b_dir).unwrap();
1779 let b_path = b_dir.join("Cargo.toml");
1780 let b_original = "[package]\nname = \"crate-b\"\nversion = \"1.0.0\"\nedition = \"2021\"\n";
1781 std::fs::write(&b_path, b_original).unwrap();
1782
1783 let a_rel = PathBuf::from("a-crate/Cargo.toml");
1784 let b_rel = PathBuf::from("b-crate/Cargo.toml");
1785
1786 let plan = VersionPlan {
1790 bumps: vec![
1791 PlannedBump {
1792 package: PackageId::parse("cargo:crate-b").unwrap(),
1793 from: cargo_version("1.0.0"),
1794 to: cargo_version("1.1.0"),
1795 severity: Severity::Minor,
1796 governed_by: None,
1797 reason: None,
1798 writes: vec![VersionWriteTarget::Manifest(b_rel.clone())],
1799 },
1800 PlannedBump {
1801 package: PackageId::parse("cargo:crate-a").unwrap(),
1802 from: cargo_version("1.0.0"),
1803 to: cargo_version("1.1.0"),
1804 severity: Severity::Minor,
1805 governed_by: None,
1806 reason: None,
1807 writes: vec![VersionWriteTarget::Manifest(a_rel.clone())],
1808 },
1809 ],
1810 ..Default::default()
1811 };
1812
1813 let permit = ApplyPermit::force_for_tests();
1814 let opts = ApplyOptions::default();
1815
1816 let original_mode = std::fs::metadata(&b_dir).unwrap().permissions().mode();
1817 std::fs::set_permissions(&b_dir, std::fs::Permissions::from_mode(0o555)).unwrap();
1818
1819 let probe_path = b_dir.join(".rtk-write-probe");
1820 let probe_write_succeeded = std::fs::write(&probe_path, b"probe").is_ok();
1821 if probe_write_succeeded {
1822 std::fs::remove_file(&probe_path).ok();
1823 std::fs::set_permissions(&b_dir, std::fs::Permissions::from_mode(original_mode)).unwrap();
1824 eprintln!(
1825 "skipping batched_groups_process_strictly_sequentially_in_btreemap_path_order: \
1826 process can write into a 0o555 directory (likely running as root); chmod-based failure injection is a no-op here"
1827 );
1828 return;
1829 }
1830
1831 let result = apply_version_plan(root, &plan, &NoopRunner, &opts, &permit);
1832
1833 std::fs::set_permissions(&b_dir, std::fs::Permissions::from_mode(original_mode)).unwrap();
1834
1835 assert!(
1836 matches!(
1837 result,
1838 Err(GraphError::Manifest(callisto_model::ManifestError::Write { .. }))
1839 ),
1840 "P2's persist failure must propagate; got: {result:?}"
1841 );
1842
1843 let a_on_disk = std::fs::read_to_string(&a_path).unwrap();
1844 assert!(
1845 a_on_disk.contains("version = \"1.1.0\""),
1846 "P1 (a-crate/Cargo.toml, sorts before P2) must already be fully processed and persisted before P2 is even attempted; got:\n{a_on_disk}"
1847 );
1848
1849 let b_on_disk = std::fs::read_to_string(&b_path).unwrap();
1850 assert_eq!(
1851 b_on_disk, b_original,
1852 "P2's own group must be byte-for-byte unchanged since its persist never succeeded"
1853 );
1854 }
1855
1856 #[test]
1857 fn batched_group_applies_bump_and_multiple_rewrites_preserving_untouched_formatting() {
1858 let dir = tempfile::tempdir().unwrap();
1859 let root = dir.path();
1860 let cargo_toml_path = root.join("Cargo.toml");
1861 let original = "# top comment\n[package]\nname = \"my-crate\"\nversion = \"1.0.0\"\nedition = \"2021\"\n\n[dependencies]\nhelper = \"1.0.0\" # inline comment\nother = \"2.0.0\"\n";
1862 std::fs::write(&cargo_toml_path, original).unwrap();
1863
1864 let manifest_rel = PathBuf::from("Cargo.toml");
1865 let plan = VersionPlan {
1866 bumps: vec![PlannedBump {
1867 package: PackageId::parse("cargo:my-crate").unwrap(),
1868 from: cargo_version("1.0.0"),
1869 to: cargo_version("1.1.0"),
1870 severity: Severity::Minor,
1871 governed_by: None,
1872 reason: None,
1873 writes: vec![VersionWriteTarget::Manifest(manifest_rel.clone())],
1874 }],
1875 rewrites: vec![
1876 crate::cascade::SpecRewrite {
1877 key: crate::cascade::RewriteKey {
1878 target: DepWriteTarget::Manifest(manifest_rel.clone()),
1879 name: "helper".to_string(),
1880 kind: Some(callisto_model::DepKind::Runtime),
1881 },
1882 dependency: PackageId::parse("cargo:helper").unwrap(),
1883 from: callisto_model::DepSpec::Range(
1884 callisto_model::VersionReq::parse("^1.0.0", callisto_model::Ecosystem::Cargo).unwrap(),
1885 "^1.0.0".to_string(),
1886 ),
1887 to: callisto_model::DepSpec::Range(
1888 callisto_model::VersionReq::parse("^1.1.0", callisto_model::Ecosystem::Cargo).unwrap(),
1889 "^1.1.0".to_string(),
1890 ),
1891 },
1892 crate::cascade::SpecRewrite {
1893 key: crate::cascade::RewriteKey {
1894 target: DepWriteTarget::Manifest(manifest_rel.clone()),
1895 name: "other".to_string(),
1896 kind: Some(callisto_model::DepKind::Runtime),
1897 },
1898 dependency: PackageId::parse("cargo:other").unwrap(),
1899 from: callisto_model::DepSpec::Range(
1900 callisto_model::VersionReq::parse("^2.0.0", callisto_model::Ecosystem::Cargo).unwrap(),
1901 "^2.0.0".to_string(),
1902 ),
1903 to: callisto_model::DepSpec::Range(
1904 callisto_model::VersionReq::parse("^2.1.0", callisto_model::Ecosystem::Cargo).unwrap(),
1905 "^2.1.0".to_string(),
1906 ),
1907 },
1908 ],
1909 ..Default::default()
1910 };
1911
1912 let permit = ApplyPermit::force_for_tests();
1913 let opts = ApplyOptions::default();
1914 let result = apply_version_plan(root, &plan, &NoopRunner, &opts, &permit);
1915 assert!(result.is_ok(), "apply_version_plan should succeed: {result:?}");
1916
1917 let on_disk = std::fs::read_to_string(&cargo_toml_path).unwrap();
1918 assert!(on_disk.contains("version = \"1.1.0\""));
1919 assert!(on_disk.contains("helper = \"^1.1.0\" # inline comment"));
1920 assert!(on_disk.contains("other = \"^2.1.0\""));
1921 assert!(on_disk.starts_with("# top comment\n"));
1922 }
1923
1924 #[test]
1925 fn rewrite_indices_within_one_group_apply_in_plan_rewrites_order() {
1926 let dir = tempfile::tempdir().unwrap();
1927 let root = dir.path();
1928 let cargo_toml_path = root.join("Cargo.toml");
1929 let original = "[package]\nname = \"my-crate\"\nversion = \"1.0.0\"\nedition = \"2021\"\n\n[dependencies]\nhelper = \"1.0.0\"\n";
1930 std::fs::write(&cargo_toml_path, original).unwrap();
1931
1932 let manifest_rel = PathBuf::from("Cargo.toml");
1933 let key = crate::cascade::RewriteKey {
1934 target: DepWriteTarget::Manifest(manifest_rel.clone()),
1935 name: "helper".to_string(),
1936 kind: Some(callisto_model::DepKind::Runtime),
1937 };
1938 let plan = VersionPlan {
1939 rewrites: vec![
1940 crate::cascade::SpecRewrite {
1941 key: key.clone(),
1942 dependency: PackageId::parse("cargo:helper").unwrap(),
1943 from: callisto_model::DepSpec::Range(
1944 callisto_model::VersionReq::parse("^1.0.0", callisto_model::Ecosystem::Cargo).unwrap(),
1945 "^1.0.0".to_string(),
1946 ),
1947 to: callisto_model::DepSpec::Range(
1948 callisto_model::VersionReq::parse("^1.1.0", callisto_model::Ecosystem::Cargo).unwrap(),
1949 "^1.1.0".to_string(),
1950 ),
1951 },
1952 crate::cascade::SpecRewrite {
1953 key: key.clone(),
1954 dependency: PackageId::parse("cargo:helper").unwrap(),
1955 from: callisto_model::DepSpec::Range(
1956 callisto_model::VersionReq::parse("^1.1.0", callisto_model::Ecosystem::Cargo).unwrap(),
1957 "^1.1.0".to_string(),
1958 ),
1959 to: callisto_model::DepSpec::Range(
1960 callisto_model::VersionReq::parse("^1.2.0", callisto_model::Ecosystem::Cargo).unwrap(),
1961 "^1.2.0".to_string(),
1962 ),
1963 },
1964 ],
1965 ..Default::default()
1966 };
1967
1968 let permit = ApplyPermit::force_for_tests();
1969 let opts = ApplyOptions::default();
1970 let result = apply_version_plan(root, &plan, &NoopRunner, &opts, &permit);
1971 assert!(result.is_ok(), "apply_version_plan should succeed: {result:?}");
1972
1973 let on_disk = std::fs::read_to_string(&cargo_toml_path).unwrap();
1974 assert!(
1975 on_disk.contains("helper = \"^1.2.0\""),
1976 "final on-disk spec must reflect the SECOND same-RewriteKey entry's `to` value, proving rewrite_indices iterate in plan.rewrites order; got:\n{on_disk}"
1977 );
1978 }
1979
1980 #[test]
1981 fn snapshot_transient_mode_mutates_manifests_but_suppresses_changelogs_changesets_and_git_staging() {
1982 let dir = tempfile::tempdir().unwrap();
1983 let root = dir.path();
1984 let cargo_toml_path = root.join("Cargo.toml");
1985 std::fs::write(
1986 &cargo_toml_path,
1987 "[package]\nname = \"my-crate\"\nversion = \"1.0.0\"\nedition = \"2021\"\n",
1988 )
1989 .unwrap();
1990
1991 let changelog_path = root.join("CHANGELOG.md");
1992 std::fs::write(&changelog_path, "# Changelog\n").unwrap();
1993
1994 let changeset_dir = root.join(".changeset");
1995 std::fs::create_dir_all(&changeset_dir).unwrap();
1996 let changeset_file = changeset_dir.join("test-change.md");
1997 std::fs::write(&changeset_file, "---\n\"my-crate\": patch\n---\nSome fix\n").unwrap();
1998
1999 let calls: CallLog = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
2000 let runner = RecordingRunner {
2001 calls: std::sync::Arc::clone(&calls),
2002 };
2003
2004 let pkg = PackageId::parse("cargo:my-crate").unwrap();
2005 let plan = VersionPlan {
2006 bumps: vec![PlannedBump {
2007 package: pkg.clone(),
2008 from: cargo_version("1.0.0"),
2009 to: cargo_version("0.0.0-snapshot-abc1234"),
2010 severity: Severity::Patch,
2011 governed_by: None,
2012 reason: None,
2013 writes: vec![VersionWriteTarget::Manifest(PathBuf::from("Cargo.toml"))],
2014 }],
2015 changelog_writes: vec![crate::plan::ChangelogWrite {
2016 changelog_path: PathBuf::from("CHANGELOG.md"),
2017 input: callisto_changelog::ChangelogInput {
2018 package: pkg,
2019 from: cargo_version("1.0.0"),
2020 to: Some(cargo_version("0.0.0-snapshot-abc1234")),
2021 entries: vec![callisto_changelog::ChangelogEntry {
2022 severity: Severity::Patch,
2023 source: callisto_changelog::ChangeSource::Changeset {
2024 filename: "test-change.md".to_string(),
2025 summary: "Some fix".to_string(),
2026 },
2027 }],
2028 },
2029 }],
2030 consumed_changesets: vec![PathBuf::from(".changeset/test-change.md")],
2031 ..Default::default()
2032 };
2033
2034 let permit = ApplyPermit::force_for_tests();
2035 let opts = ApplyOptions {
2036 refresh_lockfiles: false,
2037 transient: true,
2038 };
2039
2040 let result = apply_version_plan(root, &plan, &runner, &opts, &permit).expect("apply succeeded");
2041
2042 let manifest_content = std::fs::read_to_string(&cargo_toml_path).unwrap();
2044 assert!(manifest_content.contains("version = \"0.0.0-snapshot-abc1234\""));
2045
2046 let changelog_content = std::fs::read_to_string(&changelog_path).unwrap();
2048 assert_eq!(changelog_content, "# Changelog\n");
2049
2050 assert!(
2052 changeset_file.exists(),
2053 "changeset must not be deleted in transient mode"
2054 );
2055
2056 let recorded = calls.lock().unwrap();
2058 let git_staging_calls: Vec<_> = recorded
2059 .iter()
2060 .filter(|(prog, args)| {
2061 prog == "git"
2062 && (args.first().map(|s| s.as_str()) == Some("add")
2063 || args.first().map(|s| s.as_str()) == Some("rm"))
2064 })
2065 .collect();
2066 assert!(
2067 git_staging_calls.is_empty(),
2068 "transient mode must not execute git add / git rm: {git_staging_calls:?}"
2069 );
2070
2071 assert!(result.staged.is_empty(), "transient mode outcome staged must be empty");
2073 }
2074
2075 #[test]
2076 fn apply_version_plan_executes_platform_writes_and_optional_dep_updates() {
2077 let dir = tempfile::tempdir().unwrap();
2078 let root = dir.path();
2079
2080 let parent_pkg_path = root.join("package.json");
2081 std::fs::write(
2082 &parent_pkg_path,
2083 r#"{"name": "my-parent", "version": "1.0.0", "optionalDependencies": {"@my-scope/platform-linux": "1.0.0"}}"#,
2084 )
2085 .unwrap();
2086
2087 std::fs::create_dir_all(root.join("platform")).unwrap();
2088 let platform_pkg_path = root.join("platform/package.json");
2089 std::fs::write(
2090 &platform_pkg_path,
2091 r#"{"name": "@my-scope/platform-linux", "version": "1.0.0"}"#,
2092 )
2093 .unwrap();
2094
2095 let plan = VersionPlan {
2096 platform_writes: vec![crate::plan::PlatformWrite {
2097 manifest: PathBuf::from("platform/package.json"),
2098 version: cargo_version("1.1.0"),
2099 from: cargo_version("1.0.0"),
2100 }],
2101 optional_dep_updates: vec![crate::plan::OptionalDepUpdate {
2102 manifest: PathBuf::from("package.json"),
2103 updates: vec![("@my-scope/platform-linux".to_string(), cargo_version("1.1.0"))],
2104 }],
2105 ..Default::default()
2106 };
2107
2108 let permit = ApplyPermit::force_for_tests();
2109 let opts = ApplyOptions::default();
2110
2111 let result = apply_version_plan(root, &plan, &NoopRunner, &opts, &permit).expect("apply succeeded");
2112
2113 let platform_content = std::fs::read_to_string(&platform_pkg_path).unwrap();
2114 assert!(
2115 platform_content.contains("\"version\": \"1.1.0\"") || platform_content.contains("\"version\":\"1.1.0\""),
2116 "platform manifest version must be updated to 1.1.0: {platform_content}"
2117 );
2118
2119 let parent_content = std::fs::read_to_string(&parent_pkg_path).unwrap();
2120 assert!(
2121 parent_content.contains("\"@my-scope/platform-linux\": \"1.1.0\"")
2122 || parent_content.contains("\"@my-scope/platform-linux\":\"1.1.0\""),
2123 "parent optionalDependencies must be updated: {parent_content}"
2124 );
2125
2126 assert!(result.staged.contains(&PathBuf::from("platform/package.json")));
2127 assert!(result.staged.contains(&PathBuf::from("package.json")));
2128 }
2129
2130 #[test]
2137 fn platform_write_drift_returns_unexpected_manifest_version_and_leaves_manifest_untouched() {
2138 let dir = tempfile::tempdir().unwrap();
2139 let root = dir.path();
2140
2141 std::fs::create_dir_all(root.join("platform")).unwrap();
2142 let platform_pkg_path = root.join("platform/package.json");
2143 let original_content = r#"{"name": "@my-scope/platform-linux", "version": "9.9.9"}"#.to_string();
2144 std::fs::write(&platform_pkg_path, &original_content).unwrap();
2145
2146 let plan = VersionPlan {
2147 platform_writes: vec![crate::plan::PlatformWrite {
2148 manifest: PathBuf::from("platform/package.json"),
2149 version: cargo_version("1.1.0"),
2150 from: cargo_version("1.0.0"),
2151 }],
2152 ..Default::default()
2153 };
2154
2155 let permit = ApplyPermit::force_for_tests();
2156 let opts = ApplyOptions::default();
2157
2158 let result = apply_version_plan(root, &plan, &NoopRunner, &opts, &permit);
2159
2160 match result {
2161 Err(GraphError::UnexpectedManifestVersion {
2162 path,
2163 expected_from,
2164 expected_to,
2165 found,
2166 }) => {
2167 assert_eq!(path, PathBuf::from("platform/package.json"));
2168 assert_eq!(expected_from.render(), "1.0.0");
2169 assert_eq!(expected_to.render(), "1.1.0");
2170 assert_eq!(found.render(), "9.9.9");
2171 }
2172 other => panic!("expected Err(GraphError::UnexpectedManifestVersion), got: {other:?}"),
2173 }
2174
2175 let platform_content_after = std::fs::read_to_string(&platform_pkg_path).unwrap();
2176 assert_eq!(
2177 platform_content_after, original_content,
2178 "drifted platform manifest must be left byte-for-byte untouched"
2179 );
2180 }
2181
2182 #[test]
2188 fn platform_write_idempotent_retry_when_already_at_target_succeeds_without_rewrite() {
2189 let dir = tempfile::tempdir().unwrap();
2190 let root = dir.path();
2191
2192 std::fs::create_dir_all(root.join("platform")).unwrap();
2193 let platform_pkg_path = root.join("platform/package.json");
2194 std::fs::write(
2195 &platform_pkg_path,
2196 r#"{"name": "@my-scope/platform-linux", "version": "1.1.0"}"#,
2197 )
2198 .unwrap();
2199
2200 let plan = VersionPlan {
2201 platform_writes: vec![crate::plan::PlatformWrite {
2202 manifest: PathBuf::from("platform/package.json"),
2203 version: cargo_version("1.1.0"),
2204 from: cargo_version("1.0.0"),
2205 }],
2206 ..Default::default()
2207 };
2208
2209 let permit = ApplyPermit::force_for_tests();
2210 let opts = ApplyOptions::default();
2211
2212 let result = apply_version_plan(root, &plan, &NoopRunner, &opts, &permit)
2213 .expect("idempotent retry when already at target must succeed");
2214
2215 let platform_content = std::fs::read_to_string(&platform_pkg_path).unwrap();
2216 assert!(
2217 platform_content.contains("\"version\": \"1.1.0\"") || platform_content.contains("\"version\":\"1.1.0\""),
2218 "manifest already at target must remain at target: {platform_content}"
2219 );
2220
2221 assert!(
2222 result.staged.contains(&PathBuf::from("platform/package.json")),
2223 "idempotent-retry path must still be staged: {:?}",
2224 result.staged
2225 );
2226 }
2227
2228 #[test]
2236 fn platform_writes_only_plan_stages_npm_lockfile_without_refresh() {
2237 let dir = tempfile::tempdir().expect("create tempdir");
2238 let root = dir.path();
2239
2240 std::fs::create_dir_all(root.join("platform")).unwrap();
2241 let platform_pkg_path = root.join("platform/package.json");
2242 std::fs::write(
2243 &platform_pkg_path,
2244 r#"{"name": "@my-scope/platform-linux", "version": "1.0.0"}"#,
2245 )
2246 .unwrap();
2247 std::fs::write(root.join("package-lock.json"), "{}").unwrap();
2248
2249 let plan = VersionPlan {
2250 platform_writes: vec![crate::plan::PlatformWrite {
2251 manifest: PathBuf::from("platform/package.json"),
2252 version: cargo_version("1.1.0"),
2253 from: cargo_version("1.0.0"),
2254 }],
2255 ..Default::default()
2256 };
2257
2258 let permit = ApplyPermit::force_for_tests();
2259 let opts = ApplyOptions {
2260 refresh_lockfiles: true,
2261 transient: false,
2262 };
2263
2264 let outcome =
2265 apply_version_plan(root, &plan, &NoopRunner, &opts, &permit).expect("apply_version_plan should succeed");
2266
2267 assert!(
2268 outcome.staged.contains(&PathBuf::from("package-lock.json")),
2269 "npm lockfile must be staged when a platform_writes-only plan touches an npm manifest; staged: {:?}",
2270 outcome.staged
2271 );
2272 let has_npm_refresh = outcome
2273 .lockfile_refresh_results
2274 .as_ref()
2275 .is_some_and(|results| results.iter().any(|r| r.filename.as_os_str() == "package-lock.json"));
2276 assert!(
2277 !has_npm_refresh,
2278 "no npm entry may appear in lockfile_refresh_results; apply_version_plan has no npm refresh subprocess: {:?}",
2279 outcome.lockfile_refresh_results
2280 );
2281 }
2282
2283 #[test]
2286 fn optional_dep_updates_only_plan_stages_npm_lockfile_without_refresh() {
2287 let dir = tempfile::tempdir().expect("create tempdir");
2288 let root = dir.path();
2289
2290 let parent_pkg_path = root.join("package.json");
2291 std::fs::write(
2292 &parent_pkg_path,
2293 r#"{"name": "my-parent", "version": "1.0.0", "optionalDependencies": {"@my-scope/platform-linux": "1.0.0"}}"#,
2294 )
2295 .unwrap();
2296 std::fs::write(root.join("package-lock.json"), "{}").unwrap();
2297
2298 let plan = VersionPlan {
2299 optional_dep_updates: vec![crate::plan::OptionalDepUpdate {
2300 manifest: PathBuf::from("package.json"),
2301 updates: vec![("@my-scope/platform-linux".to_string(), cargo_version("1.1.0"))],
2302 }],
2303 ..Default::default()
2304 };
2305
2306 let permit = ApplyPermit::force_for_tests();
2307 let opts = ApplyOptions {
2308 refresh_lockfiles: true,
2309 transient: false,
2310 };
2311
2312 let outcome =
2313 apply_version_plan(root, &plan, &NoopRunner, &opts, &permit).expect("apply_version_plan should succeed");
2314
2315 assert!(
2316 outcome.staged.contains(&PathBuf::from("package-lock.json")),
2317 "npm lockfile must be staged when an optional_dep_updates-only plan touches an npm manifest; staged: {:?}",
2318 outcome.staged
2319 );
2320 let has_npm_refresh = outcome
2321 .lockfile_refresh_results
2322 .as_ref()
2323 .is_some_and(|results| results.iter().any(|r| r.filename.as_os_str() == "package-lock.json"));
2324 assert!(
2325 !has_npm_refresh,
2326 "no npm entry may appear in lockfile_refresh_results; apply_version_plan has no npm refresh subprocess: {:?}",
2327 outcome.lockfile_refresh_results
2328 );
2329 }
2330}