use std::collections::BTreeMap;
use camino::Utf8Path;
use serde::{Deserialize, Serialize};
use crate::atomic;
use crate::diagnostic::{Diagnostic, Reason};
use crate::digest::Digest;
use crate::error::RkError;
use crate::landing::Kind;
pub const MANIFEST_PATH: &str = ".release-kit/manifest.json";
pub const SCHEMA_VERSION: u64 = 1;
#[derive(Debug, Serialize, Deserialize)]
pub struct Manifest {
pub schema_version: u64,
pub rk_version: String,
pub payload_sha256: Digest,
pub origin: String,
pub tech: String,
pub forge: String,
pub landed_at: String,
pub parameters: Parameters,
pub files: Vec<FileRecord>,
pub pins: BTreeMap<String, String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Parameters {
pub repo: String,
#[serde(default)]
pub scopes: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct FileRecord {
pub destination: String,
pub kind: Kind,
pub sha256: Digest,
#[serde(skip_serializing_if = "Option::is_none")]
pub baseline_sha256: Option<Digest>,
}
impl Manifest {
#[must_use]
pub fn file(&self, destination: &str) -> Option<&FileRecord> {
self.files
.iter()
.find(|file| file.destination == destination)
}
}
pub fn load(target: &Utf8Path) -> Result<Option<Manifest>, RkError> {
let path = target.join(MANIFEST_PATH);
let bytes = match std::fs::read(&path) {
Ok(bytes) => bytes,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => {
return Err(RkError::refusal(
Diagnostic::new(Reason::Io, format!("cannot read {path}: {e}"))
.expected("a readable landing record")
.target_state("unchanged"),
));
}
};
let value: serde_json::Value = serde_json::from_slice(&bytes)
.map_err(|e| anyhow::anyhow!("{path} is not a landing record: {e}"))?;
let schema = value
.get("schema_version")
.and_then(serde_json::Value::as_u64);
if schema != Some(SCHEMA_VERSION) {
let found = schema.map_or_else(|| "none".to_owned(), |version| version.to_string());
return Err(RkError::refusal(
Diagnostic::new(
Reason::UnsupportedSchema,
format!(
"{path} declares schema_version {found}, and this binary knows only {SCHEMA_VERSION}"
),
)
.expected("a record this binary can read")
.action("run the rk release that wrote this record, or a newer one")
.target_state("unchanged"),
));
}
let manifest: Manifest = serde_json::from_value(value)
.map_err(|e| anyhow::anyhow!("{path} does not parse at schema_version 1: {e}"))?;
Ok(Some(manifest))
}
pub fn write(target: &Utf8Path, manifest: &Manifest) -> Result<(), RkError> {
let text = serde_json::to_string_pretty(manifest).map_err(anyhow::Error::from)?;
let path = target.join(MANIFEST_PATH);
atomic::write(path.as_std_path(), format!("{text}\n").as_bytes())?;
Ok(())
}
#[must_use]
pub fn now() -> String {
humantime::format_rfc3339_seconds(std::time::SystemTime::now()).to_string()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum Alignment {
Aligned,
BinaryNewer,
TargetNewer,
}
impl Alignment {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Aligned => "aligned",
Self::BinaryNewer => "binary-newer",
Self::TargetNewer => "target-newer",
}
}
}
#[must_use]
pub fn alignment(recorded: &str, binary: &str) -> Alignment {
let recorded = recorded
.split_once('+')
.map_or(recorded, |(version, _)| version);
let binary = binary
.split_once('+')
.map_or(binary, |(version, _)| version);
let recorded_core = numeric_core(recorded);
let binary_core = numeric_core(binary);
match binary_core.cmp(&recorded_core) {
std::cmp::Ordering::Greater => Alignment::BinaryNewer,
std::cmp::Ordering::Less => Alignment::TargetNewer,
std::cmp::Ordering::Equal => {
let recorded_pre = recorded.split_once('-').map(|(_, pre)| pre);
let binary_pre = binary.split_once('-').map(|(_, pre)| pre);
match (recorded_pre, binary_pre) {
(Some(_), None) => Alignment::BinaryNewer,
(None, Some(_)) => Alignment::TargetNewer,
(None, None) => Alignment::Aligned,
(Some(r), Some(b)) => match prerelease_cmp(b, r) {
std::cmp::Ordering::Greater => Alignment::BinaryNewer,
std::cmp::Ordering::Less => Alignment::TargetNewer,
std::cmp::Ordering::Equal => Alignment::Aligned,
},
}
}
}
}
#[must_use]
pub fn version_is_newer(candidate: &str, pinned: &str) -> bool {
alignment(pinned, candidate) == Alignment::BinaryNewer
}
fn prerelease_cmp(a: &str, b: &str) -> std::cmp::Ordering {
let numeric = |identifier: &str| identifier.bytes().all(|byte| byte.is_ascii_digit());
let mut left = a.split('.');
let mut right = b.split('.');
loop {
match (left.next(), right.next()) {
(None, None) => return std::cmp::Ordering::Equal,
(None, Some(_)) => return std::cmp::Ordering::Less,
(Some(_), None) => return std::cmp::Ordering::Greater,
(Some(x), Some(y)) => {
let ordering = match (numeric(x), numeric(y)) {
(true, true) => x.len().cmp(&y.len()).then_with(|| x.cmp(y)),
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
(false, false) => x.cmp(y),
};
if ordering != std::cmp::Ordering::Equal {
return ordering;
}
}
}
}
}
fn numeric_core(version: &str) -> Vec<u64> {
let core = version.split_once('-').map_or(version, |(core, _)| core);
core.split('.')
.map(|part| part.parse::<u64>().unwrap_or(0))
.collect()
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used)]
use super::{Alignment, FileRecord, Manifest, Parameters, alignment};
use crate::digest::Digest;
use crate::landing::Kind;
#[test]
fn the_manifest_schema_snapshot_holds() {
let manifest = Manifest {
schema_version: 1,
rk_version: "0.1.0".into(),
payload_sha256: Digest::of(b""),
origin: "init".into(),
tech: "rust".into(),
forge: "github".into(),
landed_at: "2026-08-29T00:00:00Z".into(),
parameters: Parameters {
repo: "acme/widget".into(),
scopes: vec!["api".into(), "cli".into()],
},
files: vec![
FileRecord {
destination: "release-plz.toml".into(),
kind: Kind::Seeded,
sha256: Digest::of(b""),
baseline_sha256: Some(Digest::of(b"")),
},
FileRecord {
destination: "VERSION".into(),
kind: Kind::State,
sha256: Digest::of(b""),
baseline_sha256: None,
},
],
pins: std::iter::once(("release-plz".to_owned(), "0.3.160".to_owned())).collect(),
};
let empty = Digest::of(b"").to_string();
assert_eq!(
serde_json::to_string(&manifest).expect("a manifest serializes"),
format!(
r#"{{"schema_version":1,"rk_version":"0.1.0","payload_sha256":"{empty}","origin":"init","tech":"rust","forge":"github","landed_at":"2026-08-29T00:00:00Z","parameters":{{"repo":"acme/widget","scopes":["api","cli"]}},"files":[{{"destination":"release-plz.toml","kind":"seeded","sha256":"{empty}","baseline_sha256":"{empty}"}},{{"destination":"VERSION","kind":"state","sha256":"{empty}"}}],"pins":{{"release-plz":"0.3.160"}}}}"#
),
"a state file must omit baseline_sha256 rather than serializing null"
);
}
#[test]
fn alignment_orders_versions_numerically() {
assert_eq!(alignment("0.1.0", "0.1.0"), Alignment::Aligned);
assert_eq!(alignment("0.1.0", "0.2.0"), Alignment::BinaryNewer);
assert_eq!(alignment("0.10.0", "0.9.9"), Alignment::TargetNewer);
assert_eq!(alignment("0.1.0-rc.1", "0.1.0"), Alignment::BinaryNewer);
assert_eq!(alignment("0.1.0", "0.1.0-rc.1"), Alignment::TargetNewer);
}
#[test]
fn alignment_orders_numeric_prerelease_identifiers_numerically() {
assert_eq!(
alignment("0.1.0-rc.10", "0.1.0-rc.2"),
Alignment::TargetNewer
);
assert_eq!(
alignment("0.1.0-rc.2", "0.1.0-rc.10"),
Alignment::BinaryNewer
);
assert_eq!(alignment("0.1.0-rc.1", "0.1.0-rc.1"), Alignment::Aligned);
assert_eq!(
alignment("0.1.0-alpha", "0.1.0-alpha.1"),
Alignment::BinaryNewer
);
assert_eq!(alignment("0.1.0-1", "0.1.0-alpha"), Alignment::BinaryNewer);
assert_eq!(
alignment("1.0.0-100000000000000000000", "1.0.0-99999999999999999999"),
Alignment::TargetNewer,
"identifiers past the u64 range still compare numerically"
);
assert_eq!(
alignment("1.0.0-99999999999999999999", "1.0.0-100000000000000000000"),
Alignment::BinaryNewer
);
}
#[test]
fn alignment_ignores_build_metadata() {
assert_eq!(alignment("1.2.10+build", "1.2.9"), Alignment::TargetNewer);
assert_eq!(alignment("1.2.9", "1.2.10+build"), Alignment::BinaryNewer);
assert_eq!(alignment("1.0.0+alpha", "1.0.0+beta"), Alignment::Aligned);
assert_eq!(
alignment("1.2.10-rc.1+build", "1.2.10-rc.1"),
Alignment::Aligned
);
assert_eq!(
alignment("1.2.10-rc.1+build", "1.2.10"),
Alignment::BinaryNewer
);
}
}