Skip to main content

spec_driven_docs/plan/
compatibility.rs

1//! What a release needs of the engine that lands it.
2//!
3//! A bundle can be authentic, intact, and still wrong for this engine: it
4//! may need a newer one, or a version the interval must pass through.
5//! Both are declared by the release rather than inferred, because only the
6//! release knows what it asked of the tool that wrote it.
7//!
8//! The declaration is required. An absence that means "no requirement"
9//! cannot be told from an absence that means somebody forgot, so a
10//! schema-one bundle without the file is invalid and says which file.
11
12use serde::{Deserialize, Serialize};
13use thiserror::Error;
14
15use crate::domain::version::CanonVersion;
16use crate::plan::readiness::{Evaluation, Precondition, Requirement};
17
18/// Where the declaration sits inside a bundle.
19pub const DECLARATION_PATH: &str = "instance/compatibility.toml";
20
21/// The schema this engine reads.
22pub const SCHEMA: &str = "sdd.compatibility/1";
23
24/// A declaration this engine cannot read.
25#[derive(Debug, Error, PartialEq, Eq)]
26pub enum CompatibilityError {
27    /// The bytes are not the declaration's shape.
28    #[error("{DECLARATION_PATH} does not parse: {0}")]
29    Malformed(String),
30
31    /// The declaration is written in a schema this engine does not read.
32    #[error("{DECLARATION_PATH} declares schema {0}, and this engine reads {SCHEMA}")]
33    UnknownSchema(String),
34}
35
36/// What one release needs of its engine.
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(deny_unknown_fields)]
39pub struct Compatibility {
40    /// Always [`SCHEMA`] once parsed.
41    pub schema: String,
42    /// The lowest engine that can land this release.
43    pub minimum_engine: CanonVersion,
44    /// Versions an upgrade may not skip.
45    #[serde(default)]
46    pub must_pass_through: Vec<CanonVersion>,
47}
48
49impl Compatibility {
50    /// Read one declaration.
51    ///
52    /// # Errors
53    ///
54    /// [`CompatibilityError`] when the bytes do not parse or the schema is
55    /// one this engine does not read.
56    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/// What the plan checks the compatibility against.
69#[derive(Debug, Clone)]
70pub struct Interval {
71    /// The engine running this plan.
72    pub engine: CanonVersion,
73    /// The release the target records, where it records one.
74    pub recorded: Option<CanonVersion>,
75    /// The release the plan converges toward.
76    pub destination: CanonVersion,
77}
78
79/// Every compatibility precondition one interval carries.
80///
81/// Three axes, each blocked where it fails, because none of them is a
82/// judgement call: an engine too old cannot decode the bundle, a skipped
83/// version was declared unskippable by the release that needs it, and a
84/// target is never downgraded.
85#[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        // A version the release says must be passed through is a small
121        // step the operator takes deliberately. The engine names it and
122        // never takes it on the operator's behalf.
123        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}