Skip to main content

release_kit/release/
declared.rs

1//! What a bundle declares beyond its bytes: the compatibility file and
2//! the guidance files, read through the seam and parsed once.
3//!
4//! `compatibility.toml` is small by design. A bundle that carries none
5//! declares no requirement beyond the payload schema, which is what
6//! [`Compatibility::default`] says. A guidance file is one release's
7//! operator step, named by the version that introduces it and carrying
8//! the destinations it concerns, so the planner can filter it against a
9//! target.
10
11use serde::{Deserialize, Serialize};
12
13use crate::error::RkError;
14
15use super::{ReleaseManifest, ReleaseSource};
16
17/// The compatibility file's path inside a bundle.
18pub const COMPATIBILITY_PATH: &str = "compatibility.toml";
19
20/// The guidance root inside a bundle.
21pub const GUIDANCE_ROOT: &str = "guidance";
22
23/// What the bundle needs beyond the payload schema.
24#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(default)]
26pub struct Compatibility {
27    /// The declaration's own shape version.
28    pub schema: u32,
29    /// The engine axis.
30    pub engine: Engine,
31    /// The guidance coverage the bundle claims.
32    pub guidance: GuidanceDecl,
33    /// Per-forge floors, by forge name.
34    pub forge: std::collections::BTreeMap<String, Floor>,
35    /// Releases a landing must pass through.
36    pub intermediate: Vec<Intermediate>,
37}
38
39/// The engine axis: the oldest engine that may land this bundle.
40#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(default)]
42pub struct Engine {
43    /// The minimum engine version, where the schema alone is too coarse.
44    pub minimum: Option<String>,
45}
46
47/// The guidance coverage the bundle claims.
48#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(default)]
50pub struct GuidanceDecl {
51    /// The release above which every release is described: it ships a
52    /// guidance file or needs no step.
53    pub since: Option<String>,
54    /// Releases that changed a landed destination and needed no step.
55    pub no_steps: Vec<String>,
56}
57
58/// One forge's version floor.
59#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(default)]
61pub struct Floor {
62    /// The floor, as `major.minor`.
63    pub minimum: Option<String>,
64}
65
66/// One release a landing must pass through.
67#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(default)]
69pub struct Intermediate {
70    /// The version.
71    pub version: String,
72    /// Why it cannot be skipped.
73    pub reason: String,
74}
75
76/// Read the compatibility file through the seam, or the default where the
77/// bundle carries none.
78///
79/// # Errors
80///
81/// Returns the source's failure where the artifact exists and its bytes
82/// cannot be read, and [`RkError::Other`] where they do not parse.
83pub fn compatibility(
84    source: &dyn ReleaseSource,
85    manifest: &ReleaseManifest,
86) -> Result<Compatibility, RkError> {
87    if manifest.artifact(COMPATIBILITY_PATH).is_none() {
88        return Ok(Compatibility::default());
89    }
90    let bytes = super::read(source, manifest, COMPATIBILITY_PATH)?;
91    parse_compatibility(&String::from_utf8_lossy(&bytes))
92}
93
94/// Parse the compatibility file's text.
95///
96/// # Errors
97///
98/// Returns [`RkError::Other`] where the text is not the declared shape.
99pub fn parse_compatibility(text: &str) -> Result<Compatibility, RkError> {
100    toml::from_str(text).map_err(|error| anyhow::anyhow!("{COMPATIBILITY_PATH}: {error}").into())
101}
102
103/// The closed set of actions a guidance file may name.
104pub const GUIDANCE_ACTIONS: [&str; 2] = ["operator-step", "plan-operation"];
105
106/// One release's guidance, parsed.
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108pub struct GuidanceFile {
109    /// The version that introduces the change, from the file name.
110    pub version: String,
111    /// The heading.
112    pub title: String,
113    /// The landed paths the step concerns.
114    pub destinations: Vec<String>,
115    /// `operator-step` or `plan-operation`.
116    pub action: String,
117    /// The body below the field list.
118    pub body: String,
119}
120
121/// Whether the bundle carries a guidance root at all. A bundle from
122/// before the root existed carries none, and its history is unavailable
123/// rather than empty.
124#[must_use]
125pub fn carries_guidance(manifest: &ReleaseManifest) -> bool {
126    manifest.under(GUIDANCE_ROOT).next().is_some()
127}
128
129/// Every guidance file the bundle carries, parsed, sorted by version.
130///
131/// # Errors
132///
133/// Returns the source's failure for bytes that cannot be read, and
134/// [`RkError::Other`] for a file that does not parse.
135pub fn guidance(
136    source: &dyn ReleaseSource,
137    manifest: &ReleaseManifest,
138) -> Result<Vec<GuidanceFile>, RkError> {
139    let mut files = Vec::new();
140    for (rest, artifact) in manifest.under(GUIDANCE_ROOT) {
141        let Some(stem) = rest.strip_suffix(".md") else {
142            continue;
143        };
144        if stem == "README" || stem.contains('/') {
145            continue;
146        }
147        let bytes = source.blob(&artifact.sha256)?;
148        files.push(parse_guidance(stem, &String::from_utf8_lossy(&bytes))?);
149    }
150    files.sort_by_key(|file| version_key(&file.version));
151    Ok(files)
152}
153
154/// Parse one guidance file.
155///
156/// # Errors
157///
158/// Returns [`RkError::Other`] for a file without a heading, without the
159/// two fields, with an action outside the closed set, or with an empty
160/// destination list.
161pub fn parse_guidance(version: &str, text: &str) -> Result<GuidanceFile, RkError> {
162    let fail =
163        |what: &str| -> RkError { anyhow::anyhow!("{GUIDANCE_ROOT}/{version}.md: {what}").into() };
164    if version_key(version).is_none() {
165        return Err(fail("the file name is not a version"));
166    }
167    let mut lines = text.lines();
168    let title = lines
169        .by_ref()
170        .find(|line| !line.trim().is_empty())
171        .and_then(|line| line.strip_prefix("# "))
172        .ok_or_else(|| fail("the first line is not a `# ` heading"))?
173        .trim()
174        .to_owned();
175    let mut destinations: Option<Vec<String>> = None;
176    let mut action: Option<String> = None;
177    let mut body = String::new();
178    let mut in_fields = true;
179    for line in lines {
180        if in_fields {
181            if line.trim().is_empty() {
182                continue;
183            }
184            if let Some(field) = line.strip_prefix("- ") {
185                if let Some((key, value)) = field.split_once(':') {
186                    match key.trim() {
187                        "destinations" => {
188                            destinations = Some(
189                                value
190                                    .split(',')
191                                    .map(str::trim)
192                                    .filter(|s| !s.is_empty())
193                                    .map(str::to_owned)
194                                    .collect(),
195                            );
196                            continue;
197                        }
198                        "action" => {
199                            action = Some(value.trim().to_owned());
200                            continue;
201                        }
202                        _ => {}
203                    }
204                }
205            }
206            in_fields = false;
207        }
208        body.push_str(line);
209        body.push('\n');
210    }
211    let destinations = destinations.ok_or_else(|| fail("no `- destinations:` field"))?;
212    if destinations.is_empty() {
213        return Err(fail("the destinations field names no path"));
214    }
215    let action = action.ok_or_else(|| fail("no `- action:` field"))?;
216    if !GUIDANCE_ACTIONS.contains(&action.as_str()) {
217        return Err(fail(&format!(
218            "action `{action}` is not one of {}",
219            GUIDANCE_ACTIONS.join(", ")
220        )));
221    }
222    Ok(GuidanceFile {
223        version: version.to_owned(),
224        title,
225        destinations,
226        action,
227        body: body.trim().to_owned(),
228    })
229}
230
231/// A version as a comparable key: the numeric components, with a leading
232/// `v` and any pre-release or build suffix dropped. `None` for text with
233/// no leading number.
234#[must_use]
235pub fn version_key(version: &str) -> Option<Vec<u64>> {
236    let core = version.strip_prefix('v').unwrap_or(version);
237    let core = core.split(['-', '+']).next().unwrap_or(core);
238    let parts: Vec<u64> = core
239        .split('.')
240        .map(|part| part.parse::<u64>().ok())
241        .collect::<Option<Vec<u64>>>()?;
242    (!parts.is_empty()).then_some(parts)
243}
244
245/// Whether `a` is below `b`, as versions. Text that is not a version
246/// compares as not below, so an unreadable value never passes a floor.
247#[must_use]
248pub fn version_below(a: &str, b: &str) -> bool {
249    match (version_key(a), version_key(b)) {
250        (Some(a), Some(b)) => a < b,
251        _ => false,
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use super::{Compatibility, parse_compatibility, parse_guidance, version_below, version_key};
258
259    #[test]
260    fn a_bundle_without_compatibility_requires_only_its_schema() {
261        let empty = Compatibility::default();
262        assert!(empty.engine.minimum.is_none());
263        assert!(empty.guidance.since.is_none());
264        assert!(empty.forge.is_empty());
265        assert!(empty.intermediate.is_empty());
266        let parsed = parse_compatibility("schema = 1\n").expect("a bare file parses");
267        assert_eq!(parsed.engine, empty.engine);
268        assert_eq!(parsed.forge, empty.forge);
269        assert_eq!(parsed.intermediate, empty.intermediate);
270        let full = parse_compatibility(
271            "schema = 1\n[engine]\nminimum = \"0.4.0\"\n[guidance]\nsince = \"0.3.0\"\nno_steps = [\"0.3.1\"]\n[forge.gitlab]\nminimum = \"18.2\"\n[[intermediate]]\nversion = \"0.5.0\"\nreason = \"the record changed shape\"\n",
272        )
273        .expect("a full file parses");
274        assert_eq!(full.engine.minimum.as_deref(), Some("0.4.0"));
275        assert_eq!(full.guidance.since.as_deref(), Some("0.3.0"));
276        assert_eq!(full.guidance.no_steps, ["0.3.1"]);
277        assert_eq!(full.forge["gitlab"].minimum.as_deref(), Some("18.2"));
278        assert_eq!(full.intermediate[0].version, "0.5.0");
279    }
280
281    #[test]
282    fn this_bundles_compatibility_parses() {
283        let parsed = parse_compatibility(crate::embedded::COMPATIBILITY).expect("this file parses");
284        assert_eq!(parsed.schema, 1);
285        assert!(parsed.guidance.since.is_some());
286        assert_eq!(parsed.forge["gitlab"].minimum.as_deref(), Some("18.2"));
287    }
288
289    #[test]
290    fn a_guidance_file_parses_its_fields_and_body() {
291        let file = parse_guidance(
292            "0.3.19",
293            "# release-kit 0.3.19\n\n- destinations: .envrc, flake.nix\n- action: operator-step\n\n## What changed\n\nText.\n",
294        )
295        .expect("the file parses");
296        assert_eq!(file.title, "release-kit 0.3.19");
297        assert_eq!(file.destinations, [".envrc", "flake.nix"]);
298        assert_eq!(file.action, "operator-step");
299        assert!(file.body.starts_with("## What changed"));
300        assert!(parse_guidance("0.3.19", "# t\n\n- action: operator-step\n").is_err());
301        assert!(
302            parse_guidance(
303                "0.3.19",
304                "# t\n\n- destinations: \n- action: operator-step\n"
305            )
306            .is_err()
307        );
308        assert!(parse_guidance("0.3.19", "# t\n\n- destinations: a\n- action: shrug\n").is_err());
309        assert!(
310            parse_guidance(
311                "next",
312                "# t\n\n- destinations: a\n- action: operator-step\n"
313            )
314            .is_err()
315        );
316    }
317
318    #[test]
319    fn versions_compare_by_their_numbers() {
320        assert_eq!(version_key("v1.2.3-rc.1"), Some(vec![1, 2, 3]));
321        assert_eq!(version_key("18.2"), Some(vec![18, 2]));
322        assert_eq!(version_key("latest"), None);
323        assert!(version_below("0.3.18", "0.3.19"));
324        assert!(version_below("18.1.9", "18.2"));
325        assert!(!version_below("18.2.0", "18.2"));
326        assert!(!version_below("latest", "1.0.0"));
327    }
328}