Skip to main content

crate_seq_core/pipeline/
execute.rs

1//! Live publish pipeline: idempotent checkout → rewrite → package → publish.
2
3#[cfg(test)]
4#[path = "execute_tests.rs"]
5mod tests;
6
7use std::path::{Path, PathBuf};
8use std::process::Command;
9
10use crate_seq_ledger::{load, save};
11use crate_seq_registry::{backoff_publish, BackoffConfig, CratesIoClient, PublishOutcome};
12
13use crate::auth::require_token;
14use crate::pipeline::patch::inject_workspace_patches;
15use crate::pipeline::source::resolve_source;
16use crate::validate::{run_cargo_check, validate_no_path_deps};
17use crate::Error;
18
19/// Outcome of publishing a single version.
20#[derive(Debug)]
21pub enum VersionPublishOutcome {
22    /// Published successfully in this run.
23    Published,
24    /// Already present on crates.io; skipped network publish.
25    AlreadyPublished,
26    /// Was already `Skipped` in the ledger.
27    Skipped,
28    /// Failed; contains the error message.
29    Failed(String),
30}
31
32/// Result of publishing a single version.
33#[derive(Debug)]
34pub struct PublishVersionResult {
35    /// The semantic version that was processed.
36    pub version: semver::Version,
37    /// The tag ref used for the checkout.
38    pub tag_ref: String,
39    /// The outcome for this version.
40    pub outcome: VersionPublishOutcome,
41}
42
43/// Report from a live publish run.
44#[derive(Debug)]
45pub struct PublishReport {
46    /// Crate name from the ledger.
47    pub crate_name: String,
48    /// Per-version results in ascending version order.
49    pub results: Vec<PublishVersionResult>,
50}
51
52/// Runs `cargo package --allow-dirty` and returns stderr on failure.
53fn run_package(manifest_path: &Path) -> Result<Option<String>, Error> {
54    let output = Command::new("cargo")
55        .args(["package", "--allow-dirty", "--manifest-path"])
56        .arg(manifest_path)
57        .output()
58        .map_err(|e| Error::Subprocess(e.to_string()))?;
59
60    if output.status.success() {
61        Ok(None)
62    } else {
63        let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
64        Ok(Some(stderr))
65    }
66}
67
68/// Saves the ledger, propagating I/O errors as [`Error::Ledger`].
69fn persist(ledger_path: &Path, ledger: &crate_seq_ledger::CrateSeqLedger) -> Result<(), Error> {
70    save(ledger_path, ledger)?;
71    Ok(())
72}
73
74/// Validates the manifest: no path deps, then `cargo check`. Propagates all errors.
75fn validate_manifest(manifest_path: &Path) -> Result<(), Error> {
76    validate_no_path_deps(manifest_path)?;
77    run_cargo_check(manifest_path)
78}
79
80/// Returns the default snapshot store adjacent to the ledger file.
81fn default_snapshot_store(ledger_path: &Path) -> PathBuf {
82    ledger_path
83        .parent()
84        .unwrap_or_else(|| Path::new("."))
85        .join(".crate-seq-snapshots")
86}
87
88/// Returns the crate directory's path relative to `repo_path`.
89///
90/// Strips `repo_path` from `ledger_path.parent()`. Falls back to an empty
91/// relative path when the ledger sits directly at the repo root (single-crate repos).
92fn crate_rel_path(ledger_path: &Path, repo_path: &Path) -> PathBuf {
93    let crate_dir = ledger_path.parent().unwrap_or(repo_path);
94    crate_dir
95        .strip_prefix(repo_path)
96        .unwrap_or(std::path::Path::new(""))
97        .to_owned()
98}
99
100/// Processes a single pending entry through the full live publish pipeline.
101#[allow(clippy::too_many_arguments)]
102fn process_entry(
103    entry: &crate_seq_ledger::LedgerEntry,
104    ledger_path: &Path,
105    ledger: &mut crate_seq_ledger::CrateSeqLedger,
106    repo_path: &Path,
107    crate_rel_path: &Path,
108    snapshot_store: &Path,
109    client: &CratesIoClient,
110    token: Option<&str>,
111    backoff: &BackoffConfig,
112    crate_name: &str,
113    results: &mut Vec<PublishVersionResult>,
114) -> Result<bool, Error> {
115    let tag_ref = entry.ref_.clone();
116    let version = entry.version.clone();
117
118    if client.check_version_exists(crate_name, &version)? {
119        ledger.mark_published(&version)?;
120        persist(ledger_path, ledger)?;
121        results.push(PublishVersionResult {
122            version,
123            tag_ref,
124            outcome: VersionPublishOutcome::AlreadyPublished,
125        });
126        return Ok(true);
127    }
128
129    let checkout = resolve_source(entry, repo_path, snapshot_store)?;
130    inject_workspace_patches(checkout.path())?; // inject before manifest ops
131    let manifest_path = checkout.path().join(crate_rel_path).join("Cargo.toml");
132
133    validate_manifest(&manifest_path)?;
134
135    crate_seq_manifest::rewrite_version(&manifest_path, &version)?;
136
137    if let Some(stderr) = run_package(&manifest_path)? {
138        results.push(PublishVersionResult {
139            version,
140            tag_ref,
141            outcome: VersionPublishOutcome::Failed(stderr),
142        });
143        persist(ledger_path, ledger)?;
144        return Ok(false);
145    }
146
147    match backoff_publish(&checkout.path().join(crate_rel_path), token, backoff)? {
148        PublishOutcome::Success | PublishOutcome::AlreadyPublished => {
149            ledger.mark_published(&version)?;
150            persist(ledger_path, ledger)?;
151            results.push(PublishVersionResult {
152                version,
153                tag_ref,
154                outcome: VersionPublishOutcome::Published,
155            });
156            Ok(true)
157        }
158        PublishOutcome::RateLimited => {
159            results.push(PublishVersionResult {
160                version,
161                tag_ref,
162                outcome: VersionPublishOutcome::Failed(
163                    "rate limited — retries exhausted".to_owned(),
164                ),
165            });
166            persist(ledger_path, ledger)?;
167            Ok(false)
168        }
169        PublishOutcome::Failed(msg) => {
170            results.push(PublishVersionResult {
171                version,
172                tag_ref,
173                outcome: VersionPublishOutcome::Failed(msg),
174            });
175            persist(ledger_path, ledger)?;
176            Ok(false)
177        }
178    }
179}
180
181/// Live publish: for each pending version, check idempotency, checkout, rewrite,
182/// package, publish, update ledger.
183///
184/// `snapshot_store` overrides the default `.crate-seq-snapshots/` directory for
185/// entries with `source = snapshot`. Git-tag entries ignore `snapshot_store`.
186///
187/// Performs a pre-flight token check before any version is processed. If the ledger
188/// configures a token source (env var or command) and it fails, returns an actionable
189/// error immediately. If no token source is configured and cargo credentials are
190/// absent, passes `None` and lets cargo decide.
191///
192/// On success for each version: marks `Published` and saves ledger immediately
193/// (crash-safe — ledger reflects exact progress). On first failure: saves current
194/// ledger state and returns the partial report.
195///
196/// # Errors
197///
198/// Returns [`Error::TokenResolution`] on pre-flight token failure,
199/// [`Error::Ledger`] on ledger I/O failure, [`Error::Git`] on checkout
200/// failure, [`Error::SnapshotNotFound`] if a snapshot tarball is missing,
201/// [`Error::Manifest`] on rewrite failure, [`Error::Registry`] on
202/// registry HTTP failure, or [`Error::Subprocess`] if `cargo` cannot be spawned.
203pub fn publish_execute(
204    ledger_path: &Path,
205    repo_path: &Path,
206    token: Option<&str>,
207    backoff_config: &BackoffConfig,
208    crate_seq_version: &str,
209    snapshot_store: Option<PathBuf>,
210) -> Result<PublishReport, Error> {
211    let client = CratesIoClient::new(crate_seq_version)?;
212    publish_execute_with_client(
213        ledger_path,
214        repo_path,
215        token,
216        backoff_config,
217        snapshot_store,
218        &client,
219    )
220}
221
222/// Inner implementation that accepts an injected [`CratesIoClient`].
223///
224/// Separated from [`publish_execute`] so tests can inject a mock registry
225/// client without hitting the live crates.io API.
226pub(crate) fn publish_execute_with_client(
227    ledger_path: &Path,
228    repo_path: &Path,
229    token: Option<&str>,
230    backoff_config: &BackoffConfig,
231    snapshot_store: Option<PathBuf>,
232    client: &CratesIoClient,
233) -> Result<PublishReport, Error> {
234    let mut ledger = load(ledger_path)?;
235    let crate_name = ledger.crate_config.name.clone();
236
237    let resolved_token = require_token(token, &ledger.auth)?;
238    let token_ref = resolved_token.as_deref();
239
240    let store = snapshot_store.unwrap_or_else(|| default_snapshot_store(ledger_path));
241    let rel_path = crate_rel_path(ledger_path, repo_path);
242
243    let pending: Vec<crate_seq_ledger::LedgerEntry> =
244        ledger.pending_versions().into_iter().cloned().collect();
245
246    let mut results = Vec::with_capacity(pending.len());
247
248    for entry in &pending {
249        let ok = process_entry(
250            entry,
251            ledger_path,
252            &mut ledger,
253            repo_path,
254            &rel_path,
255            &store,
256            client,
257            token_ref,
258            backoff_config,
259            &crate_name,
260            &mut results,
261        )?;
262        if !ok {
263            break;
264        }
265    }
266
267    Ok(PublishReport {
268        crate_name,
269        results,
270    })
271}