Skip to main content

sr_core/
publish.rs

1//! Per-package publish orchestration.
2//!
3//! Dispatches each package's `publish` config to a typed `Publisher` that
4//! knows how to query its registry (state check) and shell out to the
5//! publishing tool (run). Stateless: the registry is the source of truth,
6//! not sr. Every invocation asks every configured package "are you already
7//! at this version?" and runs when the answer is no.
8
9use crate::config::PackageConfig;
10use crate::publishers::{PublishCtx, PublishState, publisher_for};
11
12/// Outcome of a single package's publish attempt. Reported to callers
13/// (the Publish stage, or CLI `sr publish`) for aggregate reporting.
14#[derive(Debug, Clone)]
15pub enum PublishOutcome {
16    /// Package has no `publish` config — nothing to do.
17    NotConfigured { path: String },
18    /// Registry already has the target version — skipped.
19    AlreadyPublished { path: String, publisher: String },
20    /// Command ran to completion.
21    Succeeded { path: String, publisher: String },
22    /// Command failed. `message` carries the error text.
23    Failed {
24        path: String,
25        publisher: String,
26        message: String,
27    },
28}
29
30impl PublishOutcome {
31    pub fn path(&self) -> &str {
32        match self {
33            Self::NotConfigured { path }
34            | Self::AlreadyPublished { path, .. }
35            | Self::Succeeded { path, .. }
36            | Self::Failed { path, .. } => path,
37        }
38    }
39
40    pub fn is_failure(&self) -> bool {
41        matches!(self, Self::Failed { .. })
42    }
43}
44
45/// Run publish for a single package. Dispatches on the package's
46/// `publish` config:
47///   - `None` → [`PublishOutcome::NotConfigured`]
48///   - `Some(cfg)` → build the matching `Publisher`, call `check`, and
49///     either skip (Completed) or run (Needed/Unknown).
50pub fn run_package_publish(
51    package: &PackageConfig,
52    version: &str,
53    tag: &str,
54    dry_run: bool,
55    env: &[(&str, &str)],
56) -> PublishOutcome {
57    let Some(cfg) = package.publish.as_ref() else {
58        return PublishOutcome::NotConfigured {
59            path: package.path.clone(),
60        };
61    };
62
63    let publisher = publisher_for(cfg);
64    let pub_name = publisher.name().to_string();
65    let ctx = PublishCtx {
66        package,
67        version,
68        tag,
69        dry_run,
70        env,
71    };
72
73    match publisher.check(&ctx) {
74        Ok(PublishState::Completed) => {
75            eprintln!(
76                "{pub_name} ({}): already at {version} — skipping",
77                package.path
78            );
79            return PublishOutcome::AlreadyPublished {
80                path: package.path.clone(),
81                publisher: pub_name,
82            };
83        }
84        Ok(PublishState::Needed) => {}
85        Ok(PublishState::Unknown(reason)) => {
86            eprintln!(
87                "{pub_name} ({}): state check inconclusive ({reason}) — attempting publish",
88                package.path
89            );
90        }
91        Err(e) => {
92            return PublishOutcome::Failed {
93                path: package.path.clone(),
94                publisher: pub_name,
95                message: format!("check failed: {e}"),
96            };
97        }
98    }
99
100    match publisher.run(&ctx) {
101        Ok(()) => PublishOutcome::Succeeded {
102            path: package.path.clone(),
103            publisher: pub_name,
104        },
105        Err(e) => PublishOutcome::Failed {
106            path: package.path.clone(),
107            publisher: pub_name,
108            message: e.to_string(),
109        },
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use crate::config::{PackageConfig, PublishConfig};
117
118    #[test]
119    fn not_configured_when_publish_is_none() {
120        let pkg = PackageConfig {
121            path: ".".into(),
122            ..Default::default()
123        };
124        let outcome = run_package_publish(&pkg, "1.0.0", "v1.0.0", false, &[]);
125        assert!(matches!(outcome, PublishOutcome::NotConfigured { .. }));
126    }
127
128    #[test]
129    fn custom_run_succeeds() {
130        let pkg = PackageConfig {
131            path: ".".into(),
132            publish: Some(PublishConfig::Custom {
133                command: "true".into(),
134                check: None,
135                cwd: Some(".".into()),
136            }),
137            ..Default::default()
138        };
139        let outcome = run_package_publish(&pkg, "1.0.0", "v1.0.0", false, &[]);
140        assert!(matches!(outcome, PublishOutcome::Succeeded { .. }));
141    }
142
143    #[test]
144    fn custom_run_failure_captured() {
145        let pkg = PackageConfig {
146            path: ".".into(),
147            publish: Some(PublishConfig::Custom {
148                command: "false".into(),
149                check: None,
150                cwd: Some(".".into()),
151            }),
152            ..Default::default()
153        };
154        let outcome = run_package_publish(&pkg, "1.0.0", "v1.0.0", false, &[]);
155        assert!(matches!(outcome, PublishOutcome::Failed { .. }));
156    }
157
158    #[test]
159    fn custom_check_completed_short_circuits_run() {
160        // check: "true" → Completed → skip run (would fail).
161        let pkg = PackageConfig {
162            path: ".".into(),
163            publish: Some(PublishConfig::Custom {
164                command: "false".into(),
165                check: Some("true".into()),
166                cwd: Some(".".into()),
167            }),
168            ..Default::default()
169        };
170        let outcome = run_package_publish(&pkg, "1.0.0", "v1.0.0", false, &[]);
171        assert!(matches!(outcome, PublishOutcome::AlreadyPublished { .. }));
172    }
173
174    #[test]
175    fn go_always_completed() {
176        let pkg = PackageConfig {
177            path: ".".into(),
178            publish: Some(PublishConfig::Go),
179            ..Default::default()
180        };
181        let outcome = run_package_publish(&pkg, "1.0.0", "v1.0.0", false, &[]);
182        assert!(matches!(outcome, PublishOutcome::AlreadyPublished { .. }));
183    }
184
185    #[test]
186    fn unknown_check_still_runs() {
187        // `custom` with no `check` returns PublishState::Unknown — the
188        // same state any built-in publisher returns for a registry 5xx,
189        // rate-limit, or auth failure. Verify the dispatcher does NOT
190        // skip: Unknown → proceed to run(), relying on the publish tool's
191        // own idempotency to handle "already published" cleanly.
192        let pkg = PackageConfig {
193            path: ".".into(),
194            publish: Some(PublishConfig::Custom {
195                command: "true".into(),
196                check: None,
197                cwd: Some(".".into()),
198            }),
199            ..Default::default()
200        };
201        let outcome = run_package_publish(&pkg, "1.0.0", "v1.0.0", false, &[]);
202        assert!(
203            matches!(outcome, PublishOutcome::Succeeded { .. }),
204            "Unknown state should proceed to run, got {outcome:?}"
205        );
206    }
207
208    #[test]
209    fn dry_run_skips_execution() {
210        // Command would fail, but dry-run returns a synthetic Succeeded.
211        let pkg = PackageConfig {
212            path: ".".into(),
213            publish: Some(PublishConfig::Custom {
214                command: "false".into(),
215                check: None,
216                cwd: Some(".".into()),
217            }),
218            ..Default::default()
219        };
220        let outcome = run_package_publish(&pkg, "1.0.0", "v1.0.0", true, &[]);
221        // Dry-run of Custom.run returns Ok(()), so outcome is Succeeded.
222        assert!(matches!(outcome, PublishOutcome::Succeeded { .. }));
223    }
224}