Skip to main content

crate_seq_core/pipeline/
dry_run.rs

1//! Dry-run publish pipeline: checkout, validate, package — no network writes.
2
3use std::path::{Path, PathBuf};
4use std::process::Command;
5
6use crate_seq_ledger::{detect_tag_pattern, load};
7
8use crate::pipeline::patch::inject_workspace_patches;
9use crate::pipeline::source::resolve_source;
10use crate::validate::{run_cargo_check, validate_no_path_deps};
11use crate::Error;
12
13/// Outcome of a dry-run for a single version.
14#[derive(Debug)]
15pub enum DryRunOutcome {
16    /// `cargo package --allow-dirty` succeeded.
17    Pass,
18    /// `cargo package` failed; contains stderr.
19    PackageFailed(String),
20    /// Path dependencies were found; lists dep names.
21    PathDepsFound(Vec<String>),
22    /// `cargo check` failed; contains stderr.
23    CargoCheckFailed(String),
24}
25
26/// Result of a dry-run for a single version.
27#[derive(Debug)]
28pub struct DryRunVersionResult {
29    /// The semantic version that was tested.
30    pub version: semver::Version,
31    /// The tag ref used for the checkout.
32    pub tag_ref: String,
33    /// The outcome of the dry-run for this version.
34    pub outcome: DryRunOutcome,
35}
36
37/// Result of the dry-run for all pending versions.
38#[derive(Debug)]
39pub struct DryRunReport {
40    /// Crate name from the ledger.
41    pub crate_name: String,
42    /// Per-version results in ascending version order.
43    pub results: Vec<DryRunVersionResult>,
44}
45
46/// Resolves the tag pattern from ledger settings or auto-detection.
47fn resolve_tag_pattern(settings: &crate_seq_ledger::LedgerSettings, crate_name: &str) -> String {
48    settings
49        .tag_pattern
50        .clone()
51        .unwrap_or_else(|| detect_tag_pattern(crate_name, false))
52}
53
54/// Runs `cargo package --allow-dirty` for the manifest at `manifest_path`.
55///
56/// Returns `Pass` on success or `PackageFailed(stderr)` on failure.
57fn run_cargo_package(manifest_path: &Path) -> Result<DryRunOutcome, Error> {
58    let output = Command::new("cargo")
59        .args(["package", "--allow-dirty", "--manifest-path"])
60        .arg(manifest_path)
61        .output()
62        .map_err(|e| Error::Subprocess(e.to_string()))?;
63
64    if output.status.success() {
65        Ok(DryRunOutcome::Pass)
66    } else {
67        let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
68        Ok(DryRunOutcome::PackageFailed(stderr))
69    }
70}
71
72/// Maps path-dep or cargo-check errors to soft outcomes; propagates infrastructure errors.
73///
74/// Returns `None` if both validations pass (pipeline should continue).
75fn run_validations(manifest_path: &Path) -> Result<Option<DryRunOutcome>, Error> {
76    match validate_no_path_deps(manifest_path) {
77        Ok(()) => {}
78        Err(Error::PathDependencies { deps, .. }) => {
79            return Ok(Some(DryRunOutcome::PathDepsFound(deps)));
80        }
81        Err(e) => return Err(e),
82    }
83
84    match run_cargo_check(manifest_path) {
85        Ok(()) => {}
86        Err(Error::CargoCheck { stderr }) => {
87            return Ok(Some(DryRunOutcome::CargoCheckFailed(stderr)));
88        }
89        Err(e) => return Err(e),
90    }
91
92    Ok(None)
93}
94
95/// Processes a single pending entry: resolve source → validate → rewrite → package.
96fn process_entry(
97    entry: &crate_seq_ledger::LedgerEntry,
98    repo_path: &Path,
99    crate_rel_path: &Path,
100    snapshot_store: &Path,
101) -> Result<DryRunVersionResult, Error> {
102    let tag_ref = entry.ref_.clone();
103    let checkout = resolve_source(entry, repo_path, snapshot_store)?;
104    inject_workspace_patches(checkout.path())?; // inject before manifest ops
105    let manifest_path = checkout.path().join(crate_rel_path).join("Cargo.toml");
106
107    if let Some(failed_outcome) = run_validations(&manifest_path)? {
108        return Ok(DryRunVersionResult {
109            version: entry.version.clone(),
110            tag_ref,
111            outcome: failed_outcome,
112        });
113    }
114
115    crate_seq_manifest::rewrite_version(&manifest_path, &entry.version)?;
116    let outcome = run_cargo_package(&manifest_path)?;
117
118    Ok(DryRunVersionResult {
119        version: entry.version.clone(),
120        tag_ref,
121        outcome,
122    })
123}
124
125/// Returns the default snapshot store adjacent to the ledger file.
126fn default_snapshot_store(ledger_path: &Path) -> PathBuf {
127    ledger_path
128        .parent()
129        .unwrap_or_else(|| Path::new("."))
130        .join(".crate-seq-snapshots")
131}
132
133/// Returns the crate directory's path relative to `repo_path`.
134///
135/// Strips `repo_path` from `ledger_path.parent()`. Falls back to an empty
136/// relative path when the ledger sits directly at the repo root (single-crate repos).
137fn crate_rel_path(ledger_path: &Path, repo_path: &Path) -> PathBuf {
138    let crate_dir = ledger_path.parent().unwrap_or(repo_path);
139    crate_dir
140        .strip_prefix(repo_path)
141        .unwrap_or(std::path::Path::new(""))
142        .to_owned()
143}
144
145/// Dry-run: resolve each pending version, rewrite `Cargo.toml`, run `cargo package`.
146///
147/// `snapshot_store` overrides the default `.crate-seq-snapshots/` directory for
148/// entries with `source = snapshot`. Git-tag entries ignore `snapshot_store`.
149/// No network writes are performed. Does not modify the ledger.
150///
151/// # Errors
152///
153/// Returns [`Error::Ledger`] if the ledger cannot be loaded, [`Error::Git`] on
154/// checkout failure, [`Error::SnapshotNotFound`] if a snapshot tarball is missing,
155/// [`Error::Manifest`] on rewrite failure, or [`Error::Subprocess`] if `cargo
156/// package` cannot be spawned.
157pub fn publish_dry_run(
158    ledger_path: &Path,
159    repo_path: &Path,
160    snapshot_store: Option<PathBuf>,
161) -> Result<DryRunReport, Error> {
162    let ledger = load(ledger_path)?;
163    let crate_name = ledger.crate_config.name.clone();
164    let _tag_pattern = resolve_tag_pattern(&ledger.settings, &crate_name);
165
166    let store = snapshot_store.unwrap_or_else(|| default_snapshot_store(ledger_path));
167    let rel_path = crate_rel_path(ledger_path, repo_path);
168
169    let pending: Vec<crate_seq_ledger::LedgerEntry> =
170        ledger.pending_versions().into_iter().cloned().collect();
171
172    let mut results = Vec::with_capacity(pending.len());
173    for entry in &pending {
174        results.push(process_entry(entry, repo_path, &rel_path, &store)?);
175    }
176
177    Ok(DryRunReport {
178        crate_name,
179        results,
180    })
181}