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