Skip to main content

callisto_graph/
apply.rs

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/// Options governing how a version plan is applied to the workspace.
13#[derive(Clone, Debug, Default)]
14pub struct ApplyOptions {
15    /// Plumbed from `--refresh-lockfiles` but not yet consulted here;
16    /// `ApplyOutcome::lockfile_refresh_results` is consequently always `None`.
17    pub refresh_lockfiles: bool,
18    /// When true (snapshot mode, §8), manifest mutations are written to disk
19    /// but changelog prepends (step 7), changeset deletions (step 8), and
20    /// git staging (step 11) are suppressed.
21    pub transient: bool,
22}
23
24/// The result of a successful [`apply_version_plan`] call, describing which paths were written and staged.
25#[derive(Clone, Debug, Default)]
26pub struct ApplyOutcome {
27    /// Reserved for lockfile refresh results; currently always `None`.
28    pub lockfile_refresh_results: Option<Vec<LockfileRefreshResult>>,
29    /// Paths written and staged via `git add`, relative to the workspace root.
30    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
87/// Writes `plan` to disk and stages the touched paths in git.
88///
89/// Every side effect here is unconditional -- whether to apply at all is
90/// the caller's decision, which is why an [`ApplyPermit`] is required
91/// rather than a `dry_run` bool: a bool carries no guarantee any caller
92/// actually consulted it. A dry-run caller can't obtain a permit and
93/// simply doesn't call this function; it reports `plan` instead.
94///
95/// # Errors
96///
97/// - Manifest parse/write failures (malformed TOML/JSON, unsupported format).
98/// - Git subprocess failures (`git add`/`git rm --cached` non-zero exit).
99/// - I/O errors writing changelog sections or `pre.json`.
100pub 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                // Already at target — skip write, fall through to rewrites.
141            } 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                        // Already at target — skip write but still stage so git add re-stages on retry.
184                    } 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            // Already at target — skip write, but still stage so git add re-stages on retry.
239        } 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    // Collect the set of ecosystems actively involved in this plan so that
320    // only the corresponding lockfiles are staged. Staging lockfiles whose
321    // ecosystem was not touched can sweep up unrelated user changes.
322    use callisto_model::Ecosystem;
323    let active_ecosystems: std::collections::HashSet<Ecosystem> = plan
324        .bumps
325        .iter()
326        .filter_map(|b| {
327            // Prefer the ecosystem declared in the PackageId; fall back to
328            // inferring it from write targets for Bare (unprefixed) ids.
329            if let Some(eco) = b.package.ecosystem() {
330                return Some(eco);
331            }
332            // Bare id: derive ecosystem from the write targets.
333            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    // Regenerate lockfiles when the caller requested a refresh and mode is not transient.
360    // This must run BEFORE the git-staging loop so the refreshed files are on disk when they
361    // are picked up by the staging pass below.
362    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    // Map each well-known lockfile to its ecosystem, then include the file
417    // only when that ecosystem is active and the file exists on disk.
418    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
480/// Redacts known registry/VCS credential env-var values and any URL userinfo
481/// component from raw `git` subprocess stderr before it is embedded in a
482/// [`GraphError`] -- a failing `git` invocation (this module's `git add`/
483/// `git rm --cached` staging calls, `commands::validate`'s `git diff`) can
484/// surface an authenticated remote URL (e.g. GitHub Actions'
485/// `https://x-access-token:TOKEN@github.com/...`) verbatim in its own error
486/// output, and that text flows into `--format json` and miette diagnostic
487/// output downstream. Shared crate-wide (`pub(crate)`) rather than
488/// duplicated per call site, matching a single definition of "how do we
489/// redact git stderr in this crate."
490pub(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/// A `git` stderr containing a GitHub Actions authenticated remote URL must
495/// have its userinfo stripped before reaching a `GraphError` -- proving the
496/// helper both `git add`/`git rm --cached` staging failures route through
497/// actually redacts, not just that the underlying primitive can.
498#[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    /// A no-op runner that always reports success for any git command.
527    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    /// Records every (program, args) pair that CommandRunner::run is called with.
542    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    /// AC-014: a bump-precondition failure inside a batched group must block
566    /// every rewrite in that group and leave the manifest byte-for-byte
567    /// unchanged (no rewrite is ever attempted, persist is never called).
568    #[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    /// AC-016 (byte half): a batched group where the bump is skipped
641    /// (already at target) but a rewrite succeeds must still write the
642    /// rewrite's mutation to disk (not silently dropped).
643    #[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    /// When only a Cargo package is bumped, the Python lockfile (`uv.lock`)
698    /// must NOT appear in `staged`, even when it exists on disk alongside
699    /// `Cargo.lock`.
700    #[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        // Place both a Cargo lockfile and a Python lockfile on disk.
706        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![], // no manifest writes — keeps test self-contained
718            }],
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    /// A `CommandRunner` that fails every call, echoing a stderr containing
740    /// an authenticated GitHub remote URL -- the realistic shape a `git add`/
741    /// `git rm --cached` failure could surface in CI.
742    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    /// A `git add` failure while staging a bumped lockfile must not leak an
755    /// authenticated remote URL's credential into the resulting `GraphError`.
756    #[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    /// When `refresh_lockfiles: true` and the plan bumps a Cargo package,
789    /// `cargo update --workspace` must be called so that `Cargo.lock` is
790    /// regenerated after the version bump. This prevents `cargo publish --locked`
791    /// from failing with "lock file needs to be updated but --locked was passed".
792    ///
793    /// The result must appear in `ApplyOutcome::lockfile_refresh_results`.
794    #[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    /// When `refresh_lockfiles: false` (the default), `cargo update` must NOT
845    /// be called — callers that do not request a refresh should see no extra
846    /// subprocess invocations.
847    #[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(); // refresh_lockfiles: false
873        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    /// When a manifest is already at the plan's target version (from a prior
891    /// partially-applied run that crashed), `apply_version_plan` must NOT bump
892    /// it again (which would produce an unplanned 1.1.0 → 1.2.0 bump) and must
893    /// return `Ok` so the caller can complete the remaining operations safely.
894    ///
895    /// The manifest write is skipped, but the path is still pushed to staged so
896    /// that `git add` re-stages any changes that were made in the prior run.
897    #[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        // Manifest already at 1.1.0 — simulates a prior crashed apply.
903        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        // Changeset file that would be consumed.
911        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        // Manifest must stay at 1.1.0, not be bumped to 1.2.0.
938        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        // Changeset file must be deleted.
945        assert!(
946            !root.join(&cs_rel).exists(),
947            "changeset file must be deleted even on an idempotent apply"
948        );
949
950        // Manifest must still be staged (re-add for git).
951        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        // Changeset path must be staged for git rm.
958        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    /// When a manifest is at a version that is neither `from` nor `to`,
966    /// `apply_version_plan` must return an error — the workspace is in an
967    /// unexpected state that requires human intervention.
968    #[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        // Manifest at 2.0.0 — neither from=1.0.0 nor to=1.1.0.
974        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        // Manifest must be unchanged.
1006        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    /// When a changeset file was already deleted by a prior partial run,
1014    /// `apply_version_plan` must still push its path to `staged` so that
1015    /// `git rm --cached --ignore-unmatch` is called for it. Without this,
1016    /// a crashed-then-retried apply leaves the changeset in the git index.
1017    #[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        // Manifest already at 1.1.0 (prior run bumped it).
1023        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        // Changeset file does NOT exist — deleted by the prior partial run.
1031        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        // Changeset path must be in staged regardless of whether the file existed.
1058        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    /// AC-009 check (b): running `apply_version_plan` end-to-end over a Cargo
1199    /// bump must produce on-disk bytes byte-identical to a direct
1200    /// `open()` -> `write_version()` -> `persist()` sequence over the same
1201    /// starting fixture — not merely a substring match.
1202    #[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    /// AC-009 check (b), npm sibling: same byte-identity proof for a
1247    /// `package.json` bump.
1248    #[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        // Positive precondition: confirm this fixture opens successfully and
1306        // current_version() succeeds via toml_edit's inline-table Item::get,
1307        // proving that any later failure is write_version's own
1308        // as_table_mut() step failing, not open()/current_version() failing
1309        // upstream of it.
1310        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        // Some environments (e.g. containers running the test process as uid 0)
1408        // ignore directory write-permission bits entirely, which would make the
1409        // chmod above a no-op and this test's failure-injection premise false.
1410        // Probe for that before proceeding rather than assuming the chmod took
1411        // effect.
1412        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        // Same root-uid guard as the bumps-loop version of this test (T16):
1515        // skip rather than assert if the chmod did not actually block writes.
1516        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    /// AC-004: a batched group's bump succeeds, but the second of two
1670    /// rewrites for the same path fails. Neither the bump nor the first
1671    /// rewrite may land on disk, since persist() for the group is never
1672    /// reached -- proven by unchanged on-disk bytes (if persist() had
1673    /// run, it would have written the mutated document). Deliberately not
1674    /// asserting `persist_call_count()` here: `PERSIST_CALL_COUNT` is a
1675    /// process-global counter shared with several non-`#[serial]` sibling
1676    /// tests in this module, so an exact count would be racy under plain
1677    /// multi-threaded `cargo test` (see
1678    /// `persist_call_count_test.rs`/`apply_persist_open_count_test.rs` for
1679    /// the isolated integration binaries where such assertions belong).
1680    #[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    /// AC-012: `classify_manifest_writes`' `BTreeMap<PathBuf, _>` iteration
1758    /// order -- not `plan.bumps`' encounter order -- determines which
1759    /// batched group is processed first. b-crate's bump is listed first in
1760    /// the plan but a-crate sorts first as a path, so a-crate's group must
1761    /// be fully persisted before b-crate's group (whose persist fails) is
1762    /// even attempted.
1763    #[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        // b-crate's PlannedBump is listed FIRST and a-crate's SECOND, deliberately
1787        // reversed from lexicographic path order, so plan-encounter order and
1788        // BTreeMap path order disagree -- see this task's own reasoning.
1789        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        // 1. Manifests ARE mutated to the snapshot version
2043        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        // 2. Changelogs are NOT modified
2047        let changelog_content = std::fs::read_to_string(&changelog_path).unwrap();
2048        assert_eq!(changelog_content, "# Changelog\n");
2049
2050        // 3. Changeset file is NOT deleted
2051        assert!(
2052            changeset_file.exists(),
2053            "changeset must not be deleted in transient mode"
2054        );
2055
2056        // 4. Git staging commands were NOT executed
2057        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        // 5. Outcome staged list is empty
2072        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    /// AC-012: a `platform_writes` entry whose `from` does not match the
2131    /// actual on-disk `current_version()` of the platform manifest (drift)
2132    /// must cause `apply_version_plan` to return
2133    /// `Err(GraphError::UnexpectedManifestVersion { .. })` with the correct
2134    /// fields, and the platform manifest must be left byte-for-byte
2135    /// untouched on disk.
2136    #[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    /// AC-012b: a `platform_writes` entry whose `from` does not match the
2183    /// on-disk `current_version()`, but the on-disk version already equals
2184    /// the target `version` (idempotent retry after a prior interrupted
2185    /// apply), must succeed without rewriting the manifest, and the path
2186    /// must still be pushed into the staged set.
2187    #[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    /// AC-009: a plan with `bumps` empty but a `platform_writes` entry
2229    /// pointing at an npm-ecosystem manifest must still stage the npm
2230    /// lockfile present on disk. `active_ecosystems` was previously derived
2231    /// solely from `plan.bumps`, so an npm lockfile went unstaged whenever
2232    /// only `platform_writes` touched npm. Staging only: apply_version_plan
2233    /// has no npm lockfile refresh subprocess at all, so
2234    /// `lockfile_refresh_results` must contain no npm entry either way.
2235    #[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    /// AC-010: same as AC-009, but the npm manifest is touched via
2284    /// `optional_dep_updates` instead of `platform_writes`.
2285    #[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}