1use serde::{Deserialize, Serialize};
12
13use crate::error::RkError;
14
15use super::{ReleaseManifest, ReleaseSource};
16
17pub const COMPATIBILITY_PATH: &str = "compatibility.toml";
19
20pub const GUIDANCE_ROOT: &str = "guidance";
22
23#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(default)]
26pub struct Compatibility {
27 pub schema: u32,
29 pub engine: Engine,
31 pub guidance: GuidanceDecl,
33 pub forge: std::collections::BTreeMap<String, Floor>,
35 pub intermediate: Vec<Intermediate>,
37}
38
39#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(default)]
42pub struct Engine {
43 pub minimum: Option<String>,
45}
46
47#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(default)]
50pub struct GuidanceDecl {
51 pub since: Option<String>,
54 pub no_steps: Vec<String>,
56}
57
58#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(default)]
61pub struct Floor {
62 pub minimum: Option<String>,
64}
65
66#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(default)]
69pub struct Intermediate {
70 pub version: String,
72 pub reason: String,
74}
75
76pub 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
94pub fn parse_compatibility(text: &str) -> Result<Compatibility, RkError> {
100 toml::from_str(text).map_err(|error| anyhow::anyhow!("{COMPATIBILITY_PATH}: {error}").into())
101}
102
103pub const GUIDANCE_ACTIONS: [&str; 2] = ["operator-step", "plan-operation"];
105
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108pub struct GuidanceFile {
109 pub version: String,
111 pub title: String,
113 pub destinations: Vec<String>,
115 pub action: String,
117 pub body: String,
119}
120
121#[must_use]
125pub fn carries_guidance(manifest: &ReleaseManifest) -> bool {
126 manifest.under(GUIDANCE_ROOT).next().is_some()
127}
128
129pub 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
154pub 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 && let Some((key, value)) = field.split_once(':')
186 {
187 match key.trim() {
188 "destinations" => {
189 destinations = Some(
190 value
191 .split(',')
192 .map(str::trim)
193 .filter(|s| !s.is_empty())
194 .map(str::to_owned)
195 .collect(),
196 );
197 continue;
198 }
199 "action" => {
200 action = Some(value.trim().to_owned());
201 continue;
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#[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#[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}