Skip to main content

aivcs_core/
compat.rs

1//! Compatibility validator for release promotions.
2//!
3//! Evaluates a candidate [`Release`] against a [`CompatRuleSet`] to produce a
4//! [`CompatVerdict`] — the pass/fail decision that blocks or allows a promote.
5
6use serde::{Deserialize, Serialize};
7
8use crate::domain::release::Release;
9
10/// A single compatibility rule that can block a promotion.
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
12#[serde(tag = "type", rename_all = "snake_case")]
13pub enum CompatRule {
14    /// Candidate `spec_digest` must be a valid 64-char lowercase hex string.
15    SpecDigestValid,
16    /// `tools_digest` must be non-empty.
17    RequireToolsDigest,
18    /// `graph_digest` must be non-empty.
19    RequireGraphDigest,
20    /// `tools_digest` must not change vs. the current release (if one exists).
21    NoToolsChange,
22    /// `graph_digest` must not change vs. the current release (if one exists).
23    NoGraphChange,
24}
25
26/// A set of compatibility rules to evaluate before promoting.
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
28pub struct CompatRuleSet {
29    pub rules: Vec<CompatRule>,
30}
31
32impl CompatRuleSet {
33    /// Standard rule set: `SpecDigestValid` + `RequireToolsDigest` + `RequireGraphDigest`.
34    pub fn standard() -> Self {
35        Self {
36            rules: vec![
37                CompatRule::SpecDigestValid,
38                CompatRule::RequireToolsDigest,
39                CompatRule::RequireGraphDigest,
40            ],
41        }
42    }
43
44    /// Add a rule to this set (builder pattern).
45    pub fn with_rule(mut self, rule: CompatRule) -> Self {
46        self.rules.push(rule);
47        self
48    }
49}
50
51/// Context for evaluating compatibility of a candidate release.
52pub struct PromoteContext<'a> {
53    /// The candidate release to validate.
54    pub candidate: &'a Release,
55    /// The existing release at the target environment (if any).
56    pub current: Option<&'a Release>,
57}
58
59/// A single rule violation.
60#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
61pub struct CompatViolation {
62    /// Which rule was violated.
63    pub rule: CompatRule,
64    /// Human-readable explanation.
65    pub reason: String,
66}
67
68/// The outcome of evaluating a compat rule set against a promote context.
69#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
70pub struct CompatVerdict {
71    /// Violations found (empty when passed).
72    pub violations: Vec<CompatViolation>,
73}
74
75impl CompatVerdict {
76    /// Whether the verdict passed (no violations).
77    pub fn passed(&self) -> bool {
78        self.violations.is_empty()
79    }
80}
81
82/// Evaluate a [`PromoteContext`] against a [`CompatRuleSet`], returning a [`CompatVerdict`].
83pub 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}