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 crate::git::git_command;
20use std::path::{Path, PathBuf};
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 = git_command(project_root)
121        .arg("tag")
122        .output()
123        .map_err(|err| VersionError::Git(err.to_string()))?;
124    if !output.status.success() {
125        return Err(VersionError::Git(
126            String::from_utf8_lossy(&output.stderr).trim().to_string(),
127        ));
128    }
129    let count = String::from_utf8_lossy(&output.stdout)
130        .lines()
131        .filter(|l| !l.trim().is_empty())
132        .count();
133    Ok(count as u32)
134}
135
136/// Count commits since the most recent tag. If there are no tags yet, counts
137/// all commits reachable from HEAD.
138///
139/// **Superseded (D-08):** `compute_version` no longer derives PATCH from
140/// `git describe` distance — use [`classify_range_bump`] over
141/// [`release_range_start`]'s anchored range instead. Retained (rather than
142/// deleted) for the same published-crate-API reason as
143/// [`count_git_tags`]'s doc comment.
144#[deprecated(note = "superseded by `classify_range_bump` (D-08)")]
145pub fn commits_since_last_minor_tag(project_root: &Path) -> Result<u32, VersionError> {
146    let last_tag = git_command(project_root)
147        .args(["describe", "--tags", "--abbrev=0"])
148        .output()
149        .map_err(|err| VersionError::Git(err.to_string()))?;
150
151    let range = if last_tag.status.success() {
152        let tag = String::from_utf8_lossy(&last_tag.stdout).trim().to_string();
153        format!("{tag}..HEAD")
154    } else {
155        "HEAD".to_string()
156    };
157
158    let output = git_command(project_root)
159        .args(["rev-list", "--count", &range])
160        .output()
161        .map_err(|err| VersionError::Git(err.to_string()))?;
162    if !output.status.success() {
163        // No commits yet (e.g. empty repo) → zero patch.
164        return Ok(0);
165    }
166    let count = String::from_utf8_lossy(&output.stdout)
167        .trim()
168        .parse::<u32>()
169        .unwrap_or(0);
170    Ok(count)
171}
172
173/// Enumerate every tag in the repository (no reachability restriction), keep
174/// only values that parse as `vMAJOR.MINOR.PATCH` semver (a leading `v` is
175/// stripped first — the `semver` crate's grammar is bare `MAJOR.MINOR.PATCH`),
176/// and return the maximum by semver ordering (D-07). A stray non-semver tag
177/// (e.g. this repository's `archive-planning-docs-2026-07-24`) is silently
178/// excluded via `filter_map(...ok())` rather than erroring — a malformed tag
179/// can never crash this path (T-25-02).
180pub fn highest_semver_tag(project_root: &Path) -> Result<Option<semver::Version>, VersionError> {
181    let output = git_command(project_root)
182        .arg("tag")
183        .output()
184        .map_err(|err| VersionError::Git(err.to_string()))?;
185    if !output.status.success() {
186        return Err(VersionError::Git(
187            String::from_utf8_lossy(&output.stderr).trim().to_string(),
188        ));
189    }
190    Ok(String::from_utf8_lossy(&output.stdout)
191        .lines()
192        .filter_map(|line| line.trim().strip_prefix('v'))
193        .filter_map(|stripped| semver::Version::parse(stripped).ok())
194        .max())
195}
196
197/// As [`highest_semver_tag`], but restricted to tags reachable from `HEAD`
198/// via `git tag --merged HEAD` — one spawn instead of an O(n) per-tag
199/// `merge-base --is-ancestor` loop, mirroring `GitFlow::cleanup_merged`'s
200/// existing `branch --merged` precedent in `git.rs`. This is `compute_version`'s
201/// baseline (D-07).
202///
203/// **D-12 coupling:** this predicate's correctness depends on the `develop`
204/// → `main` sync PR being MERGED, not squashed — a squashed sync breaks the
205/// ancestry link this `--merged` check relies on. `compute_version`'s
206/// refusal (D-10, `VersionError::UnreachableBaseline`) is the mitigation if
207/// that discipline is ever violated; 999.52 is the backlog item that would
208/// ship a structural repair, deliberately not in this phase.
209pub fn reachable_semver_baseline(
210    project_root: &Path,
211) -> Result<Option<semver::Version>, VersionError> {
212    let output = git_command(project_root)
213        .args(["tag", "--merged", "HEAD"])
214        .output()
215        .map_err(|err| VersionError::Git(err.to_string()))?;
216    if !output.status.success() {
217        return Err(VersionError::Git(
218            String::from_utf8_lossy(&output.stderr).trim().to_string(),
219        ));
220    }
221    Ok(String::from_utf8_lossy(&output.stdout)
222        .lines()
223        .filter_map(|line| line.trim().strip_prefix('v'))
224        .filter_map(|stripped| semver::Version::parse(stripped).ok())
225        .max())
226}
227
228/// Resolve `commit`'s first parent SHA, or `Ok(None)` if `commit` is a root
229/// commit with no first parent.
230///
231/// A non-zero exit from `git rev-parse {commit}^1` means "no such parent"
232/// (root commit), not a genuine spawn/IO failure — those still propagate
233/// via `?` through the `Command::output()` call itself.
234fn first_parent(project_root: &Path, commit: &str) -> Result<Option<String>, VersionError> {
235    let output = git_command(project_root)
236        .args(["rev-parse", &format!("{commit}^1")])
237        .output()
238        .map_err(|err| VersionError::Git(err.to_string()))?;
239    if !output.status.success() {
240        return Ok(None);
241    }
242    Ok(Some(
243        String::from_utf8_lossy(&output.stdout).trim().to_string(),
244    ))
245}
246
247/// Resolve the commit range start for D-08's conventional-commit classifier,
248/// given the baseline tag name (e.g. `"v2.0.0"`).
249///
250/// This exists because every release in this repository squash-merges
251/// `develop` into `main`, so no develop-side commit is ever an ancestor of
252/// the release tag it was squashed into — a `-X ours` sync merge-back
253/// restores ancestry in the OTHER direction only (the tag becomes an
254/// ancestor of `HEAD`, which is what makes D-07's `--merged HEAD`
255/// reachability filter work), but the commits the tag *released* stay
256/// outside its ancestry forever. A literal `baseline..HEAD` range therefore
257/// re-includes the entire pre-release history on every subsequent ship —
258/// measured live 2026-07-27: `v2.0.0..HEAD` is 677 non-merge commits (62
259/// `feat`), against 5 (0 `feat`) for the anchored range this function
260/// computes. See 25-01-PLAN.md's `<measured_correction>`.
261///
262/// Anchor rule (generalized 2026-07-28 to fix CR-03 — `25-REVIEW.md`,
263/// `25-VERIFICATION.md` GAP 2):
264/// - Walk `git rev-list --ancestry-path --reverse <tag>..HEAD` oldest-first.
265///   For each candidate commit `C` in order: if `C` has no first parent (a
266///   root commit), or the baseline tag is NOT an ancestor of `C`'s first
267///   parent, `C` is where the tag's line joined `HEAD`'s line — return `C`
268///   immediately.
269/// - If every candidate's first parent already descends from the tag, the
270///   tag already sat on this mainline throughout (the ordinary,
271///   non-squashed case, e.g. `v1.8.0..v1.8.1`) — return the tag unchanged.
272/// - If the ancestry path is empty, the tag is at `HEAD` — return the tag.
273///
274/// **CR-03** — the previous rule inspected only the ancestry path's FIRST
275/// commit (`C1`). When a commit lands directly on trunk between the tag and
276/// the sync-merge-back (a hotfix pushed straight to `main`), that
277/// intervening commit becomes `C1`; its first parent IS the tag commit, so
278/// `merge-base --is-ancestor <tag> <tag>` is trivially true, and the old
279/// rule wrongly concluded the tag already sat on mainline — returning the
280/// literal `tag..HEAD` range and re-admitting pre-release `develop` history.
281/// Walking the FULL path instead of just `C1` fixes this: the sync merge
282/// itself still fails the ancestor test and is returned once the walk
283/// reaches it.
284///
285/// **Anchoring at the LAST merge commit instead (a plausible-looking
286/// alternative) is WRONG on this repository.** `GitFlow::merge_feature_into_develop`
287/// (`git.rs:86`) merges every phase branch into `develop` with `git merge
288/// --no-ff`, so ordinary post-release feature work also produces merge
289/// commits on the ancestry path — not just the sync-merge-back. Anchoring at
290/// the last one would silently truncate the range at that later feature
291/// merge instead of the sync merge, dropping any commits between the two
292/// from classification — a `feat!:` in that position would be dropped
293/// unnoticed, the exact false negative D-09 exists to prevent. See
294/// `tests::feature_merge_after_sync_merge_does_not_move_the_anchor`.
295pub fn release_range_start(
296    project_root: &Path,
297    baseline_tag: &str,
298) -> Result<String, VersionError> {
299    let ancestry = git_command(project_root)
300        .args([
301            "rev-list",
302            "--ancestry-path",
303            "--reverse",
304            &format!("{baseline_tag}..HEAD"),
305        ])
306        .output()
307        .map_err(|err| VersionError::Git(err.to_string()))?;
308    if !ancestry.status.success() {
309        return Err(VersionError::Git(
310            String::from_utf8_lossy(&ancestry.stderr).trim().to_string(),
311        ));
312    }
313    let path: Vec<String> = String::from_utf8_lossy(&ancestry.stdout)
314        .lines()
315        .filter(|line| !line.trim().is_empty())
316        .map(str::to_string)
317        .collect();
318    if path.is_empty() {
319        // Nothing after the tag — it sits at HEAD.
320        return Ok(baseline_tag.to_string());
321    }
322
323    for candidate in &path {
324        let Some(first_parent) = first_parent(project_root, candidate)? else {
325            // `candidate` is a root commit with no first parent — the tag
326            // cannot be an ancestor of something that doesn't exist; this is
327            // where the tag's line joined HEAD's line.
328            return Ok(candidate.clone());
329        };
330
331        let tag_is_ancestor_of_first_parent = git_command(project_root)
332            .args(["merge-base", "--is-ancestor", baseline_tag, &first_parent])
333            .output()
334            .map(|out| out.status.success())
335            .unwrap_or(false);
336
337        if !tag_is_ancestor_of_first_parent {
338            // `candidate` is where the tag's line joined HEAD's line (the
339            // sync merge-back, or equivalent).
340            return Ok(candidate.clone());
341        }
342        // `candidate` is on the mainline the tag already sat on: keep
343        // walking the path toward HEAD.
344    }
345
346    // Every candidate's first parent already descended from the tag — the
347    // ordinary, non-squashed release case.
348    Ok(baseline_tag.to_string())
349}
350
351/// The classified conventional-commit bump for a range of commits (D-08).
352/// Declaration order is the precedence order (lowest to highest), so
353/// `Iterator::max()`/[`Ord::max`] over a range's individual classifications
354/// yields the highest-precedence result directly.
355#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
356pub enum Bump {
357    /// No commit's type maps to a version-affecting change (`docs`, `test`,
358    /// `chore`, `ci`, `refactor`, `style`). `compute_version` collapses this
359    /// to [`Bump::Patch`] at the call site (D-10's floor) so a range with
360    /// nothing bumping still yields a distinct version.
361    None,
362    /// `fix`/`perf`; any recognised-but-unlisted conventional-commit type
363    /// (D-10's same floor); or a commit message that failed to parse as a
364    /// conventional commit at all (D-10: unrecognised/malformed → patch).
365    Patch,
366    /// `feat`.
367    Minor,
368    /// A breaking change: `!` after an optional scope and before the colon
369    /// (`feat(scope)!: ...`), or a `BREAKING CHANGE:`/`BREAKING-CHANGE:`
370    /// footer, regardless of the commit's own type.
371    Major,
372}
373
374/// Classify the highest-precedence conventional-commit bump over
375/// `--no-merges` commits in `range_start..HEAD`. `range_start` may be the
376/// empty string, meaning "no baseline tag exists" — the whole history
377/// reachable from `HEAD` is classified instead (`git log --no-merges HEAD`,
378/// no exclusion).
379///
380/// Commits are read via `%H%x1f%B%x1e`: `%B` is the raw message (subject,
381/// blank line, body and footers) in exactly the shape
382/// `git_conventional::Commit::parse` expects, and `%x1f`/`%x1e` are git's own
383/// unit/record separators — safe against arbitrary characters a commit
384/// message may contain, unlike splitting on newlines.
385pub fn classify_range_bump(project_root: &Path, range_start: &str) -> Result<Bump, VersionError> {
386    let range = if range_start.is_empty() {
387        "HEAD".to_string()
388    } else {
389        format!("{range_start}..HEAD")
390    };
391    let output = git_command(project_root)
392        .args(["log", "--no-merges", &range, "--format=%H%x1f%B%x1e"])
393        .output()
394        .map_err(|err| VersionError::Git(err.to_string()))?;
395    if !output.status.success() {
396        return Err(VersionError::Git(
397            String::from_utf8_lossy(&output.stderr).trim().to_string(),
398        ));
399    }
400    let stdout = String::from_utf8_lossy(&output.stdout);
401    let mut bump = Bump::None;
402    for record in stdout.split('\u{1e}') {
403        let record = record.trim_matches('\n');
404        if record.is_empty() {
405            continue;
406        }
407        let Some((_hash, message)) = record.split_once('\u{1f}') else {
408            continue;
409        };
410        let this_bump = classify_commit_message(message.trim());
411        bump = bump.max(this_bump);
412    }
413    Ok(bump)
414}
415
416/// Classify one commit message's bump per D-08/D-10. An unparseable message
417/// (D-10: unrecognised/malformed) and a breaking-change marker (regardless of
418/// type) are both checked before the type match, since either overrides a
419/// recognised type's own precedence.
420fn classify_commit_message(message: &str) -> Bump {
421    let Ok(commit) = git_conventional::Commit::parse(message) else {
422        return Bump::Patch;
423    };
424    if commit.breaking() {
425        return Bump::Major;
426    }
427    let ty = commit.type_();
428    if ty == git_conventional::Type::FEAT {
429        Bump::Minor
430    } else if ty == git_conventional::Type::FIX || ty == git_conventional::Type::PERF {
431        Bump::Patch
432    } else if ty == git_conventional::Type::DOCS
433        || ty == git_conventional::Type::TEST
434        || ty == git_conventional::Type::CHORE
435        || ty == "ci"
436        || ty == git_conventional::Type::REFACTOR
437        || ty == git_conventional::Type::STYLE
438    {
439        Bump::None
440    } else {
441        // Any other recognised-but-unlisted type — D-10's same floor.
442        Bump::Patch
443    }
444}
445
446/// Keep-a-Changelog heading a changelog bullet is grouped under (D-12).
447/// Declaration order is the render order [`render_changelog_body`] emits
448/// sections in: breaking changes first, then what's new, then what's fixed,
449/// then everything else.
450#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
451pub enum ChangelogHeading {
452    /// A breaking change (`!` marker or `BREAKING CHANGE:`/`BREAKING-CHANGE:`
453    /// footer), regardless of the commit's own type.
454    Breaking,
455    /// `feat`.
456    Added,
457    /// `fix`/`perf`.
458    Fixed,
459    /// Every other conventional-commit type (`docs`, `test`, `chore`, the
460    /// string `"ci"`, `refactor`, `style`, or any other recognized-but-
461    /// unlisted type), and any message that fails to parse as a
462    /// conventional commit at all.
463    Changed,
464}
465
466impl ChangelogHeading {
467    /// This heading's Keep-a-Changelog markdown heading line.
468    pub fn as_markdown_heading(self) -> &'static str {
469        match self {
470            ChangelogHeading::Breaking => "### Breaking",
471            ChangelogHeading::Added => "### Added",
472            ChangelogHeading::Fixed => "### Fixed",
473            ChangelogHeading::Changed => "### Changed",
474        }
475    }
476}
477
478/// Maximum length, in characters, of a sanitized changelog bullet
479/// ([`sanitize_changelog_subject`]).
480pub const CHANGELOG_SUBJECT_MAX_CHARS: usize = 200;
481
482/// Neutralize and bound a commit-derived changelog bullet before it reaches
483/// `CHANGELOG.md` or a `tracing` line (D-12, ASVS V7, T-26-05). Commit
484/// subjects are contributor-authored text — the same attacker-influenced
485/// class `T-17-13`/`T-25-52` already redact — so every
486/// [`char::is_control`] character is mapped to a single space, then, if the
487/// result exceeds [`CHANGELOG_SUBJECT_MAX_CHARS`] characters, it is
488/// truncated so the returned string is exactly `CHANGELOG_SUBJECT_MAX_CHARS`
489/// characters including the trailing `… [truncated]` marker. Mirrors
490/// `render_gate_context`'s properties (`pipeline_outcomes.rs:323`) — a
491/// sibling, not a shared function, since that one is `pub(crate)` inside
492/// `devflow-cli` and not importable from `devflow-core`.
493pub fn sanitize_changelog_subject(subject: &str) -> String {
494    const MARKER: &str = "… [truncated]";
495    let sanitized: String = subject
496        .chars()
497        .map(|character| {
498            if character.is_control() {
499                ' '
500            } else {
501                character
502            }
503        })
504        .collect();
505    if sanitized.chars().count() <= CHANGELOG_SUBJECT_MAX_CHARS {
506        return sanitized;
507    }
508    let marker_len = MARKER.chars().count().min(CHANGELOG_SUBJECT_MAX_CHARS);
509    let head_len = CHANGELOG_SUBJECT_MAX_CHARS.saturating_sub(marker_len);
510    let head: String = sanitized.chars().take(head_len).collect();
511    let marker: String = MARKER.chars().take(marker_len).collect();
512    format!("{head}{marker}")
513}
514
515/// Group `--no-merges` commits in `range_start..HEAD` by [`ChangelogHeading`]
516/// (D-12). Walks the identical range and `git log --no-merges <range>
517/// --format=%H%x1f%B%x1e` argv as [`classify_range_bump`] (same record
518/// separators, same [`git_conventional::Commit::parse`] call) — but, unlike
519/// `classify_range_bump` (which folds every commit down to a single
520/// aggregate [`Bump`] value; see RESEARCH.md Pitfall 1), *collects* each
521/// commit's subject into its group instead of discarding it.
522/// `classify_range_bump`'s returned `Bump` is never used as changelog
523/// content; this is sibling code, not a wrapper around it.
524///
525/// **Complete per-type mapping (D-12, Task 2), evaluated in this order:**
526/// 1. `git_conventional::Commit::parse` fails → [`ChangelogHeading::Changed`],
527///    bullet = the message's first line.
528/// 2. `commit.breaking()` is true → [`ChangelogHeading::Breaking`] — checked
529///    before the type match, mirroring `classify_commit_message`'s own
530///    precedence.
531/// 3. type is `feat` → [`ChangelogHeading::Added`].
532/// 4. type is `fix`/`perf` → [`ChangelogHeading::Fixed`].
533/// 5. every other type (`docs`, `test`, `chore`, `"ci"`, `refactor`, `style`,
534///    or any other recognized-but-unlisted type) → [`ChangelogHeading::Changed`].
535///
536/// **Deliberate divergence from `classify_commit_message`:** an unparseable
537/// message is `Bump::Patch` for versioning (D-10's floor — an unrecognized
538/// commit still bumps *something*) but `Changed` here — a message with no
539/// conventional type has no claim to `Fixed`. Do not "fix" this into
540/// agreement; it is intentional.
541///
542/// Bullets preserve git-log order (newest first) within each group; groups
543/// are emitted in [`ChangelogHeading`] declaration order, omitting any group
544/// with no bullets. A range with no commits returns `Ok(Vec::new())`.
545pub fn changelog_sections(
546    project_root: &Path,
547    range_start: &str,
548) -> Result<Vec<(ChangelogHeading, Vec<String>)>, VersionError> {
549    let range = if range_start.is_empty() {
550        "HEAD".to_string()
551    } else {
552        format!("{range_start}..HEAD")
553    };
554    let output = git_command(project_root)
555        .args(["log", "--no-merges", &range, "--format=%H%x1f%B%x1e"])
556        .output()
557        .map_err(|err| VersionError::Git(err.to_string()))?;
558    if !output.status.success() {
559        return Err(VersionError::Git(
560            String::from_utf8_lossy(&output.stderr).trim().to_string(),
561        ));
562    }
563    let stdout = String::from_utf8_lossy(&output.stdout);
564    let mut breaking: Vec<String> = Vec::new();
565    let mut added: Vec<String> = Vec::new();
566    let mut fixed: Vec<String> = Vec::new();
567    let mut changed: Vec<String> = Vec::new();
568    for record in stdout.split('\u{1e}') {
569        let record = record.trim_matches('\n');
570        if record.is_empty() {
571            continue;
572        }
573        let Some((_hash, message)) = record.split_once('\u{1f}') else {
574            continue;
575        };
576        let message = message.trim();
577        let Ok(commit) = git_conventional::Commit::parse(message) else {
578            let first_line = message.lines().next().unwrap_or(message);
579            changed.push(sanitize_changelog_subject(first_line));
580            continue;
581        };
582        let subject = sanitize_changelog_subject(commit.description());
583        if commit.breaking() {
584            breaking.push(subject);
585        } else if commit.type_() == git_conventional::Type::FEAT {
586            added.push(subject);
587        } else if commit.type_() == git_conventional::Type::FIX
588            || commit.type_() == git_conventional::Type::PERF
589        {
590            fixed.push(subject);
591        } else {
592            changed.push(subject);
593        }
594    }
595    let mut sections = Vec::new();
596    if !breaking.is_empty() {
597        sections.push((ChangelogHeading::Breaking, breaking));
598    }
599    if !added.is_empty() {
600        sections.push((ChangelogHeading::Added, added));
601    }
602    if !fixed.is_empty() {
603        sections.push((ChangelogHeading::Fixed, fixed));
604    }
605    if !changed.is_empty() {
606        sections.push((ChangelogHeading::Changed, changed));
607    }
608    Ok(sections)
609}
610
611/// Render `sections` (from [`changelog_sections`]) as Keep-a-Changelog
612/// markdown: each section's heading line, a blank line, then one `- {subject}`
613/// line per bullet, with a blank line between sections. Returns an empty
614/// string when `sections` is empty — the "nothing changed" fallback text is
615/// [`crate::ship::prepend_changelog`]'s responsibility, not this function's.
616pub fn render_changelog_body(sections: &[(ChangelogHeading, Vec<String>)]) -> String {
617    let mut body = String::new();
618    for (index, (heading, bullets)) in sections.iter().enumerate() {
619        if index > 0 {
620            body.push('\n');
621        }
622        body.push_str(heading.as_markdown_heading());
623        body.push_str("\n\n");
624        for bullet in bullets {
625            body.push_str("- ");
626            body.push_str(bullet);
627            body.push('\n');
628        }
629    }
630    body
631}
632
633/// Apply a classified [`Bump`] to a baseline version (D-08/D-10).
634fn apply_bump(baseline: &semver::Version, bump: Bump) -> semver::Version {
635    match bump {
636        Bump::Major => semver::Version::new(baseline.major + 1, 0, 0),
637        Bump::Minor => semver::Version::new(baseline.major, baseline.minor + 1, 0),
638        // D-10: no-bump collapses to patch so every completed ship still
639        // yields a distinct version.
640        Bump::Patch | Bump::None => {
641            semver::Version::new(baseline.major, baseline.minor, baseline.patch + 1)
642        }
643    }
644}
645
646/// Compute the full version: the baseline resolved from the highest
647/// reachable semver tag (D-07), bumped by the conventional-commit
648/// classification of the commits added since that baseline was released
649/// (D-08). The version file is NOT read here (D-11) — [`write_version`] is
650/// the only writer, and [`read_version`] is the only reader of what's on
651/// disk.
652pub fn compute_version(project_root: &Path) -> Result<Version, VersionError> {
653    let highest = highest_semver_tag(project_root)?;
654    let baseline = reachable_semver_baseline(project_root)?;
655
656    // D-10: refuse rather than silently falling back to the highest
657    // *reachable* tag when the true highest tag exists but is not reachable
658    // from HEAD (T-25-04) — see `reachable_semver_baseline`'s doc comment for
659    // the D-12 sync-discipline coupling this predicate depends on.
660    if let Some(highest) = &highest {
661        let unreachable = match &baseline {
662            Some(reachable) => highest > reachable,
663            None => true,
664        };
665        if unreachable {
666            return Err(VersionError::UnreachableBaseline {
667                tag: format!("v{highest}"),
668            });
669        }
670    }
671
672    let baseline_version = baseline
673        .clone()
674        .unwrap_or_else(|| semver::Version::new(0, 0, 0));
675
676    let range_start = match &baseline {
677        Some(tag) => release_range_start(project_root, &format!("v{tag}"))?,
678        None => String::new(),
679    };
680    let bump = classify_range_bump(project_root, &range_start)?;
681    let bumped = apply_bump(&baseline_version, bump);
682
683    Ok(Version {
684        major: bumped.major as u32,
685        minor: bumped.minor as u32,
686        patch: bumped.patch as u32,
687    })
688}
689
690/// Read the full [`Version`] (major/minor/patch) out of whatever version file
691/// `detect_version_file` resolves, mirroring [`write_version`]'s format
692/// handling (including `[workspace.package]`).
693///
694/// Unlike [`compute_version`], this never touches git — it reports exactly
695/// what was last written to the version file, not a freshly recomputed
696/// minor/patch. Callers that need the version a prior [`write_version`] call
697/// actually wrote (e.g. after a tag was just cut) must use this instead of
698/// `compute_version`, which would see the new tag and return a different,
699/// larger version.
700///
701/// D-11 changed what `compute_version` reads (git history only, never the
702/// version file) — it did not change this function's role: `read_version`
703/// still reports exactly what's on disk, unconditionally.
704pub fn read_version(project_root: &Path) -> Result<Version, VersionError> {
705    let path = detect_version_file(project_root)
706        .ok_or_else(|| VersionError::Parse("no version file found".into()))?;
707    let contents = std::fs::read_to_string(&path)?;
708    let field = field_for(&path, &contents);
709    let version_str = find_version_in_contents(&contents, field)
710        .ok_or_else(|| VersionError::Parse(format!("field `{field}` not found in {path:?}")))?;
711    parse_version_str(&version_str)
712}
713
714/// Parse a `MAJOR.MINOR.PATCH` string (optionally followed by `-`/`+`
715/// metadata) into a [`Version`].
716fn parse_version_str(version: &str) -> Result<Version, VersionError> {
717    let mut parts = version.split(['.', '+', '-']);
718    let mut next =
719        |label: &str| -> Result<u32, VersionError> {
720            parts.next().unwrap_or("0").parse::<u32>().map_err(|err| {
721                VersionError::Parse(format!("invalid {label} in `{version}`: {err}"))
722            })
723        };
724    let major = next("major")?;
725    let minor = next("minor")?;
726    let patch = next("patch")?;
727    Ok(Version {
728        major,
729        minor,
730        patch,
731    })
732}
733
734/// Write `version` into the project's auto-detected version file.
735pub fn write_version(project_root: &Path, version: &Version) -> Result<PathBuf, VersionError> {
736    let path = detect_version_file(project_root)
737        .ok_or_else(|| VersionError::Parse("no version file found".into()))?;
738    let contents = std::fs::read_to_string(&path)?;
739    let field = field_for(&path, &contents);
740    let replaced = replace_version_in_contents(&contents, field, &version.to_string())
741        .ok_or_else(|| VersionError::Parse(format!("field `{field}` not found")))?;
742    // 20a / DEN-49: a workspace Cargo.toml states its version twice — once in
743    // [workspace.package] version (just rewritten above), and again as an
744    // explicit `version` pin on every [workspace.dependencies] entry that
745    // points at a workspace member by `path`. This second pass is additive,
746    // not a modification of `replace_version_in_contents`'s single-field
747    // logic — pyproject.toml/package.json/plain Cargo.toml callers never
748    // reach it.
749    let replaced = if field == "workspace.package.version" {
750        rewrite_workspace_member_pins(&replaced, &version.to_string())
751    } else {
752        replaced
753    };
754    std::fs::write(&path, replaced)?;
755    Ok(path)
756}
757
758/// Additive pass (20a / DEN-49): rewrite the `version` sub-value of every
759/// SINGLE-LINE `[workspace.dependencies]` inline-table entry that pins a
760/// local workspace member by `path` (e.g. `devflow-core = { path =
761/// "crates/devflow-core", version = "1.6.0" }`).
762///
763/// This is deliberately additive to `replace_version_in_contents` rather than
764/// a modification of it — that function's `starts_with('{')` guard exists so
765/// single-field callers (`field_for` for pyproject.toml/package.json/plain
766/// Cargo.toml) never touch an inline table, and stays intact.
767///
768/// Scope, by construction:
769/// - Only entries with a local `path` key (one starting with `crates/`) are
770///   rewritten. A `version`-only third-party dependency (`serde = { version
771///   = "1" }`) is left untouched — a dependency on a crate INSIDE this
772///   workspace carries this workspace's version; anything else does not.
773/// - Only SINGLE-LINE inline tables are handled (opening and closing `}` on
774///   the same line as `path`/`version`). A multi-line inline table is a
775///   documented out-of-scope limitation (review: Antigravity/Hermes MEDIUM)
776///   — this repo's own self-pins are single-line (Cargo.toml:20), and the
777///   line-level `starts_with('{')` guard in `find_version_in_contents`/
778///   `replace_version_in_contents` could not see into one anyway.
779/// - The `version = "..."` sub-value is located and replaced independent of
780///   its position relative to `path` within the line (key-order-independent,
781///   anchored to the `version =` token itself, not a column offset) — a
782///   self-pin written `{ version = "1.6.0", path = "crates/..." }` is
783///   rewritten identically to the `path`-before-`version` case.
784/// - Whitespace, quote style, and any trailing comma/comment after the
785///   `version` token are preserved exactly (GAP-6).
786fn rewrite_workspace_member_pins(contents: &str, new_version: &str) -> String {
787    let mut current = String::new();
788    let mut output = String::new();
789    for line in contents.lines() {
790        let trimmed = line.trim();
791        if let Some(header) = parse_section_header(trimmed) {
792            current = header.to_string();
793            output.push_str(line);
794            output.push('\n');
795            continue;
796        }
797        if current == "workspace.dependencies"
798            && trimmed.contains('{')
799            && trimmed.contains('}')
800            && workspace_dependency_has_local_path(trimmed)
801            && let Some(rewritten) = rewrite_inline_table_version(line, new_version)
802        {
803            output.push_str(&rewritten);
804            output.push('\n');
805            continue;
806        }
807        output.push_str(line);
808        output.push('\n');
809    }
810    output
811}
812
813/// Split a single-line inline table's interior (`{ ... }`, braces excluded)
814/// into its top-level `key = value` fragments, alongside each fragment's
815/// absolute byte offset within `line`. Fragments are separated on `,` — this
816/// is a hand-rolled, single-line-only split (see `rewrite_workspace_member_pins`
817/// doc comment), not a general TOML parser.
818fn inline_table_fragments(line: &str) -> Option<Vec<(usize, &str)>> {
819    let brace_start = line.find('{')?;
820    let brace_end = line.rfind('}')?;
821    if brace_end <= brace_start {
822        return None;
823    }
824    let inner = &line[brace_start + 1..brace_end];
825    let mut fragments = Vec::new();
826    let mut offset = brace_start + 1;
827    for fragment in inner.split(',') {
828        fragments.push((offset, fragment));
829        offset += fragment.len() + 1; // +1 for the consumed comma
830    }
831    Some(fragments)
832}
833
834/// Whether a `[workspace.dependencies]` inline-table line carries a `path`
835/// key whose value points at a local workspace member (starts with
836/// `crates/`).
837fn workspace_dependency_has_local_path(line: &str) -> bool {
838    let Some(fragments) = inline_table_fragments(line) else {
839        return false;
840    };
841    for (_, fragment) in fragments {
842        let trimmed = fragment.trim();
843        let Some((key, value)) = trimmed.split_once('=') else {
844            continue;
845        };
846        if key.trim() != "path" {
847            continue;
848        }
849        let value = value.trim();
850        let Some(quote) = value.chars().next() else {
851            return false;
852        };
853        if quote != '"' && quote != '\'' {
854            return false;
855        }
856        let inner_value = &value[1..value.len().saturating_sub(1)];
857        return inner_value.starts_with("crates/");
858    }
859    false
860}
861
862/// Rewrite the `version = "..."` sub-value on a single-line inline-table
863/// line, preserving everything else on the line byte-for-byte. Returns
864/// `None` if the line has no `version` fragment to anchor to (e.g. a
865/// `path`-only member with no explicit version — nothing to rewrite).
866fn rewrite_inline_table_version(line: &str, new_version: &str) -> Option<String> {
867    let fragments = inline_table_fragments(line)?;
868    for (frag_start, fragment) in fragments {
869        let trimmed = fragment.trim();
870        let Some((key, _value)) = trimmed.split_once('=') else {
871            continue;
872        };
873        if key.trim() != "version" {
874            continue;
875        }
876        // Locate `=` in the ORIGINAL (untrimmed) fragment to compute an
877        // absolute offset into `line`.
878        let eq_rel = fragment.find('=')?;
879        let eq_abs = frag_start + eq_rel;
880        let after_eq = eq_abs + 1;
881        let rest = &line[after_eq..];
882        let ws_len = rest.len() - rest.trim_start().len();
883        let value_start = after_eq + ws_len;
884        let value_rest = &line[value_start..];
885        let quote_char = value_rest.chars().next()?;
886        if quote_char != '"' && quote_char != '\'' {
887            return None;
888        }
889        let after_quote = &value_rest[1..];
890        let end_rel = after_quote.find(quote_char)?;
891        let value_end = value_start + 1 + end_rel + 1;
892        let remainder = &line[value_end..];
893
894        let mut rewritten = String::with_capacity(line.len() + new_version.len());
895        rewritten.push_str(&line[..value_start]);
896        rewritten.push(quote_char);
897        rewritten.push_str(new_version);
898        rewritten.push(quote_char);
899        rewritten.push_str(remainder);
900        return Some(rewritten);
901    }
902    None
903}
904
905/// One `[workspace.dependencies]` self-pin discovered by
906/// [`read_workspace_self_pins`] — a local-path dependency's name and its
907/// pinned `version` sub-value.
908#[derive(Debug, Clone, PartialEq, Eq)]
909pub struct SelfPin {
910    /// The dependency's name (left-hand side of `=` in `[workspace.dependencies]`).
911    pub name: String,
912    /// The `version = "..."` value currently pinned in the inline table.
913    pub version: String,
914}
915
916/// Extract `[workspace.package] version` and every local-path
917/// `[workspace.dependencies]` self-pin (crate name + pinned version) from a
918/// workspace Cargo.toml's contents.
919///
920/// Read-only (20d / `devflow release --check`): asserts 20a's invariant
921/// (`write_version` keeps every self-pin equal to the workspace version)
922/// without re-implementing TOML scanning — reuses the same
923/// `parse_section_header`/`find_version_in_contents`/
924/// `workspace_dependency_has_local_path`/`inline_table_fragments` helpers
925/// `write_version`'s additive rewrite pass already uses.
926///
927/// Returns `(workspace_version, pins)`. `workspace_version` is `None` when
928/// the contents have no `[workspace.package] version` field (not a workspace
929/// root Cargo.toml) — callers must treat that as "nothing to assert", not a
930/// drift.
931pub fn read_workspace_self_pins(contents: &str) -> (Option<String>, Vec<SelfPin>) {
932    let workspace_version = find_version_in_contents(contents, "workspace.package.version");
933
934    let mut current = String::new();
935    let mut pins = Vec::new();
936    for line in contents.lines() {
937        let trimmed = line.trim();
938        if let Some(header) = parse_section_header(trimmed) {
939            current = header.to_string();
940            continue;
941        }
942        if current == "workspace.dependencies"
943            && trimmed.contains('{')
944            && trimmed.contains('}')
945            && workspace_dependency_has_local_path(trimmed)
946            && let Some(fragments) = inline_table_fragments(trimmed)
947        {
948            let name = trimmed
949                .split_once('=')
950                .map(|(n, _)| n.trim().to_string())
951                .unwrap_or_default();
952            for (_, fragment) in fragments {
953                let frag = fragment.trim();
954                let Some((key, value)) = frag.split_once('=') else {
955                    continue;
956                };
957                if key.trim() != "version" {
958                    continue;
959                }
960                let value = value.trim().trim_matches(['"', '\'']);
961                pins.push(SelfPin {
962                    name: name.clone(),
963                    version: value.to_string(),
964                });
965            }
966        }
967    }
968    (workspace_version, pins)
969}
970
971/// Split a dotted field path into its TOML section path and the final key.
972fn split_field(field: &str) -> (&str, &str) {
973    match field.rsplit_once('.') {
974        Some((section, key)) => (section, key),
975        None => ("", field),
976    }
977}
978
979/// Return the dotted table path for a TOML section header line, if any.
980fn parse_section_header(trimmed: &str) -> Option<&str> {
981    let inner = if trimmed.starts_with("[[") && trimmed.ends_with("]]") {
982        trimmed.strip_prefix("[[")?.strip_suffix("]]")?
983    } else {
984        trimmed.strip_prefix('[')?.strip_suffix(']')?
985    };
986    Some(inner.trim())
987}
988
989fn find_version_in_contents(contents: &str, field: &str) -> Option<String> {
990    let (section, key) = split_field(field);
991    let mut current = "";
992    for line in contents.lines() {
993        let trimmed = line.trim();
994        if let Some(header) = parse_section_header(trimmed) {
995            current = header;
996            continue;
997        }
998        if current != section {
999            continue;
1000        }
1001        if let Some((lhs, value)) = trimmed.split_once(['=', ':']) {
1002            let lhs_key = lhs.trim().trim_matches('"').trim_matches('\'');
1003            if lhs_key != key {
1004                continue;
1005            }
1006            let value = value.trim();
1007            if value.starts_with('{') {
1008                continue;
1009            }
1010            // Anchor on the opening quote and scan forward for the matching
1011            // closing quote, ignoring everything after it (e.g. a trailing
1012            // `# comment`), rather than `trim_matches` on the whole tail —
1013            // that would only strip a quote sitting at the very end of the
1014            // remaining string, missing it entirely when a comment follows
1015            // the closing quote on the same line. Symmetric with
1016            // `replace_version_in_contents`'s write-path remainder handling.
1017            return match value.chars().next() {
1018                Some(q @ ('"' | '\'')) => {
1019                    value[1..].find(q).map(|end| value[1..1 + end].to_string())
1020                }
1021                _ => {
1022                    let end = value.find([' ', '\t', ',', '#']).unwrap_or(value.len());
1023                    Some(value[..end].to_string())
1024                }
1025            };
1026        }
1027    }
1028    None
1029}
1030
1031fn replace_version_in_contents(contents: &str, field: &str, new_version: &str) -> Option<String> {
1032    let (section, key) = split_field(field);
1033    let mut current = "";
1034    let mut changed = false;
1035    let mut output = String::new();
1036    for line in contents.lines() {
1037        let trimmed = line.trim();
1038        if let Some(header) = parse_section_header(trimmed) {
1039            current = header;
1040            output.push_str(line);
1041            output.push('\n');
1042            continue;
1043        }
1044        if !changed
1045            && current == section
1046            && let Some((left, value)) = line.split_once(['=', ':'])
1047        {
1048            let left_key = left.trim().trim_matches('"').trim_matches('\'');
1049            if left_key == key && !value.trim().starts_with('{') {
1050                let separator: &str = if trimmed.contains('=') { " = " } else { ": " };
1051                let trimmed_value = value.trim();
1052                let needs_quote = trimmed_value.starts_with('"') || trimmed_value.starts_with('\'');
1053                let quote_char: &str = if trimmed_value.starts_with('\'') {
1054                    "'"
1055                } else {
1056                    "\""
1057                };
1058                // Capture whatever follows the version token itself (a
1059                // trailing `,` in JSON, a trailing `# comment` in TOML) so it
1060                // survives the rewrite instead of being silently dropped
1061                // (GAP-6).
1062                let remainder = if needs_quote {
1063                    // Token ends at the closing quote; skip the opening
1064                    // quote and scan for the matching close.
1065                    trimmed_value[1..]
1066                        .find(quote_char)
1067                        .map(|end| &trimmed_value[end + 2..])
1068                        .unwrap_or("")
1069                } else {
1070                    // Unquoted: token ends at the first whitespace, `,`, or `#`.
1071                    let end = trimmed_value
1072                        .find([' ', '\t', ',', '#'])
1073                        .unwrap_or(trimmed_value.len());
1074                    &trimmed_value[end..]
1075                };
1076                output.push_str(left.trim_end());
1077                output.push_str(separator);
1078                if needs_quote {
1079                    output.push_str(quote_char);
1080                    output.push_str(new_version);
1081                    output.push_str(quote_char);
1082                } else {
1083                    output.push_str(new_version);
1084                }
1085                output.push_str(remainder.trim_end());
1086                output.push('\n');
1087                changed = true;
1088                continue;
1089            }
1090        }
1091        output.push_str(line);
1092        output.push('\n');
1093    }
1094    changed.then_some(output)
1095}
1096
1097#[cfg(test)]
1098mod tests {
1099    use super::*;
1100
1101    fn git(root: &Path, args: &[&str]) {
1102        let ok = crate::test_support::git_command(root)
1103            .args(args)
1104            .output()
1105            .unwrap()
1106            .status
1107            .success();
1108        assert!(ok, "git {args:?} failed");
1109    }
1110
1111    fn init_repo(root: &Path) {
1112        git(root, &["init", "-q"]);
1113        git(root, &["config", "user.email", "test@example.com"]);
1114        git(root, &["config", "user.name", "Test"]);
1115        git(root, &["config", "commit.gpgsign", "false"]);
1116        git(root, &["config", "tag.gpgsign", "false"]);
1117        git(root, &["config", "core.hooksPath", "/dev/null"]);
1118    }
1119
1120    fn commit(root: &Path, name: &str) {
1121        std::fs::write(root.join(name), name).unwrap();
1122        git(root, &["add", "."]);
1123        git(root, &["commit", "-q", "-m", &format!("add {name}")]);
1124    }
1125
1126    /// As [`commit`], but with an explicit commit message — needed for
1127    /// conventional-commit classification fixtures, where the message
1128    /// content (not the file name) is what's under test.
1129    fn commit_msg(root: &Path, name: &str, message: &str) {
1130        std::fs::write(root.join(name), name).unwrap();
1131        git(root, &["add", "."]);
1132        git(root, &["commit", "-q", "-m", message]);
1133    }
1134
1135    /// One-line `tag` helper of the same shape as `git`/`init_repo`/`commit`.
1136    fn tag(root: &Path, name: &str) {
1137        git(root, &["tag", name]);
1138    }
1139
1140    fn current_branch(root: &Path) -> String {
1141        let output = crate::test_support::git_command(root)
1142            .args(["symbolic-ref", "--short", "HEAD"])
1143            .output()
1144            .unwrap();
1145        assert!(output.status.success(), "symbolic-ref --short HEAD failed");
1146        String::from_utf8_lossy(&output.stdout).trim().to_string()
1147    }
1148
1149    fn checkout_new(root: &Path, branch: &str) {
1150        git(root, &["checkout", "-b", branch]);
1151    }
1152
1153    fn checkout(root: &Path, branch: &str) {
1154        git(root, &["checkout", branch]);
1155    }
1156
1157    /// Simulate `scripts/sync-main-to-develop.sh`'s content-preserving
1158    /// `-X ours` merge: a real merge commit (so ancestry is restored) whose
1159    /// tree is unaffected (so nothing about `develop`'s own content changes).
1160    fn merge_ours(root: &Path, branch: &str, message: &str) {
1161        git(root, &["merge", "-s", "ours", "-m", message, branch]);
1162    }
1163
1164    /// Simulate the shape `GitFlow::merge_feature_into_develop`
1165    /// (`crates/devflow-core/src/git.rs:86`) produces for every phase branch
1166    /// merged into `develop`: a real, ordinary `--no-ff` merge commit.
1167    /// Ordinary post-release feature work lands this way too, which is what
1168    /// makes it a merge commit on the ancestry path in addition to the
1169    /// sync-merge-back — the reason `release_range_start` cannot simply
1170    /// anchor at "the last merge commit."
1171    fn merge_no_ff(root: &Path, branch: &str, message: &str) {
1172        git(root, &["merge", "--no-ff", "-m", message, branch]);
1173    }
1174
1175    /// Capture `HEAD`'s commit SHA, mirroring `current_branch`'s construction.
1176    fn head_sha(root: &Path) -> String {
1177        let output = crate::test_support::git_command(root)
1178            .args(["rev-parse", "HEAD"])
1179            .output()
1180            .unwrap();
1181        assert!(output.status.success(), "rev-parse HEAD failed");
1182        String::from_utf8_lossy(&output.stdout).trim().to_string()
1183    }
1184
1185    #[test]
1186    fn detect_prefers_cargo_then_pyproject_then_package_json() {
1187        let dir = tempfile::tempdir().unwrap();
1188        assert!(detect_version_file(dir.path()).is_none());
1189        std::fs::write(dir.path().join("package.json"), "{\"version\":\"1.0.0\"}").unwrap();
1190        assert!(
1191            detect_version_file(dir.path())
1192                .unwrap()
1193                .ends_with("package.json")
1194        );
1195        std::fs::write(
1196            dir.path().join("Cargo.toml"),
1197            "[package]\nversion=\"1.0.0\"",
1198        )
1199        .unwrap();
1200        assert!(
1201            detect_version_file(dir.path())
1202                .unwrap()
1203                .ends_with("Cargo.toml")
1204        );
1205    }
1206
1207    #[test]
1208    fn read_major_from_workspace_package() {
1209        let dir = tempfile::tempdir().unwrap();
1210        let file = dir.path().join("Cargo.toml");
1211        std::fs::write(
1212            &file,
1213            "[workspace.package]\nversion = \"2.5.7\"\nedition = \"2024\"\n",
1214        )
1215        .unwrap();
1216        assert_eq!(read_major_version(&file).unwrap(), 2);
1217    }
1218
1219    #[test]
1220    fn inline_table_version_does_not_shadow_workspace_package() {
1221        assert_eq!(parse_section_header("[[bin]]"), Some("bin"));
1222
1223        let dir = tempfile::tempdir().unwrap();
1224        let file = dir.path().join("Cargo.toml");
1225        std::fs::write(
1226            &file,
1227            "[[bin]]\nname = \"devflow\"\n\
1228             [workspace.dependencies]\nserde = { version = \"1\", features = [\"derive\"] }\n\
1229             [workspace.package]\nversion = \"1.2.0\"\n",
1230        )
1231        .unwrap();
1232
1233        assert_eq!(read_major_version(&file).unwrap(), 1);
1234        write_version(
1235            dir.path(),
1236            &Version {
1237                major: 2,
1238                minor: 3,
1239                patch: 4,
1240            },
1241        )
1242        .unwrap();
1243        let contents = std::fs::read_to_string(file).unwrap();
1244        assert!(contents.contains("serde = { version = \"1\""));
1245        assert!(contents.contains("[workspace.package]\nversion = \"2.3.4\""));
1246    }
1247
1248    #[test]
1249    fn read_major_from_package_json() {
1250        let dir = tempfile::tempdir().unwrap();
1251        let file = dir.path().join("package.json");
1252        std::fs::write(&file, "{\n  \"version\": \"3.1.0\"\n}\n").unwrap();
1253        assert_eq!(read_major_version(&file).unwrap(), 3);
1254    }
1255
1256    #[test]
1257    fn docs_only_commits_after_tag_yield_patch_floor() {
1258        let dir = tempfile::tempdir().unwrap();
1259        let root = dir.path();
1260        init_repo(root);
1261        commit_msg(root, "a.txt", "chore: init");
1262        tag(root, "v2.0.0");
1263        commit_msg(root, "b.txt", "docs: update readme");
1264        commit_msg(root, "c.txt", "docs: fix typo");
1265
1266        let v = compute_version(root).unwrap();
1267        assert_eq!(
1268            v,
1269            Version {
1270                major: 2,
1271                minor: 0,
1272                patch: 1
1273            }
1274        );
1275    }
1276
1277    #[test]
1278    fn feat_commit_after_tag_yields_minor_bump() {
1279        let dir = tempfile::tempdir().unwrap();
1280        let root = dir.path();
1281        init_repo(root);
1282        commit_msg(root, "a.txt", "chore: init");
1283        tag(root, "v2.0.0");
1284        commit_msg(root, "b.txt", "docs: update readme");
1285        commit_msg(root, "c.txt", "feat(x): add new capability");
1286
1287        let v = compute_version(root).unwrap();
1288        assert_eq!(
1289            v,
1290            Version {
1291                major: 2,
1292                minor: 1,
1293                patch: 0
1294            }
1295        );
1296    }
1297
1298    #[test]
1299    fn fix_commit_after_tag_yields_patch_bump() {
1300        let dir = tempfile::tempdir().unwrap();
1301        let root = dir.path();
1302        init_repo(root);
1303        commit_msg(root, "a.txt", "chore: init");
1304        tag(root, "v2.0.0");
1305        commit_msg(root, "b.txt", "fix(x): correct off-by-one");
1306
1307        let v = compute_version(root).unwrap();
1308        assert_eq!(
1309            v,
1310            Version {
1311                major: 2,
1312                minor: 0,
1313                patch: 1
1314            }
1315        );
1316    }
1317
1318    #[test]
1319    fn no_semver_tag_at_all_yields_documented_empty_repo_contract() {
1320        // Empty-repo contract (D-07/D-08 with no baseline tag): baseline is
1321        // 0.0.0, and the very first commit's own classification applies
1322        // directly — a `feat` yields the minor floor, `0.1.0`.
1323        let dir = tempfile::tempdir().unwrap();
1324        let root = dir.path();
1325        init_repo(root);
1326        commit_msg(root, "a.txt", "feat: initial capability");
1327
1328        let v = compute_version(root).unwrap();
1329        assert_eq!(
1330            v,
1331            Version {
1332                major: 0,
1333                minor: 1,
1334                patch: 0
1335            }
1336        );
1337    }
1338
1339    #[test]
1340    fn squash_sync_topology_classifies_only_post_merge_commits() {
1341        // Reproduces this repository's real release shape: `develop` work is
1342        // squash-merged into a fresh commit on the trunk (no ancestry back to
1343        // develop's originals), then a content-preserving `-X ours` merge
1344        // syncs the trunk back into develop, restoring ancestry in the OTHER
1345        // direction only. The classifier must see only the commit(s) added
1346        // AFTER that sync merge, not develop's pre-squash originals.
1347        let dir = tempfile::tempdir().unwrap();
1348        let root = dir.path();
1349        init_repo(root);
1350        commit_msg(root, "base.txt", "chore: init");
1351        let trunk = current_branch(root);
1352
1353        checkout_new(root, "develop");
1354        commit_msg(root, "d1.txt", "feat: develop work one");
1355        commit_msg(root, "d2.txt", "feat: develop work two");
1356
1357        checkout(root, &trunk);
1358        commit_msg(root, "sq1.txt", "feat: squashed release of develop work");
1359        tag(root, "v2.0.0");
1360
1361        checkout(root, "develop");
1362        merge_ours(
1363            root,
1364            &trunk,
1365            "merge: sync main back into develop after release",
1366        );
1367        commit_msg(root, "f1.txt", "fix: patch after sync");
1368
1369        let v = compute_version(root).unwrap();
1370        assert_eq!(
1371            v,
1372            Version {
1373                major: 2,
1374                minor: 0,
1375                patch: 1
1376            }
1377        );
1378    }
1379
1380    #[test]
1381    fn two_squash_sync_cycles_anchor_to_the_second_merge_only() {
1382        // Pins the property release_range_start's doc comment names: because
1383        // reachable_semver_baseline always selects the highest reachable
1384        // tag, the ancestry path from that tag to HEAD crosses exactly one
1385        // sync merge — so inspecting only C1's first parent is sufficient
1386        // even with TWO release cycles in history. If baseline selection
1387        // ever regressed to anchor at the first cycle's merge instead of the
1388        // second, this fixture's first-cycle `feat` (d1) would leak back
1389        // into the classified range and wrongly produce a minor bump.
1390        let dir = tempfile::tempdir().unwrap();
1391        let root = dir.path();
1392        init_repo(root);
1393        commit_msg(root, "base.txt", "chore: init");
1394        let trunk = current_branch(root);
1395
1396        checkout_new(root, "develop");
1397        commit_msg(root, "d1.txt", "feat: first cycle work");
1398
1399        checkout(root, &trunk);
1400        commit_msg(root, "sq1.txt", "feat: first squashed release");
1401        tag(root, "v2.0.0");
1402
1403        checkout(root, "develop");
1404        merge_ours(
1405            root,
1406            &trunk,
1407            "merge: sync main back into develop after release (1)",
1408        );
1409        commit_msg(root, "d3.txt", "feat: second cycle work");
1410
1411        checkout(root, &trunk);
1412        commit_msg(root, "sq2.txt", "feat: second squashed release");
1413        tag(root, "v2.1.0");
1414
1415        checkout(root, "develop");
1416        merge_ours(
1417            root,
1418            &trunk,
1419            "merge: sync main back into develop after release (2)",
1420        );
1421        commit_msg(root, "f1.txt", "fix: patch after second sync");
1422
1423        let v = compute_version(root).unwrap();
1424        assert_eq!(
1425            v,
1426            Version {
1427                major: 2,
1428                minor: 1,
1429                patch: 1
1430            }
1431        );
1432    }
1433
1434    /// Reproduces CR-03 (`25-REVIEW.md`): the current `release_range_start`
1435    /// inspects only the ancestry path's FIRST commit (`C1`) and tests
1436    /// whether the baseline tag is an ancestor of `C1`'s first parent. When a
1437    /// commit lands directly on trunk between the tag and the sync-merge-back
1438    /// (a hotfix pushed straight to `main`), that intervening commit becomes
1439    /// `C1` — its first parent IS the tag commit, so
1440    /// `git merge-base --is-ancestor <tag> <tag>` is trivially true, the
1441    /// function wrongly concludes the tag already sat on mainline, and it
1442    /// returns the literal `tag..HEAD` range — reintroducing the pre-release
1443    /// `develop` history the whole D-08 anchor exists to exclude.
1444    ///
1445    /// RED until Task 2 lands (`release_range_start` walks the whole
1446    /// ancestry path instead of only `C1`).
1447    #[test]
1448    fn trunk_commit_between_tag_and_sync_merge_still_anchors_at_the_sync_merge() {
1449        let dir = tempfile::tempdir().unwrap();
1450        let root = dir.path();
1451        init_repo(root);
1452        commit_msg(root, "base.txt", "chore: init");
1453        let trunk = current_branch(root);
1454
1455        checkout_new(root, "develop");
1456        commit_msg(root, "d1.txt", "feat: develop work one");
1457        commit_msg(root, "d2.txt", "feat: develop work two");
1458
1459        checkout(root, &trunk);
1460        commit_msg(root, "sq1.txt", "feat: squashed release of develop work");
1461        tag(root, "v2.0.0");
1462
1463        // Still on trunk: the intervening direct-trunk commit that turns
1464        // CR-03's C1-only heuristic into a false positive.
1465        commit_msg(root, "hot.txt", "fix: hotfix pushed straight to main");
1466
1467        checkout(root, "develop");
1468        merge_ours(
1469            root,
1470            &trunk,
1471            "merge: sync main back into develop after release",
1472        );
1473        let sync_merge = head_sha(root);
1474        commit_msg(root, "f1.txt", "fix: patch after sync");
1475
1476        assert_eq!(
1477            release_range_start(root, "v2.0.0").unwrap(),
1478            sync_merge,
1479            "anchor must be the sync merge, not the hotfix's tag-ancestor first parent"
1480        );
1481        assert_eq!(
1482            compute_version(root).unwrap(),
1483            Version {
1484                major: 2,
1485                minor: 0,
1486                patch: 1
1487            },
1488            "pre-fix this yields 2.1.0: the range collapses to tag..HEAD and \
1489             re-admits d1/d2's two feat commits"
1490        );
1491    }
1492
1493    /// Tripwire pinning this plan's deliberate deviation from
1494    /// `25-REVIEW.md`/`25-VERIFICATION.md`'s fix sketch ("anchor at the last
1495    /// merge commit in the ancestry path"). `GitFlow::merge_feature_into_develop`
1496    /// (`crates/devflow-core/src/git.rs:86`) merges every phase branch into
1497    /// `develop` with `git merge --no-ff`, so ordinary POST-RELEASE feature
1498    /// work also produces merge commits on the ancestry path — not just the
1499    /// sync-merge-back. Measured live against this repository 2026-07-28
1500    /// (`git rev-list --ancestry-path --reverse v2.0.0..develop`): the
1501    /// correct anchor is `c92229e` (the sync merge), but the literal "last
1502    /// merge commit" rule would return `819987b` (a later, unrelated PR
1503    /// merge), whose range silently drops an intervening commit from
1504    /// classification. Today that dropped commit is a `docs:` commit and
1505    /// nothing breaks; a `feat!:` in that same position would be dropped
1506    /// instead — a false negative that lets a major bump ship unattended,
1507    /// exactly what D-09 exists to prevent.
1508    ///
1509    /// This test is GREEN before AND after Task 2: it is green today (this
1510    /// is what the CURRENT C1-only code already gets right), and it must
1511    /// stay green under the generalized full-ancestry-path rule Task 2
1512    /// implements. It goes RED only under the review's literal "last merge
1513    /// commit" sketch — do not simplify the implementation into that sketch.
1514    ///
1515    /// Deviation from this plan's literal construction: without the
1516    /// intervening `chore: continue develop work after sync` commit below,
1517    /// `git rev-list --ancestry-path --reverse` places the feature branch's
1518    /// single-parent commit (`ft1`) BEFORE the sync-merge commit itself in
1519    /// its output — a real, measured property of that exact shape (verified
1520    /// live 2026-07-28; see 25-09-SUMMARY.md), not test flakiness — which
1521    /// made the fixture as originally specified fail pre-fix (asserting
1522    /// behavior the current C1-only code does not actually have). Per this
1523    /// plan's own instruction ("If Test 2 fails pre-fix, the fixture is
1524    /// malformed — stop and fix it"), one ordinary intervening develop
1525    /// commit was inserted between the sync merge and the feature branch's
1526    /// creation, which is itself realistic (post-release develop work
1527    /// commonly precedes the next feature branch) and restores C1 = the
1528    /// sync merge under the current implementation without changing either
1529    /// assertion.
1530    #[test]
1531    fn feature_merge_after_sync_merge_does_not_move_the_anchor() {
1532        let dir = tempfile::tempdir().unwrap();
1533        let root = dir.path();
1534        init_repo(root);
1535        commit_msg(root, "base.txt", "chore: init");
1536        let trunk = current_branch(root);
1537
1538        checkout_new(root, "develop");
1539        commit_msg(root, "d1.txt", "feat: develop work one");
1540
1541        checkout(root, &trunk);
1542        commit_msg(root, "sq1.txt", "feat: squashed release of develop work");
1543        tag(root, "v2.0.0");
1544
1545        checkout(root, "develop");
1546        merge_ours(
1547            root,
1548            &trunk,
1549            "merge: sync main back into develop after release",
1550        );
1551        let sync_merge = head_sha(root);
1552        commit_msg(root, "tail.txt", "chore: continue develop work after sync");
1553
1554        checkout_new(root, "feature/phase-99");
1555        commit_msg(root, "ft1.txt", "feat: post-release capability");
1556        checkout(root, "develop");
1557        merge_no_ff(
1558            root,
1559            "feature/phase-99",
1560            "Merge pull request #99 from feature/phase-99",
1561        );
1562        commit_msg(root, "f1.txt", "fix: patch after the feature merge");
1563
1564        assert_eq!(
1565            release_range_start(root, "v2.0.0").unwrap(),
1566            sync_merge,
1567            "anchor must be the sync merge, not the later feature-branch pull-request merge"
1568        );
1569        assert_eq!(
1570            compute_version(root).unwrap(),
1571            Version {
1572                major: 2,
1573                minor: 1,
1574                patch: 0
1575            },
1576            "ft1's feat must be inside the classified range"
1577        );
1578    }
1579
1580    #[test]
1581    fn unreachable_highest_tag_refuses_rather_than_falling_back() {
1582        // D-10: when the highest semver tag overall is not reachable from
1583        // HEAD, compute_version must refuse — never silently fall back to
1584        // the highest *reachable* tag (which would compute a version below
1585        // the real release history, T-25-04).
1586        let dir = tempfile::tempdir().unwrap();
1587        let root = dir.path();
1588        init_repo(root);
1589        commit_msg(root, "a.txt", "chore: init");
1590        tag(root, "v1.0.0");
1591        let main_branch = current_branch(root);
1592
1593        git(root, &["checkout", "--orphan", "orphan-release"]);
1594        git(
1595            root,
1596            &["commit", "--allow-empty", "-q", "-m", "chore: orphan"],
1597        );
1598        tag(root, "v9.9.9");
1599        git(root, &["checkout", &main_branch]);
1600
1601        let err = compute_version(root).unwrap_err();
1602        match err {
1603            VersionError::UnreachableBaseline { tag } => {
1604                assert_eq!(tag, "v9.9.9", "refusal must name the unreachable tag");
1605            }
1606            other => {
1607                panic!("expected UnreachableBaseline (never a silent smaller Ok), got: {other:?}")
1608            }
1609        }
1610    }
1611
1612    #[test]
1613    fn range_with_no_bumping_commits_yields_patch_floor() {
1614        let dir = tempfile::tempdir().unwrap();
1615        let root = dir.path();
1616        init_repo(root);
1617        commit_msg(root, "a.txt", "chore: init");
1618        tag(root, "v1.0.0");
1619        commit_msg(root, "b.txt", "docs: update readme");
1620        commit_msg(root, "c.txt", "chore: tidy up");
1621        commit_msg(root, "d.txt", "ci: tweak workflow");
1622
1623        let v = compute_version(root).unwrap();
1624        assert_eq!(
1625            v,
1626            Version {
1627                major: 1,
1628                minor: 0,
1629                patch: 1
1630            }
1631        );
1632    }
1633
1634    #[test]
1635    fn malformed_commit_message_yields_patch_not_crash_or_major() {
1636        let dir = tempfile::tempdir().unwrap();
1637        let root = dir.path();
1638        init_repo(root);
1639        commit_msg(root, "a.txt", "chore: init");
1640        tag(root, "v1.0.0");
1641        commit_msg(
1642            root,
1643            "b.txt",
1644            "just a plain message with no conventional type prefix!!!",
1645        );
1646
1647        let v = compute_version(root).unwrap();
1648        assert_eq!(
1649            v,
1650            Version {
1651                major: 1,
1652                minor: 0,
1653                patch: 1
1654            }
1655        );
1656    }
1657
1658    #[test]
1659    fn exclamation_before_colon_yields_major() {
1660        let dir = tempfile::tempdir().unwrap();
1661        let root = dir.path();
1662        init_repo(root);
1663        commit_msg(root, "a.txt", "chore: init");
1664        tag(root, "v1.0.0");
1665        commit_msg(root, "b.txt", "feat(scope)!: drop legacy api");
1666
1667        let v = compute_version(root).unwrap();
1668        assert_eq!(
1669            v,
1670            Version {
1671                major: 2,
1672                minor: 0,
1673                patch: 0
1674            }
1675        );
1676    }
1677
1678    #[test]
1679    fn breaking_change_footer_yields_major_even_with_fix_subject() {
1680        let dir = tempfile::tempdir().unwrap();
1681        let root = dir.path();
1682        init_repo(root);
1683        commit_msg(root, "a.txt", "chore: init");
1684        tag(root, "v1.0.0");
1685        git(
1686            root,
1687            &[
1688                "commit",
1689                "--allow-empty",
1690                "-q",
1691                "-m",
1692                "fix: patch a thing\n\nBREAKING CHANGE: removes an implicit default",
1693            ],
1694        );
1695
1696        let v = compute_version(root).unwrap();
1697        assert_eq!(
1698            v,
1699            Version {
1700                major: 2,
1701                minor: 0,
1702                patch: 0
1703            }
1704        );
1705    }
1706
1707    #[test]
1708    fn exclamation_only_in_description_does_not_yield_major() {
1709        let dir = tempfile::tempdir().unwrap();
1710        let root = dir.path();
1711        init_repo(root);
1712        commit_msg(root, "a.txt", "chore: init");
1713        tag(root, "v1.0.0");
1714        commit_msg(root, "b.txt", "fix: stop the crash!!!");
1715
1716        let v = compute_version(root).unwrap();
1717        assert_eq!(
1718            v,
1719            Version {
1720                major: 1,
1721                minor: 0,
1722                patch: 1
1723            }
1724        );
1725    }
1726
1727    #[test]
1728    fn write_version_replaces_in_cargo_toml() {
1729        let dir = tempfile::tempdir().unwrap();
1730        std::fs::write(
1731            dir.path().join("Cargo.toml"),
1732            "[package]\nversion = \"0.1.0\"\n",
1733        )
1734        .unwrap();
1735        let path = write_version(
1736            dir.path(),
1737            &Version {
1738                major: 2,
1739                minor: 3,
1740                patch: 4,
1741            },
1742        )
1743        .unwrap();
1744        let contents = std::fs::read_to_string(&path).unwrap();
1745        assert!(contents.contains("version = \"2.3.4\""));
1746    }
1747
1748    #[test]
1749    fn write_version_replaces_in_workspace_cargo_toml() {
1750        let dir = tempfile::tempdir().unwrap();
1751        std::fs::write(
1752            dir.path().join("Cargo.toml"),
1753            "[workspace.package]\nversion = \"0.1.0\"\nedition = \"2024\"\n",
1754        )
1755        .unwrap();
1756        let path = write_version(
1757            dir.path(),
1758            &Version {
1759                major: 2,
1760                minor: 3,
1761                patch: 4,
1762            },
1763        )
1764        .unwrap();
1765        let contents = std::fs::read_to_string(&path).unwrap();
1766        assert!(contents.contains("[workspace.package]\nversion = \"2.3.4\""));
1767    }
1768
1769    #[test]
1770    fn write_version_errors_without_version_file() {
1771        let dir = tempfile::tempdir().unwrap();
1772        assert!(matches!(
1773            write_version(
1774                dir.path(),
1775                &Version {
1776                    major: 1,
1777                    minor: 0,
1778                    patch: 0
1779                }
1780            ),
1781            Err(VersionError::Parse(_))
1782        ));
1783    }
1784
1785    #[test]
1786    fn read_version_round_trips_through_write_version_in_plain_cargo_toml() {
1787        let dir = tempfile::tempdir().unwrap();
1788        std::fs::write(
1789            dir.path().join("Cargo.toml"),
1790            "[package]\nversion = \"0.1.0\"\n",
1791        )
1792        .unwrap();
1793        let written = Version {
1794            major: 2,
1795            minor: 3,
1796            patch: 4,
1797        };
1798        write_version(dir.path(), &written).unwrap();
1799        assert_eq!(read_version(dir.path()).unwrap(), written);
1800    }
1801
1802    #[test]
1803    fn read_version_round_trips_through_write_version_in_workspace_cargo_toml() {
1804        let dir = tempfile::tempdir().unwrap();
1805        std::fs::write(
1806            dir.path().join("Cargo.toml"),
1807            "[workspace.package]\nversion = \"0.1.0\"\nedition = \"2024\"\n",
1808        )
1809        .unwrap();
1810        let written = Version {
1811            major: 5,
1812            minor: 6,
1813            patch: 7,
1814        };
1815        write_version(dir.path(), &written).unwrap();
1816        assert_eq!(read_version(dir.path()).unwrap(), written);
1817    }
1818
1819    #[test]
1820    fn read_version_round_trips_through_write_version_in_package_json() {
1821        let dir = tempfile::tempdir().unwrap();
1822        std::fs::write(
1823            dir.path().join("package.json"),
1824            "{\n  \"version\": \"0.1.0\"\n}\n",
1825        )
1826        .unwrap();
1827        let written = Version {
1828            major: 1,
1829            minor: 9,
1830            patch: 12,
1831        };
1832        write_version(dir.path(), &written).unwrap();
1833        assert_eq!(read_version(dir.path()).unwrap(), written);
1834    }
1835
1836    #[test]
1837    fn read_version_errors_without_version_file() {
1838        let dir = tempfile::tempdir().unwrap();
1839        assert!(matches!(
1840            read_version(dir.path()),
1841            Err(VersionError::Parse(_))
1842        ));
1843    }
1844
1845    #[test]
1846    fn write_version_preserves_trailing_comma_in_package_json() {
1847        // GAP-6: replace_version_in_contents reassembles the matched line as
1848        // `left.trim_end() + separator + quoted_version + '\n'`, discarding
1849        // everything in `value` after the version token. For a real
1850        // package.json where `version` is not the last key, that eats the
1851        // mandatory trailing comma and produces invalid JSON. Parsing is the
1852        // assertion that matters here — a substring check would be a
1853        // vacuous fixture that can't reach this defect.
1854        let dir = tempfile::tempdir().unwrap();
1855        std::fs::write(
1856            dir.path().join("package.json"),
1857            "{\n  \"name\": \"x\",\n  \"version\": \"0.1.0\",\n  \"private\": true\n}\n",
1858        )
1859        .unwrap();
1860        write_version(
1861            dir.path(),
1862            &Version {
1863                major: 2,
1864                minor: 3,
1865                patch: 4,
1866            },
1867        )
1868        .unwrap();
1869        let contents = std::fs::read_to_string(dir.path().join("package.json")).unwrap();
1870        let parsed: serde_json::Value = serde_json::from_str(&contents).unwrap_or_else(|err| {
1871            panic!("package.json no longer parses as JSON: {err}\n{contents}")
1872        });
1873        assert_eq!(parsed["name"], "x");
1874        assert_eq!(parsed["private"], true);
1875        assert_eq!(parsed["version"], "2.3.4");
1876    }
1877
1878    #[test]
1879    fn write_version_preserves_trailing_comment_in_toml() {
1880        // GAP-6, TOML variant: a trailing `# comment` after the quoted
1881        // version is discarded by the same line-reassembly defect.
1882        let dir = tempfile::tempdir().unwrap();
1883        std::fs::write(
1884            dir.path().join("Cargo.toml"),
1885            "[package]\nversion = \"0.1.0\"  # pinned\n",
1886        )
1887        .unwrap();
1888        write_version(
1889            dir.path(),
1890            &Version {
1891                major: 2,
1892                minor: 3,
1893                patch: 4,
1894            },
1895        )
1896        .unwrap();
1897        let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
1898        assert!(
1899            contents.contains("version = \"2.3.4\"  # pinned"),
1900            "expected trailing comment to survive, got: {contents}"
1901        );
1902    }
1903
1904    #[test]
1905    fn write_version_preserves_trailing_comment_in_single_quoted_toml() {
1906        // GAP-6, TOML literal-string variant (17-13 review IN-03): the
1907        // remainder scan keys off the OPENING quote character, so the
1908        // single-quote branch is a distinct path from the double-quote case
1909        // above and needs its own fixture.
1910        let dir = tempfile::tempdir().unwrap();
1911        std::fs::write(
1912            dir.path().join("Cargo.toml"),
1913            "[package]\nversion = '0.1.0'  # pinned\n",
1914        )
1915        .unwrap();
1916        write_version(
1917            dir.path(),
1918            &Version {
1919                major: 2,
1920                minor: 3,
1921                patch: 4,
1922            },
1923        )
1924        .unwrap();
1925        let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
1926        assert!(
1927            contents.contains("version = '2.3.4'  # pinned"),
1928            "expected single-quoted value and trailing comment to survive, got: {contents}"
1929        );
1930    }
1931
1932    #[test]
1933    fn read_version_extracts_clean_value_with_trailing_comment() {
1934        // CR-01 (phase 20 review): `find_version_in_contents` used to
1935        // `trim_matches` the whole tail of the line, which only strips a
1936        // quote sitting at the very end of the remaining string. With a
1937        // trailing `# comment` after the closing quote, the real closing
1938        // quote is never stripped and the corrupted value fails to parse.
1939        // `write_version` already preserves this exact pattern (GAP-6); the
1940        // read path must be symmetric with it.
1941        let dir = tempfile::tempdir().unwrap();
1942        std::fs::write(
1943            dir.path().join("Cargo.toml"),
1944            "[package]\nversion = \"1.7.0\"  # pinned release version\n",
1945        )
1946        .unwrap();
1947        assert_eq!(
1948            read_version(dir.path()).unwrap(),
1949            Version {
1950                major: 1,
1951                minor: 7,
1952                patch: 0
1953            }
1954        );
1955    }
1956
1957    #[test]
1958    fn read_version_extracts_clean_value_without_trailing_comment() {
1959        // Bare `version = "1.7.0"` (no comment) must still work.
1960        let dir = tempfile::tempdir().unwrap();
1961        std::fs::write(
1962            dir.path().join("Cargo.toml"),
1963            "[package]\nversion = \"1.7.0\"\n",
1964        )
1965        .unwrap();
1966        assert_eq!(
1967            read_version(dir.path()).unwrap(),
1968            Version {
1969                major: 1,
1970                minor: 7,
1971                patch: 0
1972            }
1973        );
1974    }
1975
1976    #[test]
1977    fn read_workspace_self_pins_extracts_clean_workspace_version_with_trailing_comment() {
1978        // CR-01: `read_workspace_self_pins` calls `find_version_in_contents`
1979        // for `workspace_version` too — a trailing comment next to
1980        // `[workspace.package] version` must not corrupt the value
1981        // `check_self_pin` compares pins against.
1982        let (workspace_version, _pins) = read_workspace_self_pins(
1983            "[workspace.package]\nversion = \"1.7.0\"  # pinned release version\nedition = \"2024\"\n",
1984        );
1985        assert_eq!(workspace_version.as_deref(), Some("1.7.0"));
1986    }
1987
1988    #[test]
1989    fn read_version_does_not_recompute_from_git_tags() {
1990        // read_version must report exactly what's on disk, not a freshly
1991        // computed minor/patch — this is the property VersionBump/
1992        // ChangelogAppend ordering depends on (version.rs must never see a
1993        // tag VersionBump just created and derive a different number).
1994        let dir = tempfile::tempdir().unwrap();
1995        let root = dir.path();
1996        init_repo(root);
1997        std::fs::write(root.join("Cargo.toml"), "[package]\nversion = \"2.0.0\"\n").unwrap();
1998        commit(root, "a.txt");
1999        write_version(
2000            root,
2001            &Version {
2002                major: 2,
2003                minor: 0,
2004                patch: 0,
2005            },
2006        )
2007        .unwrap();
2008        git(root, &["tag", "v2.0.0"]);
2009        commit(root, "b.txt");
2010        commit(root, "c.txt");
2011        // compute_version would recompute from git history (baseline v2.0.0,
2012        // bumped by whatever the two later commits classify to) instead of
2013        // reporting the version file. read_version must still report exactly
2014        // what's on disk: 2.0.0.
2015        assert_eq!(
2016            read_version(root).unwrap(),
2017            Version {
2018                major: 2,
2019                minor: 0,
2020                patch: 0
2021            }
2022        );
2023    }
2024
2025    #[test]
2026    fn write_version_rewrites_workspace_dependency_self_pin() {
2027        // 20a / DEN-49: a published Cargo workspace states its version twice —
2028        // once in [workspace.package] version, and again as an explicit
2029        // `version` pin on every [workspace.dependencies] entry that points
2030        // at a workspace member by `path` (Cargo has no interpolation for
2031        // dependency versions, and a path dependency of a *published* crate
2032        // requires an explicit version). write_version must rewrite BOTH in
2033        // one write, or the self-pin ships stale and `cargo publish` rejects
2034        // the upload as a duplicate on release day (shipped broken twice:
2035        // v1.5.0 by 7ad260c, v1.6.0 by PR #15).
2036        let dir = tempfile::tempdir().unwrap();
2037        std::fs::write(
2038            dir.path().join("Cargo.toml"),
2039            "[workspace.package]\nversion = \"1.6.0\"\nedition = \"2024\"\n\n\
2040             [workspace.dependencies]\n\
2041             devflow-core = { path = \"crates/devflow-core\", version = \"1.6.0\" }\n",
2042        )
2043        .unwrap();
2044        write_version(
2045            dir.path(),
2046            &Version {
2047                major: 1,
2048                minor: 7,
2049                patch: 0,
2050            },
2051        )
2052        .unwrap();
2053        let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
2054        assert!(
2055            contents.contains("[workspace.package]\nversion = \"1.7.0\""),
2056            "expected [workspace.package] version to be rewritten, got: {contents}"
2057        );
2058        assert!(
2059            contents
2060                .contains("devflow-core = { path = \"crates/devflow-core\", version = \"1.7.0\" }"),
2061            "expected the [workspace.dependencies] self-pin to be rewritten to 1.7.0 \
2062             alongside [workspace.package] version, got: {contents}"
2063        );
2064    }
2065
2066    #[test]
2067    fn write_version_no_ops_on_missing_workspace_dependencies_section() {
2068        // 20a/empty: a workspace Cargo.toml with no [workspace.dependencies]
2069        // section at all must not panic — the additive pass simply never
2070        // matches and the file is otherwise rewritten normally.
2071        let dir = tempfile::tempdir().unwrap();
2072        std::fs::write(
2073            dir.path().join("Cargo.toml"),
2074            "[workspace.package]\nversion = \"1.6.0\"\nedition = \"2024\"\n",
2075        )
2076        .unwrap();
2077        write_version(
2078            dir.path(),
2079            &Version {
2080                major: 1,
2081                minor: 7,
2082                patch: 0,
2083            },
2084        )
2085        .unwrap();
2086        let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
2087        assert_eq!(
2088            contents,
2089            "[workspace.package]\nversion = \"1.7.0\"\nedition = \"2024\"\n"
2090        );
2091    }
2092
2093    #[test]
2094    fn write_version_no_ops_on_member_with_no_version_key() {
2095        // 20a/empty: a [workspace.dependencies] entry with a local `path`
2096        // but no `version` key at all is left unchanged — nothing to
2097        // rewrite, and no panic.
2098        let dir = tempfile::tempdir().unwrap();
2099        let toml = "[workspace.package]\nversion = \"1.6.0\"\nedition = \"2024\"\n\n\
2100             [workspace.dependencies]\n\
2101             devflow-core = { path = \"crates/devflow-core\" }\n";
2102        std::fs::write(dir.path().join("Cargo.toml"), toml).unwrap();
2103        write_version(
2104            dir.path(),
2105            &Version {
2106                major: 1,
2107                minor: 7,
2108                patch: 0,
2109            },
2110        )
2111        .unwrap();
2112        let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
2113        assert!(
2114            contents.contains("devflow-core = { path = \"crates/devflow-core\" }"),
2115            "expected the version-less path member to be left byte-identical, got: {contents}"
2116        );
2117    }
2118
2119    #[test]
2120    fn write_version_leaves_third_party_version_only_dep_untouched() {
2121        // 20a/adjacency: a third-party version-only dep sitting adjacent to
2122        // a local path member is left byte-for-byte unchanged — only the
2123        // path member's version sub-value is rewritten.
2124        let dir = tempfile::tempdir().unwrap();
2125        let third_party_line = "serde = { version = \"1\", features = [\"derive\"] }";
2126        let toml = format!(
2127            "[workspace.package]\nversion = \"1.6.0\"\nedition = \"2024\"\n\n\
2128             [workspace.dependencies]\n\
2129             devflow-core = {{ path = \"crates/devflow-core\", version = \"1.6.0\" }}\n\
2130             {third_party_line}\n"
2131        );
2132        std::fs::write(dir.path().join("Cargo.toml"), &toml).unwrap();
2133        write_version(
2134            dir.path(),
2135            &Version {
2136                major: 1,
2137                minor: 7,
2138                patch: 0,
2139            },
2140        )
2141        .unwrap();
2142        let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
2143        assert!(
2144            contents
2145                .contains("devflow-core = { path = \"crates/devflow-core\", version = \"1.7.0\" }"),
2146            "expected the local path member's version to be rewritten, got: {contents}"
2147        );
2148        assert!(
2149            contents.contains(third_party_line),
2150            "expected the third-party version-only dep to be byte-identical, got: {contents}"
2151        );
2152    }
2153
2154    #[test]
2155    fn write_version_preserves_comment_and_quote_in_workspace_dependency_pin() {
2156        // GAP-6, inline-table variant: a self-pin line with a trailing
2157        // comment and single-quoted values keeps its comment and quote
2158        // style after rewrite.
2159        let dir = tempfile::tempdir().unwrap();
2160        let toml = "[workspace.package]\nversion = \"1.6.0\"\nedition = \"2024\"\n\n\
2161             [workspace.dependencies]\n\
2162             devflow-core = { path = 'crates/devflow-core', version = '1.6.0' }  # pinned\n";
2163        std::fs::write(dir.path().join("Cargo.toml"), toml).unwrap();
2164        write_version(
2165            dir.path(),
2166            &Version {
2167                major: 1,
2168                minor: 7,
2169                patch: 0,
2170            },
2171        )
2172        .unwrap();
2173        let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
2174        assert!(
2175            contents.contains(
2176                "devflow-core = { path = 'crates/devflow-core', version = '1.7.0' }  # pinned"
2177            ),
2178            "expected single-quote style and trailing comment to survive the rewrite, got: {contents}"
2179        );
2180    }
2181
2182    #[test]
2183    fn write_version_rewrites_self_pin_regardless_of_key_order() {
2184        // review: inline-table key-order — the version sub-value is
2185        // rewritten whether it appears BEFORE or AFTER path in the inline
2186        // table; the replacement is anchored strictly to the path=/
2187        // version= tokens, not a column offset.
2188        let dir = tempfile::tempdir().unwrap();
2189        let toml = "[workspace.package]\nversion = \"1.6.0\"\nedition = \"2024\"\n\n\
2190             [workspace.dependencies]\n\
2191             devflow-core = { version = \"1.6.0\", path = \"crates/devflow-core\" }\n";
2192        std::fs::write(dir.path().join("Cargo.toml"), toml).unwrap();
2193        write_version(
2194            dir.path(),
2195            &Version {
2196                major: 1,
2197                minor: 7,
2198                patch: 0,
2199            },
2200        )
2201        .unwrap();
2202        let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
2203        assert!(
2204            contents
2205                .contains("devflow-core = { version = \"1.7.0\", path = \"crates/devflow-core\" }"),
2206            "expected version to be rewritten regardless of key order, got: {contents}"
2207        );
2208    }
2209
2210    #[test]
2211    fn changelog_sections_groups_a_feat_commit_under_added() {
2212        let dir = tempfile::tempdir().unwrap();
2213        let root = dir.path();
2214        init_repo(root);
2215        commit_msg(root, "a.txt", "chore: init");
2216        tag(root, "v1.0.0");
2217        commit_msg(root, "b.txt", "feat: add the widget endpoint");
2218
2219        let sections = changelog_sections(root, "v1.0.0").unwrap();
2220        assert_eq!(
2221            sections,
2222            vec![(
2223                ChangelogHeading::Added,
2224                vec!["add the widget endpoint".to_string()]
2225            )]
2226        );
2227    }
2228
2229    #[test]
2230    fn render_changelog_body_renders_heading_and_bullets() {
2231        let sections = vec![(
2232            ChangelogHeading::Added,
2233            vec!["add the widget endpoint".to_string()],
2234        )];
2235        let body = render_changelog_body(&sections);
2236        assert_eq!(body, "### Added\n\n- add the widget endpoint\n");
2237    }
2238
2239    /// D-12 Task 2: fix/perf -> Fixed; docs/chore/test/ci/refactor/style all
2240    /// -> one Changed section, in git-log order (newest first). Each
2241    /// expected value is written out literally, never recomputed from the
2242    /// mapping under test (test-signal-rejection.md rejection pattern 2).
2243    #[test]
2244    fn changelog_sections_maps_every_recognized_type() {
2245        let dir = tempfile::tempdir().unwrap();
2246        let root = dir.path();
2247        init_repo(root);
2248        commit_msg(root, "a.txt", "chore: init");
2249        tag(root, "v1.0.0");
2250        commit_msg(root, "b.txt", "fix: correct y");
2251        commit_msg(root, "c.txt", "perf: speed up z");
2252        commit_msg(root, "d.txt", "docs: clarify readme");
2253        commit_msg(root, "e.txt", "chore: bump dep");
2254        commit_msg(root, "f.txt", "test: add case");
2255        commit_msg(root, "g.txt", "ci: pin image");
2256        commit_msg(root, "h.txt", "refactor: extract helper");
2257        commit_msg(root, "i.txt", "style: reformat");
2258
2259        let sections = changelog_sections(root, "v1.0.0").unwrap();
2260        assert_eq!(
2261            sections,
2262            vec![
2263                (
2264                    ChangelogHeading::Fixed,
2265                    vec!["speed up z".to_string(), "correct y".to_string()]
2266                ),
2267                (
2268                    ChangelogHeading::Changed,
2269                    vec![
2270                        "reformat".to_string(),
2271                        "extract helper".to_string(),
2272                        "pin image".to_string(),
2273                        "add case".to_string(),
2274                        "bump dep".to_string(),
2275                        "clarify readme".to_string(),
2276                    ]
2277                ),
2278            ]
2279        );
2280    }
2281
2282    /// D-12 Task 2: both breaking-change forms (the `!` marker and a
2283    /// `BREAKING CHANGE:` footer) route to `Breaking`, never `Added`/`Fixed`,
2284    /// regardless of the commit's own type — checked before the type match,
2285    /// mirroring `classify_commit_message`'s own precedence.
2286    #[test]
2287    fn changelog_sections_routes_breaking_changes_to_their_own_heading() {
2288        let dir = tempfile::tempdir().unwrap();
2289        let root = dir.path();
2290        init_repo(root);
2291        commit_msg(root, "a.txt", "chore: init");
2292        tag(root, "v1.0.0");
2293        commit_msg(root, "b.txt", "feat(api)!: drop the legacy flag");
2294        git(
2295            root,
2296            &[
2297                "commit",
2298                "--allow-empty",
2299                "-q",
2300                "-m",
2301                "fix: patch a thing\n\nBREAKING CHANGE: removes an implicit default",
2302            ],
2303        );
2304
2305        let sections = changelog_sections(root, "v1.0.0").unwrap();
2306        assert_eq!(
2307            sections,
2308            vec![(
2309                ChangelogHeading::Breaking,
2310                vec![
2311                    "patch a thing".to_string(),
2312                    "drop the legacy flag".to_string()
2313                ]
2314            )]
2315        );
2316    }
2317
2318    /// D-12 Task 2: a message that fails `git_conventional::Commit::parse`
2319    /// still contributes a bullet (must_haves.truths) — grouped as `Changed`,
2320    /// never dropped. Deliberate divergence from `classify_commit_message`
2321    /// (which maps the same failure to `Bump::Patch` for versioning): a
2322    /// message with no conventional type has no claim to `Fixed`.
2323    #[test]
2324    fn changelog_sections_treats_unparseable_messages_as_changed() {
2325        let dir = tempfile::tempdir().unwrap();
2326        let root = dir.path();
2327        init_repo(root);
2328        commit_msg(root, "a.txt", "chore: init");
2329        tag(root, "v1.0.0");
2330        commit_msg(
2331            root,
2332            "b.txt",
2333            "just a plain message with no conventional type prefix!!!",
2334        );
2335
2336        let sections = changelog_sections(root, "v1.0.0").unwrap();
2337        assert_eq!(
2338            sections,
2339            vec![(
2340                ChangelogHeading::Changed,
2341                vec!["just a plain message with no conventional type prefix!!!".to_string()]
2342            )]
2343        );
2344    }
2345
2346    #[test]
2347    fn changelog_sections_returns_no_sections_for_an_empty_range() {
2348        let dir = tempfile::tempdir().unwrap();
2349        let root = dir.path();
2350        init_repo(root);
2351        commit_msg(root, "a.txt", "chore: init");
2352        tag(root, "v1.0.0");
2353
2354        let sections = changelog_sections(root, "v1.0.0").unwrap();
2355        assert_eq!(sections, Vec::new());
2356        assert_eq!(render_changelog_body(&sections), "");
2357    }
2358
2359    /// D-12/ASVS V7 (Task 3), mirrors `render_gate_context`'s properties
2360    /// (`pipeline_outcomes.rs:323`): every `char::is_control()` character is
2361    /// neutralized, an over-length subject is capped at exactly
2362    /// `CHANGELOG_SUBJECT_MAX_CHARS` including the truncation marker, and a
2363    /// short ordinary subject passes through unchanged.
2364    #[test]
2365    fn sanitize_changelog_subject_neutralizes_controls_and_caps_length() {
2366        let controls = "line 1\u{1b}[2J\tline 2\u{7}";
2367        let sanitized = sanitize_changelog_subject(controls);
2368        assert!(
2369            sanitized.chars().all(|c| !c.is_control()),
2370            "expected no control characters, got: {sanitized:?}"
2371        );
2372
2373        let long = "x".repeat(5000);
2374        let capped = sanitize_changelog_subject(&long);
2375        assert!(capped.chars().count() <= CHANGELOG_SUBJECT_MAX_CHARS);
2376        assert!(capped.ends_with("… [truncated]"));
2377
2378        let short = "add the widget endpoint";
2379        assert_eq!(sanitize_changelog_subject(short), short);
2380    }
2381
2382    /// D-12/ASVS V7 (Task 3): asserts on `changelog_sections`' output (the
2383    /// public boundary), not on `sanitize_changelog_subject` alone — proving
2384    /// the call site exists, not merely the helper.
2385    #[test]
2386    fn changelog_sections_sanitizes_subjects_before_grouping() {
2387        let dir = tempfile::tempdir().unwrap();
2388        let root = dir.path();
2389        init_repo(root);
2390        commit_msg(root, "a.txt", "chore: init");
2391        tag(root, "v1.0.0");
2392        commit_msg(root, "b.txt", "feat: add \u{1b}[31mcolored\u{1b}[0m widget");
2393
2394        let sections = changelog_sections(root, "v1.0.0").unwrap();
2395        assert_eq!(sections.len(), 1);
2396        let (heading, bullets) = &sections[0];
2397        assert_eq!(*heading, ChangelogHeading::Added);
2398        assert_eq!(bullets.len(), 1);
2399        assert!(
2400            bullets[0].chars().all(|c| !c.is_control()),
2401            "expected no control characters in the grouped bullet, got: {:?}",
2402            bullets[0]
2403        );
2404    }
2405
2406    // -----------------------------------------------------------------
2407    // 27-03 (D-01/D-03): tag reads resolve the caller's own repository
2408    // under a hostile GIT_DIR, not an unrelated one.
2409    // -----------------------------------------------------------------
2410
2411    /// D-03: `count_git_tags`/`highest_semver_tag` resolve `root`'s own tags
2412    /// even when the process inherited a hostile `GIT_DIR` pointed at an
2413    /// unrelated repository — proven with a real spawned `git` process, not
2414    /// by inspecting a `Command` object alone. Mirrors
2415    /// `origin_main_ancestor_status_holds_under_a_hostile_git_dir`
2416    /// (`git.rs`, 27-01): `count_git_tags`/`highest_semver_tag` take only
2417    /// `project_root`, so the hostile `GIT_DIR` this test's own `<verify>`
2418    /// entries exercise (`GIT_DIR=<hostile>/.git cargo test ... this test`)
2419    /// is injected the same way any inherited-env attack reaches these
2420    /// functions in production: via the whole process's environment, then
2421    /// down into the spawned child unless the constructor scrubs it. Before
2422    /// this plan's migration, both bare `Command::new("git")` sites this
2423    /// test exercises inherit that `GIT_DIR` unscrubbed and silently read
2424    /// the hostile repository instead — an empty repository with zero tags
2425    /// is the clearest contrast against `root`'s two, so this test fails
2426    /// pre-migration under the hostile harness and passes once
2427    /// `git_command` scrubs it.
2428    // `count_git_tags` is deprecated (D-07) but still `pub`; this test still
2429    // exercises its own scrub, independent of `compute_version`'s supersession.
2430    /// 27-REVIEW WR-01: this test previously set no hostile environment at
2431    /// all — it asserted ordinary-path behavior and claimed a hostile-
2432    /// `GIT_DIR` proof, so it passed identically with or without the scrub.
2433    /// It now uses the spawned-child shape this phase established in
2434    /// `staleness.rs`: `GIT_DIR` is never set on this process (Rust 2024
2435    /// `unsafe`, unsound under threaded tests — Phase 25 D-14), only on one
2436    /// freshly spawned child re-invoking this binary filtered to this test.
2437    #[test]
2438    #[allow(deprecated)]
2439    fn tag_reads_resolve_caller_root_under_a_hostile_git_dir() {
2440        const INNER_ROOT: &str = "DEVFLOW_27_03_TAG_READS_INNER_ROOT";
2441
2442        if let Ok(root) = std::env::var(INNER_ROOT) {
2443            // Inner mode: GIT_DIR points at a foreign repository that has
2444            // no tags at all, scoped to this child process only.
2445            let root = std::path::PathBuf::from(root);
2446
2447            assert_eq!(
2448                count_git_tags(&root).unwrap(),
2449                2,
2450                "count_git_tags must resolve root's own two tags, not a \
2451                 hostile GIT_DIR's repository"
2452            );
2453            assert_eq!(
2454                highest_semver_tag(&root).unwrap(),
2455                Some(semver::Version::new(0, 2, 0)),
2456                "highest_semver_tag must resolve root's own highest tag, not \
2457                 a hostile GIT_DIR's repository"
2458            );
2459            return;
2460        }
2461
2462        // Outer mode: the real repository has two tags; the foreign one has
2463        // none. Unscrubbed, the child would read the foreign repository and
2464        // see zero tags / no baseline.
2465        let dir = tempfile::tempdir().unwrap();
2466        let root = dir.path();
2467        init_repo(root);
2468        commit(root, "a.txt");
2469        tag(root, "v0.1.0");
2470        commit(root, "b.txt");
2471        tag(root, "v0.2.0");
2472
2473        let foreign = tempfile::tempdir().unwrap();
2474        init_repo(foreign.path());
2475
2476        let exe = std::env::current_exe().expect("current_exe for child re-invocation");
2477        let out = std::process::Command::new(&exe)
2478            // Substring filter, NOT `--exact`: the binary's real test name is
2479            // module-qualified (`version::tests::tag_reads_...`), so `--exact`
2480            // against the bare name matches nothing, runs zero tests, and
2481            // still exits 0 — a false green that made the first version of
2482            // this fix as vacuous as the test it replaced.
2483            .arg("tag_reads_resolve_caller_root_under_a_hostile_git_dir")
2484            .arg("--test-threads=1")
2485            .env(INNER_ROOT, root.to_str().unwrap())
2486            .env("GIT_DIR", foreign.path().join(".git"))
2487            .output()
2488            .expect("spawn hostile child test process");
2489
2490        let stdout = String::from_utf8_lossy(&out.stdout);
2491        // Assert the child actually RAN the test, not merely that it exited
2492        // 0. A filter that matches nothing exits 0 with "0 passed", so the
2493        // exit status alone cannot distinguish "proved it" from "ran nothing".
2494        assert!(
2495            stdout.contains("1 passed"),
2496            "child test process must have run exactly the inner test; \
2497             stdout:\n{stdout}"
2498        );
2499        assert!(
2500            out.status.success(),
2501            "child test process (hostile GIT_DIR pointed at an unrelated \
2502             foreign repository with no tags) must still resolve root's own \
2503             tags; child exit status {:?}\nstdout:\n{stdout}",
2504            out.status
2505        );
2506    }
2507}