Skip to main content

devflow_core/
version.rs

1//! Hybrid Git-based SemVer.
2//!
3//! DevFlow derives the version entirely from git history (D-11) — the
4//! version file (`Cargo.toml`, `pyproject.toml`, or `package.json`) is no
5//! longer an input to [`compute_version`], only an output [`write_version`]
6//! produces:
7//!
8//! - **Baseline** — the highest semver tag reachable from `HEAD`
9//!   ([`reachable_semver_baseline`], D-07). If the highest semver tag in the
10//!   repository overall is NOT reachable from `HEAD`, `compute_version`
11//!   refuses rather than silently falling back to a smaller reachable tag
12//!   (D-10).
13//! - **Bump** — classified from the conventional-commit intent of the
14//!   commits added since that baseline was released
15//!   ([`classify_range_bump`], D-08), over a range anchored by
16//!   [`release_range_start`] to survive this repository's squash-merge +
17//!   sync-back release topology.
18
19use std::path::{Path, PathBuf};
20use std::process::Command;
21
22/// A semantic version, whether read from disk or computed from git history.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub struct Version {
25    /// Major version component.
26    pub major: u32,
27    /// Minor version component.
28    pub minor: u32,
29    /// Patch version component.
30    pub patch: u32,
31}
32
33impl std::fmt::Display for Version {
34    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
36    }
37}
38
39/// Errors produced by version operations.
40#[derive(Debug, thiserror::Error)]
41pub enum VersionError {
42    /// Filesystem operation failed.
43    #[error("version file I/O failed: {0}")]
44    Io(#[from] std::io::Error),
45    /// Version field could not be found or parsed.
46    #[error("version parse failed: {0}")]
47    Parse(String),
48    /// A git command failed.
49    #[error("git command failed: {0}")]
50    Git(String),
51    /// D-10: the highest semver tag in the repository is not reachable from
52    /// `HEAD` — refuse rather than silently computing a version below the
53    /// real release history (T-25-04). Typically means a `develop` -> `main`
54    /// sync was squashed instead of merged (999.52), or the tag was created
55    /// on an orphan ref never merged anywhere.
56    #[error(
57        "highest semver tag `{tag}` is not reachable from HEAD — merge its branch into \
58         the current branch (or, if a develop/main sync was squashed instead of merged, \
59         re-run `scripts/sync-main-to-develop.sh`), then retry"
60    )]
61    UnreachableBaseline {
62        /// The unreachable tag's name (e.g. `"v9.9.9"`).
63        tag: String,
64    },
65}
66
67/// Detect the project's version file, checking Cargo.toml, then pyproject.toml,
68/// then package.json. Returns the first that exists.
69pub fn detect_version_file(project_root: &Path) -> Option<PathBuf> {
70    for name in ["Cargo.toml", "pyproject.toml", "package.json"] {
71        let path = project_root.join(name);
72        if path.exists() {
73            return Some(path);
74        }
75    }
76    None
77}
78
79/// The dotted field path that holds the version in a given file.
80fn field_for(path: &Path, contents: &str) -> &'static str {
81    match path.file_name().and_then(|n| n.to_str()) {
82        Some("Cargo.toml") => {
83            if contents.contains("[workspace.package]") {
84                "workspace.package.version"
85            } else {
86                "package.version"
87            }
88        }
89        Some("pyproject.toml") => "project.version",
90        Some("package.json") => "version",
91        _ => "version",
92    }
93}
94
95/// Read the MAJOR version component from a version file.
96pub fn read_major_version(path: &Path) -> Result<u32, VersionError> {
97    let contents = std::fs::read_to_string(path)?;
98    let field = field_for(path, &contents);
99    let version = find_version_in_contents(&contents, field)
100        .ok_or_else(|| VersionError::Parse(format!("field `{field}` not found in {path:?}")))?;
101    let major = version
102        .split(['.', '+', '-'])
103        .next()
104        .unwrap_or("0")
105        .parse::<u32>()
106        .map_err(|err| VersionError::Parse(format!("invalid major in `{version}`: {err}")))?;
107    Ok(major)
108}
109
110/// Count all git tags.
111///
112/// **Superseded (D-07):** `compute_version` no longer derives MINOR from a
113/// raw tag count — use [`reachable_semver_baseline`] instead. Retained
114/// (rather than deleted) because `devflow-core` has no `publish = false` and
115/// this function is `pub`, so removal would be a breaking API change of a
116/// published crate (same reasoning CONTEXT.md D-13 records for
117/// `looks_like_devflow_process`).
118#[deprecated(note = "superseded by `reachable_semver_baseline` (D-07)")]
119pub fn count_git_tags(project_root: &Path) -> Result<u32, VersionError> {
120    let output = Command::new("git")
121        .arg("tag")
122        .current_dir(project_root)
123        .output()
124        .map_err(|err| VersionError::Git(err.to_string()))?;
125    if !output.status.success() {
126        return Err(VersionError::Git(
127            String::from_utf8_lossy(&output.stderr).trim().to_string(),
128        ));
129    }
130    let count = String::from_utf8_lossy(&output.stdout)
131        .lines()
132        .filter(|l| !l.trim().is_empty())
133        .count();
134    Ok(count as u32)
135}
136
137/// Count commits since the most recent tag. If there are no tags yet, counts
138/// all commits reachable from HEAD.
139///
140/// **Superseded (D-08):** `compute_version` no longer derives PATCH from
141/// `git describe` distance — use [`classify_range_bump`] over
142/// [`release_range_start`]'s anchored range instead. Retained (rather than
143/// deleted) for the same published-crate-API reason as
144/// [`count_git_tags`]'s doc comment.
145#[deprecated(note = "superseded by `classify_range_bump` (D-08)")]
146pub fn commits_since_last_minor_tag(project_root: &Path) -> Result<u32, VersionError> {
147    let last_tag = Command::new("git")
148        .args(["describe", "--tags", "--abbrev=0"])
149        .current_dir(project_root)
150        .output()
151        .map_err(|err| VersionError::Git(err.to_string()))?;
152
153    let range = if last_tag.status.success() {
154        let tag = String::from_utf8_lossy(&last_tag.stdout).trim().to_string();
155        format!("{tag}..HEAD")
156    } else {
157        "HEAD".to_string()
158    };
159
160    let output = Command::new("git")
161        .args(["rev-list", "--count", &range])
162        .current_dir(project_root)
163        .output()
164        .map_err(|err| VersionError::Git(err.to_string()))?;
165    if !output.status.success() {
166        // No commits yet (e.g. empty repo) → zero patch.
167        return Ok(0);
168    }
169    let count = String::from_utf8_lossy(&output.stdout)
170        .trim()
171        .parse::<u32>()
172        .unwrap_or(0);
173    Ok(count)
174}
175
176/// Enumerate every tag in the repository (no reachability restriction), keep
177/// only values that parse as `vMAJOR.MINOR.PATCH` semver (a leading `v` is
178/// stripped first — the `semver` crate's grammar is bare `MAJOR.MINOR.PATCH`),
179/// and return the maximum by semver ordering (D-07). A stray non-semver tag
180/// (e.g. this repository's `archive-planning-docs-2026-07-24`) is silently
181/// excluded via `filter_map(...ok())` rather than erroring — a malformed tag
182/// can never crash this path (T-25-02).
183pub fn highest_semver_tag(project_root: &Path) -> Result<Option<semver::Version>, VersionError> {
184    let output = Command::new("git")
185        .arg("tag")
186        .current_dir(project_root)
187        .output()
188        .map_err(|err| VersionError::Git(err.to_string()))?;
189    if !output.status.success() {
190        return Err(VersionError::Git(
191            String::from_utf8_lossy(&output.stderr).trim().to_string(),
192        ));
193    }
194    Ok(String::from_utf8_lossy(&output.stdout)
195        .lines()
196        .filter_map(|line| line.trim().strip_prefix('v'))
197        .filter_map(|stripped| semver::Version::parse(stripped).ok())
198        .max())
199}
200
201/// As [`highest_semver_tag`], but restricted to tags reachable from `HEAD`
202/// via `git tag --merged HEAD` — one spawn instead of an O(n) per-tag
203/// `merge-base --is-ancestor` loop, mirroring `GitFlow::cleanup_merged`'s
204/// existing `branch --merged` precedent in `git.rs`. This is `compute_version`'s
205/// baseline (D-07).
206///
207/// **D-12 coupling:** this predicate's correctness depends on the `develop`
208/// → `main` sync PR being MERGED, not squashed — a squashed sync breaks the
209/// ancestry link this `--merged` check relies on. `compute_version`'s
210/// refusal (D-10, `VersionError::UnreachableBaseline`) is the mitigation if
211/// that discipline is ever violated; 999.52 is the backlog item that would
212/// ship a structural repair, deliberately not in this phase.
213pub fn reachable_semver_baseline(
214    project_root: &Path,
215) -> Result<Option<semver::Version>, VersionError> {
216    let output = Command::new("git")
217        .args(["tag", "--merged", "HEAD"])
218        .current_dir(project_root)
219        .output()
220        .map_err(|err| VersionError::Git(err.to_string()))?;
221    if !output.status.success() {
222        return Err(VersionError::Git(
223            String::from_utf8_lossy(&output.stderr).trim().to_string(),
224        ));
225    }
226    Ok(String::from_utf8_lossy(&output.stdout)
227        .lines()
228        .filter_map(|line| line.trim().strip_prefix('v'))
229        .filter_map(|stripped| semver::Version::parse(stripped).ok())
230        .max())
231}
232
233/// Resolve `commit`'s first parent SHA, or `Ok(None)` if `commit` is a root
234/// commit with no first parent.
235///
236/// A non-zero exit from `git rev-parse {commit}^1` means "no such parent"
237/// (root commit), not a genuine spawn/IO failure — those still propagate
238/// via `?` through the `Command::output()` call itself.
239fn first_parent(project_root: &Path, commit: &str) -> Result<Option<String>, VersionError> {
240    let output = Command::new("git")
241        .args(["rev-parse", &format!("{commit}^1")])
242        .current_dir(project_root)
243        .output()
244        .map_err(|err| VersionError::Git(err.to_string()))?;
245    if !output.status.success() {
246        return Ok(None);
247    }
248    Ok(Some(
249        String::from_utf8_lossy(&output.stdout).trim().to_string(),
250    ))
251}
252
253/// Resolve the commit range start for D-08's conventional-commit classifier,
254/// given the baseline tag name (e.g. `"v2.0.0"`).
255///
256/// This exists because every release in this repository squash-merges
257/// `develop` into `main`, so no develop-side commit is ever an ancestor of
258/// the release tag it was squashed into — a `-X ours` sync merge-back
259/// restores ancestry in the OTHER direction only (the tag becomes an
260/// ancestor of `HEAD`, which is what makes D-07's `--merged HEAD`
261/// reachability filter work), but the commits the tag *released* stay
262/// outside its ancestry forever. A literal `baseline..HEAD` range therefore
263/// re-includes the entire pre-release history on every subsequent ship —
264/// measured live 2026-07-27: `v2.0.0..HEAD` is 677 non-merge commits (62
265/// `feat`), against 5 (0 `feat`) for the anchored range this function
266/// computes. See 25-01-PLAN.md's `<measured_correction>`.
267///
268/// Anchor rule (generalized 2026-07-28 to fix CR-03 — `25-REVIEW.md`,
269/// `25-VERIFICATION.md` GAP 2):
270/// - Walk `git rev-list --ancestry-path --reverse <tag>..HEAD` oldest-first.
271///   For each candidate commit `C` in order: if `C` has no first parent (a
272///   root commit), or the baseline tag is NOT an ancestor of `C`'s first
273///   parent, `C` is where the tag's line joined `HEAD`'s line — return `C`
274///   immediately.
275/// - If every candidate's first parent already descends from the tag, the
276///   tag already sat on this mainline throughout (the ordinary,
277///   non-squashed case, e.g. `v1.8.0..v1.8.1`) — return the tag unchanged.
278/// - If the ancestry path is empty, the tag is at `HEAD` — return the tag.
279///
280/// **CR-03** — the previous rule inspected only the ancestry path's FIRST
281/// commit (`C1`). When a commit lands directly on trunk between the tag and
282/// the sync-merge-back (a hotfix pushed straight to `main`), that
283/// intervening commit becomes `C1`; its first parent IS the tag commit, so
284/// `merge-base --is-ancestor <tag> <tag>` is trivially true, and the old
285/// rule wrongly concluded the tag already sat on mainline — returning the
286/// literal `tag..HEAD` range and re-admitting pre-release `develop` history.
287/// Walking the FULL path instead of just `C1` fixes this: the sync merge
288/// itself still fails the ancestor test and is returned once the walk
289/// reaches it.
290///
291/// **Anchoring at the LAST merge commit instead (a plausible-looking
292/// alternative) is WRONG on this repository.** `GitFlow::merge_feature_into_develop`
293/// (`git.rs:86`) merges every phase branch into `develop` with `git merge
294/// --no-ff`, so ordinary post-release feature work also produces merge
295/// commits on the ancestry path — not just the sync-merge-back. Anchoring at
296/// the last one would silently truncate the range at that later feature
297/// merge instead of the sync merge, dropping any commits between the two
298/// from classification — a `feat!:` in that position would be dropped
299/// unnoticed, the exact false negative D-09 exists to prevent. See
300/// `tests::feature_merge_after_sync_merge_does_not_move_the_anchor`.
301pub fn release_range_start(
302    project_root: &Path,
303    baseline_tag: &str,
304) -> Result<String, VersionError> {
305    let ancestry = Command::new("git")
306        .args([
307            "rev-list",
308            "--ancestry-path",
309            "--reverse",
310            &format!("{baseline_tag}..HEAD"),
311        ])
312        .current_dir(project_root)
313        .output()
314        .map_err(|err| VersionError::Git(err.to_string()))?;
315    if !ancestry.status.success() {
316        return Err(VersionError::Git(
317            String::from_utf8_lossy(&ancestry.stderr).trim().to_string(),
318        ));
319    }
320    let path: Vec<String> = String::from_utf8_lossy(&ancestry.stdout)
321        .lines()
322        .filter(|line| !line.trim().is_empty())
323        .map(str::to_string)
324        .collect();
325    if path.is_empty() {
326        // Nothing after the tag — it sits at HEAD.
327        return Ok(baseline_tag.to_string());
328    }
329
330    for candidate in &path {
331        let Some(first_parent) = first_parent(project_root, candidate)? else {
332            // `candidate` is a root commit with no first parent — the tag
333            // cannot be an ancestor of something that doesn't exist; this is
334            // where the tag's line joined HEAD's line.
335            return Ok(candidate.clone());
336        };
337
338        let tag_is_ancestor_of_first_parent = Command::new("git")
339            .args(["merge-base", "--is-ancestor", baseline_tag, &first_parent])
340            .current_dir(project_root)
341            .output()
342            .map(|out| out.status.success())
343            .unwrap_or(false);
344
345        if !tag_is_ancestor_of_first_parent {
346            // `candidate` is where the tag's line joined HEAD's line (the
347            // sync merge-back, or equivalent).
348            return Ok(candidate.clone());
349        }
350        // `candidate` is on the mainline the tag already sat on: keep
351        // walking the path toward HEAD.
352    }
353
354    // Every candidate's first parent already descended from the tag — the
355    // ordinary, non-squashed release case.
356    Ok(baseline_tag.to_string())
357}
358
359/// The classified conventional-commit bump for a range of commits (D-08).
360/// Declaration order is the precedence order (lowest to highest), so
361/// `Iterator::max()`/[`Ord::max`] over a range's individual classifications
362/// yields the highest-precedence result directly.
363#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
364pub enum Bump {
365    /// No commit's type maps to a version-affecting change (`docs`, `test`,
366    /// `chore`, `ci`, `refactor`, `style`). `compute_version` collapses this
367    /// to [`Bump::Patch`] at the call site (D-10's floor) so a range with
368    /// nothing bumping still yields a distinct version.
369    None,
370    /// `fix`/`perf`; any recognised-but-unlisted conventional-commit type
371    /// (D-10's same floor); or a commit message that failed to parse as a
372    /// conventional commit at all (D-10: unrecognised/malformed → patch).
373    Patch,
374    /// `feat`.
375    Minor,
376    /// A breaking change: `!` after an optional scope and before the colon
377    /// (`feat(scope)!: ...`), or a `BREAKING CHANGE:`/`BREAKING-CHANGE:`
378    /// footer, regardless of the commit's own type.
379    Major,
380}
381
382/// Classify the highest-precedence conventional-commit bump over
383/// `--no-merges` commits in `range_start..HEAD`. `range_start` may be the
384/// empty string, meaning "no baseline tag exists" — the whole history
385/// reachable from `HEAD` is classified instead (`git log --no-merges HEAD`,
386/// no exclusion).
387///
388/// Commits are read via `%H%x1f%B%x1e`: `%B` is the raw message (subject,
389/// blank line, body and footers) in exactly the shape
390/// `git_conventional::Commit::parse` expects, and `%x1f`/`%x1e` are git's own
391/// unit/record separators — safe against arbitrary characters a commit
392/// message may contain, unlike splitting on newlines.
393pub fn classify_range_bump(project_root: &Path, range_start: &str) -> Result<Bump, VersionError> {
394    let range = if range_start.is_empty() {
395        "HEAD".to_string()
396    } else {
397        format!("{range_start}..HEAD")
398    };
399    let output = Command::new("git")
400        .args(["log", "--no-merges", &range, "--format=%H%x1f%B%x1e"])
401        .current_dir(project_root)
402        .output()
403        .map_err(|err| VersionError::Git(err.to_string()))?;
404    if !output.status.success() {
405        return Err(VersionError::Git(
406            String::from_utf8_lossy(&output.stderr).trim().to_string(),
407        ));
408    }
409    let stdout = String::from_utf8_lossy(&output.stdout);
410    let mut bump = Bump::None;
411    for record in stdout.split('\u{1e}') {
412        let record = record.trim_matches('\n');
413        if record.is_empty() {
414            continue;
415        }
416        let Some((_hash, message)) = record.split_once('\u{1f}') else {
417            continue;
418        };
419        let this_bump = classify_commit_message(message.trim());
420        bump = bump.max(this_bump);
421    }
422    Ok(bump)
423}
424
425/// Classify one commit message's bump per D-08/D-10. An unparseable message
426/// (D-10: unrecognised/malformed) and a breaking-change marker (regardless of
427/// type) are both checked before the type match, since either overrides a
428/// recognised type's own precedence.
429fn classify_commit_message(message: &str) -> Bump {
430    let Ok(commit) = git_conventional::Commit::parse(message) else {
431        return Bump::Patch;
432    };
433    if commit.breaking() {
434        return Bump::Major;
435    }
436    let ty = commit.type_();
437    if ty == git_conventional::Type::FEAT {
438        Bump::Minor
439    } else if ty == git_conventional::Type::FIX || ty == git_conventional::Type::PERF {
440        Bump::Patch
441    } else if ty == git_conventional::Type::DOCS
442        || ty == git_conventional::Type::TEST
443        || ty == git_conventional::Type::CHORE
444        || ty == "ci"
445        || ty == git_conventional::Type::REFACTOR
446        || ty == git_conventional::Type::STYLE
447    {
448        Bump::None
449    } else {
450        // Any other recognised-but-unlisted type — D-10's same floor.
451        Bump::Patch
452    }
453}
454
455/// Apply a classified [`Bump`] to a baseline version (D-08/D-10).
456fn apply_bump(baseline: &semver::Version, bump: Bump) -> semver::Version {
457    match bump {
458        Bump::Major => semver::Version::new(baseline.major + 1, 0, 0),
459        Bump::Minor => semver::Version::new(baseline.major, baseline.minor + 1, 0),
460        // D-10: no-bump collapses to patch so every completed ship still
461        // yields a distinct version.
462        Bump::Patch | Bump::None => {
463            semver::Version::new(baseline.major, baseline.minor, baseline.patch + 1)
464        }
465    }
466}
467
468/// Compute the full version: the baseline resolved from the highest
469/// reachable semver tag (D-07), bumped by the conventional-commit
470/// classification of the commits added since that baseline was released
471/// (D-08). The version file is NOT read here (D-11) — [`write_version`] is
472/// the only writer, and [`read_version`] is the only reader of what's on
473/// disk.
474pub fn compute_version(project_root: &Path) -> Result<Version, VersionError> {
475    let highest = highest_semver_tag(project_root)?;
476    let baseline = reachable_semver_baseline(project_root)?;
477
478    // D-10: refuse rather than silently falling back to the highest
479    // *reachable* tag when the true highest tag exists but is not reachable
480    // from HEAD (T-25-04) — see `reachable_semver_baseline`'s doc comment for
481    // the D-12 sync-discipline coupling this predicate depends on.
482    if let Some(highest) = &highest {
483        let unreachable = match &baseline {
484            Some(reachable) => highest > reachable,
485            None => true,
486        };
487        if unreachable {
488            return Err(VersionError::UnreachableBaseline {
489                tag: format!("v{highest}"),
490            });
491        }
492    }
493
494    let baseline_version = baseline
495        .clone()
496        .unwrap_or_else(|| semver::Version::new(0, 0, 0));
497
498    let range_start = match &baseline {
499        Some(tag) => release_range_start(project_root, &format!("v{tag}"))?,
500        None => String::new(),
501    };
502    let bump = classify_range_bump(project_root, &range_start)?;
503    let bumped = apply_bump(&baseline_version, bump);
504
505    Ok(Version {
506        major: bumped.major as u32,
507        minor: bumped.minor as u32,
508        patch: bumped.patch as u32,
509    })
510}
511
512/// Read the full [`Version`] (major/minor/patch) out of whatever version file
513/// `detect_version_file` resolves, mirroring [`write_version`]'s format
514/// handling (including `[workspace.package]`).
515///
516/// Unlike [`compute_version`], this never touches git — it reports exactly
517/// what was last written to the version file, not a freshly recomputed
518/// minor/patch. Callers that need the version a prior [`write_version`] call
519/// actually wrote (e.g. after a tag was just cut) must use this instead of
520/// `compute_version`, which would see the new tag and return a different,
521/// larger version.
522///
523/// D-11 changed what `compute_version` reads (git history only, never the
524/// version file) — it did not change this function's role: `read_version`
525/// still reports exactly what's on disk, unconditionally.
526pub fn read_version(project_root: &Path) -> Result<Version, VersionError> {
527    let path = detect_version_file(project_root)
528        .ok_or_else(|| VersionError::Parse("no version file found".into()))?;
529    let contents = std::fs::read_to_string(&path)?;
530    let field = field_for(&path, &contents);
531    let version_str = find_version_in_contents(&contents, field)
532        .ok_or_else(|| VersionError::Parse(format!("field `{field}` not found in {path:?}")))?;
533    parse_version_str(&version_str)
534}
535
536/// Parse a `MAJOR.MINOR.PATCH` string (optionally followed by `-`/`+`
537/// metadata) into a [`Version`].
538fn parse_version_str(version: &str) -> Result<Version, VersionError> {
539    let mut parts = version.split(['.', '+', '-']);
540    let mut next =
541        |label: &str| -> Result<u32, VersionError> {
542            parts.next().unwrap_or("0").parse::<u32>().map_err(|err| {
543                VersionError::Parse(format!("invalid {label} in `{version}`: {err}"))
544            })
545        };
546    let major = next("major")?;
547    let minor = next("minor")?;
548    let patch = next("patch")?;
549    Ok(Version {
550        major,
551        minor,
552        patch,
553    })
554}
555
556/// Write `version` into the project's auto-detected version file.
557pub fn write_version(project_root: &Path, version: &Version) -> Result<PathBuf, VersionError> {
558    let path = detect_version_file(project_root)
559        .ok_or_else(|| VersionError::Parse("no version file found".into()))?;
560    let contents = std::fs::read_to_string(&path)?;
561    let field = field_for(&path, &contents);
562    let replaced = replace_version_in_contents(&contents, field, &version.to_string())
563        .ok_or_else(|| VersionError::Parse(format!("field `{field}` not found")))?;
564    // 20a / DEN-49: a workspace Cargo.toml states its version twice — once in
565    // [workspace.package] version (just rewritten above), and again as an
566    // explicit `version` pin on every [workspace.dependencies] entry that
567    // points at a workspace member by `path`. This second pass is additive,
568    // not a modification of `replace_version_in_contents`'s single-field
569    // logic — pyproject.toml/package.json/plain Cargo.toml callers never
570    // reach it.
571    let replaced = if field == "workspace.package.version" {
572        rewrite_workspace_member_pins(&replaced, &version.to_string())
573    } else {
574        replaced
575    };
576    std::fs::write(&path, replaced)?;
577    Ok(path)
578}
579
580/// Additive pass (20a / DEN-49): rewrite the `version` sub-value of every
581/// SINGLE-LINE `[workspace.dependencies]` inline-table entry that pins a
582/// local workspace member by `path` (e.g. `devflow-core = { path =
583/// "crates/devflow-core", version = "1.6.0" }`).
584///
585/// This is deliberately additive to `replace_version_in_contents` rather than
586/// a modification of it — that function's `starts_with('{')` guard exists so
587/// single-field callers (`field_for` for pyproject.toml/package.json/plain
588/// Cargo.toml) never touch an inline table, and stays intact.
589///
590/// Scope, by construction:
591/// - Only entries with a local `path` key (one starting with `crates/`) are
592///   rewritten. A `version`-only third-party dependency (`serde = { version
593///   = "1" }`) is left untouched — a dependency on a crate INSIDE this
594///   workspace carries this workspace's version; anything else does not.
595/// - Only SINGLE-LINE inline tables are handled (opening and closing `}` on
596///   the same line as `path`/`version`). A multi-line inline table is a
597///   documented out-of-scope limitation (review: Antigravity/Hermes MEDIUM)
598///   — this repo's own self-pins are single-line (Cargo.toml:20), and the
599///   line-level `starts_with('{')` guard in `find_version_in_contents`/
600///   `replace_version_in_contents` could not see into one anyway.
601/// - The `version = "..."` sub-value is located and replaced independent of
602///   its position relative to `path` within the line (key-order-independent,
603///   anchored to the `version =` token itself, not a column offset) — a
604///   self-pin written `{ version = "1.6.0", path = "crates/..." }` is
605///   rewritten identically to the `path`-before-`version` case.
606/// - Whitespace, quote style, and any trailing comma/comment after the
607///   `version` token are preserved exactly (GAP-6).
608fn rewrite_workspace_member_pins(contents: &str, new_version: &str) -> String {
609    let mut current = String::new();
610    let mut output = String::new();
611    for line in contents.lines() {
612        let trimmed = line.trim();
613        if let Some(header) = parse_section_header(trimmed) {
614            current = header.to_string();
615            output.push_str(line);
616            output.push('\n');
617            continue;
618        }
619        if current == "workspace.dependencies"
620            && trimmed.contains('{')
621            && trimmed.contains('}')
622            && workspace_dependency_has_local_path(trimmed)
623            && let Some(rewritten) = rewrite_inline_table_version(line, new_version)
624        {
625            output.push_str(&rewritten);
626            output.push('\n');
627            continue;
628        }
629        output.push_str(line);
630        output.push('\n');
631    }
632    output
633}
634
635/// Split a single-line inline table's interior (`{ ... }`, braces excluded)
636/// into its top-level `key = value` fragments, alongside each fragment's
637/// absolute byte offset within `line`. Fragments are separated on `,` — this
638/// is a hand-rolled, single-line-only split (see `rewrite_workspace_member_pins`
639/// doc comment), not a general TOML parser.
640fn inline_table_fragments(line: &str) -> Option<Vec<(usize, &str)>> {
641    let brace_start = line.find('{')?;
642    let brace_end = line.rfind('}')?;
643    if brace_end <= brace_start {
644        return None;
645    }
646    let inner = &line[brace_start + 1..brace_end];
647    let mut fragments = Vec::new();
648    let mut offset = brace_start + 1;
649    for fragment in inner.split(',') {
650        fragments.push((offset, fragment));
651        offset += fragment.len() + 1; // +1 for the consumed comma
652    }
653    Some(fragments)
654}
655
656/// Whether a `[workspace.dependencies]` inline-table line carries a `path`
657/// key whose value points at a local workspace member (starts with
658/// `crates/`).
659fn workspace_dependency_has_local_path(line: &str) -> bool {
660    let Some(fragments) = inline_table_fragments(line) else {
661        return false;
662    };
663    for (_, fragment) in fragments {
664        let trimmed = fragment.trim();
665        let Some((key, value)) = trimmed.split_once('=') else {
666            continue;
667        };
668        if key.trim() != "path" {
669            continue;
670        }
671        let value = value.trim();
672        let Some(quote) = value.chars().next() else {
673            return false;
674        };
675        if quote != '"' && quote != '\'' {
676            return false;
677        }
678        let inner_value = &value[1..value.len().saturating_sub(1)];
679        return inner_value.starts_with("crates/");
680    }
681    false
682}
683
684/// Rewrite the `version = "..."` sub-value on a single-line inline-table
685/// line, preserving everything else on the line byte-for-byte. Returns
686/// `None` if the line has no `version` fragment to anchor to (e.g. a
687/// `path`-only member with no explicit version — nothing to rewrite).
688fn rewrite_inline_table_version(line: &str, new_version: &str) -> Option<String> {
689    let fragments = inline_table_fragments(line)?;
690    for (frag_start, fragment) in fragments {
691        let trimmed = fragment.trim();
692        let Some((key, _value)) = trimmed.split_once('=') else {
693            continue;
694        };
695        if key.trim() != "version" {
696            continue;
697        }
698        // Locate `=` in the ORIGINAL (untrimmed) fragment to compute an
699        // absolute offset into `line`.
700        let eq_rel = fragment.find('=')?;
701        let eq_abs = frag_start + eq_rel;
702        let after_eq = eq_abs + 1;
703        let rest = &line[after_eq..];
704        let ws_len = rest.len() - rest.trim_start().len();
705        let value_start = after_eq + ws_len;
706        let value_rest = &line[value_start..];
707        let quote_char = value_rest.chars().next()?;
708        if quote_char != '"' && quote_char != '\'' {
709            return None;
710        }
711        let after_quote = &value_rest[1..];
712        let end_rel = after_quote.find(quote_char)?;
713        let value_end = value_start + 1 + end_rel + 1;
714        let remainder = &line[value_end..];
715
716        let mut rewritten = String::with_capacity(line.len() + new_version.len());
717        rewritten.push_str(&line[..value_start]);
718        rewritten.push(quote_char);
719        rewritten.push_str(new_version);
720        rewritten.push(quote_char);
721        rewritten.push_str(remainder);
722        return Some(rewritten);
723    }
724    None
725}
726
727/// One `[workspace.dependencies]` self-pin discovered by
728/// [`read_workspace_self_pins`] — a local-path dependency's name and its
729/// pinned `version` sub-value.
730#[derive(Debug, Clone, PartialEq, Eq)]
731pub struct SelfPin {
732    /// The dependency's name (left-hand side of `=` in `[workspace.dependencies]`).
733    pub name: String,
734    /// The `version = "..."` value currently pinned in the inline table.
735    pub version: String,
736}
737
738/// Extract `[workspace.package] version` and every local-path
739/// `[workspace.dependencies]` self-pin (crate name + pinned version) from a
740/// workspace Cargo.toml's contents.
741///
742/// Read-only (20d / `devflow release --check`): asserts 20a's invariant
743/// (`write_version` keeps every self-pin equal to the workspace version)
744/// without re-implementing TOML scanning — reuses the same
745/// `parse_section_header`/`find_version_in_contents`/
746/// `workspace_dependency_has_local_path`/`inline_table_fragments` helpers
747/// `write_version`'s additive rewrite pass already uses.
748///
749/// Returns `(workspace_version, pins)`. `workspace_version` is `None` when
750/// the contents have no `[workspace.package] version` field (not a workspace
751/// root Cargo.toml) — callers must treat that as "nothing to assert", not a
752/// drift.
753pub fn read_workspace_self_pins(contents: &str) -> (Option<String>, Vec<SelfPin>) {
754    let workspace_version = find_version_in_contents(contents, "workspace.package.version");
755
756    let mut current = String::new();
757    let mut pins = Vec::new();
758    for line in contents.lines() {
759        let trimmed = line.trim();
760        if let Some(header) = parse_section_header(trimmed) {
761            current = header.to_string();
762            continue;
763        }
764        if current == "workspace.dependencies"
765            && trimmed.contains('{')
766            && trimmed.contains('}')
767            && workspace_dependency_has_local_path(trimmed)
768            && let Some(fragments) = inline_table_fragments(trimmed)
769        {
770            let name = trimmed
771                .split_once('=')
772                .map(|(n, _)| n.trim().to_string())
773                .unwrap_or_default();
774            for (_, fragment) in fragments {
775                let frag = fragment.trim();
776                let Some((key, value)) = frag.split_once('=') else {
777                    continue;
778                };
779                if key.trim() != "version" {
780                    continue;
781                }
782                let value = value.trim().trim_matches(['"', '\'']);
783                pins.push(SelfPin {
784                    name: name.clone(),
785                    version: value.to_string(),
786                });
787            }
788        }
789    }
790    (workspace_version, pins)
791}
792
793/// Split a dotted field path into its TOML section path and the final key.
794fn split_field(field: &str) -> (&str, &str) {
795    match field.rsplit_once('.') {
796        Some((section, key)) => (section, key),
797        None => ("", field),
798    }
799}
800
801/// Return the dotted table path for a TOML section header line, if any.
802fn parse_section_header(trimmed: &str) -> Option<&str> {
803    let inner = if trimmed.starts_with("[[") && trimmed.ends_with("]]") {
804        trimmed.strip_prefix("[[")?.strip_suffix("]]")?
805    } else {
806        trimmed.strip_prefix('[')?.strip_suffix(']')?
807    };
808    Some(inner.trim())
809}
810
811fn find_version_in_contents(contents: &str, field: &str) -> Option<String> {
812    let (section, key) = split_field(field);
813    let mut current = "";
814    for line in contents.lines() {
815        let trimmed = line.trim();
816        if let Some(header) = parse_section_header(trimmed) {
817            current = header;
818            continue;
819        }
820        if current != section {
821            continue;
822        }
823        if let Some((lhs, value)) = trimmed.split_once(['=', ':']) {
824            let lhs_key = lhs.trim().trim_matches('"').trim_matches('\'');
825            if lhs_key != key {
826                continue;
827            }
828            let value = value.trim();
829            if value.starts_with('{') {
830                continue;
831            }
832            // Anchor on the opening quote and scan forward for the matching
833            // closing quote, ignoring everything after it (e.g. a trailing
834            // `# comment`), rather than `trim_matches` on the whole tail —
835            // that would only strip a quote sitting at the very end of the
836            // remaining string, missing it entirely when a comment follows
837            // the closing quote on the same line. Symmetric with
838            // `replace_version_in_contents`'s write-path remainder handling.
839            return match value.chars().next() {
840                Some(q @ ('"' | '\'')) => {
841                    value[1..].find(q).map(|end| value[1..1 + end].to_string())
842                }
843                _ => {
844                    let end = value.find([' ', '\t', ',', '#']).unwrap_or(value.len());
845                    Some(value[..end].to_string())
846                }
847            };
848        }
849    }
850    None
851}
852
853fn replace_version_in_contents(contents: &str, field: &str, new_version: &str) -> Option<String> {
854    let (section, key) = split_field(field);
855    let mut current = "";
856    let mut changed = false;
857    let mut output = String::new();
858    for line in contents.lines() {
859        let trimmed = line.trim();
860        if let Some(header) = parse_section_header(trimmed) {
861            current = header;
862            output.push_str(line);
863            output.push('\n');
864            continue;
865        }
866        if !changed
867            && current == section
868            && let Some((left, value)) = line.split_once(['=', ':'])
869        {
870            let left_key = left.trim().trim_matches('"').trim_matches('\'');
871            if left_key == key && !value.trim().starts_with('{') {
872                let separator: &str = if trimmed.contains('=') { " = " } else { ": " };
873                let trimmed_value = value.trim();
874                let needs_quote = trimmed_value.starts_with('"') || trimmed_value.starts_with('\'');
875                let quote_char: &str = if trimmed_value.starts_with('\'') {
876                    "'"
877                } else {
878                    "\""
879                };
880                // Capture whatever follows the version token itself (a
881                // trailing `,` in JSON, a trailing `# comment` in TOML) so it
882                // survives the rewrite instead of being silently dropped
883                // (GAP-6).
884                let remainder = if needs_quote {
885                    // Token ends at the closing quote; skip the opening
886                    // quote and scan for the matching close.
887                    trimmed_value[1..]
888                        .find(quote_char)
889                        .map(|end| &trimmed_value[end + 2..])
890                        .unwrap_or("")
891                } else {
892                    // Unquoted: token ends at the first whitespace, `,`, or `#`.
893                    let end = trimmed_value
894                        .find([' ', '\t', ',', '#'])
895                        .unwrap_or(trimmed_value.len());
896                    &trimmed_value[end..]
897                };
898                output.push_str(left.trim_end());
899                output.push_str(separator);
900                if needs_quote {
901                    output.push_str(quote_char);
902                    output.push_str(new_version);
903                    output.push_str(quote_char);
904                } else {
905                    output.push_str(new_version);
906                }
907                output.push_str(remainder.trim_end());
908                output.push('\n');
909                changed = true;
910                continue;
911            }
912        }
913        output.push_str(line);
914        output.push('\n');
915    }
916    changed.then_some(output)
917}
918
919#[cfg(test)]
920mod tests {
921    use super::*;
922
923    fn git(root: &Path, args: &[&str]) {
924        let ok = crate::test_support::git_command(root)
925            .args(args)
926            .output()
927            .unwrap()
928            .status
929            .success();
930        assert!(ok, "git {args:?} failed");
931    }
932
933    fn init_repo(root: &Path) {
934        git(root, &["init", "-q"]);
935        git(root, &["config", "user.email", "test@example.com"]);
936        git(root, &["config", "user.name", "Test"]);
937        git(root, &["config", "commit.gpgsign", "false"]);
938        git(root, &["config", "tag.gpgsign", "false"]);
939        git(root, &["config", "core.hooksPath", "/dev/null"]);
940    }
941
942    fn commit(root: &Path, name: &str) {
943        std::fs::write(root.join(name), name).unwrap();
944        git(root, &["add", "."]);
945        git(root, &["commit", "-q", "-m", &format!("add {name}")]);
946    }
947
948    /// As [`commit`], but with an explicit commit message — needed for
949    /// conventional-commit classification fixtures, where the message
950    /// content (not the file name) is what's under test.
951    fn commit_msg(root: &Path, name: &str, message: &str) {
952        std::fs::write(root.join(name), name).unwrap();
953        git(root, &["add", "."]);
954        git(root, &["commit", "-q", "-m", message]);
955    }
956
957    /// One-line `tag` helper of the same shape as `git`/`init_repo`/`commit`.
958    fn tag(root: &Path, name: &str) {
959        git(root, &["tag", name]);
960    }
961
962    fn current_branch(root: &Path) -> String {
963        let output = crate::test_support::git_command(root)
964            .args(["symbolic-ref", "--short", "HEAD"])
965            .output()
966            .unwrap();
967        assert!(output.status.success(), "symbolic-ref --short HEAD failed");
968        String::from_utf8_lossy(&output.stdout).trim().to_string()
969    }
970
971    fn checkout_new(root: &Path, branch: &str) {
972        git(root, &["checkout", "-b", branch]);
973    }
974
975    fn checkout(root: &Path, branch: &str) {
976        git(root, &["checkout", branch]);
977    }
978
979    /// Simulate `scripts/sync-main-to-develop.sh`'s content-preserving
980    /// `-X ours` merge: a real merge commit (so ancestry is restored) whose
981    /// tree is unaffected (so nothing about `develop`'s own content changes).
982    fn merge_ours(root: &Path, branch: &str, message: &str) {
983        git(root, &["merge", "-s", "ours", "-m", message, branch]);
984    }
985
986    /// Simulate the shape `GitFlow::merge_feature_into_develop`
987    /// (`crates/devflow-core/src/git.rs:86`) produces for every phase branch
988    /// merged into `develop`: a real, ordinary `--no-ff` merge commit.
989    /// Ordinary post-release feature work lands this way too, which is what
990    /// makes it a merge commit on the ancestry path in addition to the
991    /// sync-merge-back — the reason `release_range_start` cannot simply
992    /// anchor at "the last merge commit."
993    fn merge_no_ff(root: &Path, branch: &str, message: &str) {
994        git(root, &["merge", "--no-ff", "-m", message, branch]);
995    }
996
997    /// Capture `HEAD`'s commit SHA, mirroring `current_branch`'s construction.
998    fn head_sha(root: &Path) -> String {
999        let output = crate::test_support::git_command(root)
1000            .args(["rev-parse", "HEAD"])
1001            .output()
1002            .unwrap();
1003        assert!(output.status.success(), "rev-parse HEAD failed");
1004        String::from_utf8_lossy(&output.stdout).trim().to_string()
1005    }
1006
1007    #[test]
1008    fn detect_prefers_cargo_then_pyproject_then_package_json() {
1009        let dir = tempfile::tempdir().unwrap();
1010        assert!(detect_version_file(dir.path()).is_none());
1011        std::fs::write(dir.path().join("package.json"), "{\"version\":\"1.0.0\"}").unwrap();
1012        assert!(
1013            detect_version_file(dir.path())
1014                .unwrap()
1015                .ends_with("package.json")
1016        );
1017        std::fs::write(
1018            dir.path().join("Cargo.toml"),
1019            "[package]\nversion=\"1.0.0\"",
1020        )
1021        .unwrap();
1022        assert!(
1023            detect_version_file(dir.path())
1024                .unwrap()
1025                .ends_with("Cargo.toml")
1026        );
1027    }
1028
1029    #[test]
1030    fn read_major_from_workspace_package() {
1031        let dir = tempfile::tempdir().unwrap();
1032        let file = dir.path().join("Cargo.toml");
1033        std::fs::write(
1034            &file,
1035            "[workspace.package]\nversion = \"2.5.7\"\nedition = \"2024\"\n",
1036        )
1037        .unwrap();
1038        assert_eq!(read_major_version(&file).unwrap(), 2);
1039    }
1040
1041    #[test]
1042    fn inline_table_version_does_not_shadow_workspace_package() {
1043        assert_eq!(parse_section_header("[[bin]]"), Some("bin"));
1044
1045        let dir = tempfile::tempdir().unwrap();
1046        let file = dir.path().join("Cargo.toml");
1047        std::fs::write(
1048            &file,
1049            "[[bin]]\nname = \"devflow\"\n\
1050             [workspace.dependencies]\nserde = { version = \"1\", features = [\"derive\"] }\n\
1051             [workspace.package]\nversion = \"1.2.0\"\n",
1052        )
1053        .unwrap();
1054
1055        assert_eq!(read_major_version(&file).unwrap(), 1);
1056        write_version(
1057            dir.path(),
1058            &Version {
1059                major: 2,
1060                minor: 3,
1061                patch: 4,
1062            },
1063        )
1064        .unwrap();
1065        let contents = std::fs::read_to_string(file).unwrap();
1066        assert!(contents.contains("serde = { version = \"1\""));
1067        assert!(contents.contains("[workspace.package]\nversion = \"2.3.4\""));
1068    }
1069
1070    #[test]
1071    fn read_major_from_package_json() {
1072        let dir = tempfile::tempdir().unwrap();
1073        let file = dir.path().join("package.json");
1074        std::fs::write(&file, "{\n  \"version\": \"3.1.0\"\n}\n").unwrap();
1075        assert_eq!(read_major_version(&file).unwrap(), 3);
1076    }
1077
1078    #[test]
1079    fn docs_only_commits_after_tag_yield_patch_floor() {
1080        let dir = tempfile::tempdir().unwrap();
1081        let root = dir.path();
1082        init_repo(root);
1083        commit_msg(root, "a.txt", "chore: init");
1084        tag(root, "v2.0.0");
1085        commit_msg(root, "b.txt", "docs: update readme");
1086        commit_msg(root, "c.txt", "docs: fix typo");
1087
1088        let v = compute_version(root).unwrap();
1089        assert_eq!(
1090            v,
1091            Version {
1092                major: 2,
1093                minor: 0,
1094                patch: 1
1095            }
1096        );
1097    }
1098
1099    #[test]
1100    fn feat_commit_after_tag_yields_minor_bump() {
1101        let dir = tempfile::tempdir().unwrap();
1102        let root = dir.path();
1103        init_repo(root);
1104        commit_msg(root, "a.txt", "chore: init");
1105        tag(root, "v2.0.0");
1106        commit_msg(root, "b.txt", "docs: update readme");
1107        commit_msg(root, "c.txt", "feat(x): add new capability");
1108
1109        let v = compute_version(root).unwrap();
1110        assert_eq!(
1111            v,
1112            Version {
1113                major: 2,
1114                minor: 1,
1115                patch: 0
1116            }
1117        );
1118    }
1119
1120    #[test]
1121    fn fix_commit_after_tag_yields_patch_bump() {
1122        let dir = tempfile::tempdir().unwrap();
1123        let root = dir.path();
1124        init_repo(root);
1125        commit_msg(root, "a.txt", "chore: init");
1126        tag(root, "v2.0.0");
1127        commit_msg(root, "b.txt", "fix(x): correct off-by-one");
1128
1129        let v = compute_version(root).unwrap();
1130        assert_eq!(
1131            v,
1132            Version {
1133                major: 2,
1134                minor: 0,
1135                patch: 1
1136            }
1137        );
1138    }
1139
1140    #[test]
1141    fn no_semver_tag_at_all_yields_documented_empty_repo_contract() {
1142        // Empty-repo contract (D-07/D-08 with no baseline tag): baseline is
1143        // 0.0.0, and the very first commit's own classification applies
1144        // directly — a `feat` yields the minor floor, `0.1.0`.
1145        let dir = tempfile::tempdir().unwrap();
1146        let root = dir.path();
1147        init_repo(root);
1148        commit_msg(root, "a.txt", "feat: initial capability");
1149
1150        let v = compute_version(root).unwrap();
1151        assert_eq!(
1152            v,
1153            Version {
1154                major: 0,
1155                minor: 1,
1156                patch: 0
1157            }
1158        );
1159    }
1160
1161    #[test]
1162    fn squash_sync_topology_classifies_only_post_merge_commits() {
1163        // Reproduces this repository's real release shape: `develop` work is
1164        // squash-merged into a fresh commit on the trunk (no ancestry back to
1165        // develop's originals), then a content-preserving `-X ours` merge
1166        // syncs the trunk back into develop, restoring ancestry in the OTHER
1167        // direction only. The classifier must see only the commit(s) added
1168        // AFTER that sync merge, not develop's pre-squash originals.
1169        let dir = tempfile::tempdir().unwrap();
1170        let root = dir.path();
1171        init_repo(root);
1172        commit_msg(root, "base.txt", "chore: init");
1173        let trunk = current_branch(root);
1174
1175        checkout_new(root, "develop");
1176        commit_msg(root, "d1.txt", "feat: develop work one");
1177        commit_msg(root, "d2.txt", "feat: develop work two");
1178
1179        checkout(root, &trunk);
1180        commit_msg(root, "sq1.txt", "feat: squashed release of develop work");
1181        tag(root, "v2.0.0");
1182
1183        checkout(root, "develop");
1184        merge_ours(
1185            root,
1186            &trunk,
1187            "merge: sync main back into develop after release",
1188        );
1189        commit_msg(root, "f1.txt", "fix: patch after sync");
1190
1191        let v = compute_version(root).unwrap();
1192        assert_eq!(
1193            v,
1194            Version {
1195                major: 2,
1196                minor: 0,
1197                patch: 1
1198            }
1199        );
1200    }
1201
1202    #[test]
1203    fn two_squash_sync_cycles_anchor_to_the_second_merge_only() {
1204        // Pins the property release_range_start's doc comment names: because
1205        // reachable_semver_baseline always selects the highest reachable
1206        // tag, the ancestry path from that tag to HEAD crosses exactly one
1207        // sync merge — so inspecting only C1's first parent is sufficient
1208        // even with TWO release cycles in history. If baseline selection
1209        // ever regressed to anchor at the first cycle's merge instead of the
1210        // second, this fixture's first-cycle `feat` (d1) would leak back
1211        // into the classified range and wrongly produce a minor bump.
1212        let dir = tempfile::tempdir().unwrap();
1213        let root = dir.path();
1214        init_repo(root);
1215        commit_msg(root, "base.txt", "chore: init");
1216        let trunk = current_branch(root);
1217
1218        checkout_new(root, "develop");
1219        commit_msg(root, "d1.txt", "feat: first cycle work");
1220
1221        checkout(root, &trunk);
1222        commit_msg(root, "sq1.txt", "feat: first squashed release");
1223        tag(root, "v2.0.0");
1224
1225        checkout(root, "develop");
1226        merge_ours(
1227            root,
1228            &trunk,
1229            "merge: sync main back into develop after release (1)",
1230        );
1231        commit_msg(root, "d3.txt", "feat: second cycle work");
1232
1233        checkout(root, &trunk);
1234        commit_msg(root, "sq2.txt", "feat: second squashed release");
1235        tag(root, "v2.1.0");
1236
1237        checkout(root, "develop");
1238        merge_ours(
1239            root,
1240            &trunk,
1241            "merge: sync main back into develop after release (2)",
1242        );
1243        commit_msg(root, "f1.txt", "fix: patch after second sync");
1244
1245        let v = compute_version(root).unwrap();
1246        assert_eq!(
1247            v,
1248            Version {
1249                major: 2,
1250                minor: 1,
1251                patch: 1
1252            }
1253        );
1254    }
1255
1256    /// Reproduces CR-03 (`25-REVIEW.md`): the current `release_range_start`
1257    /// inspects only the ancestry path's FIRST commit (`C1`) and tests
1258    /// whether the baseline tag is an ancestor of `C1`'s first parent. When a
1259    /// commit lands directly on trunk between the tag and the sync-merge-back
1260    /// (a hotfix pushed straight to `main`), that intervening commit becomes
1261    /// `C1` — its first parent IS the tag commit, so
1262    /// `git merge-base --is-ancestor <tag> <tag>` is trivially true, the
1263    /// function wrongly concludes the tag already sat on mainline, and it
1264    /// returns the literal `tag..HEAD` range — reintroducing the pre-release
1265    /// `develop` history the whole D-08 anchor exists to exclude.
1266    ///
1267    /// RED until Task 2 lands (`release_range_start` walks the whole
1268    /// ancestry path instead of only `C1`).
1269    #[test]
1270    fn trunk_commit_between_tag_and_sync_merge_still_anchors_at_the_sync_merge() {
1271        let dir = tempfile::tempdir().unwrap();
1272        let root = dir.path();
1273        init_repo(root);
1274        commit_msg(root, "base.txt", "chore: init");
1275        let trunk = current_branch(root);
1276
1277        checkout_new(root, "develop");
1278        commit_msg(root, "d1.txt", "feat: develop work one");
1279        commit_msg(root, "d2.txt", "feat: develop work two");
1280
1281        checkout(root, &trunk);
1282        commit_msg(root, "sq1.txt", "feat: squashed release of develop work");
1283        tag(root, "v2.0.0");
1284
1285        // Still on trunk: the intervening direct-trunk commit that turns
1286        // CR-03's C1-only heuristic into a false positive.
1287        commit_msg(root, "hot.txt", "fix: hotfix pushed straight to main");
1288
1289        checkout(root, "develop");
1290        merge_ours(
1291            root,
1292            &trunk,
1293            "merge: sync main back into develop after release",
1294        );
1295        let sync_merge = head_sha(root);
1296        commit_msg(root, "f1.txt", "fix: patch after sync");
1297
1298        assert_eq!(
1299            release_range_start(root, "v2.0.0").unwrap(),
1300            sync_merge,
1301            "anchor must be the sync merge, not the hotfix's tag-ancestor first parent"
1302        );
1303        assert_eq!(
1304            compute_version(root).unwrap(),
1305            Version {
1306                major: 2,
1307                minor: 0,
1308                patch: 1
1309            },
1310            "pre-fix this yields 2.1.0: the range collapses to tag..HEAD and \
1311             re-admits d1/d2's two feat commits"
1312        );
1313    }
1314
1315    /// Tripwire pinning this plan's deliberate deviation from
1316    /// `25-REVIEW.md`/`25-VERIFICATION.md`'s fix sketch ("anchor at the last
1317    /// merge commit in the ancestry path"). `GitFlow::merge_feature_into_develop`
1318    /// (`crates/devflow-core/src/git.rs:86`) merges every phase branch into
1319    /// `develop` with `git merge --no-ff`, so ordinary POST-RELEASE feature
1320    /// work also produces merge commits on the ancestry path — not just the
1321    /// sync-merge-back. Measured live against this repository 2026-07-28
1322    /// (`git rev-list --ancestry-path --reverse v2.0.0..develop`): the
1323    /// correct anchor is `c92229e` (the sync merge), but the literal "last
1324    /// merge commit" rule would return `819987b` (a later, unrelated PR
1325    /// merge), whose range silently drops an intervening commit from
1326    /// classification. Today that dropped commit is a `docs:` commit and
1327    /// nothing breaks; a `feat!:` in that same position would be dropped
1328    /// instead — a false negative that lets a major bump ship unattended,
1329    /// exactly what D-09 exists to prevent.
1330    ///
1331    /// This test is GREEN before AND after Task 2: it is green today (this
1332    /// is what the CURRENT C1-only code already gets right), and it must
1333    /// stay green under the generalized full-ancestry-path rule Task 2
1334    /// implements. It goes RED only under the review's literal "last merge
1335    /// commit" sketch — do not simplify the implementation into that sketch.
1336    ///
1337    /// Deviation from this plan's literal construction: without the
1338    /// intervening `chore: continue develop work after sync` commit below,
1339    /// `git rev-list --ancestry-path --reverse` places the feature branch's
1340    /// single-parent commit (`ft1`) BEFORE the sync-merge commit itself in
1341    /// its output — a real, measured property of that exact shape (verified
1342    /// live 2026-07-28; see 25-09-SUMMARY.md), not test flakiness — which
1343    /// made the fixture as originally specified fail pre-fix (asserting
1344    /// behavior the current C1-only code does not actually have). Per this
1345    /// plan's own instruction ("If Test 2 fails pre-fix, the fixture is
1346    /// malformed — stop and fix it"), one ordinary intervening develop
1347    /// commit was inserted between the sync merge and the feature branch's
1348    /// creation, which is itself realistic (post-release develop work
1349    /// commonly precedes the next feature branch) and restores C1 = the
1350    /// sync merge under the current implementation without changing either
1351    /// assertion.
1352    #[test]
1353    fn feature_merge_after_sync_merge_does_not_move_the_anchor() {
1354        let dir = tempfile::tempdir().unwrap();
1355        let root = dir.path();
1356        init_repo(root);
1357        commit_msg(root, "base.txt", "chore: init");
1358        let trunk = current_branch(root);
1359
1360        checkout_new(root, "develop");
1361        commit_msg(root, "d1.txt", "feat: develop work one");
1362
1363        checkout(root, &trunk);
1364        commit_msg(root, "sq1.txt", "feat: squashed release of develop work");
1365        tag(root, "v2.0.0");
1366
1367        checkout(root, "develop");
1368        merge_ours(
1369            root,
1370            &trunk,
1371            "merge: sync main back into develop after release",
1372        );
1373        let sync_merge = head_sha(root);
1374        commit_msg(root, "tail.txt", "chore: continue develop work after sync");
1375
1376        checkout_new(root, "feature/phase-99");
1377        commit_msg(root, "ft1.txt", "feat: post-release capability");
1378        checkout(root, "develop");
1379        merge_no_ff(
1380            root,
1381            "feature/phase-99",
1382            "Merge pull request #99 from feature/phase-99",
1383        );
1384        commit_msg(root, "f1.txt", "fix: patch after the feature merge");
1385
1386        assert_eq!(
1387            release_range_start(root, "v2.0.0").unwrap(),
1388            sync_merge,
1389            "anchor must be the sync merge, not the later feature-branch pull-request merge"
1390        );
1391        assert_eq!(
1392            compute_version(root).unwrap(),
1393            Version {
1394                major: 2,
1395                minor: 1,
1396                patch: 0
1397            },
1398            "ft1's feat must be inside the classified range"
1399        );
1400    }
1401
1402    #[test]
1403    fn unreachable_highest_tag_refuses_rather_than_falling_back() {
1404        // D-10: when the highest semver tag overall is not reachable from
1405        // HEAD, compute_version must refuse — never silently fall back to
1406        // the highest *reachable* tag (which would compute a version below
1407        // the real release history, T-25-04).
1408        let dir = tempfile::tempdir().unwrap();
1409        let root = dir.path();
1410        init_repo(root);
1411        commit_msg(root, "a.txt", "chore: init");
1412        tag(root, "v1.0.0");
1413        let main_branch = current_branch(root);
1414
1415        git(root, &["checkout", "--orphan", "orphan-release"]);
1416        git(
1417            root,
1418            &["commit", "--allow-empty", "-q", "-m", "chore: orphan"],
1419        );
1420        tag(root, "v9.9.9");
1421        git(root, &["checkout", &main_branch]);
1422
1423        let err = compute_version(root).unwrap_err();
1424        match err {
1425            VersionError::UnreachableBaseline { tag } => {
1426                assert_eq!(tag, "v9.9.9", "refusal must name the unreachable tag");
1427            }
1428            other => {
1429                panic!("expected UnreachableBaseline (never a silent smaller Ok), got: {other:?}")
1430            }
1431        }
1432    }
1433
1434    #[test]
1435    fn range_with_no_bumping_commits_yields_patch_floor() {
1436        let dir = tempfile::tempdir().unwrap();
1437        let root = dir.path();
1438        init_repo(root);
1439        commit_msg(root, "a.txt", "chore: init");
1440        tag(root, "v1.0.0");
1441        commit_msg(root, "b.txt", "docs: update readme");
1442        commit_msg(root, "c.txt", "chore: tidy up");
1443        commit_msg(root, "d.txt", "ci: tweak workflow");
1444
1445        let v = compute_version(root).unwrap();
1446        assert_eq!(
1447            v,
1448            Version {
1449                major: 1,
1450                minor: 0,
1451                patch: 1
1452            }
1453        );
1454    }
1455
1456    #[test]
1457    fn malformed_commit_message_yields_patch_not_crash_or_major() {
1458        let dir = tempfile::tempdir().unwrap();
1459        let root = dir.path();
1460        init_repo(root);
1461        commit_msg(root, "a.txt", "chore: init");
1462        tag(root, "v1.0.0");
1463        commit_msg(
1464            root,
1465            "b.txt",
1466            "just a plain message with no conventional type prefix!!!",
1467        );
1468
1469        let v = compute_version(root).unwrap();
1470        assert_eq!(
1471            v,
1472            Version {
1473                major: 1,
1474                minor: 0,
1475                patch: 1
1476            }
1477        );
1478    }
1479
1480    #[test]
1481    fn exclamation_before_colon_yields_major() {
1482        let dir = tempfile::tempdir().unwrap();
1483        let root = dir.path();
1484        init_repo(root);
1485        commit_msg(root, "a.txt", "chore: init");
1486        tag(root, "v1.0.0");
1487        commit_msg(root, "b.txt", "feat(scope)!: drop legacy api");
1488
1489        let v = compute_version(root).unwrap();
1490        assert_eq!(
1491            v,
1492            Version {
1493                major: 2,
1494                minor: 0,
1495                patch: 0
1496            }
1497        );
1498    }
1499
1500    #[test]
1501    fn breaking_change_footer_yields_major_even_with_fix_subject() {
1502        let dir = tempfile::tempdir().unwrap();
1503        let root = dir.path();
1504        init_repo(root);
1505        commit_msg(root, "a.txt", "chore: init");
1506        tag(root, "v1.0.0");
1507        git(
1508            root,
1509            &[
1510                "commit",
1511                "--allow-empty",
1512                "-q",
1513                "-m",
1514                "fix: patch a thing\n\nBREAKING CHANGE: removes an implicit default",
1515            ],
1516        );
1517
1518        let v = compute_version(root).unwrap();
1519        assert_eq!(
1520            v,
1521            Version {
1522                major: 2,
1523                minor: 0,
1524                patch: 0
1525            }
1526        );
1527    }
1528
1529    #[test]
1530    fn exclamation_only_in_description_does_not_yield_major() {
1531        let dir = tempfile::tempdir().unwrap();
1532        let root = dir.path();
1533        init_repo(root);
1534        commit_msg(root, "a.txt", "chore: init");
1535        tag(root, "v1.0.0");
1536        commit_msg(root, "b.txt", "fix: stop the crash!!!");
1537
1538        let v = compute_version(root).unwrap();
1539        assert_eq!(
1540            v,
1541            Version {
1542                major: 1,
1543                minor: 0,
1544                patch: 1
1545            }
1546        );
1547    }
1548
1549    #[test]
1550    fn write_version_replaces_in_cargo_toml() {
1551        let dir = tempfile::tempdir().unwrap();
1552        std::fs::write(
1553            dir.path().join("Cargo.toml"),
1554            "[package]\nversion = \"0.1.0\"\n",
1555        )
1556        .unwrap();
1557        let path = write_version(
1558            dir.path(),
1559            &Version {
1560                major: 2,
1561                minor: 3,
1562                patch: 4,
1563            },
1564        )
1565        .unwrap();
1566        let contents = std::fs::read_to_string(&path).unwrap();
1567        assert!(contents.contains("version = \"2.3.4\""));
1568    }
1569
1570    #[test]
1571    fn write_version_replaces_in_workspace_cargo_toml() {
1572        let dir = tempfile::tempdir().unwrap();
1573        std::fs::write(
1574            dir.path().join("Cargo.toml"),
1575            "[workspace.package]\nversion = \"0.1.0\"\nedition = \"2024\"\n",
1576        )
1577        .unwrap();
1578        let path = write_version(
1579            dir.path(),
1580            &Version {
1581                major: 2,
1582                minor: 3,
1583                patch: 4,
1584            },
1585        )
1586        .unwrap();
1587        let contents = std::fs::read_to_string(&path).unwrap();
1588        assert!(contents.contains("[workspace.package]\nversion = \"2.3.4\""));
1589    }
1590
1591    #[test]
1592    fn write_version_errors_without_version_file() {
1593        let dir = tempfile::tempdir().unwrap();
1594        assert!(matches!(
1595            write_version(
1596                dir.path(),
1597                &Version {
1598                    major: 1,
1599                    minor: 0,
1600                    patch: 0
1601                }
1602            ),
1603            Err(VersionError::Parse(_))
1604        ));
1605    }
1606
1607    #[test]
1608    fn read_version_round_trips_through_write_version_in_plain_cargo_toml() {
1609        let dir = tempfile::tempdir().unwrap();
1610        std::fs::write(
1611            dir.path().join("Cargo.toml"),
1612            "[package]\nversion = \"0.1.0\"\n",
1613        )
1614        .unwrap();
1615        let written = Version {
1616            major: 2,
1617            minor: 3,
1618            patch: 4,
1619        };
1620        write_version(dir.path(), &written).unwrap();
1621        assert_eq!(read_version(dir.path()).unwrap(), written);
1622    }
1623
1624    #[test]
1625    fn read_version_round_trips_through_write_version_in_workspace_cargo_toml() {
1626        let dir = tempfile::tempdir().unwrap();
1627        std::fs::write(
1628            dir.path().join("Cargo.toml"),
1629            "[workspace.package]\nversion = \"0.1.0\"\nedition = \"2024\"\n",
1630        )
1631        .unwrap();
1632        let written = Version {
1633            major: 5,
1634            minor: 6,
1635            patch: 7,
1636        };
1637        write_version(dir.path(), &written).unwrap();
1638        assert_eq!(read_version(dir.path()).unwrap(), written);
1639    }
1640
1641    #[test]
1642    fn read_version_round_trips_through_write_version_in_package_json() {
1643        let dir = tempfile::tempdir().unwrap();
1644        std::fs::write(
1645            dir.path().join("package.json"),
1646            "{\n  \"version\": \"0.1.0\"\n}\n",
1647        )
1648        .unwrap();
1649        let written = Version {
1650            major: 1,
1651            minor: 9,
1652            patch: 12,
1653        };
1654        write_version(dir.path(), &written).unwrap();
1655        assert_eq!(read_version(dir.path()).unwrap(), written);
1656    }
1657
1658    #[test]
1659    fn read_version_errors_without_version_file() {
1660        let dir = tempfile::tempdir().unwrap();
1661        assert!(matches!(
1662            read_version(dir.path()),
1663            Err(VersionError::Parse(_))
1664        ));
1665    }
1666
1667    #[test]
1668    fn write_version_preserves_trailing_comma_in_package_json() {
1669        // GAP-6: replace_version_in_contents reassembles the matched line as
1670        // `left.trim_end() + separator + quoted_version + '\n'`, discarding
1671        // everything in `value` after the version token. For a real
1672        // package.json where `version` is not the last key, that eats the
1673        // mandatory trailing comma and produces invalid JSON. Parsing is the
1674        // assertion that matters here — a substring check would be a
1675        // vacuous fixture that can't reach this defect.
1676        let dir = tempfile::tempdir().unwrap();
1677        std::fs::write(
1678            dir.path().join("package.json"),
1679            "{\n  \"name\": \"x\",\n  \"version\": \"0.1.0\",\n  \"private\": true\n}\n",
1680        )
1681        .unwrap();
1682        write_version(
1683            dir.path(),
1684            &Version {
1685                major: 2,
1686                minor: 3,
1687                patch: 4,
1688            },
1689        )
1690        .unwrap();
1691        let contents = std::fs::read_to_string(dir.path().join("package.json")).unwrap();
1692        let parsed: serde_json::Value = serde_json::from_str(&contents).unwrap_or_else(|err| {
1693            panic!("package.json no longer parses as JSON: {err}\n{contents}")
1694        });
1695        assert_eq!(parsed["name"], "x");
1696        assert_eq!(parsed["private"], true);
1697        assert_eq!(parsed["version"], "2.3.4");
1698    }
1699
1700    #[test]
1701    fn write_version_preserves_trailing_comment_in_toml() {
1702        // GAP-6, TOML variant: a trailing `# comment` after the quoted
1703        // version is discarded by the same line-reassembly defect.
1704        let dir = tempfile::tempdir().unwrap();
1705        std::fs::write(
1706            dir.path().join("Cargo.toml"),
1707            "[package]\nversion = \"0.1.0\"  # pinned\n",
1708        )
1709        .unwrap();
1710        write_version(
1711            dir.path(),
1712            &Version {
1713                major: 2,
1714                minor: 3,
1715                patch: 4,
1716            },
1717        )
1718        .unwrap();
1719        let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
1720        assert!(
1721            contents.contains("version = \"2.3.4\"  # pinned"),
1722            "expected trailing comment to survive, got: {contents}"
1723        );
1724    }
1725
1726    #[test]
1727    fn write_version_preserves_trailing_comment_in_single_quoted_toml() {
1728        // GAP-6, TOML literal-string variant (17-13 review IN-03): the
1729        // remainder scan keys off the OPENING quote character, so the
1730        // single-quote branch is a distinct path from the double-quote case
1731        // above and needs its own fixture.
1732        let dir = tempfile::tempdir().unwrap();
1733        std::fs::write(
1734            dir.path().join("Cargo.toml"),
1735            "[package]\nversion = '0.1.0'  # pinned\n",
1736        )
1737        .unwrap();
1738        write_version(
1739            dir.path(),
1740            &Version {
1741                major: 2,
1742                minor: 3,
1743                patch: 4,
1744            },
1745        )
1746        .unwrap();
1747        let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
1748        assert!(
1749            contents.contains("version = '2.3.4'  # pinned"),
1750            "expected single-quoted value and trailing comment to survive, got: {contents}"
1751        );
1752    }
1753
1754    #[test]
1755    fn read_version_extracts_clean_value_with_trailing_comment() {
1756        // CR-01 (phase 20 review): `find_version_in_contents` used to
1757        // `trim_matches` the whole tail of the line, which only strips a
1758        // quote sitting at the very end of the remaining string. With a
1759        // trailing `# comment` after the closing quote, the real closing
1760        // quote is never stripped and the corrupted value fails to parse.
1761        // `write_version` already preserves this exact pattern (GAP-6); the
1762        // read path must be symmetric with it.
1763        let dir = tempfile::tempdir().unwrap();
1764        std::fs::write(
1765            dir.path().join("Cargo.toml"),
1766            "[package]\nversion = \"1.7.0\"  # pinned release version\n",
1767        )
1768        .unwrap();
1769        assert_eq!(
1770            read_version(dir.path()).unwrap(),
1771            Version {
1772                major: 1,
1773                minor: 7,
1774                patch: 0
1775            }
1776        );
1777    }
1778
1779    #[test]
1780    fn read_version_extracts_clean_value_without_trailing_comment() {
1781        // Bare `version = "1.7.0"` (no comment) must still work.
1782        let dir = tempfile::tempdir().unwrap();
1783        std::fs::write(
1784            dir.path().join("Cargo.toml"),
1785            "[package]\nversion = \"1.7.0\"\n",
1786        )
1787        .unwrap();
1788        assert_eq!(
1789            read_version(dir.path()).unwrap(),
1790            Version {
1791                major: 1,
1792                minor: 7,
1793                patch: 0
1794            }
1795        );
1796    }
1797
1798    #[test]
1799    fn read_workspace_self_pins_extracts_clean_workspace_version_with_trailing_comment() {
1800        // CR-01: `read_workspace_self_pins` calls `find_version_in_contents`
1801        // for `workspace_version` too — a trailing comment next to
1802        // `[workspace.package] version` must not corrupt the value
1803        // `check_self_pin` compares pins against.
1804        let (workspace_version, _pins) = read_workspace_self_pins(
1805            "[workspace.package]\nversion = \"1.7.0\"  # pinned release version\nedition = \"2024\"\n",
1806        );
1807        assert_eq!(workspace_version.as_deref(), Some("1.7.0"));
1808    }
1809
1810    #[test]
1811    fn read_version_does_not_recompute_from_git_tags() {
1812        // read_version must report exactly what's on disk, not a freshly
1813        // computed minor/patch — this is the property VersionBump/
1814        // ChangelogAppend ordering depends on (version.rs must never see a
1815        // tag VersionBump just created and derive a different number).
1816        let dir = tempfile::tempdir().unwrap();
1817        let root = dir.path();
1818        init_repo(root);
1819        std::fs::write(root.join("Cargo.toml"), "[package]\nversion = \"2.0.0\"\n").unwrap();
1820        commit(root, "a.txt");
1821        write_version(
1822            root,
1823            &Version {
1824                major: 2,
1825                minor: 0,
1826                patch: 0,
1827            },
1828        )
1829        .unwrap();
1830        git(root, &["tag", "v2.0.0"]);
1831        commit(root, "b.txt");
1832        commit(root, "c.txt");
1833        // compute_version would recompute from git history (baseline v2.0.0,
1834        // bumped by whatever the two later commits classify to) instead of
1835        // reporting the version file. read_version must still report exactly
1836        // what's on disk: 2.0.0.
1837        assert_eq!(
1838            read_version(root).unwrap(),
1839            Version {
1840                major: 2,
1841                minor: 0,
1842                patch: 0
1843            }
1844        );
1845    }
1846
1847    #[test]
1848    fn write_version_rewrites_workspace_dependency_self_pin() {
1849        // 20a / DEN-49: a published Cargo workspace states its version twice —
1850        // once in [workspace.package] version, and again as an explicit
1851        // `version` pin on every [workspace.dependencies] entry that points
1852        // at a workspace member by `path` (Cargo has no interpolation for
1853        // dependency versions, and a path dependency of a *published* crate
1854        // requires an explicit version). write_version must rewrite BOTH in
1855        // one write, or the self-pin ships stale and `cargo publish` rejects
1856        // the upload as a duplicate on release day (shipped broken twice:
1857        // v1.5.0 by 7ad260c, v1.6.0 by PR #15).
1858        let dir = tempfile::tempdir().unwrap();
1859        std::fs::write(
1860            dir.path().join("Cargo.toml"),
1861            "[workspace.package]\nversion = \"1.6.0\"\nedition = \"2024\"\n\n\
1862             [workspace.dependencies]\n\
1863             devflow-core = { path = \"crates/devflow-core\", version = \"1.6.0\" }\n",
1864        )
1865        .unwrap();
1866        write_version(
1867            dir.path(),
1868            &Version {
1869                major: 1,
1870                minor: 7,
1871                patch: 0,
1872            },
1873        )
1874        .unwrap();
1875        let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
1876        assert!(
1877            contents.contains("[workspace.package]\nversion = \"1.7.0\""),
1878            "expected [workspace.package] version to be rewritten, got: {contents}"
1879        );
1880        assert!(
1881            contents
1882                .contains("devflow-core = { path = \"crates/devflow-core\", version = \"1.7.0\" }"),
1883            "expected the [workspace.dependencies] self-pin to be rewritten to 1.7.0 \
1884             alongside [workspace.package] version, got: {contents}"
1885        );
1886    }
1887
1888    #[test]
1889    fn write_version_no_ops_on_missing_workspace_dependencies_section() {
1890        // 20a/empty: a workspace Cargo.toml with no [workspace.dependencies]
1891        // section at all must not panic — the additive pass simply never
1892        // matches and the file is otherwise rewritten normally.
1893        let dir = tempfile::tempdir().unwrap();
1894        std::fs::write(
1895            dir.path().join("Cargo.toml"),
1896            "[workspace.package]\nversion = \"1.6.0\"\nedition = \"2024\"\n",
1897        )
1898        .unwrap();
1899        write_version(
1900            dir.path(),
1901            &Version {
1902                major: 1,
1903                minor: 7,
1904                patch: 0,
1905            },
1906        )
1907        .unwrap();
1908        let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
1909        assert_eq!(
1910            contents,
1911            "[workspace.package]\nversion = \"1.7.0\"\nedition = \"2024\"\n"
1912        );
1913    }
1914
1915    #[test]
1916    fn write_version_no_ops_on_member_with_no_version_key() {
1917        // 20a/empty: a [workspace.dependencies] entry with a local `path`
1918        // but no `version` key at all is left unchanged — nothing to
1919        // rewrite, and no panic.
1920        let dir = tempfile::tempdir().unwrap();
1921        let toml = "[workspace.package]\nversion = \"1.6.0\"\nedition = \"2024\"\n\n\
1922             [workspace.dependencies]\n\
1923             devflow-core = { path = \"crates/devflow-core\" }\n";
1924        std::fs::write(dir.path().join("Cargo.toml"), toml).unwrap();
1925        write_version(
1926            dir.path(),
1927            &Version {
1928                major: 1,
1929                minor: 7,
1930                patch: 0,
1931            },
1932        )
1933        .unwrap();
1934        let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
1935        assert!(
1936            contents.contains("devflow-core = { path = \"crates/devflow-core\" }"),
1937            "expected the version-less path member to be left byte-identical, got: {contents}"
1938        );
1939    }
1940
1941    #[test]
1942    fn write_version_leaves_third_party_version_only_dep_untouched() {
1943        // 20a/adjacency: a third-party version-only dep sitting adjacent to
1944        // a local path member is left byte-for-byte unchanged — only the
1945        // path member's version sub-value is rewritten.
1946        let dir = tempfile::tempdir().unwrap();
1947        let third_party_line = "serde = { version = \"1\", features = [\"derive\"] }";
1948        let toml = format!(
1949            "[workspace.package]\nversion = \"1.6.0\"\nedition = \"2024\"\n\n\
1950             [workspace.dependencies]\n\
1951             devflow-core = {{ path = \"crates/devflow-core\", version = \"1.6.0\" }}\n\
1952             {third_party_line}\n"
1953        );
1954        std::fs::write(dir.path().join("Cargo.toml"), &toml).unwrap();
1955        write_version(
1956            dir.path(),
1957            &Version {
1958                major: 1,
1959                minor: 7,
1960                patch: 0,
1961            },
1962        )
1963        .unwrap();
1964        let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
1965        assert!(
1966            contents
1967                .contains("devflow-core = { path = \"crates/devflow-core\", version = \"1.7.0\" }"),
1968            "expected the local path member's version to be rewritten, got: {contents}"
1969        );
1970        assert!(
1971            contents.contains(third_party_line),
1972            "expected the third-party version-only dep to be byte-identical, got: {contents}"
1973        );
1974    }
1975
1976    #[test]
1977    fn write_version_preserves_comment_and_quote_in_workspace_dependency_pin() {
1978        // GAP-6, inline-table variant: a self-pin line with a trailing
1979        // comment and single-quoted values keeps its comment and quote
1980        // style after rewrite.
1981        let dir = tempfile::tempdir().unwrap();
1982        let toml = "[workspace.package]\nversion = \"1.6.0\"\nedition = \"2024\"\n\n\
1983             [workspace.dependencies]\n\
1984             devflow-core = { path = 'crates/devflow-core', version = '1.6.0' }  # pinned\n";
1985        std::fs::write(dir.path().join("Cargo.toml"), toml).unwrap();
1986        write_version(
1987            dir.path(),
1988            &Version {
1989                major: 1,
1990                minor: 7,
1991                patch: 0,
1992            },
1993        )
1994        .unwrap();
1995        let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
1996        assert!(
1997            contents.contains(
1998                "devflow-core = { path = 'crates/devflow-core', version = '1.7.0' }  # pinned"
1999            ),
2000            "expected single-quote style and trailing comment to survive the rewrite, got: {contents}"
2001        );
2002    }
2003
2004    #[test]
2005    fn write_version_rewrites_self_pin_regardless_of_key_order() {
2006        // review: inline-table key-order — the version sub-value is
2007        // rewritten whether it appears BEFORE or AFTER path in the inline
2008        // table; the replacement is anchored strictly to the path=/
2009        // version= tokens, not a column offset.
2010        let dir = tempfile::tempdir().unwrap();
2011        let toml = "[workspace.package]\nversion = \"1.6.0\"\nedition = \"2024\"\n\n\
2012             [workspace.dependencies]\n\
2013             devflow-core = { version = \"1.6.0\", path = \"crates/devflow-core\" }\n";
2014        std::fs::write(dir.path().join("Cargo.toml"), toml).unwrap();
2015        write_version(
2016            dir.path(),
2017            &Version {
2018                major: 1,
2019                minor: 7,
2020                patch: 0,
2021            },
2022        )
2023        .unwrap();
2024        let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
2025        assert!(
2026            contents
2027                .contains("devflow-core = { version = \"1.7.0\", path = \"crates/devflow-core\" }"),
2028            "expected version to be rewritten regardless of key order, got: {contents}"
2029        );
2030    }
2031}