Skip to main content

shipshape_core/release/
bump.rs

1//! The engine-owned version-bump arithmetic (`release-rust-workspace-multicrate`
2//! facet 2).
3//!
4//! `shipshape release plan --bump major|minor|patch` supplies only a semantic level;
5//! the engine **computes** the new version from the current manifest version — there
6//! is no hand-typed literal version (`--version` was removed in 0.3.0,
7//! `release-drop-version-flag`, and stays removed). This module is the pure,
8//! side-effect-free core of that computation: parse a strict `X.Y.Z` version and
9//! apply a [`BumpLevel`]. It fails **closed** on a non-semver manifest version rather
10//! than guess, so a malformed version aborts `release plan` instead of publishing an
11//! unintended number.
12//!
13//! The bump is strict `MAJOR.MINOR.PATCH` (three non-negative integers): a
14//! pre-release or build-metadata version (`1.2.3-rc.1`, `1.2.3+build`) is refused —
15//! bumping such a version is ambiguous, and a release cut publishes a plain release
16//! version, so refusing is the safe, unsurprising behaviour.
17
18use crate::protocol::plan::BumpLevel;
19
20/// Why an engine-owned bump plan could not be built. This covers an invalid source
21/// version and plan-time edit-set conflicts such as non-equivalent workspace pins;
22/// both refuse before an approval artifact is sealed.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct BumpError {
25    /// The source version associated with the failed bump plan.
26    pub version: String,
27    /// Why it was rejected (human-readable), e.g. "expected MAJOR.MINOR.PATCH".
28    pub reason: String,
29}
30
31/// Compute the next version by applying `level` to a strict `X.Y.Z` `current`
32/// version.
33///
34/// - `major` → `(X+1).0.0`
35/// - `minor` → `X.(Y+1).0`
36/// - `patch` → `X.Y.(Z+1)`
37///
38/// # Errors
39/// [`BumpError`] when `current` is not a strict `MAJOR.MINOR.PATCH` of three
40/// non-negative integers (a pre-release/build suffix, a missing/extra component, a
41/// non-numeric or empty component, or a `u64`-overflowing component). Failing closed
42/// here means a malformed manifest version aborts the plan rather than silently
43/// producing a wrong release version.
44pub fn bump_version(level: BumpLevel, current: &str) -> Result<String, BumpError> {
45    let (major, minor, patch) = parse_semver_core(current)?;
46    let (major, minor, patch) = match level {
47        // A checked add keeps the (practically unreachable) `u64::MAX` overflow a loud
48        // error rather than a wrapped, silently-wrong version.
49        BumpLevel::Major => (checked_incr(major, current)?, 0, 0),
50        BumpLevel::Minor => (major, checked_incr(minor, current)?, 0),
51        BumpLevel::Patch => (major, minor, checked_incr(patch, current)?),
52    };
53    Ok(format!("{major}.{minor}.{patch}"))
54}
55
56/// Parse a strict `MAJOR.MINOR.PATCH` core into its three integers, rejecting
57/// anything else.
58fn parse_semver_core(v: &str) -> Result<(u64, u64, u64), BumpError> {
59    let reject = |reason: &str| BumpError {
60        version: v.to_string(),
61        reason: reason.to_string(),
62    };
63    // A pre-release (`-`) or build-metadata (`+`) suffix is not a plain release
64    // version — refuse rather than bump ambiguously.
65    if v.contains('-') || v.contains('+') {
66        return Err(reject(
67            "a pre-release or build-metadata version cannot be bumped; expected a plain \
68             MAJOR.MINOR.PATCH release version",
69        ));
70    }
71    let mut parts = v.split('.');
72    let mut next = |which: &str| -> Result<u64, BumpError> {
73        let comp = parts
74            .next()
75            .ok_or_else(|| reject("expected MAJOR.MINOR.PATCH (a component is missing)"))?;
76        parse_component(comp, which, v)
77    };
78    let major = next("major")?;
79    let minor = next("minor")?;
80    let patch = next("patch")?;
81    // A fourth component (or trailing dot) is not `X.Y.Z`.
82    if parts.next().is_some() {
83        return Err(reject(
84            "expected exactly MAJOR.MINOR.PATCH (too many components)",
85        ));
86    }
87    Ok((major, minor, patch))
88}
89
90/// Parse one version component as a non-negative integer, rejecting empty,
91/// non-digit, or leading-zero forms (`01`) so the version is canonical.
92fn parse_component(comp: &str, which: &str, full: &str) -> Result<u64, BumpError> {
93    let reject = |reason: String| BumpError {
94        version: full.to_string(),
95        reason,
96    };
97    if comp.is_empty() {
98        return Err(reject(format!("the {which} component is empty")));
99    }
100    if !comp.bytes().all(|b| b.is_ascii_digit()) {
101        return Err(reject(format!(
102            "the {which} component `{comp}` is not a non-negative integer"
103        )));
104    }
105    // Reject a non-canonical leading zero (`01`) — `0` itself is fine.
106    if comp.len() > 1 && comp.starts_with('0') {
107        return Err(reject(format!(
108            "the {which} component `{comp}` has a leading zero"
109        )));
110    }
111    comp.parse::<u64>().map_err(|_| {
112        reject(format!(
113            "the {which} component `{comp}` does not fit in a u64"
114        ))
115    })
116}
117
118/// Increment a component, turning the (unreachable in practice) overflow into a
119/// loud [`BumpError`] rather than a wrapped value.
120fn checked_incr(n: u64, full: &str) -> Result<u64, BumpError> {
121    n.checked_add(1).ok_or_else(|| BumpError {
122        version: full.to_string(),
123        reason: "a version component would overflow on bump".to_string(),
124    })
125}
126
127// ── Cut-time edit transforms (pure) ──────────────────────────────────────────
128//
129// The engine-owned bump phase applies a deterministic edit set inside the clean
130// checkout (`release-rust-workspace-multicrate` facet 2). These are the *pure* text
131// transforms behind those edits — no filesystem, no process — so each is exhaustively
132// unit-tested and the effectful executor ([`crate::release::bump_exec`]) is thin glue.
133// Every transform **fails closed**: it returns a [`BumpEditError`] rather than write an
134// ambiguous or silently-wrong result onto the irreversible cut path.
135
136/// Why a cut-time bump edit could not be applied to a file's text. Each variant is a
137/// fail-closed refusal — the executor aborts the cut rather than commit a wrong edit.
138#[derive(Debug, Clone, PartialEq, Eq)]
139pub enum BumpEditError {
140    /// The `[workspace.package]` section, or its `version = "…"` line, was not found —
141    /// the workspace root manifest is not the shape the bump expects.
142    WorkspaceVersionNotFound,
143    /// Neither the root `[workspace.package]` nor `[package]` table carried the
144    /// expected `version = "…"` line, so the engine cannot identify a version source.
145    RootManifestVersionNotFound,
146    /// A pin rewrite found no line declaring `dependency` with the exact `from`
147    /// requirement — the sealed pin does not match the tree, so the executor refuses
148    /// rather than guess (fail closed on **zero** matches).
149    PinNotFound {
150        /// The dependency whose `=<from>` pin was expected.
151        dependency: String,
152        /// The exact requirement string that was expected (`=<from_version>`).
153        from: String,
154    },
155    /// A pin rewrite found declarations of `dependency` whose requirements are not
156    /// all the sealed `from` value, so equivalence cannot be established. Equivalent
157    /// duplicates are supported and rewritten as one deterministic set.
158    PinAmbiguous {
159        /// The dependency whose explicit requirements conflict.
160        dependency: String,
161        /// The exact sealed requirement every explicit declaration must carry.
162        from: String,
163        /// Total number of explicit version declarations inspected.
164        count: usize,
165    },
166    /// A Cargo manifest could not be parsed by the shared discovery/edit parser.
167    ManifestUnparseable {
168        /// Parser diagnostic suitable for an actionable plan/cut refusal.
169        reason: String,
170    },
171    /// The CHANGELOG had no `## [Unreleased]` section to finalize, but the contract's
172    /// changelog mode said the engine should finalize one — fail closed rather than
173    /// tag a release whose notes were never promoted.
174    ChangelogUnreleasedNotFound,
175    /// Marker-aware finalization requires exactly one ordered marker pair.
176    ChangelogMarkersMalformed,
177    /// The requested release heading already exists while new notes remain to cut.
178    ChangelogReleaseConflict {
179        /// Version whose existing section conflicts with the pending notes.
180        version: String,
181    },
182    /// Finalization found neither authored entries nor compiled fragment/trailer notes.
183    ChangelogNotesEmpty,
184}
185
186impl std::fmt::Display for BumpEditError {
187    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
188        match self {
189            Self::WorkspaceVersionNotFound => write!(
190                f,
191                "could not find a `[workspace.package]` `version = \"…\"` line matching the \
192                 sealed bump in the workspace root manifest"
193            ),
194            Self::RootManifestVersionNotFound => write!(
195                f,
196                "could not find a root `[package]` `version = \"…\"` line matching the sealed \
197                 bump after no `[workspace.package]` version source was found"
198            ),
199            Self::PinNotFound { dependency, from } => write!(
200                f,
201                "no `{dependency} = \"{from}\"` intra-workspace pin found to rewrite (the sealed \
202                 plan's pin does not match the tree)"
203            ),
204            Self::PinAmbiguous {
205                dependency,
206                from,
207                count,
208            } => write!(
209                f,
210                "`{dependency}` has {count} explicit version declarations that are not all \
211                 `{from}` — refusing to rewrite an ambiguous pin set"
212            ),
213            Self::ManifestUnparseable { reason } => {
214                write!(
215                    f,
216                    "could not parse Cargo manifest while discovering exact pins: {reason}"
217                )
218            }
219            Self::ChangelogUnreleasedNotFound => write!(
220                f,
221                "the contract asks the engine to finalize the CHANGELOG, but no `## [Unreleased]` \
222                 section was found to promote"
223            ),
224            Self::ChangelogMarkersMalformed => write!(
225                f,
226                "the CHANGELOG must contain exactly one ordered shipshape-changelog Unreleased marker pair"
227            ),
228            Self::ChangelogReleaseConflict { version } => write!(
229                f,
230                "the CHANGELOG already contains a release heading for `{version}` while pending notes remain"
231            ),
232            Self::ChangelogNotesEmpty => write!(
233                f,
234                "the CHANGELOG has no authored, fragment, or trailer-derived notes to release"
235            ),
236        }
237    }
238}
239
240impl std::error::Error for BumpEditError {}
241
242/// The `[workspace.package]` `version = "…"` value, or `None` when the section or its
243/// `version` line is absent.
244#[must_use]
245pub fn workspace_version(manifest: &str) -> Option<String> {
246    section_version(manifest, "workspace.package")
247}
248
249/// The root `[package]` `version = "…"` value, or `None` when the table or its version
250/// line is absent.
251#[must_use]
252pub fn package_version(manifest: &str) -> Option<String> {
253    section_version(manifest, "package")
254}
255
256/// The release version source in a root Cargo manifest. A workspace package version is
257/// authoritative when present; otherwise a plain single-crate `[package]` version is
258/// used. This is deliberately a shape check, not a best-effort search across tables.
259#[must_use]
260pub fn root_manifest_version(manifest: &str) -> Option<String> {
261    workspace_version(manifest).or_else(|| package_version(manifest))
262}
263
264fn section_version(manifest: &str, section: &str) -> Option<String> {
265    let mut in_section = false;
266    for line in manifest.lines() {
267        let trimmed = strip_comment(line).trim();
268        if let Some(header) = section_header(trimmed) {
269            in_section = header == section;
270        } else if in_section && line_starts_with_key(trimmed, "version") {
271            if let Some(v) = scan_key_string(trimmed, "version") {
272                return Some(v);
273            }
274        }
275    }
276    None
277}
278
279/// Rewrite the `[workspace.package]` `version = "<from>"` line to `to`, returning the
280/// new manifest text.
281///
282/// Scoped to the `[workspace.package]` section (the single source of truth for the
283/// release version) so a `version` key in any other table — `[package]`,
284/// `[dependencies.foo]`, `[workspace.dependencies]` — is never touched. Preserves the
285/// line's exact indentation and quote style; only the value between the quotes changes.
286///
287/// **Verified against `from`** (llm-review defense-in-depth): the line is rewritten only
288/// when its current value is exactly `from` (the sealed pre-bump version). This makes the
289/// edit fail closed on a tree that does not match the plan, and — since the whole-key scan
290/// is line-oriented — it also sidesteps a `version = "…"` occurrence *inside a quoted
291/// string value* (e.g. a `description` that mentions a version) unless that string
292/// happens to equal `from`, in which case a following real `version` line still matches.
293///
294/// # Errors
295/// [`BumpEditError::WorkspaceVersionNotFound`] when the section, or a `version = "<from>"`
296/// line within it, is absent (fail closed rather than write a manifest with no bump).
297pub fn set_workspace_version(
298    manifest: &str,
299    from: &str,
300    to: &str,
301) -> Result<String, BumpEditError> {
302    set_section_version(manifest, "workspace.package", from, to)
303        .ok_or(BumpEditError::WorkspaceVersionNotFound)
304}
305
306/// Rewrite the root `[package]` `version = "<from>"` line to `to`, preserving the
307/// line's formatting and failing closed when the sealed source version is absent.
308pub fn set_package_version(manifest: &str, from: &str, to: &str) -> Result<String, BumpEditError> {
309    set_section_version(manifest, "package", from, to)
310        .ok_or(BumpEditError::RootManifestVersionNotFound)
311}
312
313fn set_section_version(manifest: &str, section: &str, from: &str, to: &str) -> Option<String> {
314    let mut out = String::with_capacity(manifest.len() + to.len());
315    let mut in_section = false;
316    let mut replaced = false;
317    let ends_with_newline = manifest.ends_with('\n');
318    let mut lines = manifest.lines().peekable();
319    while let Some(line) = lines.next() {
320        let trimmed = strip_comment(line).trim();
321        if let Some(header) = section_header(trimmed) {
322            in_section = header == section;
323        } else if in_section && !replaced && line_starts_with_key(trimmed, "version") {
324            if let Some(rewritten) = replace_exact_string_value(line, "version", from, to) {
325                out.push_str(&rewritten);
326                push_line_ending(&mut out, lines.peek().is_some(), ends_with_newline);
327                replaced = true;
328                continue;
329            }
330        }
331        out.push_str(line);
332        push_line_ending(&mut out, lines.peek().is_some(), ends_with_newline);
333    }
334    replaced.then_some(out)
335}
336
337/// One local Cargo dependency declaration discovered by the shared plan/cut parser.
338#[derive(Debug, Clone, PartialEq, Eq)]
339pub(crate) struct PinDeclaration {
340    /// Resolved package name (`package = "…"` when renamed, otherwise the table key).
341    pub(crate) package: String,
342    /// Literal version requirement, or `None` for a path/workspace-only declaration.
343    pub(crate) requirement: Option<String>,
344}
345
346/// Parse local dependency declarations from normal, dev, build, and target-specific
347/// Cargo dependency tables. `toml_edit` owns the TOML grammar, so dotted keys and
348/// multiline inline tables have the same meaning during discovery and execution.
349pub(crate) fn cargo_pin_declarations(manifest: &str) -> Result<Vec<PinDeclaration>, String> {
350    pin_declarations(manifest, false)
351}
352
353/// Parse the root `[workspace.dependencies]` declarations. These declarations are
354/// edit targets in their own right: a member's `{ workspace = true }` use contains no
355/// version literal, while the exact internal pin lives here.
356pub(crate) fn cargo_workspace_pin_declarations(
357    manifest: &str,
358) -> Result<Vec<PinDeclaration>, String> {
359    pin_declarations(manifest, true)
360}
361
362fn pin_declarations(manifest: &str, workspace_only: bool) -> Result<Vec<PinDeclaration>, String> {
363    use toml_edit::{DocumentMut, Item};
364
365    fn declaration(key: &str, item: &Item, require_local: bool) -> Option<PinDeclaration> {
366        let fields = item.as_table_like()?;
367        let local = fields.get("path").and_then(Item::as_str).is_some()
368            || fields.get("workspace").and_then(Item::as_bool) == Some(true);
369        if require_local && !local {
370            return None;
371        }
372        Some(PinDeclaration {
373            package: fields
374                .get("package")
375                .and_then(Item::as_str)
376                .unwrap_or(key)
377                .to_string(),
378            requirement: fields
379                .get("version")
380                .and_then(Item::as_str)
381                .map(str::to_string),
382        })
383    }
384
385    fn collect_member_tables(doc: &DocumentMut, out: &mut Vec<PinDeclaration>) {
386        const KINDS: [&str; 3] = ["dependencies", "dev-dependencies", "build-dependencies"];
387        for kind in KINDS {
388            if let Some(deps) = doc.get(kind).and_then(Item::as_table_like) {
389                out.extend(
390                    deps.iter()
391                        .filter_map(|(name, dep)| declaration(name, dep, true)),
392                );
393            }
394        }
395        if let Some(targets) = doc.get("target").and_then(Item::as_table_like) {
396            for (_, target) in targets.iter() {
397                let Some(target) = target.as_table_like() else {
398                    continue;
399                };
400                for kind in KINDS {
401                    if let Some(deps) = target.get(kind).and_then(Item::as_table_like) {
402                        out.extend(
403                            deps.iter()
404                                .filter_map(|(name, dep)| declaration(name, dep, true)),
405                        );
406                    }
407                }
408            }
409        }
410    }
411
412    let doc = manifest
413        .parse::<DocumentMut>()
414        .map_err(|error| format!("Cargo manifest TOML could not be parsed: {error}"))?;
415    let mut out = Vec::new();
416    if workspace_only {
417        if let Some(deps) = doc
418            .get("workspace")
419            .and_then(Item::as_table_like)
420            .and_then(|workspace| workspace.get("dependencies"))
421            .and_then(Item::as_table_like)
422        {
423            out.extend(
424                deps.iter()
425                    .filter_map(|(name, dep)| declaration(name, dep, false)),
426            );
427        }
428    } else {
429        collect_member_tables(&doc, &mut out);
430    }
431    Ok(out)
432}
433
434/// Rewrite a single intra-workspace `=`-pin (`dependency = "…, version = \"<from>\""`)
435/// from `from` to `to`, returning the new manifest text.
436///
437/// Precise and fail-closed (`release-rust-workspace-multicrate` facet 3): it collects
438/// declarations of `dependency` and rewrites every declaration iff all literal version
439/// requirements are **exactly** `from` — refusing on zero
440/// ([`BumpEditError::PinNotFound`]) or any non-equivalent requirement
441/// ([`BumpEditError::PinAmbiguous`]). It
442/// matches both the inline-table form (`dep = { path = "…", version = "=X" }`) and the
443/// dependency sub-table form (`[dependencies.dep]` … `version = "=X"`), the two shapes
444/// [`crate::facts`] records a requirement for.
445///
446/// # Errors
447/// [`BumpEditError::PinNotFound`] / [`BumpEditError::PinAmbiguous`] as above.
448pub fn rewrite_pin(
449    manifest: &str,
450    dependency: &str,
451    from: &str,
452    to: &str,
453) -> Result<String, BumpEditError> {
454    rewrite_pin_inner(manifest, dependency, from, to, false)
455}
456
457/// Rewrite an exact internal pin owned by the root `[workspace.dependencies]` table.
458pub fn rewrite_workspace_pin(
459    manifest: &str,
460    dependency: &str,
461    from: &str,
462    to: &str,
463) -> Result<String, BumpEditError> {
464    rewrite_pin_inner(manifest, dependency, from, to, true)
465}
466
467fn rewrite_deps(
468    deps: &mut dyn toml_edit::TableLike,
469    dependency: &str,
470    from: &str,
471    to: &str,
472    require_local: bool,
473) -> usize {
474    use toml_edit::{Item, Value};
475    let mut rewritten = 0;
476    for (key, item) in deps.iter_mut() {
477        let Some(fields) = item.as_table_like_mut() else {
478            continue;
479        };
480        let local = fields.get("path").and_then(Item::as_str).is_some()
481            || fields.get("workspace").and_then(Item::as_bool) == Some(true);
482        let package = fields
483            .get("package")
484            .and_then(Item::as_str)
485            .unwrap_or(key.get());
486        if package == dependency
487            && (!require_local || local)
488            && fields.get("version").and_then(Item::as_str) == Some(from)
489        {
490            let version = fields
491                .get_mut("version")
492                .and_then(Item::as_value_mut)
493                .expect("a string version is a value");
494            let decor = version.decor().clone();
495            *version = Value::from(to);
496            *version.decor_mut() = decor;
497            rewritten += 1;
498        }
499    }
500    rewritten
501}
502
503fn rewrite_member_tables(
504    doc: &mut toml_edit::DocumentMut,
505    dependency: &str,
506    from: &str,
507    to: &str,
508) -> usize {
509    use toml_edit::Item;
510    const KINDS: [&str; 3] = ["dependencies", "dev-dependencies", "build-dependencies"];
511    let mut rewritten = 0;
512    for kind in KINDS {
513        if let Some(deps) = doc.get_mut(kind).and_then(Item::as_table_like_mut) {
514            rewritten += rewrite_deps(deps, dependency, from, to, true);
515        }
516    }
517    if let Some(targets) = doc.get_mut("target").and_then(Item::as_table_like_mut) {
518        for (_, target) in targets.iter_mut() {
519            let Some(target) = target.as_table_like_mut() else {
520                continue;
521            };
522            for kind in KINDS {
523                if let Some(deps) = target.get_mut(kind).and_then(Item::as_table_like_mut) {
524                    rewritten += rewrite_deps(deps, dependency, from, to, true);
525                }
526            }
527        }
528    }
529    rewritten
530}
531
532fn rewrite_pin_inner(
533    manifest: &str,
534    dependency: &str,
535    from: &str,
536    to: &str,
537    workspace_only: bool,
538) -> Result<String, BumpEditError> {
539    use toml_edit::{DocumentMut, Item};
540
541    let declarations: Vec<PinDeclaration> = (if workspace_only {
542        cargo_workspace_pin_declarations(manifest)
543    } else {
544        cargo_pin_declarations(manifest)
545    })
546    .map_err(|reason| BumpEditError::ManifestUnparseable { reason })?
547    .into_iter()
548    .filter(|d| d.package == dependency)
549    .collect();
550    let explicit = declarations
551        .iter()
552        .filter(|d| d.requirement.is_some())
553        .count();
554    let matching = declarations
555        .iter()
556        .filter(|d| d.requirement.as_deref() == Some(from))
557        .count();
558    if matching == 0 {
559        return Err(BumpEditError::PinNotFound {
560            dependency: dependency.to_string(),
561            from: from.to_string(),
562        });
563    }
564    if matching != explicit {
565        return Err(BumpEditError::PinAmbiguous {
566            dependency: dependency.to_string(),
567            from: from.to_string(),
568            count: explicit,
569        });
570    }
571
572    let mut doc =
573        manifest
574            .parse::<DocumentMut>()
575            .map_err(|error| BumpEditError::ManifestUnparseable {
576                reason: error.to_string(),
577            })?;
578    let rewritten = if workspace_only {
579        doc.get_mut("workspace")
580            .and_then(Item::as_table_like_mut)
581            .and_then(|workspace| workspace.get_mut("dependencies"))
582            .and_then(Item::as_table_like_mut)
583            .map_or(0, |deps| rewrite_deps(deps, dependency, from, to, false))
584    } else {
585        rewrite_member_tables(&mut doc, dependency, from, to)
586    };
587    if rewritten != matching {
588        return Err(BumpEditError::PinAmbiguous {
589            dependency: dependency.to_string(),
590            from: from.to_string(),
591            count: explicit,
592        });
593    }
594    Ok(doc.to_string())
595}
596
597/// Finalize a Keep-a-Changelog CHANGELOG: promote the `## [Unreleased]` section's
598/// content under a new dated `## [<version>] - <date>` header, leaving a fresh empty
599/// `## [Unreleased]` above it for the next cycle. Returns the new text.
600///
601/// Deliberately conservative: it inserts one dated header immediately after the
602/// `## [Unreleased]` line and does not otherwise reflow the file, so it composes with a
603/// human-curated body. `date` is `YYYY-MM-DD`.
604///
605/// # Errors
606/// [`BumpEditError::ChangelogUnreleasedNotFound`] when there is no `## [Unreleased]`
607/// header to promote (fail closed — the contract asked for a finalize there is nothing
608/// to finalize).
609pub fn finalize_changelog(text: &str, version: &str, date: &str) -> Result<String, BumpEditError> {
610    let ends_with_newline = text.ends_with('\n');
611    let mut out = String::with_capacity(text.len() + version.len() + date.len() + 16);
612    let mut inserted = false;
613    let mut lines = text.lines().peekable();
614    while let Some(line) = lines.next() {
615        out.push_str(line);
616        push_line_ending(&mut out, lines.peek().is_some(), ends_with_newline);
617        if !inserted && is_unreleased_header(line) {
618            // A blank line, then the dated release header — Keep a Changelog style.
619            out.push('\n');
620            out.push_str("## [");
621            out.push_str(version);
622            out.push_str("] - ");
623            out.push_str(date);
624            // Guarantee a newline after the inserted header even at EOF, so the
625            // promoted content is not glued onto it.
626            out.push('\n');
627            inserted = true;
628        }
629    }
630    if inserted {
631        Ok(out)
632    } else {
633        Err(BumpEditError::ChangelogUnreleasedNotFound)
634    }
635}
636
637/// Finalize a marker-owned changelog region without allowing the released section or
638/// marker comments to enter the release notes. `compiled_notes` contains any fragment
639/// and trailer-derived material gathered by the effectful executor.
640pub fn finalize_marker_changelog(
641    text: &str,
642    version: &str,
643    date: &str,
644    compiled_notes: &str,
645) -> Result<String, BumpEditError> {
646    const START: &str = "<!-- oss-changelog:unreleased-start -->";
647    const END: &str = "<!-- oss-changelog:unreleased-end -->";
648    const SKELETON: &str = "<!-- oss-changelog:unreleased-start -->\n## [Unreleased]\n\n### Added\n\n### Changed\n\n### Fixed\n<!-- oss-changelog:unreleased-end -->";
649
650    let starts: Vec<_> = text.match_indices(START).map(|(i, _)| i).collect();
651    let ends: Vec<_> = text.match_indices(END).map(|(i, _)| i).collect();
652    if starts.is_empty() && ends.is_empty() {
653        let marked = wrap_unreleased_markers(text)?;
654        return finalize_marker_changelog(&marked, version, date, compiled_notes);
655    }
656    if starts.len() != 1 || ends.len() != 1 || starts[0] >= ends[0] {
657        return Err(BumpEditError::ChangelogMarkersMalformed);
658    }
659    let start = starts[0];
660    let end = ends[0];
661    let region = &text[start + START.len()..end];
662    if !region.lines().any(is_unreleased_header) {
663        return Err(BumpEditError::ChangelogUnreleasedNotFound);
664    }
665    if region.lines().any(is_release_heading) {
666        return Err(BumpEditError::ChangelogMarkersMalformed);
667    }
668
669    let notes = release_note_content(&[region, compiled_notes]);
670    let heading_prefix = format!("## [{version}]");
671    let existing = text
672        .lines()
673        .any(|line| line.trim().starts_with(&heading_prefix));
674    if existing {
675        if notes.is_empty() {
676            return Ok(text.to_string());
677        }
678        return Err(BumpEditError::ChangelogReleaseConflict {
679            version: version.to_string(),
680        });
681    }
682    if notes.is_empty() {
683        return Err(BumpEditError::ChangelogNotesEmpty);
684    }
685
686    let after_marker = end + END.len();
687    let prefix = text[..start].trim_end_matches('\n');
688    let suffix = text[after_marker..].trim_start_matches('\n');
689    let mut out = String::with_capacity(text.len() + notes.len() + version.len() + 64);
690    if !prefix.is_empty() {
691        out.push_str(prefix);
692        out.push_str("\n\n");
693    }
694    out.push_str(SKELETON);
695    out.push_str("\n\n## [");
696    out.push_str(version);
697    out.push_str("] - ");
698    out.push_str(date);
699    out.push_str("\n\n");
700    out.push_str(&notes);
701    if !suffix.is_empty() {
702        out.push_str("\n\n");
703        out.push_str(suffix.trim_end_matches('\n'));
704    }
705    if text.ends_with('\n') {
706        out.push('\n');
707    }
708    Ok(out)
709}
710
711/// Remove structural marker/header lines and empty category headings from one note
712/// source. This is also the final defense that marker comments cannot leak into a
713/// cargo-dist announcement body.
714fn release_note_content(sources: &[&str]) -> String {
715    use std::collections::BTreeMap;
716
717    const START: &str = "<!-- oss-changelog:unreleased-start -->";
718    const END: &str = "<!-- oss-changelog:unreleased-end -->";
719    const ORDER: [&str; 6] = [
720        "Added",
721        "Changed",
722        "Deprecated",
723        "Removed",
724        "Fixed",
725        "Security",
726    ];
727    let mut preamble = Vec::new();
728    let mut sections: BTreeMap<String, Vec<String>> = BTreeMap::new();
729    for source in sources {
730        let mut current: Option<String> = None;
731        for line in source.lines() {
732            let trimmed = line.trim();
733            if trimmed == START || trimmed == END || is_unreleased_header(line) {
734                continue;
735            }
736            if let Some(heading) = trimmed.strip_prefix("### ") {
737                current = Some(heading.to_string());
738                sections.entry(heading.to_string()).or_default();
739            } else if let Some(heading) = &current {
740                sections
741                    .entry(heading.clone())
742                    .or_default()
743                    .push(line.to_string());
744            } else {
745                preamble.push(line.to_string());
746            }
747        }
748    }
749
750    let mut kept = Vec::new();
751    let preamble = preamble.join("\n").trim().to_string();
752    if !preamble.is_empty() {
753        kept.push(preamble);
754    }
755    let mut headings: Vec<_> = sections.keys().cloned().collect();
756    headings.sort_by_key(|heading| {
757        ORDER
758            .iter()
759            .position(|candidate| candidate == heading)
760            .unwrap_or(ORDER.len())
761    });
762    for heading in headings {
763        let body = sections.remove(&heading).expect("heading came from map");
764        let body = collapse_blank_lines(&body.join("\n"));
765        if !body.is_empty() {
766            kept.push(format!("### {heading}\n\n{body}"));
767        }
768    }
769    kept.join("\n\n")
770}
771
772fn collapse_blank_lines(text: &str) -> String {
773    let mut out = Vec::new();
774    let mut previous_blank = false;
775    for line in text.trim().lines() {
776        let blank = line.trim().is_empty();
777        if blank && previous_blank {
778            continue;
779        }
780        out.push(line);
781        previous_blank = blank;
782    }
783    out.join("\n")
784}
785
786fn wrap_unreleased_markers(text: &str) -> Result<String, BumpEditError> {
787    let mut offset = 0;
788    let mut header_start = None;
789    let mut section_end = text.len();
790    for line in text.split_inclusive('\n') {
791        if header_start.is_none() && is_unreleased_header(line.trim_end_matches('\n')) {
792            header_start = Some(offset);
793        } else if header_start.is_some()
794            && (is_release_heading(line) || is_link_definition(line.trim()))
795        {
796            section_end = offset;
797            break;
798        }
799        offset += line.len();
800    }
801    let header_start = header_start.ok_or(BumpEditError::ChangelogUnreleasedNotFound)?;
802    let mut marked = String::with_capacity(text.len() + 100);
803    marked.push_str(&text[..header_start]);
804    marked.push_str("<!-- oss-changelog:unreleased-start -->\n");
805    marked.push_str(&text[header_start..section_end]);
806    if !marked.ends_with('\n') {
807        marked.push('\n');
808    }
809    marked.push_str("<!-- oss-changelog:unreleased-end -->\n");
810    marked.push_str(text[section_end..].trim_start_matches('\n'));
811    Ok(marked)
812}
813
814/// Whether `line` is a `## [Unreleased]` header (Keep a Changelog), tolerant of
815/// surrounding whitespace and `Unreleased` letter-case.
816fn is_release_heading(line: &str) -> bool {
817    let trimmed = line.trim();
818    trimmed.starts_with("## ") && !trimmed.starts_with("### ") && !is_unreleased_header(line)
819}
820
821fn is_link_definition(line: &str) -> bool {
822    line.starts_with('[') && line.contains("]: ")
823}
824
825fn is_unreleased_header(line: &str) -> bool {
826    let t = line.trim();
827    let Some(rest) = t.strip_prefix("##") else {
828        return false;
829    };
830    let rest = rest.trim();
831    rest.eq_ignore_ascii_case("[unreleased]")
832}
833
834/// The bracketed section name of a TOML header line (`[a.b.c]` → `Some("a.b.c")`), or
835/// `None` when the line is not a bare section header.
836fn section_header(trimmed: &str) -> Option<&str> {
837    // Only a plain `[header]`; an array-of-tables `[[x]]` is not a bump target.
838    let inner = trimmed.strip_prefix('[')?.strip_suffix(']')?;
839    if inner.starts_with('[') || inner.contains('[') {
840        return None;
841    }
842    Some(inner.trim())
843}
844
845/// Whether a trimmed line starts with `key =`, excluding a matching string embedded in
846/// another key's value. Section version reads and writes use this stricter rule; inline
847/// dependency-table scans intentionally use the more flexible token search below.
848fn line_starts_with_key(line: &str, key: &str) -> bool {
849    line.strip_prefix(key)
850        .is_some_and(|rest| rest.trim_start().starts_with('='))
851}
852
853/// Replace a whole-key `key = "<old>"` with `key = "<new>"` on `line`, but only when
854/// the current value is exactly `old`; returns the rewritten line or `None`.
855fn replace_exact_string_value(line: &str, key: &str, old: &str, new: &str) -> Option<String> {
856    let current = scan_key_string(strip_comment(line).trim(), key)?;
857    if current != old {
858        return None;
859    }
860    replace_string_value(line, key, new)
861}
862
863/// Replace the value of a whole-key `key = "…"` on `line` with `new` (keeping quote
864/// style and everything else on the line), or `None` when the line has no such key.
865///
866/// Operates on the raw `line` (so indentation and a trailing inline `# comment` are
867/// preserved), locating the quoted value via the same whole-key scan used to read it.
868fn replace_string_value(line: &str, key: &str, new: &str) -> Option<String> {
869    let (val_start, quote) = locate_key_string(line, key)?;
870    // `val_start` points at the opening quote; find the closing quote.
871    let after_open = val_start + 1;
872    let rel_close = line[after_open..].find(quote)?;
873    let close = after_open + rel_close;
874    let mut out = String::with_capacity(line.len() + new.len());
875    out.push_str(&line[..after_open]);
876    out.push_str(new);
877    out.push_str(&line[close..]);
878    Some(out)
879}
880
881/// The value of a whole-key `key = "…"` in `s` (matching the facts parser's whole-token
882/// discipline), or `None`.
883fn scan_key_string(s: &str, key: &str) -> Option<String> {
884    let (open, quote) = locate_key_string(s, key)?;
885    let after_open = open + 1;
886    let rel_close = s[after_open..].find(quote)?;
887    Some(s[after_open..after_open + rel_close].to_string())
888}
889
890/// Locate a whole-key `key = "…"` in `s`, returning the byte offset of the opening
891/// quote and the quote char. "Whole key" = the char before `key` is not an identifier
892/// char, and `key` is immediately followed (past spaces) by `=` then a quote.
893fn locate_key_string(s: &str, key: &str) -> Option<(usize, char)> {
894    let mut search = 0;
895    while let Some(rel) = s[search..].find(key) {
896        let pos = search + rel;
897        let prev_is_ident = s[..pos]
898            .chars()
899            .next_back()
900            .is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '-');
901        let after = &s[pos + key.len()..];
902        let after_trimmed = after.trim_start();
903        if !prev_is_ident {
904            if let Some(rest) = after_trimmed.strip_prefix('=') {
905                let rest_trimmed = rest.trim_start();
906                if let Some(q) = rest_trimmed.chars().next() {
907                    if q == '"' || q == '\'' {
908                        // Offset of the quote in the original string.
909                        let consumed = s.len() - rest_trimmed.len();
910                        return Some((consumed, q));
911                    }
912                }
913            }
914        }
915        search = pos + key.len();
916    }
917    None
918}
919
920/// Strip a trailing `# comment` from a TOML line, respecting quoted `#`s crudely: it
921/// cuts at the first `#` not inside a quote. Sufficient for manifest lines the bump
922/// touches (version/pin values never contain `#`).
923fn strip_comment(line: &str) -> &str {
924    let mut in_str: Option<char> = None;
925    for (i, c) in line.char_indices() {
926        match in_str {
927            Some(q) => {
928                if c == q {
929                    in_str = None;
930                }
931            }
932            None => match c {
933                '"' | '\'' => in_str = Some(c),
934                '#' => return &line[..i],
935                _ => {}
936            },
937        }
938    }
939    line
940}
941
942/// Append the correct line ending: a `\n` between lines, and preserve whether the file
943/// ended with a trailing newline (so a rewrite is byte-faithful).
944fn push_line_ending(out: &mut String, more_lines: bool, ends_with_newline: bool) {
945    if more_lines || ends_with_newline {
946        out.push('\n');
947    }
948}
949
950#[cfg(test)]
951mod tests {
952    use super::*;
953
954    #[test]
955    fn patch_minor_major_from_a_normal_version() {
956        assert_eq!(bump_version(BumpLevel::Patch, "0.4.0").unwrap(), "0.4.1");
957        assert_eq!(bump_version(BumpLevel::Minor, "0.4.0").unwrap(), "0.5.0");
958        assert_eq!(bump_version(BumpLevel::Major, "0.4.0").unwrap(), "1.0.0");
959    }
960
961    #[test]
962    fn minor_and_major_reset_lower_components() {
963        assert_eq!(bump_version(BumpLevel::Minor, "1.2.3").unwrap(), "1.3.0");
964        assert_eq!(bump_version(BumpLevel::Major, "1.2.3").unwrap(), "2.0.0");
965        assert_eq!(bump_version(BumpLevel::Patch, "1.2.3").unwrap(), "1.2.4");
966    }
967
968    #[test]
969    fn zero_versions_bump_canonically() {
970        assert_eq!(bump_version(BumpLevel::Patch, "0.0.0").unwrap(), "0.0.1");
971        assert_eq!(bump_version(BumpLevel::Minor, "0.0.0").unwrap(), "0.1.0");
972        assert_eq!(bump_version(BumpLevel::Major, "0.0.0").unwrap(), "1.0.0");
973    }
974
975    #[test]
976    fn a_pre_release_or_build_version_is_refused() {
977        assert!(bump_version(BumpLevel::Patch, "1.2.3-rc.1").is_err());
978        assert!(bump_version(BumpLevel::Patch, "1.2.3+build.5").is_err());
979    }
980
981    #[test]
982    fn a_non_xyz_version_is_refused() {
983        for bad in ["1.2", "1.2.3.4", "1", "", "v1.2.3", "1.2.x", "1..2", "1.2."] {
984            assert!(
985                bump_version(BumpLevel::Patch, bad).is_err(),
986                "expected `{bad}` to be refused"
987            );
988        }
989    }
990
991    #[test]
992    fn a_leading_zero_component_is_refused() {
993        assert!(bump_version(BumpLevel::Patch, "1.02.3").is_err());
994        assert!(bump_version(BumpLevel::Patch, "01.2.3").is_err());
995        // But a bare zero component is canonical and fine.
996        assert!(bump_version(BumpLevel::Patch, "0.1.0").is_ok());
997    }
998
999    #[test]
1000    fn the_error_carries_the_offending_version() {
1001        let err = bump_version(BumpLevel::Patch, "not-semver").unwrap_err();
1002        assert_eq!(err.version, "not-semver");
1003        assert!(!err.reason.is_empty());
1004    }
1005
1006    // ── set_workspace_version ────────────────────────────────────────────────
1007
1008    #[test]
1009    fn sets_the_workspace_package_version_only() {
1010        let manifest = "[workspace]\nmembers = [\"a\"]\n\n[workspace.package]\nversion = \"0.4.0\"\nedition = \"2021\"\n";
1011        let out = set_workspace_version(manifest, "0.4.0", "0.5.0").unwrap();
1012        assert!(out.contains("version = \"0.5.0\""));
1013        assert!(!out.contains("0.4.0"));
1014        // Everything else preserved.
1015        assert!(out.contains("edition = \"2021\""));
1016        assert!(out.ends_with('\n'));
1017    }
1018
1019    #[test]
1020    fn does_not_touch_a_version_in_another_section() {
1021        let manifest =
1022            "[package]\nversion = \"9.9.9\"\n\n[workspace.package]\nversion = \"0.4.0\"\n";
1023        let out = set_workspace_version(manifest, "0.4.0", "0.5.0").unwrap();
1024        assert!(out.contains("[package]\nversion = \"9.9.9\""));
1025        assert!(out.contains("[workspace.package]\nversion = \"0.5.0\""));
1026    }
1027
1028    #[test]
1029    fn does_not_match_a_version_inside_a_description_string() {
1030        let manifest = "[workspace.package]\ndescription = 'requires version = \"0.4.0\"'\nversion = \"0.4.0\"\n";
1031        let out = set_workspace_version(manifest, "0.4.0", "0.5.0").unwrap();
1032        assert!(
1033            out.contains("requires version = \"0.4.0\""),
1034            "description untouched: {out}"
1035        );
1036        assert!(
1037            out.contains("version = \"0.5.0\""),
1038            "real version bumped: {out}"
1039        );
1040    }
1041
1042    #[test]
1043    fn fails_closed_when_the_current_version_does_not_match_from() {
1044        let manifest = "[workspace.package]\nversion = \"1.2.3\"\n";
1045        assert_eq!(
1046            set_workspace_version(manifest, "0.4.0", "0.5.0"),
1047            Err(BumpEditError::WorkspaceVersionNotFound)
1048        );
1049    }
1050
1051    #[test]
1052    fn package_version_is_available_for_a_plain_single_crate_manifest() {
1053        let manifest = "[package]\nname = \"acme\"\nversion = \"1.0.0\"\n";
1054        assert_eq!(package_version(manifest).as_deref(), Some("1.0.0"));
1055        assert_eq!(root_manifest_version(manifest).as_deref(), Some("1.0.0"));
1056        assert_eq!(
1057            set_package_version(manifest, "1.0.0", "2.0.0").unwrap(),
1058            "[package]\nname = \"acme\"\nversion = \"2.0.0\"\n"
1059        );
1060        let with_description =
1061            "[package]\ndescription = 'requires version = \"1.0.0\"'\nversion = \"1.0.0\"\n";
1062        let out = set_package_version(with_description, "1.0.0", "2.0.0").unwrap();
1063        assert!(out.contains("requires version = \"1.0.0\""));
1064        assert_eq!(package_version(&out).as_deref(), Some("2.0.0"));
1065    }
1066
1067    #[test]
1068    fn root_manifest_version_prefers_workspace_inheritance() {
1069        let manifest =
1070            "[package]\nversion = \"9.9.9\"\n\n[workspace.package]\nversion = \"1.0.0\"\n";
1071        assert_eq!(root_manifest_version(manifest).as_deref(), Some("1.0.0"));
1072    }
1073
1074    #[test]
1075    fn package_rewrite_fails_closed_when_neither_root_version_shape_matches() {
1076        let manifest = "[package]\nname = \"acme\"\n";
1077        assert_eq!(
1078            set_package_version(manifest, "1.0.0", "2.0.0"),
1079            Err(BumpEditError::RootManifestVersionNotFound)
1080        );
1081    }
1082
1083    // ── rewrite_pin ──────────────────────────────────────────────────────────
1084
1085    #[test]
1086    fn rewrites_an_inline_table_pin() {
1087        let manifest = "[dependencies]\nshipshape-core = { path = \"../shipshape-core\", version = \"=0.4.0\" }\nserde = \"1\"\n";
1088        let out = rewrite_pin(manifest, "shipshape-core", "=0.4.0", "=0.5.0").unwrap();
1089        assert!(out.contains("version = \"=0.5.0\""));
1090        assert!(out.contains("path = \"../shipshape-core\""));
1091        assert!(out.contains("serde = \"1\""));
1092    }
1093
1094    #[test]
1095    fn rewrites_a_subtable_pin() {
1096        let manifest =
1097            "[dependencies.shipshape-core]\npath = \"../shipshape-core\"\nversion = \"=0.4.0\"\n";
1098        let out = rewrite_pin(manifest, "shipshape-core", "=0.4.0", "=0.5.0").unwrap();
1099        assert!(out.contains("version = \"=0.5.0\""));
1100    }
1101
1102    #[test]
1103    fn rewrites_dotted_dependency_keys() {
1104        let manifest = "[dependencies]\ncore.path = \"../core\"\ncore.version = \"=0.4.0\"\n";
1105        let out = rewrite_pin(manifest, "core", "=0.4.0", "=0.5.0").unwrap();
1106        assert!(out.contains("core.version = \"=0.5.0\""), "{out}");
1107    }
1108
1109    #[test]
1110    fn rewrites_multiline_inline_workspace_dependency() {
1111        let manifest = "[workspace.dependencies]\ncore = {\n  path = \"crates/core\",\n  version = \"=0.4.0\"\n}\n";
1112        let declarations = cargo_workspace_pin_declarations(manifest).unwrap();
1113        assert_eq!(declarations.len(), 1);
1114        let out = rewrite_workspace_pin(manifest, "core", "=0.4.0", "=0.5.0").unwrap();
1115        assert!(out.contains("version = \"=0.5.0\""), "{out}");
1116    }
1117
1118    #[test]
1119    fn rewrites_dotted_workspace_dependency_keys() {
1120        let manifest =
1121            "[workspace.dependencies]\ncore.path = \"crates/core\"\ncore.version = \"=0.4.0\"\n";
1122        let out = rewrite_workspace_pin(manifest, "core", "=0.4.0", "=0.5.0").unwrap();
1123        assert!(out.contains("core.version = \"=0.5.0\""), "{out}");
1124    }
1125
1126    #[test]
1127    fn root_exact_pin_without_path_is_still_an_edit_target() {
1128        let manifest = "[workspace.dependencies]\ncore = { version = \"=0.4.0\" }\n";
1129        let declarations = cargo_workspace_pin_declarations(manifest).unwrap();
1130        assert_eq!(declarations[0].requirement.as_deref(), Some("=0.4.0"));
1131        let out = rewrite_workspace_pin(manifest, "core", "=0.4.0", "=0.5.0").unwrap();
1132        assert!(out.contains("version = \"=0.5.0\""), "{out}");
1133    }
1134
1135    #[test]
1136    fn rewrite_is_scoped_to_cargo_tables_and_local_declarations() {
1137        let manifest = "[dependencies]\ncore = { path = \"../core\", version = \"=0.4.0\" }\n[dev-dependencies]\nregistry-core = { package = \"core\", version = \"=0.4.0\" }\n[package.metadata.tool.dependencies]\ncore = { path = \"schema/core\", version = \"=0.4.0\" }\n";
1138        let out = rewrite_pin(manifest, "core", "=0.4.0", "=0.5.0").unwrap();
1139        assert_eq!(out.matches("=0.5.0").count(), 1, "{out}");
1140        assert_eq!(out.matches("=0.4.0").count(), 2, "{out}");
1141    }
1142
1143    #[test]
1144    fn rewrite_preserves_version_value_comments() {
1145        let manifest =
1146            "[dependencies.core]\npath = \"../core\"\nversion = \"=0.4.0\" # release-managed\n";
1147        let out = rewrite_pin(manifest, "core", "=0.4.0", "=0.5.0").unwrap();
1148        assert!(
1149            out.contains("version = \"=0.5.0\" # release-managed"),
1150            "{out}"
1151        );
1152    }
1153
1154    #[test]
1155    fn pin_rewrite_fails_closed_when_absent() {
1156        let manifest =
1157            "[dependencies]\nshipshape-core = { path = \"../shipshape-core\", version = \"^0.4\" }\n";
1158        assert_eq!(
1159            rewrite_pin(manifest, "shipshape-core", "=0.4.0", "=0.5.0"),
1160            Err(BumpEditError::PinNotFound {
1161                dependency: "shipshape-core".into(),
1162                from: "=0.4.0".into(),
1163            })
1164        );
1165    }
1166
1167    #[test]
1168    fn pin_rewrite_leaves_a_caret_dep_untouched_even_with_same_crate() {
1169        // A different crate sharing the exact from-string must not be rewritten.
1170        let manifest = "[dependencies]\nshipshape-core = { path = \"../c\", version = \"=0.4.0\" }\nother = \"=0.4.0\"\n";
1171        let out = rewrite_pin(manifest, "shipshape-core", "=0.4.0", "=0.5.0").unwrap();
1172        assert!(out.contains("shipshape-core = { path = \"../c\", version = \"=0.5.0\" }"));
1173        // `other = "=0.4.0"` is a plain registry dep, not our pin — untouched.
1174        assert!(out.contains("other = \"=0.4.0\""));
1175    }
1176
1177    #[test]
1178    fn pin_rewrite_updates_every_equivalent_dependency_table() {
1179        let manifest = "[dependencies]\ncore = { path = \"a\", version = \"=0.4.0\" }\n[dev-dependencies]\ncore = { path = \"a\", version = \"=0.4.0\" }\n[build-dependencies]\ncore = { path = \"a\", version = \"=0.4.0\" }\n[target.'cfg(unix)'.dependencies]\ncore = { path = \"a\", version = \"=0.4.0\" }\n";
1180        let out = rewrite_pin(manifest, "core", "=0.4.0", "=0.5.0").unwrap();
1181        assert_eq!(out.matches("version = \"=0.5.0\"").count(), 4);
1182        assert!(!out.contains("version = \"=0.4.0\""));
1183    }
1184
1185    #[test]
1186    fn pin_rewrite_fails_closed_on_non_equivalent_matches() {
1187        let manifest = "[dependencies]\ncore = { path = \"a\", version = \"=0.4.0\" }\n[dev-dependencies]\ncore = { path = \"a\", version = \"^0.4\" }\n";
1188        let err = rewrite_pin(manifest, "core", "=0.4.0", "=0.5.0").unwrap_err();
1189        assert!(matches!(err, BumpEditError::PinAmbiguous { count: 2, .. }));
1190    }
1191
1192    #[test]
1193    fn pin_rewrite_uses_resolved_package_aliases() {
1194        let manifest = "[dependencies]\nalias = { package = \"core\", path = \"../core\", version = \"=0.4.0\" }\n[dev-dependencies.alias]\npackage = \"core\"\npath = \"../core\"\nversion = \"=0.4.0\"\n";
1195        let out = rewrite_pin(manifest, "core", "=0.4.0", "=0.5.0").unwrap();
1196        assert_eq!(out.matches("version = \"=0.5.0\"").count(), 2);
1197    }
1198
1199    #[test]
1200    fn pin_rewrite_ignores_non_dependency_tables_and_registry_dependencies() {
1201        let manifest = "[dependencies]\ncore = { path = \"../core\", version = \"=0.4.0\" }\n[dev-dependencies]\ncore = { version = \"^0.4\" }\n[package.metadata.release]\ncore = { version = \"=999.0.0\" }\n[patch.crates-io]\ncore = { path = \"vendor/core\", version = \"=999.0.0\" }\n";
1202        let out = rewrite_pin(manifest, "core", "=0.4.0", "=0.5.0").unwrap();
1203        assert!(out.contains("version = \"=0.5.0\""));
1204        assert!(out.contains("core = { version = \"^0.4\" }"));
1205        assert_eq!(out.matches("version = \"=999.0.0\"").count(), 2);
1206    }
1207
1208    #[test]
1209    fn path_only_duplicates_are_neutral() {
1210        let manifest = "[dependencies]\ncore = { path = \"../core\", version = \"=0.4.0\" }\n[target.'cfg(unix)'.dev-dependencies.core]\npath = \"../core\"\n";
1211        let out = rewrite_pin(manifest, "core", "=0.4.0", "=0.5.0").unwrap();
1212        assert_eq!(out.matches("=0.5.0").count(), 1);
1213        assert!(out.contains("[target.'cfg(unix)'.dev-dependencies.core]\npath"));
1214    }
1215
1216    // ── finalize_changelog ───────────────────────────────────────────────────
1217
1218    #[test]
1219    fn finalizes_the_unreleased_section() {
1220        let text = "# Changelog\n\n## [Unreleased]\n### Added\n- a thing\n";
1221        let out = finalize_changelog(text, "0.5.0", "2026-08-13").unwrap();
1222        assert!(out.contains("## [Unreleased]\n\n## [0.5.0] - 2026-08-13"));
1223        assert!(out.contains("- a thing"));
1224    }
1225
1226    #[test]
1227    fn changelog_finalize_fails_closed_without_unreleased() {
1228        let text = "# Changelog\n\n## [0.4.0] - 2026-01-01\n";
1229        assert_eq!(
1230            finalize_changelog(text, "0.5.0", "2026-08-13"),
1231            Err(BumpEditError::ChangelogUnreleasedNotFound)
1232        );
1233    }
1234
1235    #[test]
1236    fn marker_finalize_places_release_outside_markers_and_strips_markers_from_notes() {
1237        let text = "# Changelog\n\n<!-- oss-changelog:unreleased-start -->\n## [Unreleased]\n\n### Added\n\n### Changed\n\n### Fixed\n<!-- oss-changelog:unreleased-end -->\n\n## [0.6.1] - 2026-08-21\n\nOld.\n";
1238        let compiled =
1239            "### Changed\n\n- Agent Skills terminology.\n<!-- oss-changelog:unreleased-end -->\n";
1240        let out = finalize_marker_changelog(text, "0.6.2", "2026-08-23", compiled).unwrap();
1241        let end = out.find("<!-- oss-changelog:unreleased-end -->").unwrap();
1242        let release = out.find("## [0.6.2] - 2026-08-23").unwrap();
1243        assert!(
1244            release > end,
1245            "released section must be outside markers: {out}"
1246        );
1247        assert_eq!(
1248            out.matches("<!-- oss-changelog:unreleased-end -->").count(),
1249            1
1250        );
1251        assert!(out.contains("### Changed\n\n- Agent Skills terminology."));
1252        assert_eq!(out.matches("### Changed").count(), 2, "skeleton + release");
1253        assert!(out.contains("## [0.6.1] - 2026-08-21"));
1254    }
1255
1256    #[test]
1257    fn marker_finalize_is_idempotent_only_when_no_notes_are_pending() {
1258        let text = "<!-- oss-changelog:unreleased-start -->\n## [Unreleased]\n\n### Added\n### Changed\n### Fixed\n<!-- oss-changelog:unreleased-end -->\n\n## [0.6.2] - 2026-08-23\n\n- shipped\n";
1259        assert_eq!(
1260            finalize_marker_changelog(text, "0.6.2", "2026-08-23", "").unwrap(),
1261            text
1262        );
1263        assert!(matches!(
1264            finalize_marker_changelog(text, "0.6.2", "2026-08-23", "- pending"),
1265            Err(BumpEditError::ChangelogReleaseConflict { .. })
1266        ));
1267    }
1268
1269    #[test]
1270    fn marker_finalize_migrates_a_markerless_changelog() {
1271        let text = "# Changelog\n\n## [Unreleased]\n\n### Fixed\n\n- Authored fix.\n\n## 0.9.0 - 2026-08-01\n\nOld.\n\n[unreleased]: https://example.test/compare/v0.9.0...HEAD\n";
1272        let out =
1273            finalize_marker_changelog(text, "1.0.0", "2026-08-23", "### Fixed\n\n- Trailer fix.")
1274                .unwrap();
1275        assert!(out.contains("<!-- oss-changelog:unreleased-start -->"));
1276        assert!(
1277            out.contains(
1278                "## [1.0.0] - 2026-08-23\n\n### Fixed\n\n- Authored fix.\n\n- Trailer fix."
1279            ),
1280            "{out}"
1281        );
1282        assert_eq!(out.matches("### Fixed").count(), 2, "skeleton + release");
1283        assert!(out.contains("## 0.9.0 - 2026-08-01"));
1284        assert!(out.contains("[unreleased]: https://example.test/compare/v0.9.0...HEAD"));
1285    }
1286
1287    #[test]
1288    fn markerless_unreleased_does_not_promote_link_definitions() {
1289        let text = "## [Unreleased]\n\n### Added\n\n- First release.\n\n[unreleased]: https://example.test/compare/v0.1.0...HEAD\n";
1290        let out = finalize_marker_changelog(text, "0.1.0", "2026-08-23", "").unwrap();
1291        let release = out.find("## [0.1.0] - 2026-08-23").unwrap();
1292        let link = out.find("[unreleased]: https://example.test").unwrap();
1293        assert!(link > release);
1294        assert!(!out[release..link].contains("[unreleased]:"));
1295    }
1296
1297    #[test]
1298    fn marker_finalize_refuses_a_dated_release_inside_unreleased() {
1299        let broken = "<!-- oss-changelog:unreleased-start -->\n## [Unreleased]\n\n## [0.6.2] - 2026-08-23\n\n### Fixed\n- old\n<!-- oss-changelog:unreleased-end -->\n";
1300        assert_eq!(
1301            finalize_marker_changelog(broken, "0.6.3", "2026-08-24", "- new"),
1302            Err(BumpEditError::ChangelogMarkersMalformed)
1303        );
1304    }
1305
1306    #[test]
1307    fn marker_finalize_refuses_malformed_markers_and_empty_releases() {
1308        let malformed = "<!-- oss-changelog:unreleased-start -->\n## [Unreleased]\n";
1309        assert_eq!(
1310            finalize_marker_changelog(malformed, "1.0.0", "2026-08-23", "- note"),
1311            Err(BumpEditError::ChangelogMarkersMalformed)
1312        );
1313        let empty = "<!-- oss-changelog:unreleased-start -->\n## [Unreleased]\n### Added\n### Changed\n### Fixed\n<!-- oss-changelog:unreleased-end -->\n";
1314        assert_eq!(
1315            finalize_marker_changelog(empty, "1.0.0", "2026-08-23", ""),
1316            Err(BumpEditError::ChangelogNotesEmpty)
1317        );
1318    }
1319}