1use serde::{Deserialize, Serialize};
13use thiserror::Error;
14
15use crate::domain::version::CanonVersion;
16use crate::plan::readiness::{Evaluation, Precondition, Requirement};
17
18pub const DECLARATION_PATH: &str = "instance/compatibility.toml";
20
21pub const SCHEMA: &str = "sdd.compatibility/1";
23
24#[derive(Debug, Error, PartialEq, Eq)]
26pub enum CompatibilityError {
27 #[error("{DECLARATION_PATH} does not parse: {0}")]
29 Malformed(String),
30
31 #[error("{DECLARATION_PATH} declares schema {0}, and this engine reads {SCHEMA}")]
33 UnknownSchema(String),
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(deny_unknown_fields)]
39pub struct Compatibility {
40 pub schema: String,
42 pub minimum_engine: CanonVersion,
44 #[serde(default)]
46 pub must_pass_through: Vec<CanonVersion>,
47}
48
49impl Compatibility {
50 pub fn parse(bytes: &[u8]) -> Result<Self, CompatibilityError> {
57 let text = std::str::from_utf8(bytes)
58 .map_err(|source| CompatibilityError::Malformed(source.to_string()))?;
59 let held: Self = toml::from_str(text)
60 .map_err(|source| CompatibilityError::Malformed(source.to_string()))?;
61 if held.schema != SCHEMA {
62 return Err(CompatibilityError::UnknownSchema(held.schema));
63 }
64 Ok(held)
65 }
66}
67
68#[derive(Debug, Clone)]
70pub struct Interval {
71 pub engine: CanonVersion,
73 pub recorded: Option<CanonVersion>,
75 pub destination: CanonVersion,
77}
78
79#[must_use]
86pub fn preconditions(held: &Compatibility, interval: &Interval) -> Vec<Precondition> {
87 let mut preconditions = Vec::new();
88 if interval.engine < held.minimum_engine {
89 preconditions.push(Precondition {
90 id: "engine-is-new-enough".to_string(),
91 statement: format!("this release needs sdd {} or newer", held.minimum_engine),
92 requirement: Requirement::Required,
93 evaluation: Evaluation::Unsatisfied {
94 reason: format!(
95 "this engine is {} and {} needs {}; install a newer sdd",
96 interval.engine, interval.destination, held.minimum_engine
97 ),
98 },
99 resolved_by: None,
100 evidence_refs: vec!["release".to_string()],
101 });
102 }
103
104 if let Some(recorded) = interval.recorded {
105 if recorded > interval.destination {
106 preconditions.push(Precondition {
107 id: "the-target-is-not-downgraded".to_string(),
108 statement: "the destination is not older than what the target records".to_string(),
109 requirement: Requirement::Required,
110 evaluation: Evaluation::Unsatisfied {
111 reason: format!(
112 "the target records {recorded} and the destination is {}; a target is never downgraded",
113 interval.destination
114 ),
115 },
116 resolved_by: None,
117 evidence_refs: vec!["record".to_string()],
118 });
119 }
120 let skipped: Vec<String> = held
124 .must_pass_through
125 .iter()
126 .filter(|version| **version > recorded && **version < interval.destination)
127 .map(std::string::ToString::to_string)
128 .collect();
129 if !skipped.is_empty() {
130 preconditions.push(Precondition {
131 id: "no-version-is-skipped".to_string(),
132 statement: "the interval passes through every version that requires it".to_string(),
133 requirement: Requirement::Required,
134 evaluation: Evaluation::Unsatisfied {
135 reason: format!(
136 "{} must be passed through; plan --to {} first",
137 skipped.join(", "),
138 skipped.first().cloned().unwrap_or_default()
139 ),
140 },
141 resolved_by: None,
142 evidence_refs: vec!["release".to_string()],
143 });
144 }
145 }
146 preconditions
147}
148
149#[cfg(test)]
150mod tests {
151 #![allow(
152 clippy::unwrap_used,
153 reason = "a test panics as its failure signal, not as control flow"
154 )]
155
156 use super::*;
157
158 fn version(value: &str) -> CanonVersion {
159 value.parse().unwrap()
160 }
161
162 fn declaration(minimum: &str, through: &[&str]) -> Compatibility {
163 Compatibility {
164 schema: SCHEMA.to_string(),
165 minimum_engine: version(minimum),
166 must_pass_through: through.iter().map(|held| version(held)).collect(),
167 }
168 }
169
170 fn interval(engine: &str, recorded: Option<&str>, destination: &str) -> Interval {
171 Interval {
172 engine: version(engine),
173 recorded: recorded.map(version),
174 destination: version(destination),
175 }
176 }
177
178 #[test]
179 fn the_smallest_true_declaration_requires_only_its_engine() {
180 let held =
181 Compatibility::parse(b"schema = \"sdd.compatibility/1\"\nminimum_engine = \"0.9.0\"\n")
182 .unwrap();
183 assert_eq!(held.minimum_engine, version("0.9.0"));
184 assert!(held.must_pass_through.is_empty());
185 assert!(preconditions(&held, &interval("0.9.0", None, "0.9.0")).is_empty());
186 }
187
188 #[test]
189 fn an_unknown_schema_or_shape_refuses() {
190 assert!(matches!(
191 Compatibility::parse(b"schema = \"sdd.compatibility/9\"\nminimum_engine = \"0.9.0\"\n")
192 .unwrap_err(),
193 CompatibilityError::UnknownSchema(_)
194 ));
195 assert!(matches!(
196 Compatibility::parse(b"minimum_engine = \"0.9.0\"\n").unwrap_err(),
197 CompatibilityError::Malformed(_)
198 ));
199 assert!(matches!(
200 Compatibility::parse(
201 b"schema = \"sdd.compatibility/1\"\nminimum_engine = \"0.9.0\"\nextra = 1\n"
202 )
203 .unwrap_err(),
204 CompatibilityError::Malformed(_)
205 ));
206 }
207
208 #[test]
209 fn an_engine_below_requirement_is_blocked_naming_the_engine() {
210 let held = declaration("0.9.0", &[]);
211 let found = preconditions(&held, &interval("0.8.1", Some("0.8.0"), "0.9.0"));
212 assert_eq!(found.len(), 1);
213 assert_eq!(found[0].id, "engine-is-new-enough");
214 assert_eq!(found[0].requirement, Requirement::Required);
215 assert_eq!(
216 found[0].verdict(),
217 crate::plan::readiness::Readiness::Blocked
218 );
219 }
220
221 #[test]
222 fn a_skipped_intermediate_version_is_blocked_naming_it() {
223 let held = declaration("0.1.0", &["0.8.0"]);
224 let found = preconditions(&held, &interval("1.0.0", Some("0.7.0"), "0.9.0"));
225 assert_eq!(found.len(), 1);
226 assert_eq!(found[0].id, "no-version-is-skipped");
227 let reason = format!("{:?}", found[0].evaluation);
228 assert!(reason.contains("0.8.0"), "{reason}");
229 assert!(reason.contains("--to 0.8.0"), "{reason}");
230 }
231
232 #[test]
233 fn a_version_outside_the_interval_is_not_skipped() {
234 let held = declaration("0.1.0", &["0.5.0", "0.9.5"]);
235 assert!(preconditions(&held, &interval("1.0.0", Some("0.7.0"), "0.9.0")).is_empty());
236 }
237
238 #[test]
239 fn a_downgrade_is_blocked() {
240 let held = declaration("0.1.0", &[]);
241 let found = preconditions(&held, &interval("1.0.0", Some("0.9.0"), "0.8.0"));
242 assert_eq!(found.len(), 1);
243 assert_eq!(found[0].id, "the-target-is-not-downgraded");
244 }
245}