1use serde::{Deserialize, Serialize};
7
8use crate::domain::release::Release;
9
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
12#[serde(tag = "type", rename_all = "snake_case")]
13pub enum CompatRule {
14 SpecDigestValid,
16 RequireToolsDigest,
18 RequireGraphDigest,
20 NoToolsChange,
22 NoGraphChange,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
28pub struct CompatRuleSet {
29 pub rules: Vec<CompatRule>,
30}
31
32impl CompatRuleSet {
33 pub fn standard() -> Self {
35 Self {
36 rules: vec![
37 CompatRule::SpecDigestValid,
38 CompatRule::RequireToolsDigest,
39 CompatRule::RequireGraphDigest,
40 ],
41 }
42 }
43
44 pub fn with_rule(mut self, rule: CompatRule) -> Self {
46 self.rules.push(rule);
47 self
48 }
49}
50
51pub struct PromoteContext<'a> {
53 pub candidate: &'a Release,
55 pub current: Option<&'a Release>,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
61pub struct CompatViolation {
62 pub rule: CompatRule,
64 pub reason: String,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
70pub struct CompatVerdict {
71 pub violations: Vec<CompatViolation>,
73}
74
75impl CompatVerdict {
76 pub fn passed(&self) -> bool {
78 self.violations.is_empty()
79 }
80}
81
82pub fn evaluate_compat(rule_set: &CompatRuleSet, ctx: &PromoteContext) -> CompatVerdict {
84 let mut violations = Vec::new();
85
86 for rule in &rule_set.rules {
87 if let Some(v) = check_rule(rule, ctx) {
88 violations.push(v);
89 }
90 }
91
92 CompatVerdict { violations }
93}
94
95fn is_valid_hex_digest(s: &str) -> bool {
96 s.len() == 64
97 && s.chars()
98 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
99}
100
101fn check_rule(rule: &CompatRule, ctx: &PromoteContext) -> Option<CompatViolation> {
102 match rule {
103 CompatRule::SpecDigestValid => {
104 if !is_valid_hex_digest(&ctx.candidate.spec_digest) {
105 Some(CompatViolation {
106 rule: rule.clone(),
107 reason: format!(
108 "spec_digest '{}' is not a valid 64-char lowercase hex string",
109 ctx.candidate.spec_digest,
110 ),
111 })
112 } else {
113 None
114 }
115 }
116 CompatRule::RequireToolsDigest => {
117 if ctx.candidate.tools_digest.is_empty() {
118 Some(CompatViolation {
119 rule: rule.clone(),
120 reason: "tools_digest is empty".to_string(),
121 })
122 } else {
123 None
124 }
125 }
126 CompatRule::RequireGraphDigest => {
127 if ctx.candidate.graph_digest.is_empty() {
128 Some(CompatViolation {
129 rule: rule.clone(),
130 reason: "graph_digest is empty".to_string(),
131 })
132 } else {
133 None
134 }
135 }
136 CompatRule::NoToolsChange => {
137 if let Some(current) = ctx.current {
138 if ctx.candidate.tools_digest != current.tools_digest {
139 Some(CompatViolation {
140 rule: rule.clone(),
141 reason: format!(
142 "tools_digest changed: '{}' -> '{}'",
143 current.tools_digest, ctx.candidate.tools_digest,
144 ),
145 })
146 } else {
147 None
148 }
149 } else {
150 None
151 }
152 }
153 CompatRule::NoGraphChange => {
154 if let Some(current) = ctx.current {
155 if ctx.candidate.graph_digest != current.graph_digest {
156 Some(CompatViolation {
157 rule: rule.clone(),
158 reason: format!(
159 "graph_digest changed: '{}' -> '{}'",
160 current.graph_digest, ctx.candidate.graph_digest,
161 ),
162 })
163 } else {
164 None
165 }
166 } else {
167 None
168 }
169 }
170 }
171}