Skip to main content

ossctl_core/release/
bump.rs

1//! The engine-owned version-bump arithmetic (`release-rust-workspace-multicrate`
2//! facet 2).
3//!
4//! `ossctl 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 a manifest version could not be bumped: it is not a strict `X.Y.Z` semver
21/// core, so the engine will not guess a new number (fail closed).
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct BumpError {
24    /// The offending version string, echoed for the CLI's `invalid_value`.
25    pub version: String,
26    /// Why it was rejected (human-readable), e.g. "expected MAJOR.MINOR.PATCH".
27    pub reason: String,
28}
29
30/// Compute the next version by applying `level` to a strict `X.Y.Z` `current`
31/// version.
32///
33/// - `major` → `(X+1).0.0`
34/// - `minor` → `X.(Y+1).0`
35/// - `patch` → `X.Y.(Z+1)`
36///
37/// # Errors
38/// [`BumpError`] when `current` is not a strict `MAJOR.MINOR.PATCH` of three
39/// non-negative integers (a pre-release/build suffix, a missing/extra component, a
40/// non-numeric or empty component, or a `u64`-overflowing component). Failing closed
41/// here means a malformed manifest version aborts the plan rather than silently
42/// producing a wrong release version.
43pub fn bump_version(level: BumpLevel, current: &str) -> Result<String, BumpError> {
44    let (major, minor, patch) = parse_semver_core(current)?;
45    let (major, minor, patch) = match level {
46        // A checked add keeps the (practically unreachable) `u64::MAX` overflow a loud
47        // error rather than a wrapped, silently-wrong version.
48        BumpLevel::Major => (checked_incr(major, current)?, 0, 0),
49        BumpLevel::Minor => (major, checked_incr(minor, current)?, 0),
50        BumpLevel::Patch => (major, minor, checked_incr(patch, current)?),
51    };
52    Ok(format!("{major}.{minor}.{patch}"))
53}
54
55/// Parse a strict `MAJOR.MINOR.PATCH` core into its three integers, rejecting
56/// anything else.
57fn parse_semver_core(v: &str) -> Result<(u64, u64, u64), BumpError> {
58    let reject = |reason: &str| BumpError {
59        version: v.to_string(),
60        reason: reason.to_string(),
61    };
62    // A pre-release (`-`) or build-metadata (`+`) suffix is not a plain release
63    // version — refuse rather than bump ambiguously.
64    if v.contains('-') || v.contains('+') {
65        return Err(reject(
66            "a pre-release or build-metadata version cannot be bumped; expected a plain \
67             MAJOR.MINOR.PATCH release version",
68        ));
69    }
70    let mut parts = v.split('.');
71    let mut next = |which: &str| -> Result<u64, BumpError> {
72        let comp = parts
73            .next()
74            .ok_or_else(|| reject("expected MAJOR.MINOR.PATCH (a component is missing)"))?;
75        parse_component(comp, which, v)
76    };
77    let major = next("major")?;
78    let minor = next("minor")?;
79    let patch = next("patch")?;
80    // A fourth component (or trailing dot) is not `X.Y.Z`.
81    if parts.next().is_some() {
82        return Err(reject(
83            "expected exactly MAJOR.MINOR.PATCH (too many components)",
84        ));
85    }
86    Ok((major, minor, patch))
87}
88
89/// Parse one version component as a non-negative integer, rejecting empty,
90/// non-digit, or leading-zero forms (`01`) so the version is canonical.
91fn parse_component(comp: &str, which: &str, full: &str) -> Result<u64, BumpError> {
92    let reject = |reason: String| BumpError {
93        version: full.to_string(),
94        reason,
95    };
96    if comp.is_empty() {
97        return Err(reject(format!("the {which} component is empty")));
98    }
99    if !comp.bytes().all(|b| b.is_ascii_digit()) {
100        return Err(reject(format!(
101            "the {which} component `{comp}` is not a non-negative integer"
102        )));
103    }
104    // Reject a non-canonical leading zero (`01`) — `0` itself is fine.
105    if comp.len() > 1 && comp.starts_with('0') {
106        return Err(reject(format!(
107            "the {which} component `{comp}` has a leading zero"
108        )));
109    }
110    comp.parse::<u64>().map_err(|_| {
111        reject(format!(
112            "the {which} component `{comp}` does not fit in a u64"
113        ))
114    })
115}
116
117/// Increment a component, turning the (unreachable in practice) overflow into a
118/// loud [`BumpError`] rather than a wrapped value.
119fn checked_incr(n: u64, full: &str) -> Result<u64, BumpError> {
120    n.checked_add(1).ok_or_else(|| BumpError {
121        version: full.to_string(),
122        reason: "a version component would overflow on bump".to_string(),
123    })
124}
125
126// ── Cut-time edit transforms (pure) ──────────────────────────────────────────
127//
128// The engine-owned bump phase applies a deterministic edit set inside the clean
129// checkout (`release-rust-workspace-multicrate` facet 2). These are the *pure* text
130// transforms behind those edits — no filesystem, no process — so each is exhaustively
131// unit-tested and the effectful executor ([`crate::release::bump_exec`]) is thin glue.
132// Every transform **fails closed**: it returns a [`BumpEditError`] rather than write an
133// ambiguous or silently-wrong result onto the irreversible cut path.
134
135/// Why a cut-time bump edit could not be applied to a file's text. Each variant is a
136/// fail-closed refusal — the executor aborts the cut rather than commit a wrong edit.
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub enum BumpEditError {
139    /// The `[workspace.package]` section, or its `version = "…"` line, was not found —
140    /// the workspace root manifest is not the shape the bump expects.
141    WorkspaceVersionNotFound,
142    /// A pin rewrite found no line declaring `dependency` with the exact `from`
143    /// requirement — the sealed pin does not match the tree, so the executor refuses
144    /// rather than guess (fail closed on **zero** matches).
145    PinNotFound {
146        /// The dependency whose `=<from>` pin was expected.
147        dependency: String,
148        /// The exact requirement string that was expected (`=<from_version>`).
149        from: String,
150    },
151    /// A pin rewrite matched `dependency`'s `from` requirement in **more than one**
152    /// place, so replacing is ambiguous — the executor refuses rather than rewrite the
153    /// wrong one (fail closed on **multiple** matches).
154    PinAmbiguous {
155        /// The dependency whose pin matched more than once.
156        dependency: String,
157        /// The requirement string that matched multiply.
158        from: String,
159        /// How many declarations matched.
160        count: usize,
161    },
162    /// The CHANGELOG had no `## [Unreleased]` section to finalize, but the contract's
163    /// changelog mode said the engine should finalize one — fail closed rather than
164    /// tag a release whose notes were never promoted.
165    ChangelogUnreleasedNotFound,
166}
167
168impl std::fmt::Display for BumpEditError {
169    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
170        match self {
171            Self::WorkspaceVersionNotFound => write!(
172                f,
173                "could not find a `[workspace.package]` `version = \"…\"` line to bump in the \
174                 workspace root manifest"
175            ),
176            Self::PinNotFound { dependency, from } => write!(
177                f,
178                "no `{dependency} = \"{from}\"` intra-workspace pin found to rewrite (the sealed \
179                 plan's pin does not match the tree)"
180            ),
181            Self::PinAmbiguous {
182                dependency,
183                from,
184                count,
185            } => write!(
186                f,
187                "the `{dependency} = \"{from}\"` pin matched {count} declarations — refusing to \
188                 rewrite an ambiguous pin"
189            ),
190            Self::ChangelogUnreleasedNotFound => write!(
191                f,
192                "the contract asks the engine to finalize the CHANGELOG, but no `## [Unreleased]` \
193                 section was found to promote"
194            ),
195        }
196    }
197}
198
199impl std::error::Error for BumpEditError {}
200
201/// The `[workspace.package]` `version = "…"` value, or `None` when the section or its
202/// `version` line is absent — the post-hook validation read (`bump_exec`).
203#[must_use]
204pub fn workspace_version(manifest: &str) -> Option<String> {
205    let mut in_section = false;
206    for line in manifest.lines() {
207        let trimmed = strip_comment(line).trim();
208        if let Some(header) = section_header(trimmed) {
209            in_section = header == "workspace.package";
210        } else if in_section {
211            if let Some(v) = scan_key_string(trimmed, "version") {
212                return Some(v);
213            }
214        }
215    }
216    None
217}
218
219/// Rewrite the `[workspace.package]` `version = "<from>"` line to `to`, returning the
220/// new manifest text.
221///
222/// Scoped to the `[workspace.package]` section (the single source of truth for the
223/// release version) so a `version` key in any other table — `[package]`,
224/// `[dependencies.foo]`, `[workspace.dependencies]` — is never touched. Preserves the
225/// line's exact indentation and quote style; only the value between the quotes changes.
226///
227/// **Verified against `from`** (llm-review defense-in-depth): the line is rewritten only
228/// when its current value is exactly `from` (the sealed pre-bump version). This makes the
229/// edit fail closed on a tree that does not match the plan, and — since the whole-key scan
230/// is line-oriented — it also sidesteps a `version = "…"` occurrence *inside a quoted
231/// string value* (e.g. a `description` that mentions a version) unless that string
232/// happens to equal `from`, in which case a following real `version` line still matches.
233///
234/// # Errors
235/// [`BumpEditError::WorkspaceVersionNotFound`] when the section, or a `version = "<from>"`
236/// line within it, is absent (fail closed rather than write a manifest with no bump).
237pub fn set_workspace_version(
238    manifest: &str,
239    from: &str,
240    to: &str,
241) -> Result<String, BumpEditError> {
242    let mut out = String::with_capacity(manifest.len() + to.len());
243    let mut in_section = false;
244    let mut replaced = false;
245    let ends_with_newline = manifest.ends_with('\n');
246    let mut lines = manifest.lines().peekable();
247    while let Some(line) = lines.next() {
248        let trimmed = strip_comment(line).trim();
249        if let Some(header) = section_header(trimmed) {
250            in_section = header == "workspace.package";
251        } else if in_section && !replaced {
252            if let Some(rewritten) = replace_exact_string_value(line, "version", from, to) {
253                out.push_str(&rewritten);
254                push_line_ending(&mut out, lines.peek().is_some(), ends_with_newline);
255                replaced = true;
256                continue;
257            }
258        }
259        out.push_str(line);
260        push_line_ending(&mut out, lines.peek().is_some(), ends_with_newline);
261    }
262    if replaced {
263        Ok(out)
264    } else {
265        Err(BumpEditError::WorkspaceVersionNotFound)
266    }
267}
268
269/// Rewrite a single intra-workspace `=`-pin (`dependency = "…, version = \"<from>\""`)
270/// from `from` to `to`, returning the new manifest text.
271///
272/// Precise and fail-closed (`release-rust-workspace-multicrate` facet 3): it counts the
273/// declarations of `dependency` whose version requirement is **exactly** `from` and
274/// rewrites iff there is exactly one — refusing on zero
275/// ([`BumpEditError::PinNotFound`]) or several ([`BumpEditError::PinAmbiguous`]). It
276/// matches both the inline-table form (`dep = { path = "…", version = "=X" }`) and the
277/// dependency sub-table form (`[dependencies.dep]` … `version = "=X"`), the two shapes
278/// [`crate::facts`] records a requirement for.
279///
280/// # Errors
281/// [`BumpEditError::PinNotFound`] / [`BumpEditError::PinAmbiguous`] as above.
282pub fn rewrite_pin(
283    manifest: &str,
284    dependency: &str,
285    from: &str,
286    to: &str,
287) -> Result<String, BumpEditError> {
288    // First pass: count matches so we can fail closed on 0 or >1 without a partial edit.
289    let matches = count_pin_matches(manifest, dependency, from);
290    if matches == 0 {
291        return Err(BumpEditError::PinNotFound {
292            dependency: dependency.to_string(),
293            from: from.to_string(),
294        });
295    }
296    if matches > 1 {
297        return Err(BumpEditError::PinAmbiguous {
298            dependency: dependency.to_string(),
299            from: from.to_string(),
300            count: matches,
301        });
302    }
303    // Exactly one match: rewrite it.
304    let mut out = String::with_capacity(manifest.len() + to.len());
305    let mut in_dep_subtable = false;
306    let ends_with_newline = manifest.ends_with('\n');
307    let mut done = false;
308    let mut lines = manifest.lines().peekable();
309    while let Some(line) = lines.next() {
310        let trimmed = strip_comment(line).trim();
311        let mut rewritten: Option<String> = None;
312        if let Some(header) = section_header(trimmed) {
313            in_dep_subtable = dep_subtable_matches(header, dependency);
314        } else if !done {
315            if in_dep_subtable {
316                // A `version = "<from>"` line inside `[dependencies.<dep>]`.
317                rewritten = replace_exact_string_value(line, "version", from, to);
318            } else if line_declares_dep_inline(trimmed, dependency) {
319                // An inline `dep = { …, version = "<from>" }` line.
320                rewritten = replace_exact_string_value(line, "version", from, to);
321            }
322        }
323        match rewritten {
324            Some(r) => {
325                out.push_str(&r);
326                done = true;
327            }
328            None => out.push_str(line),
329        }
330        push_line_ending(&mut out, lines.peek().is_some(), ends_with_newline);
331    }
332    Ok(out)
333}
334
335/// Count the declarations of `dependency` whose version requirement is exactly `from`
336/// — the fail-closed gate [`rewrite_pin`] keys on.
337fn count_pin_matches(manifest: &str, dependency: &str, from: &str) -> usize {
338    let mut count = 0;
339    let mut in_dep_subtable = false;
340    for line in manifest.lines() {
341        let trimmed = strip_comment(line).trim();
342        if let Some(header) = section_header(trimmed) {
343            in_dep_subtable = dep_subtable_matches(header, dependency);
344        } else if in_dep_subtable {
345            if key_has_exact_string(trimmed, "version", from) {
346                count += 1;
347            }
348        } else if line_declares_dep_inline(trimmed, dependency)
349            && inline_has_exact_version(trimmed, from)
350        {
351            count += 1;
352        }
353    }
354    count
355}
356
357/// Finalize a Keep-a-Changelog CHANGELOG: promote the `## [Unreleased]` section's
358/// content under a new dated `## [<version>] - <date>` header, leaving a fresh empty
359/// `## [Unreleased]` above it for the next cycle. Returns the new text.
360///
361/// Deliberately conservative: it inserts one dated header immediately after the
362/// `## [Unreleased]` line and does not otherwise reflow the file, so it composes with a
363/// human-curated body. `date` is `YYYY-MM-DD`.
364///
365/// # Errors
366/// [`BumpEditError::ChangelogUnreleasedNotFound`] when there is no `## [Unreleased]`
367/// header to promote (fail closed — the contract asked for a finalize there is nothing
368/// to finalize).
369pub fn finalize_changelog(text: &str, version: &str, date: &str) -> Result<String, BumpEditError> {
370    let ends_with_newline = text.ends_with('\n');
371    let mut out = String::with_capacity(text.len() + version.len() + date.len() + 16);
372    let mut inserted = false;
373    let mut lines = text.lines().peekable();
374    while let Some(line) = lines.next() {
375        out.push_str(line);
376        push_line_ending(&mut out, lines.peek().is_some(), ends_with_newline);
377        if !inserted && is_unreleased_header(line) {
378            // A blank line, then the dated release header — Keep a Changelog style.
379            out.push('\n');
380            out.push_str("## [");
381            out.push_str(version);
382            out.push_str("] - ");
383            out.push_str(date);
384            // Guarantee a newline after the inserted header even at EOF, so the
385            // promoted content is not glued onto it.
386            out.push('\n');
387            inserted = true;
388        }
389    }
390    if inserted {
391        Ok(out)
392    } else {
393        Err(BumpEditError::ChangelogUnreleasedNotFound)
394    }
395}
396
397/// Whether `line` is a `## [Unreleased]` header (Keep a Changelog), tolerant of
398/// surrounding whitespace and `Unreleased` letter-case.
399fn is_unreleased_header(line: &str) -> bool {
400    let t = line.trim();
401    let Some(rest) = t.strip_prefix("##") else {
402        return false;
403    };
404    let rest = rest.trim();
405    rest.eq_ignore_ascii_case("[unreleased]")
406}
407
408/// The bracketed section name of a TOML header line (`[a.b.c]` → `Some("a.b.c")`), or
409/// `None` when the line is not a bare section header.
410fn section_header(trimmed: &str) -> Option<&str> {
411    // Only a plain `[header]`; an array-of-tables `[[x]]` is not a bump target.
412    let inner = trimmed.strip_prefix('[')?.strip_suffix(']')?;
413    if inner.starts_with('[') || inner.contains('[') {
414        return None;
415    }
416    Some(inner.trim())
417}
418
419/// Whether a TOML section `header` is the dependency sub-table for `dependency`
420/// (`[dependencies.<dep>]`, `[build-dependencies.<dep>]`, or a target-specific
421/// `[target.<cfg>.dependencies.<dep>]`), excluding every `dev-dependencies` form.
422fn dep_subtable_matches(header: &str, dependency: &str) -> bool {
423    if header.contains("dev-dependencies") {
424        return false;
425    }
426    for infix in ["dependencies.", "build-dependencies."] {
427        if let Some(idx) = header.rfind(infix) {
428            let name = header[idx + infix.len()..].trim().trim_matches(['"', '\'']);
429            return name == dependency;
430        }
431    }
432    false
433}
434
435/// Whether `trimmed` (a `[dependencies]`-table line) declares `dependency` as an inline
436/// table (`dep = { … }` or `"dep" = { … }`) — the form whose `version` an inline pin
437/// rewrite edits. A dotted `dep.version = …` line is not matched here (its own key is
438/// `dep.version`, handled by the sub-table/dotted paths).
439fn line_declares_dep_inline(trimmed: &str, dependency: &str) -> bool {
440    let Some(eq) = trimmed.find('=') else {
441        return false;
442    };
443    let key = trimmed[..eq].trim().trim_matches(['"', '\'']);
444    if key != dependency {
445        return false;
446    }
447    trimmed[eq + 1..].trim_start().starts_with('{')
448}
449
450/// Whether an inline dependency table `trimmed` carries `version = "<from>"` exactly.
451fn inline_has_exact_version(trimmed: &str, from: &str) -> bool {
452    inline_version_value(trimmed).is_some_and(|v| v == from)
453}
454
455/// The `version = "…"` value inside an inline dependency table, matching `version` as a
456/// whole key (mirrors the facts parser's discipline).
457fn inline_version_value(inline: &str) -> Option<String> {
458    scan_key_string(inline, "version")
459}
460
461/// Whether a `key = "value"` line (whole-key `key`) has value exactly `expected`.
462fn key_has_exact_string(trimmed: &str, key: &str, expected: &str) -> bool {
463    scan_key_string(trimmed, key).is_some_and(|v| v == expected)
464}
465
466/// Replace a whole-key `key = "<old>"` with `key = "<new>"` on `line`, but only when
467/// the current value is exactly `old`; returns the rewritten line or `None`.
468fn replace_exact_string_value(line: &str, key: &str, old: &str, new: &str) -> Option<String> {
469    let current = scan_key_string(strip_comment(line).trim(), key)?;
470    if current != old {
471        return None;
472    }
473    replace_string_value(line, key, new)
474}
475
476/// Replace the value of a whole-key `key = "…"` on `line` with `new` (keeping quote
477/// style and everything else on the line), or `None` when the line has no such key.
478///
479/// Operates on the raw `line` (so indentation and a trailing inline `# comment` are
480/// preserved), locating the quoted value via the same whole-key scan used to read it.
481fn replace_string_value(line: &str, key: &str, new: &str) -> Option<String> {
482    let (val_start, quote) = locate_key_string(line, key)?;
483    // `val_start` points at the opening quote; find the closing quote.
484    let after_open = val_start + 1;
485    let rel_close = line[after_open..].find(quote)?;
486    let close = after_open + rel_close;
487    let mut out = String::with_capacity(line.len() + new.len());
488    out.push_str(&line[..after_open]);
489    out.push_str(new);
490    out.push_str(&line[close..]);
491    Some(out)
492}
493
494/// The value of a whole-key `key = "…"` in `s` (matching the facts parser's whole-token
495/// discipline), or `None`.
496fn scan_key_string(s: &str, key: &str) -> Option<String> {
497    let (open, quote) = locate_key_string(s, key)?;
498    let after_open = open + 1;
499    let rel_close = s[after_open..].find(quote)?;
500    Some(s[after_open..after_open + rel_close].to_string())
501}
502
503/// Locate a whole-key `key = "…"` in `s`, returning the byte offset of the opening
504/// quote and the quote char. "Whole key" = the char before `key` is not an identifier
505/// char, and `key` is immediately followed (past spaces) by `=` then a quote.
506fn locate_key_string(s: &str, key: &str) -> Option<(usize, char)> {
507    let mut search = 0;
508    while let Some(rel) = s[search..].find(key) {
509        let pos = search + rel;
510        let prev_is_ident = s[..pos]
511            .chars()
512            .next_back()
513            .is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '-');
514        let after = &s[pos + key.len()..];
515        let after_trimmed = after.trim_start();
516        if !prev_is_ident {
517            if let Some(rest) = after_trimmed.strip_prefix('=') {
518                let rest_trimmed = rest.trim_start();
519                if let Some(q) = rest_trimmed.chars().next() {
520                    if q == '"' || q == '\'' {
521                        // Offset of the quote in the original string.
522                        let consumed = s.len() - rest_trimmed.len();
523                        return Some((consumed, q));
524                    }
525                }
526            }
527        }
528        search = pos + key.len();
529    }
530    None
531}
532
533/// Strip a trailing `# comment` from a TOML line, respecting quoted `#`s crudely: it
534/// cuts at the first `#` not inside a quote. Sufficient for manifest lines the bump
535/// touches (version/pin values never contain `#`).
536fn strip_comment(line: &str) -> &str {
537    let mut in_str: Option<char> = None;
538    for (i, c) in line.char_indices() {
539        match in_str {
540            Some(q) => {
541                if c == q {
542                    in_str = None;
543                }
544            }
545            None => match c {
546                '"' | '\'' => in_str = Some(c),
547                '#' => return &line[..i],
548                _ => {}
549            },
550        }
551    }
552    line
553}
554
555/// Append the correct line ending: a `\n` between lines, and preserve whether the file
556/// ended with a trailing newline (so a rewrite is byte-faithful).
557fn push_line_ending(out: &mut String, more_lines: bool, ends_with_newline: bool) {
558    if more_lines || ends_with_newline {
559        out.push('\n');
560    }
561}
562
563#[cfg(test)]
564mod tests {
565    use super::*;
566
567    #[test]
568    fn patch_minor_major_from_a_normal_version() {
569        assert_eq!(bump_version(BumpLevel::Patch, "0.4.0").unwrap(), "0.4.1");
570        assert_eq!(bump_version(BumpLevel::Minor, "0.4.0").unwrap(), "0.5.0");
571        assert_eq!(bump_version(BumpLevel::Major, "0.4.0").unwrap(), "1.0.0");
572    }
573
574    #[test]
575    fn minor_and_major_reset_lower_components() {
576        assert_eq!(bump_version(BumpLevel::Minor, "1.2.3").unwrap(), "1.3.0");
577        assert_eq!(bump_version(BumpLevel::Major, "1.2.3").unwrap(), "2.0.0");
578        assert_eq!(bump_version(BumpLevel::Patch, "1.2.3").unwrap(), "1.2.4");
579    }
580
581    #[test]
582    fn zero_versions_bump_canonically() {
583        assert_eq!(bump_version(BumpLevel::Patch, "0.0.0").unwrap(), "0.0.1");
584        assert_eq!(bump_version(BumpLevel::Minor, "0.0.0").unwrap(), "0.1.0");
585        assert_eq!(bump_version(BumpLevel::Major, "0.0.0").unwrap(), "1.0.0");
586    }
587
588    #[test]
589    fn a_pre_release_or_build_version_is_refused() {
590        assert!(bump_version(BumpLevel::Patch, "1.2.3-rc.1").is_err());
591        assert!(bump_version(BumpLevel::Patch, "1.2.3+build.5").is_err());
592    }
593
594    #[test]
595    fn a_non_xyz_version_is_refused() {
596        for bad in ["1.2", "1.2.3.4", "1", "", "v1.2.3", "1.2.x", "1..2", "1.2."] {
597            assert!(
598                bump_version(BumpLevel::Patch, bad).is_err(),
599                "expected `{bad}` to be refused"
600            );
601        }
602    }
603
604    #[test]
605    fn a_leading_zero_component_is_refused() {
606        assert!(bump_version(BumpLevel::Patch, "1.02.3").is_err());
607        assert!(bump_version(BumpLevel::Patch, "01.2.3").is_err());
608        // But a bare zero component is canonical and fine.
609        assert!(bump_version(BumpLevel::Patch, "0.1.0").is_ok());
610    }
611
612    #[test]
613    fn the_error_carries_the_offending_version() {
614        let err = bump_version(BumpLevel::Patch, "not-semver").unwrap_err();
615        assert_eq!(err.version, "not-semver");
616        assert!(!err.reason.is_empty());
617    }
618
619    // ── set_workspace_version ────────────────────────────────────────────────
620
621    #[test]
622    fn sets_the_workspace_package_version_only() {
623        let manifest = "[workspace]\nmembers = [\"a\"]\n\n[workspace.package]\nversion = \"0.4.0\"\nedition = \"2021\"\n";
624        let out = set_workspace_version(manifest, "0.4.0", "0.5.0").unwrap();
625        assert!(out.contains("version = \"0.5.0\""));
626        assert!(!out.contains("0.4.0"));
627        // Everything else preserved.
628        assert!(out.contains("edition = \"2021\""));
629        assert!(out.ends_with('\n'));
630    }
631
632    #[test]
633    fn does_not_touch_a_version_in_another_section() {
634        let manifest =
635            "[package]\nversion = \"9.9.9\"\n\n[workspace.package]\nversion = \"0.4.0\"\n";
636        let out = set_workspace_version(manifest, "0.4.0", "0.5.0").unwrap();
637        assert!(out.contains("[package]\nversion = \"9.9.9\""));
638        assert!(out.contains("[workspace.package]\nversion = \"0.5.0\""));
639    }
640
641    #[test]
642    fn does_not_match_a_version_inside_a_description_string() {
643        // A `version = "…"` inside a quoted string value (≠ `from`) is not rewritten; the
644        // real version line still is.
645        let manifest =
646            "[workspace.package]\ndescription = \"needs version = 1.0\"\nversion = \"0.4.0\"\n";
647        let out = set_workspace_version(manifest, "0.4.0", "0.5.0").unwrap();
648        assert!(
649            out.contains("needs version = 1.0"),
650            "description untouched: {out}"
651        );
652        assert!(
653            out.contains("version = \"0.5.0\""),
654            "real version bumped: {out}"
655        );
656    }
657
658    #[test]
659    fn fails_closed_when_the_current_version_does_not_match_from() {
660        let manifest = "[workspace.package]\nversion = \"1.2.3\"\n";
661        assert_eq!(
662            set_workspace_version(manifest, "0.4.0", "0.5.0"),
663            Err(BumpEditError::WorkspaceVersionNotFound)
664        );
665    }
666
667    #[test]
668    fn fails_closed_when_no_workspace_package_version() {
669        let manifest = "[package]\nversion = \"1.0.0\"\n";
670        assert_eq!(
671            set_workspace_version(manifest, "1.0.0", "2.0.0"),
672            Err(BumpEditError::WorkspaceVersionNotFound)
673        );
674    }
675
676    // ── rewrite_pin ──────────────────────────────────────────────────────────
677
678    #[test]
679    fn rewrites_an_inline_table_pin() {
680        let manifest = "[dependencies]\nossctl-core = { path = \"../ossctl-core\", version = \"=0.4.0\" }\nserde = \"1\"\n";
681        let out = rewrite_pin(manifest, "ossctl-core", "=0.4.0", "=0.5.0").unwrap();
682        assert!(out.contains("version = \"=0.5.0\""));
683        assert!(out.contains("path = \"../ossctl-core\""));
684        assert!(out.contains("serde = \"1\""));
685    }
686
687    #[test]
688    fn rewrites_a_subtable_pin() {
689        let manifest =
690            "[dependencies.ossctl-core]\npath = \"../ossctl-core\"\nversion = \"=0.4.0\"\n";
691        let out = rewrite_pin(manifest, "ossctl-core", "=0.4.0", "=0.5.0").unwrap();
692        assert!(out.contains("version = \"=0.5.0\""));
693    }
694
695    #[test]
696    fn pin_rewrite_fails_closed_when_absent() {
697        let manifest =
698            "[dependencies]\nossctl-core = { path = \"../ossctl-core\", version = \"^0.4\" }\n";
699        assert_eq!(
700            rewrite_pin(manifest, "ossctl-core", "=0.4.0", "=0.5.0"),
701            Err(BumpEditError::PinNotFound {
702                dependency: "ossctl-core".into(),
703                from: "=0.4.0".into(),
704            })
705        );
706    }
707
708    #[test]
709    fn pin_rewrite_leaves_a_caret_dep_untouched_even_with_same_crate() {
710        // A different crate sharing the exact from-string must not be rewritten.
711        let manifest = "[dependencies]\nossctl-core = { path = \"../c\", version = \"=0.4.0\" }\nother = \"=0.4.0\"\n";
712        let out = rewrite_pin(manifest, "ossctl-core", "=0.4.0", "=0.5.0").unwrap();
713        assert!(out.contains("ossctl-core = { path = \"../c\", version = \"=0.5.0\" }"));
714        // `other = "=0.4.0"` is a plain registry dep, not our pin — untouched.
715        assert!(out.contains("other = \"=0.4.0\""));
716    }
717
718    #[test]
719    fn pin_rewrite_fails_closed_on_multiple_matches() {
720        let manifest = "[dependencies]\ncore = { path = \"a\", version = \"=0.4.0\" }\n[build-dependencies]\ncore = { path = \"a\", version = \"=0.4.0\" }\n";
721        let err = rewrite_pin(manifest, "core", "=0.4.0", "=0.5.0").unwrap_err();
722        assert!(matches!(err, BumpEditError::PinAmbiguous { count: 2, .. }));
723    }
724
725    // ── finalize_changelog ───────────────────────────────────────────────────
726
727    #[test]
728    fn finalizes_the_unreleased_section() {
729        let text = "# Changelog\n\n## [Unreleased]\n### Added\n- a thing\n";
730        let out = finalize_changelog(text, "0.5.0", "2026-08-13").unwrap();
731        assert!(out.contains("## [Unreleased]\n\n## [0.5.0] - 2026-08-13"));
732        assert!(out.contains("- a thing"));
733    }
734
735    #[test]
736    fn changelog_finalize_fails_closed_without_unreleased() {
737        let text = "# Changelog\n\n## [0.4.0] - 2026-01-01\n";
738        assert_eq!(
739            finalize_changelog(text, "0.5.0", "2026-08-13"),
740            Err(BumpEditError::ChangelogUnreleasedNotFound)
741        );
742    }
743}