Skip to main content

crate_seq_core/pipeline/
execute.rs

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