use serde::{Deserialize, Serialize};
use crate::error::RkError;
use super::{ReleaseManifest, ReleaseSource};
pub const COMPATIBILITY_PATH: &str = "compatibility.toml";
pub const GUIDANCE_ROOT: &str = "guidance";
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct Compatibility {
pub schema: u32,
pub engine: Engine,
pub guidance: GuidanceDecl,
pub forge: std::collections::BTreeMap<String, Floor>,
pub intermediate: Vec<Intermediate>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct Engine {
pub minimum: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct GuidanceDecl {
pub since: Option<String>,
pub no_steps: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct Floor {
pub minimum: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct Intermediate {
pub version: String,
pub reason: String,
}
pub fn compatibility(
source: &dyn ReleaseSource,
manifest: &ReleaseManifest,
) -> Result<Compatibility, RkError> {
if manifest.artifact(COMPATIBILITY_PATH).is_none() {
return Ok(Compatibility::default());
}
let bytes = super::read(source, manifest, COMPATIBILITY_PATH)?;
parse_compatibility(&String::from_utf8_lossy(&bytes))
}
pub fn parse_compatibility(text: &str) -> Result<Compatibility, RkError> {
toml::from_str(text).map_err(|error| anyhow::anyhow!("{COMPATIBILITY_PATH}: {error}").into())
}
pub const GUIDANCE_ACTIONS: [&str; 2] = ["operator-step", "plan-operation"];
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GuidanceFile {
pub version: String,
pub title: String,
pub destinations: Vec<String>,
pub action: String,
pub body: String,
}
#[must_use]
pub fn carries_guidance(manifest: &ReleaseManifest) -> bool {
manifest.under(GUIDANCE_ROOT).next().is_some()
}
pub fn guidance(
source: &dyn ReleaseSource,
manifest: &ReleaseManifest,
) -> Result<Vec<GuidanceFile>, RkError> {
let mut files = Vec::new();
for (rest, artifact) in manifest.under(GUIDANCE_ROOT) {
let Some(stem) = rest.strip_suffix(".md") else {
continue;
};
if stem == "README" || stem.contains('/') {
continue;
}
let bytes = source.blob(&artifact.sha256)?;
files.push(parse_guidance(stem, &String::from_utf8_lossy(&bytes))?);
}
files.sort_by_key(|file| version_key(&file.version));
Ok(files)
}
pub fn parse_guidance(version: &str, text: &str) -> Result<GuidanceFile, RkError> {
let fail =
|what: &str| -> RkError { anyhow::anyhow!("{GUIDANCE_ROOT}/{version}.md: {what}").into() };
if version_key(version).is_none() {
return Err(fail("the file name is not a version"));
}
let mut lines = text.lines();
let title = lines
.by_ref()
.find(|line| !line.trim().is_empty())
.and_then(|line| line.strip_prefix("# "))
.ok_or_else(|| fail("the first line is not a `# ` heading"))?
.trim()
.to_owned();
let mut destinations: Option<Vec<String>> = None;
let mut action: Option<String> = None;
let mut body = String::new();
let mut in_fields = true;
for line in lines {
if in_fields {
if line.trim().is_empty() {
continue;
}
if let Some(field) = line.strip_prefix("- ") {
if let Some((key, value)) = field.split_once(':') {
match key.trim() {
"destinations" => {
destinations = Some(
value
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned)
.collect(),
);
continue;
}
"action" => {
action = Some(value.trim().to_owned());
continue;
}
_ => {}
}
}
}
in_fields = false;
}
body.push_str(line);
body.push('\n');
}
let destinations = destinations.ok_or_else(|| fail("no `- destinations:` field"))?;
if destinations.is_empty() {
return Err(fail("the destinations field names no path"));
}
let action = action.ok_or_else(|| fail("no `- action:` field"))?;
if !GUIDANCE_ACTIONS.contains(&action.as_str()) {
return Err(fail(&format!(
"action `{action}` is not one of {}",
GUIDANCE_ACTIONS.join(", ")
)));
}
Ok(GuidanceFile {
version: version.to_owned(),
title,
destinations,
action,
body: body.trim().to_owned(),
})
}
#[must_use]
pub fn version_key(version: &str) -> Option<Vec<u64>> {
let core = version.strip_prefix('v').unwrap_or(version);
let core = core.split(['-', '+']).next().unwrap_or(core);
let parts: Vec<u64> = core
.split('.')
.map(|part| part.parse::<u64>().ok())
.collect::<Option<Vec<u64>>>()?;
(!parts.is_empty()).then_some(parts)
}
#[must_use]
pub fn version_below(a: &str, b: &str) -> bool {
match (version_key(a), version_key(b)) {
(Some(a), Some(b)) => a < b,
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::{Compatibility, parse_compatibility, parse_guidance, version_below, version_key};
#[test]
fn a_bundle_without_compatibility_requires_only_its_schema() {
let empty = Compatibility::default();
assert!(empty.engine.minimum.is_none());
assert!(empty.guidance.since.is_none());
assert!(empty.forge.is_empty());
assert!(empty.intermediate.is_empty());
let parsed = parse_compatibility("schema = 1\n").expect("a bare file parses");
assert_eq!(parsed.engine, empty.engine);
assert_eq!(parsed.forge, empty.forge);
assert_eq!(parsed.intermediate, empty.intermediate);
let full = parse_compatibility(
"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",
)
.expect("a full file parses");
assert_eq!(full.engine.minimum.as_deref(), Some("0.4.0"));
assert_eq!(full.guidance.since.as_deref(), Some("0.3.0"));
assert_eq!(full.guidance.no_steps, ["0.3.1"]);
assert_eq!(full.forge["gitlab"].minimum.as_deref(), Some("18.2"));
assert_eq!(full.intermediate[0].version, "0.5.0");
}
#[test]
fn this_bundles_compatibility_parses() {
let parsed = parse_compatibility(crate::embedded::COMPATIBILITY).expect("this file parses");
assert_eq!(parsed.schema, 1);
assert!(parsed.guidance.since.is_some());
assert_eq!(parsed.forge["gitlab"].minimum.as_deref(), Some("18.2"));
}
#[test]
fn a_guidance_file_parses_its_fields_and_body() {
let file = parse_guidance(
"0.3.19",
"# release-kit 0.3.19\n\n- destinations: .envrc, flake.nix\n- action: operator-step\n\n## What changed\n\nText.\n",
)
.expect("the file parses");
assert_eq!(file.title, "release-kit 0.3.19");
assert_eq!(file.destinations, [".envrc", "flake.nix"]);
assert_eq!(file.action, "operator-step");
assert!(file.body.starts_with("## What changed"));
assert!(parse_guidance("0.3.19", "# t\n\n- action: operator-step\n").is_err());
assert!(
parse_guidance(
"0.3.19",
"# t\n\n- destinations: \n- action: operator-step\n"
)
.is_err()
);
assert!(parse_guidance("0.3.19", "# t\n\n- destinations: a\n- action: shrug\n").is_err());
assert!(
parse_guidance(
"next",
"# t\n\n- destinations: a\n- action: operator-step\n"
)
.is_err()
);
}
#[test]
fn versions_compare_by_their_numbers() {
assert_eq!(version_key("v1.2.3-rc.1"), Some(vec![1, 2, 3]));
assert_eq!(version_key("18.2"), Some(vec![18, 2]));
assert_eq!(version_key("latest"), None);
assert!(version_below("0.3.18", "0.3.19"));
assert!(version_below("18.1.9", "18.2"));
assert!(!version_below("18.2.0", "18.2"));
assert!(!version_below("latest", "1.0.0"));
}
}