use serde::{Deserialize, Serialize};
use crate::context::Context;
use crate::publish_report::PublishReport;
pub const CANONICAL_TRACKS: &[&str] = &["stable", "prerelease", "candidate", "beta", "edge"];
pub const CANONICAL_PRETRACKS: &[&str] = &["prerelease", "candidate", "beta", "edge"];
pub fn is_canonical_pretrack(name: &str) -> bool {
CANONICAL_PRETRACKS.contains(&name)
}
pub const DEFAULT_FROM_TRACK: &str = "prerelease";
pub const PROMOTABLE_PUBLISHERS: &[&str] = &["snapcraft", "npm", "docker", "github"];
pub fn is_promotion_capable(name: &str) -> bool {
PROMOTABLE_PUBLISHERS.contains(&name)
}
#[derive(Debug, Clone)]
pub enum PromoteSelector {
Newest,
Version(String),
FromRun {
run_id: String,
report: PublishReport,
},
}
impl PromoteSelector {
pub fn describe(&self) -> String {
match self {
PromoteSelector::Newest => "newest".to_string(),
PromoteSelector::Version(v) => format!("version {v}"),
PromoteSelector::FromRun { run_id, .. } => format!("run {run_id}"),
}
}
pub fn source_label(&self, from_track: &str) -> String {
match self {
PromoteSelector::Newest => from_track.to_string(),
PromoteSelector::Version(v) => v.clone(),
PromoteSelector::FromRun { run_id, .. } => format!("run {run_id}"),
}
}
}
pub struct PromoteRequest<'a> {
pub from: String,
pub to: String,
pub selector: &'a PromoteSelector,
pub dry_run: bool,
pub ctx: &'a Context,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum PromoteSkipReason {
Unsupported,
NothingToPromote,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum PromoteStatus {
Promoted,
DryRun,
Skipped(PromoteSkipReason),
Failed(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PromoteOutcome {
pub publisher: String,
pub from: String,
pub to: String,
pub what: Option<String>,
pub status: PromoteStatus,
}
impl PromoteOutcome {
pub fn promoted(
publisher: impl Into<String>,
from: impl Into<String>,
to: impl Into<String>,
what: impl Into<String>,
) -> Self {
Self {
publisher: publisher.into(),
from: from.into(),
to: to.into(),
what: Some(what.into()),
status: PromoteStatus::Promoted,
}
}
pub fn dry_run(
publisher: impl Into<String>,
from: impl Into<String>,
to: impl Into<String>,
what: Option<String>,
) -> Self {
Self {
publisher: publisher.into(),
from: from.into(),
to: to.into(),
what,
status: PromoteStatus::DryRun,
}
}
pub fn skipped(
publisher: impl Into<String>,
from: impl Into<String>,
to: impl Into<String>,
reason: PromoteSkipReason,
) -> Self {
Self {
publisher: publisher.into(),
from: from.into(),
to: to.into(),
what: None,
status: PromoteStatus::Skipped(reason),
}
}
pub fn failed(
publisher: impl Into<String>,
from: impl Into<String>,
to: impl Into<String>,
error: impl Into<String>,
) -> Self {
Self {
publisher: publisher.into(),
from: from.into(),
to: to.into(),
what: None,
status: PromoteStatus::Failed(error.into()),
}
}
pub fn is_failure(&self) -> bool {
match self.status {
PromoteStatus::Failed(_) => true,
PromoteStatus::Promoted | PromoteStatus::DryRun | PromoteStatus::Skipped(_) => false,
}
}
pub fn summary_line(&self) -> String {
let coord = self
.what
.as_deref()
.map(|w| format!("{w} "))
.unwrap_or_default();
let state = match &self.status {
PromoteStatus::Promoted => "promoted".to_string(),
PromoteStatus::DryRun => "dry-run".to_string(),
PromoteStatus::Skipped(PromoteSkipReason::Unsupported) => {
"skipped (unsupported)".into()
}
PromoteStatus::Skipped(PromoteSkipReason::NothingToPromote) => {
"skipped (nothing to promote)".into()
}
PromoteStatus::Failed(msg) => format!("failed: {msg}"),
};
format!(
"{}: {}{}→{} ({})",
self.publisher, coord, self.from, self.to, state
)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct PromoteReport {
pub results: Vec<PromoteOutcome>,
}
impl PromoteReport {
pub fn any_failure(&self) -> bool {
self.results.iter().any(PromoteOutcome::is_failure)
}
pub fn failure_names(&self) -> Vec<&str> {
self.results
.iter()
.filter(|r| r.is_failure())
.map(|r| r.publisher.as_str())
.collect()
}
}
pub fn partial_promotion_error(applied: &[String], failed: &[(String, String)]) -> String {
let total = applied.len() + failed.len();
let applied_list = if applied.is_empty() {
"none".to_string()
} else {
applied.join(", ")
};
let failed_detail = failed
.iter()
.map(|(target, cause)| format!("{target}: {cause}"))
.collect::<Vec<_>>()
.join("; ");
format!(
"promoted {}/{total} ({applied_list}); failed on {failed_detail}",
applied.len()
)
}
pub trait Promotable: Send + Sync {
fn name(&self) -> &str;
fn resolve_track(&self, canonical: &str) -> String;
fn promote(&self, req: &PromoteRequest) -> anyhow::Result<PromoteOutcome>;
}
pub fn dispatch_promotions(
publishers: &[Box<dyn Promotable>],
canonical_from: &str,
canonical_to: &str,
selector: &PromoteSelector,
ctx: &Context,
) -> PromoteReport {
let dry_run = ctx.is_dry_run();
let mut report = PromoteReport::default();
for p in publishers {
let from = p.resolve_track(canonical_from);
let to = p.resolve_track(canonical_to);
let req = PromoteRequest {
from: from.clone(),
to: to.clone(),
selector,
dry_run,
ctx,
};
let outcome = match p.promote(&req) {
Ok(o) => o,
Err(err) => PromoteOutcome::failed(p.name(), from, to, format!("{err:#}")),
};
report.results.push(outcome);
}
report
}
#[cfg(test)]
mod tests {
use super::*;
struct FakePromotable {
name: &'static str,
behavior: FakeBehavior,
}
enum FakeBehavior {
Echo,
Fail(&'static str),
}
impl Promotable for FakePromotable {
fn name(&self) -> &str {
self.name
}
fn resolve_track(&self, canonical: &str) -> String {
match canonical {
"prerelease" => "beta".to_string(),
other => other.to_string(),
}
}
fn promote(&self, req: &PromoteRequest) -> anyhow::Result<PromoteOutcome> {
match self.behavior {
FakeBehavior::Echo => Ok(PromoteOutcome::promoted(
self.name,
&req.from,
&req.to,
req.selector.describe(),
)),
FakeBehavior::Fail(msg) => anyhow::bail!("{msg}"),
}
}
}
fn fake(name: &'static str, behavior: FakeBehavior) -> Box<dyn Promotable> {
Box::new(FakePromotable { name, behavior })
}
#[test]
fn is_canonical_pretrack_membership() {
assert!(is_canonical_pretrack("prerelease"));
assert!(is_canonical_pretrack("candidate"));
assert!(is_canonical_pretrack("beta"));
assert!(is_canonical_pretrack("edge"));
assert!(!is_canonical_pretrack("stable"));
assert!(!is_canonical_pretrack("canary"));
}
#[test]
fn is_promotion_capable_tracks_the_registry() {
assert!(is_promotion_capable("snapcraft"));
assert!(is_promotion_capable("npm"));
assert!(is_promotion_capable("docker"));
assert!(is_promotion_capable("github"));
assert!(!is_promotion_capable("cargo"));
assert!(!is_promotion_capable("pypi"));
}
#[test]
fn selector_describe_is_stable() {
assert_eq!(PromoteSelector::Newest.describe(), "newest");
assert_eq!(
PromoteSelector::Version("1.2.3".into()).describe(),
"version 1.2.3"
);
assert_eq!(
PromoteSelector::FromRun {
run_id: "abc".into(),
report: PublishReport::default(),
}
.describe(),
"run abc"
);
}
#[test]
fn dispatch_resolves_tracks_into_native_vocabulary() {
let ctx = Context::test_fixture();
let publishers = vec![fake("fake", FakeBehavior::Echo)];
let report = dispatch_promotions(
&publishers,
"prerelease",
"stable",
&PromoteSelector::Newest,
&ctx,
);
assert_eq!(report.results.len(), 1);
let o = &report.results[0];
assert_eq!(o.from, "beta");
assert_eq!(o.to, "stable");
assert!(matches!(o.status, PromoteStatus::Promoted));
assert!(!report.any_failure());
}
#[test]
fn dispatch_records_err_as_failed_without_aborting() {
let ctx = Context::test_fixture();
let publishers = vec![
fake("boom", FakeBehavior::Fail("kaboom")),
fake("ok", FakeBehavior::Echo),
];
let report = dispatch_promotions(
&publishers,
"candidate",
"stable",
&PromoteSelector::Version("9.9.9".into()),
&ctx,
);
assert_eq!(report.results.len(), 2, "fan-out continues past a failure");
assert!(report.any_failure());
assert_eq!(report.failure_names(), vec!["boom"]);
let boom = &report.results[0];
assert!(matches!(boom.status, PromoteStatus::Failed(ref m) if m.contains("kaboom")));
}
#[test]
fn source_label_reflects_selector() {
assert_eq!(PromoteSelector::Newest.source_label("edge"), "edge");
assert_eq!(
PromoteSelector::Version("1.4.0".into()).source_label("edge"),
"1.4.0"
);
assert_eq!(
PromoteSelector::FromRun {
run_id: "abc".into(),
report: PublishReport::default(),
}
.source_label("edge"),
"run abc"
);
}
#[test]
fn partial_promotion_error_names_applied_and_failed() {
let msg = partial_promotion_error(
&["acme/app".to_string(), "acme/lib".to_string()],
&[("acme/tool".to_string(), "boom".to_string())],
);
assert_eq!(
msg,
"promoted 2/3 (acme/app, acme/lib); failed on acme/tool: boom"
);
let all_failed =
partial_promotion_error(&[], &[("acme/only".to_string(), "nope".to_string())]);
assert_eq!(all_failed, "promoted 0/1 (none); failed on acme/only: nope");
}
#[test]
fn summary_line_renders_each_state() {
let promoted = PromoteOutcome::promoted("snapcraft", "candidate", "stable", "revision 42");
assert_eq!(
promoted.summary_line(),
"snapcraft: revision 42 candidate→stable (promoted)"
);
let dry = PromoteOutcome::dry_run("snapcraft", "candidate", "stable", None);
assert_eq!(dry.summary_line(), "snapcraft: candidate→stable (dry-run)");
let skipped = PromoteOutcome::skipped(
"snapcraft",
"candidate",
"stable",
PromoteSkipReason::NothingToPromote,
);
assert_eq!(
skipped.summary_line(),
"snapcraft: candidate→stable (skipped (nothing to promote))"
);
let failed = PromoteOutcome::failed("snapcraft", "candidate", "stable", "boom");
assert_eq!(
failed.summary_line(),
"snapcraft: candidate→stable (failed: boom)"
);
}
#[test]
fn is_failure_only_true_for_failed() {
assert!(PromoteOutcome::failed("p", "a", "b", "e").is_failure());
assert!(!PromoteOutcome::promoted("p", "a", "b", "w").is_failure());
assert!(!PromoteOutcome::dry_run("p", "a", "b", None).is_failure());
assert!(
!PromoteOutcome::skipped("p", "a", "b", PromoteSkipReason::Unsupported).is_failure()
);
}
}