use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq)]
struct Semver {
major: u64,
minor: u64,
patch: u64,
pre: Option<String>,
}
impl Semver {
fn parse(input: &str) -> Option<Self> {
let (version_part, pre) = match input.split_once('-') {
Some((v, p)) if !p.is_empty() => (v, Some(p.to_string())),
_ => (input, None),
};
let parts: Vec<&str> = version_part.split('.').collect();
if parts.len() != 3 {
return None;
}
let major = parts[0].parse::<u64>().ok()?;
let minor = parts[1].parse::<u64>().ok()?;
let patch = parts[2].parse::<u64>().ok()?;
Some(Self {
major,
minor,
patch,
pre,
})
}
fn cmp_version(&self, other: &Self) -> std::cmp::Ordering {
let tuple_cmp =
(self.major, self.minor, self.patch).cmp(&(other.major, other.minor, other.patch));
if tuple_cmp != std::cmp::Ordering::Equal {
return tuple_cmp;
}
match (&self.pre, &other.pre) {
(None, None) => std::cmp::Ordering::Equal,
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(Some(a), Some(b)) => a.cmp(b),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PublishCandidate {
pub version_label: Option<String>,
pub previous_version: Option<String>,
pub existing_versions: Vec<String>,
pub notes: Option<String>,
pub spec_digest: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum PublishRule {
SemverFormat,
VersionBump,
UniqueVersion,
RequireNotes,
RequireSpecDigest,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PublishRuleSet {
pub rules: Vec<PublishRule>,
pub fail_fast: bool,
}
impl PublishRuleSet {
pub fn standard() -> Self {
Self {
rules: vec![
PublishRule::SemverFormat,
PublishRule::VersionBump,
PublishRule::RequireSpecDigest,
],
fail_fast: false,
}
}
pub fn with_rule(mut self, rule: PublishRule) -> Self {
self.rules.push(rule);
self
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct PublishViolation {
pub rule: PublishRule,
pub reason: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PublishVerdict {
pub passed: bool,
pub violations: Vec<PublishViolation>,
}
impl PublishVerdict {
fn pass() -> Self {
Self {
passed: true,
violations: Vec::new(),
}
}
fn fail(violations: Vec<PublishViolation>) -> Self {
Self {
passed: false,
violations,
}
}
}
pub fn evaluate_publish_gate(
rule_set: &PublishRuleSet,
candidate: &PublishCandidate,
) -> PublishVerdict {
let mut violations = Vec::new();
for rule in &rule_set.rules {
if let Some(v) = check_rule(rule, candidate) {
violations.push(v);
if rule_set.fail_fast {
return PublishVerdict::fail(violations);
}
}
}
if violations.is_empty() {
PublishVerdict::pass()
} else {
PublishVerdict::fail(violations)
}
}
fn check_rule(rule: &PublishRule, candidate: &PublishCandidate) -> Option<PublishViolation> {
match rule {
PublishRule::SemverFormat => {
let label = match &candidate.version_label {
Some(l) if !l.is_empty() => l,
_ => {
return Some(PublishViolation {
rule: rule.clone(),
reason: "version_label is missing or empty".to_string(),
});
}
};
if Semver::parse(label).is_none() {
Some(PublishViolation {
rule: rule.clone(),
reason: format!(
"'{}' is not valid semver (expected MAJOR.MINOR.PATCH)",
label
),
})
} else {
None
}
}
PublishRule::VersionBump => {
let current_label = match &candidate.version_label {
Some(l) if !l.is_empty() => l,
_ => return None, };
let prev_label = match &candidate.previous_version {
Some(l) if !l.is_empty() => l,
_ => return None, };
let current = Semver::parse(current_label)?;
let previous = Semver::parse(prev_label)?;
if current.cmp_version(&previous) != std::cmp::Ordering::Greater {
Some(PublishViolation {
rule: rule.clone(),
reason: format!(
"version '{}' is not greater than previous '{}'",
current_label, prev_label,
),
})
} else {
None
}
}
PublishRule::UniqueVersion => {
let label = match &candidate.version_label {
Some(l) if !l.is_empty() => l,
_ => return None,
};
if candidate.existing_versions.contains(label) {
Some(PublishViolation {
rule: rule.clone(),
reason: format!("version '{}' already exists in history", label),
})
} else {
None
}
}
PublishRule::RequireNotes => match &candidate.notes {
Some(n) if !n.trim().is_empty() => None,
_ => Some(PublishViolation {
rule: rule.clone(),
reason: "release notes are missing or empty".to_string(),
}),
},
PublishRule::RequireSpecDigest => {
if candidate.spec_digest.trim().is_empty() {
Some(PublishViolation {
rule: rule.clone(),
reason: "spec_digest is missing or empty".to_string(),
})
} else {
None
}
}
}
}