Skip to main content

shipshape_core/release/
bump_exec.rs

1//! Cut-time execution of the sealed version-bump phase (`release-rust-workspace-
2//! multicrate` facet 2/3) — the effectful half whose pure text transforms live in
3//! [`crate::release::bump`].
4//!
5//! [`apply_bump`] runs **inside the coordinator's clean checkout of the sealed commit**
6//! (`release-cut-clean-checkout`), before the build barrier, so every later phase
7//! builds and publishes the bumped tree. It:
8//!
9//! 1. sets `[workspace.package] version`, or a plain root `[package] version` when the
10//!    workspace-inheritance source is absent;
11//! 2. rewrites each sealed intra-workspace `=`-pin set (verifying every declaration is
12//!    equivalent, then updating all of them; fail closed on zero/non-equivalence,
13//!    [`crate::release::bump::rewrite_pin`]);
14//! 3. refreshes `Cargo.lock` (`cargo update --workspace`);
15//! 4. finalizes the CHANGELOG (`[Unreleased]` → a dated section) when the contract's
16//!    changelog mode asked for it;
17//! 5. runs any contract-declared `bump_hook` (see the execution contract below);
18//! 6. commits the edits and returns the **bump commit sha** — the commit the tag points
19//!    at (not the pre-bump sealed HEAD).
20//!
21//! Every step **fails closed**: a failed edit, lockfile refresh, hook, post-hook
22//! validation, or commit aborts the cut with a [`BumpExecError`] before the build
23//! barrier — nothing external has happened yet (no publish, no tag).
24//!
25//! # File I/O
26//!
27//! Like the [`homebrew`](crate::release::adapters::homebrew) adapter, this reads and
28//! writes manifests/CHANGELOG with `std::fs` directly (a manifest edit has no CLI to
29//! route through [`CommandRunner`](crate::ports::CommandRunner)); all writes are confined to the throwaway sealed
30//! checkout the coordinator just materialized. Process effects (`cargo`, the hook,
31//! `git`) go through the injected [`CommandRunner`](crate::ports::CommandRunner), so the executor is unit-testable
32//! against a real temp checkout + a recording fake runner.
33//!
34//! # `bump_hook` execution contract (supply-chain surface, schema.rs)
35//!
36//! A declared `bump_hook` is **arbitrary code the engine runs during the release**,
37//! equivalent in trust to a `build.rs` the cut already compiles. Its purpose is to
38//! regenerate version-embedding artifacts (test snapshots that embed the version) so
39//! they do not go stale on the bump and red CI. The contract this executor honors:
40//!
41//! - **Invocation:** `sh -c "<hook>"` with the hook string passed as a **single,
42//!   verbatim** argv element — **no** dynamic data (version, package names) is ever
43//!   interpolated into it, so there is no shell-injection surface from cut-time data
44//!   (schema.rs:399). The hook is surfaced verbatim as a plan-time reviewer warning, so
45//!   an approver has seen exactly what runs.
46//! - **Working directory:** the sealed checkout root.
47//! - **Environment:** the cut's ambient environment (inherited), the same trust
48//!   boundary as a `build.rs`. Secrets the cut carries (registry tokens) are reachable —
49//!   this is why the hook is an eyes-on supply-chain surface, not a sandbox.
50//! - **Permitted effect:** regenerating derived files. **Post-hook validation** re-reads
51//!   the workspace manifest and rejects the cut if the hook reverted or altered the
52//!   version bump ([`BumpExecError::HookViolatedVersion`]) — the one invariant that must
53//!   survive, since a hook that changed the version would publish the wrong number. A
54//!   full permitted-path allowlist is intentionally not enforced (snapshot regen writes
55//!   an open-ended set of test files); the version invariant is the load-bearing check.
56//! - **Failure:** a non-zero exit fails the cut closed ([`BumpExecError::Hook`]).
57//! - **Timeout:** the [`CommandRunner`](crate::ports::CommandRunner) port carries no timeout today, so the hook runs
58//!   to completion (a CI-level job timeout is the backstop). A first-class per-command
59//!   timeout is a tracked follow-up.
60
61use std::collections::BTreeMap;
62use std::path::{Path, PathBuf};
63
64use crate::contract::schema::{ChangelogMode, ChangelogSource};
65use crate::protocol::plan::{BumpPlan, ChangelogFinalizePlan};
66use crate::release::adapters::EffectCtx;
67use crate::release::bump::{self, BumpEditError};
68
69/// The outcome of an applied bump: the commit the edits landed in (the tag target) and
70/// the CHANGELOG effective date (journalled for resume reuse).
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct BumpOutcome {
73    /// The bump commit sha — the commit the release tag points at.
74    pub commit: String,
75    /// The `YYYY-MM-DD` date the CHANGELOG was finalized under.
76    pub effective_date: String,
77}
78
79/// Why the cut-time bump could not be applied. Every variant is a fail-closed refusal
80/// **before** the build barrier — no publish or tag has happened.
81#[derive(Debug)]
82pub enum BumpExecError {
83    /// A pure edit transform refused (missing root manifest version, a non-matching or
84    /// ambiguous pin, or an absent `## [Unreleased]` section).
85    Edit(BumpEditError),
86    /// A manifest/CHANGELOG file could not be read or written in the checkout.
87    Fs {
88        /// The offending path.
89        path: PathBuf,
90        /// The underlying I/O error.
91        source: std::io::Error,
92    },
93    /// A pin rewrite named a workspace member whose manifest could not be located in
94    /// the checkout (a `package` the workspace graph resolved but whose directory the
95    /// executor could not map). Fail closed rather than skip a sealed pin.
96    MemberManifestNotFound {
97        /// The dependent crate whose manifest was expected.
98        package: String,
99    },
100    /// `cargo update --workspace` (the lockfile refresh) failed.
101    LockRefresh(String),
102    /// Trailer-derived changelog compilation could not be run or its JSON was invalid.
103    ChangelogCompile(String),
104    /// The declared `bump_hook` exited non-zero.
105    Hook {
106        /// The exit code (or a signal note).
107        status: String,
108        /// Captured stderr, trimmed.
109        stderr: String,
110    },
111    /// The `bump_hook` altered the root release version away from the bumped value — a
112    /// contract violation (the hook may regenerate derived artifacts, not re-version).
113    HookViolatedVersion {
114        /// The version the bump set.
115        expected: String,
116        /// What the manifest read after the hook.
117        found: String,
118    },
119    /// A `git` step (add / commit / rev-parse / push) failed.
120    Git(String),
121}
122
123impl std::fmt::Display for BumpExecError {
124    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125        match self {
126            Self::Edit(e) => write!(f, "{e}"),
127            Self::Fs { path, source } => {
128                write!(
129                    f,
130                    "cannot access `{}` in the sealed checkout: {source}",
131                    path.display()
132                )
133            }
134            Self::MemberManifestNotFound { package } => write!(
135                f,
136                "the bump names crate `{package}` but its manifest could not be located in the \
137                 sealed checkout"
138            ),
139            Self::LockRefresh(m) => write!(f, "refreshing Cargo.lock failed: {m}"),
140            Self::ChangelogCompile(m) => write!(f, "compiling changelog notes failed: {m}"),
141            Self::Hook { status, stderr } => {
142                write!(f, "the bump_hook failed ({status}): {stderr}")
143            }
144            Self::HookViolatedVersion { expected, found } => write!(
145                f,
146                "the bump_hook changed the root manifest version to `{found}`, but the bump set \
147                 `{expected}` — refusing to publish a hook-altered version"
148            ),
149            Self::Git(m) => write!(f, "git step failed during the bump: {m}"),
150        }
151    }
152}
153
154impl std::error::Error for BumpExecError {}
155
156impl From<BumpEditError> for BumpExecError {
157    fn from(e: BumpEditError) -> Self {
158        Self::Edit(e)
159    }
160}
161
162/// Apply the sealed `bump` inside `ctx.repo_root` (the coordinator's clean checkout),
163/// committing the edits and returning the [`BumpOutcome`]. `effective_date` is the
164/// `YYYY-MM-DD` CHANGELOG date (freshly computed on a first run, or the journalled date
165/// on resume so it never re-dates).
166///
167/// The bump commit is created on the checkout's detached HEAD; its object lives in the
168/// shared store, so the coordinator's tagger (running against the real repo) can point
169/// the release tag at it within the same cut. Advancing the real repo's *branch* to the
170/// bump commit is deliberately **not** done here (a best-effort pre-publish push would be
171/// an externally-visible effect before the build barrier, and a failed one leaves a
172/// split-brain remote) — it is a documented follow-up (`release-rust-workspace-multicrate`).
173///
174/// # Errors
175/// [`BumpExecError`] on any failed edit, lockfile refresh, hook, post-hook validation,
176/// or git step — always **before** the build barrier, so nothing external happened.
177pub fn apply_bump(
178    ctx: &EffectCtx<'_>,
179    bump: &BumpPlan,
180    effective_date: &str,
181) -> Result<BumpOutcome, BumpExecError> {
182    let root = ctx.repo_root;
183
184    // 1. Set the root release version (verified against `from_version`). A workspace
185    //    package version is authoritative when present; only an absent workspace source
186    //    falls back to a plain single-crate `[package]` version.
187    let root_manifest = root.join("Cargo.toml");
188    let text = read(&root_manifest)?;
189    let bumped = if bump::workspace_version(&text).is_some() {
190        bump::set_workspace_version(&text, &bump.from_version, &bump.to_version)?
191    } else {
192        bump::set_package_version(&text, &bump.from_version, &bump.to_version)?
193    };
194    write(&root_manifest, &bumped)?;
195
196    // 2. Rewrite each sealed intra-workspace pin set in its dependent crate's manifest,
197    //    verifying all declarations are the exact old value (fail closed on zero or a
198    //    non-equivalent declaration).
199    if !bump.pin_rewrites.is_empty() {
200        let members = member_manifest_paths(root)?;
201        for pin in &bump.pin_rewrites {
202            let manifest = if pin.workspace_root {
203                &root_manifest
204            } else {
205                members.get(&pin.in_package).ok_or_else(|| {
206                    BumpExecError::MemberManifestNotFound {
207                        package: pin.in_package.clone(),
208                    }
209                })?
210            };
211            let text = read(manifest)?;
212            let rewritten = if pin.workspace_root {
213                bump::rewrite_workspace_pin(&text, &pin.dependency, &pin.from, &pin.to)?
214            } else {
215                bump::rewrite_pin(&text, &pin.dependency, &pin.from, &pin.to)?
216            };
217            write(manifest, &rewritten)?;
218        }
219    }
220
221    // 3. Refresh Cargo.lock so its workspace-member entries carry the new version — but
222    //    only when the repo tracks a lockfile (a library workspace that intentionally
223    //    git-ignores Cargo.lock must not have one introduced by the cut). NOTE: this runs
224    //    `cargo update --workspace`, which re-resolves within the manifests' semver ranges;
225    //    for a version-only bump the third-party graph is unchanged in practice, but
226    //    pinning the resolution deterministically is a documented follow-up.
227    if root.join("Cargo.lock").exists() {
228        refresh_lockfile(ctx)?;
229    }
230
231    // 4. Finalize the CHANGELOG when the contract's mode asked for it. Missing files
232    //    and malformed marker state fail before any publish rather than silently cutting
233    //    a release without its promised notes.
234    if bump.changelog_finalize {
235        let changelog = root.join("CHANGELOG.md");
236        if changelog.is_file() {
237            let text = read(&changelog)?;
238            let (finalized, consumed) = if let Some(plan) = &bump.changelog {
239                let (compiled, consumed) = compile_changelog(ctx, plan)?;
240                (
241                    bump::finalize_marker_changelog(
242                        &text,
243                        &bump.to_version,
244                        effective_date,
245                        &compiled,
246                    )?,
247                    consumed,
248                )
249            } else {
250                // Stored v8-and-earlier approvals retain their sealed header-only
251                // semantics when resumed; fresh v9 plans always carry `changelog`.
252                (
253                    bump::finalize_changelog(&text, &bump.to_version, effective_date)?,
254                    Vec::new(),
255                )
256            };
257            write(&changelog, &finalized)?;
258            for fragment in consumed {
259                std::fs::remove_file(&fragment).map_err(|source| BumpExecError::Fs {
260                    path: fragment,
261                    source,
262                })?;
263            }
264        } else if bump.changelog.is_some() {
265            return Err(BumpEditError::ChangelogUnreleasedNotFound.into());
266        }
267    }
268
269    // 5. Run the declared bump_hook (supply-chain surface — see the module docs), then
270    //    validate the hook did not alter the version.
271    if let Some(hook) = &bump.bump_hook {
272        run_hook(ctx, hook)?;
273        let after = read(&root_manifest)?;
274        let found = bump::root_manifest_version(&after);
275        if found.as_deref() != Some(bump.to_version.as_str()) {
276            return Err(BumpExecError::HookViolatedVersion {
277                expected: bump.to_version.clone(),
278                found: found.unwrap_or_default(),
279            });
280        }
281    }
282
283    // 6. Commit the edits in the checkout and read back the bump commit sha.
284    let commit = commit_bump(ctx, &bump.to_version)?;
285
286    Ok(BumpOutcome {
287        commit,
288        effective_date: effective_date.to_string(),
289    })
290}
291
292/// Compile every configured release-note producer before the marker transform. Fragment
293/// files are returned as a consume set and are deleted only after the rewritten changelog
294/// has been written successfully in the throwaway checkout.
295fn compile_changelog(
296    ctx: &EffectCtx<'_>,
297    plan: &ChangelogFinalizePlan,
298) -> Result<(String, Vec<PathBuf>), BumpExecError> {
299    let mut sources = Vec::new();
300    let mut consumed = Vec::new();
301
302    match plan.mode {
303        ChangelogMode::Fragment => {
304            collect_fragments(ctx.repo_root, plan, &mut sources, &mut consumed)?;
305        }
306        ChangelogMode::Curated => {}
307        ChangelogMode::Automated => {
308            return Err(BumpExecError::ChangelogCompile(
309                "an automated changelog cannot carry engine finalization intent".into(),
310            ));
311        }
312    }
313
314    match plan.source {
315        ChangelogSource::IssuectlTrailers => {
316            let range = plan.issuectl_range.as_deref().ok_or_else(|| {
317                BumpExecError::ChangelogCompile(
318                    "the sealed changelog plan has no issuectl revision range".into(),
319                )
320            })?;
321            let root = ctx.repo_root.to_string_lossy();
322            if let Ok(output) = ctx.runner.run(
323                "issuectl",
324                &["changelog", range, "--json", "--root", &root],
325                ctx.repo_root,
326            ) {
327                if output.status == Some(0) {
328                    if let Ok(notes) = render_issuectl_notes(&output.stdout) {
329                        if !notes.is_empty() {
330                            sources.push(notes);
331                        }
332                    }
333                }
334            }
335            // The bundled skill's documented manual fallback is deliberate: a missing,
336            // failing, or incompatible issuectl leaves authored/fragment notes in force.
337        }
338        ChangelogSource::Manual | ChangelogSource::ConventionalCommits => {}
339    }
340
341    Ok((
342        sources
343            .into_iter()
344            .map(|source| source.trim().to_string())
345            .filter(|source| !source.is_empty())
346            .collect::<Vec<_>>()
347            .join("\n\n"),
348        consumed,
349    ))
350}
351
352fn collect_fragments(
353    root: &Path,
354    plan: &ChangelogFinalizePlan,
355    sources: &mut Vec<String>,
356    consumed: &mut Vec<PathBuf>,
357) -> Result<(), BumpExecError> {
358    let dir = root.join(&plan.fragment_dir);
359    if !dir.exists() {
360        return Ok(());
361    }
362    let metadata = std::fs::symlink_metadata(&dir).map_err(|source| BumpExecError::Fs {
363        path: dir.clone(),
364        source,
365    })?;
366    if metadata.file_type().is_symlink() || !metadata.is_dir() {
367        return Err(BumpExecError::ChangelogCompile(format!(
368            "fragment directory `{}` must be a real directory inside the checkout",
369            plan.fragment_dir
370        )));
371    }
372    let canonical_root = std::fs::canonicalize(root).map_err(|source| BumpExecError::Fs {
373        path: root.to_path_buf(),
374        source,
375    })?;
376    let canonical_dir = std::fs::canonicalize(&dir).map_err(|source| BumpExecError::Fs {
377        path: dir.clone(),
378        source,
379    })?;
380    if !canonical_dir.starts_with(&canonical_root) {
381        return Err(BumpExecError::ChangelogCompile(format!(
382            "fragment directory `{}` resolves outside the checkout",
383            plan.fragment_dir
384        )));
385    }
386    let entries = std::fs::read_dir(&dir).map_err(|source| BumpExecError::Fs {
387        path: dir.clone(),
388        source,
389    })?;
390    let mut paths = entries
391        .map(|entry| {
392            entry
393                .map(|entry| entry.path())
394                .map_err(|source| BumpExecError::Fs {
395                    path: dir.clone(),
396                    source,
397                })
398        })
399        .collect::<Result<Vec<_>, _>>()?;
400    paths.sort();
401    for path in paths {
402        let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
403            continue;
404        };
405        if name.starts_with('.')
406            || name.eq_ignore_ascii_case("README.md")
407            || !path
408                .extension()
409                .and_then(|extension| extension.to_str())
410                .is_some_and(|extension| extension.eq_ignore_ascii_case("md"))
411        {
412            continue;
413        }
414        let metadata = std::fs::symlink_metadata(&path).map_err(|source| BumpExecError::Fs {
415            path: path.clone(),
416            source,
417        })?;
418        if !metadata.file_type().is_file() {
419            continue;
420        }
421        let contents = read(&path)?;
422        if !contents.trim().is_empty() {
423            sources.push(contents);
424            consumed.push(path);
425        }
426    }
427    Ok(())
428}
429
430/// Validate issuectl's JSON envelope and render its issue groups as stable markdown.
431fn render_issuectl_notes(json: &str) -> Result<String, BumpExecError> {
432    let value: serde_json::Value = serde_json::from_str(json)
433        .map_err(|e| BumpExecError::ChangelogCompile(format!("invalid issuectl JSON: {e}")))?;
434    if value
435        .get("schema_version")
436        .and_then(serde_json::Value::as_u64)
437        != Some(1)
438    {
439        return Err(BumpExecError::ChangelogCompile(
440            "issuectl JSON has an unsupported schema_version".into(),
441        ));
442    }
443    let groups = value
444        .get("data")
445        .and_then(|data| data.get("groups"))
446        .and_then(serde_json::Value::as_object)
447        .ok_or_else(|| {
448            BumpExecError::ChangelogCompile("issuectl JSON has no data.groups object".into())
449        })?;
450    let mut categories: BTreeMap<&'static str, Vec<String>> = BTreeMap::new();
451    for (kind, issues) in groups {
452        let heading = match kind.as_str() {
453            "feature" => "Added",
454            "bug" => "Fixed",
455            _ => "Changed",
456        };
457        let issues = issues.as_array().ok_or_else(|| {
458            BumpExecError::ChangelogCompile(format!("issuectl group `{kind}` is not an array"))
459        })?;
460        for issue in issues {
461            let title = issue
462                .get("title")
463                .and_then(serde_json::Value::as_str)
464                .ok_or_else(|| {
465                    BumpExecError::ChangelogCompile(format!(
466                        "issuectl group `{kind}` has an item without title"
467                    ))
468                })?;
469            let slug = issue
470                .get("slug")
471                .and_then(serde_json::Value::as_str)
472                .ok_or_else(|| {
473                    BumpExecError::ChangelogCompile(format!(
474                        "issuectl group `{kind}` has an item without slug"
475                    ))
476                })?;
477            categories
478                .entry(heading)
479                .or_default()
480                .push(format!("- {title} (`{slug}`)."));
481        }
482    }
483    let mut rendered = Vec::new();
484    for (heading, mut bullets) in categories {
485        bullets.sort();
486        bullets.dedup();
487        if !bullets.is_empty() {
488            rendered.push(format!("### {heading}\n\n{}", bullets.join("\n")));
489        }
490    }
491    Ok(rendered.join("\n\n"))
492}
493
494/// Read a checkout file, mapping I/O errors to [`BumpExecError::Fs`].
495fn read(path: &Path) -> Result<String, BumpExecError> {
496    std::fs::read_to_string(path).map_err(|source| BumpExecError::Fs {
497        path: path.to_path_buf(),
498        source,
499    })
500}
501
502/// Write a checkout file, mapping I/O errors to [`BumpExecError::Fs`].
503fn write(path: &Path, contents: &str) -> Result<(), BumpExecError> {
504    std::fs::write(path, contents).map_err(|source| BumpExecError::Fs {
505        path: path.to_path_buf(),
506        source,
507    })
508}
509
510/// Refresh `Cargo.lock`'s workspace-member entries to the bumped version via
511/// `cargo update --workspace` (dependencies untouched).
512fn refresh_lockfile(ctx: &EffectCtx<'_>) -> Result<(), BumpExecError> {
513    let out = ctx
514        .runner
515        .run("cargo", &["update", "--workspace"], ctx.repo_root)
516        .map_err(|e| BumpExecError::LockRefresh(format!("cannot run cargo: {e}")))?;
517    if out.status != Some(0) {
518        return Err(BumpExecError::LockRefresh(format!(
519            "exit {}: {}",
520            status_str(out.status),
521            out.stderr.trim()
522        )));
523    }
524    Ok(())
525}
526
527/// Run the contract-declared `bump_hook` as `sh -c "<hook>"` in the checkout — the
528/// verbatim string as a single argv element, no interpolation (see the module docs).
529fn run_hook(ctx: &EffectCtx<'_>, hook: &str) -> Result<(), BumpExecError> {
530    let out = ctx
531        .runner
532        .run("sh", &["-c", hook], ctx.repo_root)
533        .map_err(|e| BumpExecError::Hook {
534            status: "spawn failed".to_string(),
535            stderr: e.to_string(),
536        })?;
537    if out.status != Some(0) {
538        return Err(BumpExecError::Hook {
539            status: status_str(out.status),
540            stderr: out.stderr.trim().to_string(),
541        });
542    }
543    Ok(())
544}
545
546/// `git add -A` then `git commit` the bump edits in the checkout, returning the new
547/// commit sha (`git rev-parse HEAD`).
548fn commit_bump(ctx: &EffectCtx<'_>, version: &str) -> Result<String, BumpExecError> {
549    let root = ctx.repo_root;
550    run_git(ctx, &["add", "-A"], root)?;
551    let message = format!("release: v{version}");
552    run_git(ctx, &["commit", "-m", &message], root)?;
553    let out = ctx
554        .runner
555        .run("git", &["rev-parse", "HEAD"], root)
556        .map_err(|e| BumpExecError::Git(format!("rev-parse HEAD: {e}")))?;
557    if out.status != Some(0) {
558        return Err(BumpExecError::Git(format!(
559            "rev-parse HEAD exit {}: {}",
560            status_str(out.status),
561            out.stderr.trim()
562        )));
563    }
564    let sha = out.stdout.trim().to_string();
565    if sha.is_empty() {
566        return Err(BumpExecError::Git(
567            "git rev-parse HEAD returned no commit sha after the bump commit".to_string(),
568        ));
569    }
570    Ok(sha)
571}
572
573/// Run a `git` subcommand in `cwd`, failing closed on a non-zero exit.
574fn run_git(ctx: &EffectCtx<'_>, args: &[&str], cwd: &Path) -> Result<(), BumpExecError> {
575    let out = ctx
576        .runner
577        .run("git", args, cwd)
578        .map_err(|e| BumpExecError::Git(format!("`git {}`: {e}", args.join(" "))))?;
579    if out.status != Some(0) {
580        return Err(BumpExecError::Git(format!(
581            "`git {}` exit {}: {}",
582            args.join(" "),
583            status_str(out.status),
584            out.stderr.trim()
585        )));
586    }
587    Ok(())
588}
589
590/// Map member crate names to their manifest paths by scanning the workspace root's
591/// `[workspace] members` (explicit paths + trailing single-level globs), reading each
592/// `[package].name`. Mirrors the facts detector's member resolution enough for the
593/// lib+bin shape; an unresolved dependent fails the pin rewrite closed.
594fn member_manifest_paths(root: &Path) -> Result<BTreeMap<String, PathBuf>, BumpExecError> {
595    let root_manifest = root.join("Cargo.toml");
596    let text = read(&root_manifest)?;
597    let mut map = BTreeMap::new();
598    for rel in workspace_member_dirs(root, &text) {
599        let manifest = root.join(&rel).join("Cargo.toml");
600        let Ok(member_text) = std::fs::read_to_string(&manifest) else {
601            continue;
602        };
603        if let Some(name) = package_name(&member_text) {
604            map.insert(name, manifest);
605        }
606    }
607    Ok(map)
608}
609
610/// The workspace member directories declared in a root manifest's `[workspace] members`
611/// array — explicit entries plus a trailing single-level glob (`crates/*`) expanded by
612/// listing that directory. A best-effort line scan matching the facts detector's shape.
613fn workspace_member_dirs(root: &Path, root_text: &str) -> Vec<String> {
614    let Some(members) = toml_string_array(root_text, "members") else {
615        return Vec::new();
616    };
617    let mut dirs = Vec::new();
618    for entry in members {
619        if let Some(parent) = entry.strip_suffix("/*") {
620            // Expand a single-level glob by listing the parent directory.
621            if let Ok(read_dir) = std::fs::read_dir(root.join(parent)) {
622                for e in read_dir.flatten() {
623                    if e.path().is_dir() {
624                        dirs.push(format!("{parent}/{}", e.file_name().to_string_lossy()));
625                    }
626                }
627            }
628        } else if !entry.contains('*') {
629            dirs.push(entry);
630        }
631    }
632    dirs
633}
634
635/// The `members = ["…", …]` string array under a `[workspace]` table, as owned strings.
636/// A best-effort single-array scan (members are declared once, near the top).
637fn toml_string_array(text: &str, key: &str) -> Option<Vec<String>> {
638    // Find `<key> = [` and read until the closing `]` (possibly multi-line).
639    let mut in_workspace = false;
640    let mut collecting = false;
641    let mut buf = String::new();
642    for line in text.lines() {
643        let t = line.trim();
644        if let Some(h) = t.strip_prefix('[').and_then(|h| h.strip_suffix(']')) {
645            in_workspace = h.trim() == "workspace";
646            continue;
647        }
648        if collecting {
649            buf.push_str(line);
650            if line.contains(']') {
651                break;
652            }
653            continue;
654        }
655        if in_workspace {
656            if let Some(rest) = strip_key(t, key) {
657                if let Some(after) = rest.trim_start().strip_prefix('[') {
658                    buf.push_str(after);
659                    if t.contains(']') {
660                        break;
661                    }
662                    collecting = true;
663                }
664            }
665        }
666    }
667    if buf.is_empty() && !collecting {
668        return None;
669    }
670    let inner = buf.split(']').next().unwrap_or("");
671    let items: Vec<String> = inner
672        .split(',')
673        .filter_map(|s| {
674            let s = s.trim().trim_matches(['"', '\'']);
675            (!s.is_empty()).then(|| s.to_string())
676        })
677        .collect();
678    Some(items)
679}
680
681/// If `line` is `key = <rest>` (whole key), return `<rest>`; else `None`.
682fn strip_key<'a>(line: &'a str, key: &str) -> Option<&'a str> {
683    let rest = line.strip_prefix(key)?;
684    let rest = rest.trim_start();
685    rest.strip_prefix('=')
686}
687
688/// The `[package].name` of a member manifest, or `None`.
689fn package_name(text: &str) -> Option<String> {
690    let mut in_package = false;
691    for line in text.lines() {
692        let t = line.trim();
693        if let Some(h) = t.strip_prefix('[').and_then(|h| h.strip_suffix(']')) {
694            in_package = h.trim() == "package";
695            continue;
696        }
697        if in_package {
698            if let Some(rest) = strip_key(t, "name") {
699                return Some(rest.trim().trim_matches(['"', '\'']).to_string());
700            }
701        }
702    }
703    None
704}
705
706/// A subprocess status rendered for an error message.
707fn status_str(status: Option<i32>) -> String {
708    status.map_or_else(|| "signal".to_string(), |c| c.to_string())
709}
710
711/// The UTC `YYYY-MM-DD` civil date for a Unix timestamp — the CHANGELOG effective date
712/// the bump finalizes under. A self-contained `days → (y, m, d)` conversion (Howard
713/// Hinnant's `civil_from_days`) so the release path needs no chrono dependency; the
714/// injected [`Clock`](crate::ports::Clock) supplies the timestamp, so it is deterministic
715/// under test.
716#[must_use]
717pub fn civil_date(unix_secs: u64) -> String {
718    let days = i64::try_from(unix_secs / 86_400).unwrap_or(i64::MAX);
719    // Shift the epoch to 0000-03-01 and compute the year-of-era / day-of-year.
720    let z = days + 719_468;
721    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
722    let doe = z - era * 146_097; // [0, 146096]
723    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
724    let y = yoe + era * 400;
725    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
726    let mp = (5 * doy + 2) / 153; // [0, 11]
727    let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31]
728    let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12]
729    let y = if m <= 2 { y + 1 } else { y };
730    format!("{y:04}-{m:02}-{d:02}")
731}
732
733#[cfg(test)]
734mod tests;