1use std::collections::BTreeMap;
13
14use camino::Utf8Path;
15use serde::{Deserialize, Serialize};
16
17use crate::atomic;
18use crate::diagnostic::{Diagnostic, Reason};
19use crate::digest::Digest;
20use crate::error::RkError;
21use crate::landing::Kind;
22
23pub const MANIFEST_PATH: &str = ".release-kit/manifest.json";
25
26pub const SCHEMA_VERSION: u64 = 1;
28
29#[derive(Debug, Serialize, Deserialize)]
31pub struct Manifest {
32 pub schema_version: u64,
34 pub rk_version: String,
36 pub payload_sha256: Digest,
39 pub origin: String,
41 pub tech: String,
43 pub forge: String,
45 pub landed_at: String,
47 pub parameters: Parameters,
50 pub files: Vec<FileRecord>,
52 pub pins: BTreeMap<String, String>,
55}
56
57#[derive(Debug, Serialize, Deserialize)]
59pub struct Parameters {
60 pub repo: String,
63}
64
65#[derive(Debug, Serialize, Deserialize)]
67pub struct FileRecord {
68 pub destination: String,
70 pub kind: Kind,
72 pub sha256: Digest,
75 #[serde(skip_serializing_if = "Option::is_none")]
84 pub baseline_sha256: Option<Digest>,
85}
86
87impl Manifest {
88 #[must_use]
90 pub fn file(&self, destination: &str) -> Option<&FileRecord> {
91 self.files
92 .iter()
93 .find(|file| file.destination == destination)
94 }
95}
96
97pub fn load(target: &Utf8Path) -> Result<Option<Manifest>, RkError> {
106 let path = target.join(MANIFEST_PATH);
107 let bytes = match std::fs::read(&path) {
108 Ok(bytes) => bytes,
109 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
110 Err(e) => {
111 return Err(RkError::refusal(
112 Diagnostic::new(Reason::Io, format!("cannot read {path}: {e}"))
113 .expected("a readable landing record")
114 .target_state("unchanged"),
115 ));
116 }
117 };
118 let value: serde_json::Value = serde_json::from_slice(&bytes)
119 .map_err(|e| anyhow::anyhow!("{path} is not a landing record: {e}"))?;
120 let schema = value
121 .get("schema_version")
122 .and_then(serde_json::Value::as_u64);
123 if schema != Some(SCHEMA_VERSION) {
124 let found = schema.map_or_else(|| "none".to_owned(), |version| version.to_string());
125 return Err(RkError::refusal(
126 Diagnostic::new(
127 Reason::UnsupportedSchema,
128 format!(
129 "{path} declares schema_version {found}, and this binary knows only {SCHEMA_VERSION}"
130 ),
131 )
132 .expected("a record this binary can read")
133 .action("run the rk release that wrote this record, or a newer one")
134 .target_state("unchanged"),
135 ));
136 }
137 let manifest: Manifest = serde_json::from_value(value)
138 .map_err(|e| anyhow::anyhow!("{path} does not parse at schema_version 1: {e}"))?;
139 Ok(Some(manifest))
140}
141
142pub fn write(target: &Utf8Path, manifest: &Manifest) -> Result<(), RkError> {
148 let text = serde_json::to_string_pretty(manifest).map_err(anyhow::Error::from)?;
149 let path = target.join(MANIFEST_PATH);
150 atomic::write(path.as_std_path(), format!("{text}\n").as_bytes())?;
151 Ok(())
152}
153
154#[must_use]
156pub fn now() -> String {
157 humantime::format_rfc3339_seconds(std::time::SystemTime::now()).to_string()
158}
159
160#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
162#[serde(rename_all = "kebab-case")]
163pub enum Alignment {
164 Aligned,
166 BinaryNewer,
168 TargetNewer,
171}
172
173impl Alignment {
174 #[must_use]
176 pub const fn as_str(self) -> &'static str {
177 match self {
178 Self::Aligned => "aligned",
179 Self::BinaryNewer => "binary-newer",
180 Self::TargetNewer => "target-newer",
181 }
182 }
183}
184
185#[must_use]
187pub fn alignment(recorded: &str, binary: &str) -> Alignment {
188 let recorded = recorded
190 .split_once('+')
191 .map_or(recorded, |(version, _)| version);
192 let binary = binary
193 .split_once('+')
194 .map_or(binary, |(version, _)| version);
195 let recorded_core = numeric_core(recorded);
196 let binary_core = numeric_core(binary);
197 match binary_core.cmp(&recorded_core) {
198 std::cmp::Ordering::Greater => Alignment::BinaryNewer,
199 std::cmp::Ordering::Less => Alignment::TargetNewer,
200 std::cmp::Ordering::Equal => {
201 let recorded_pre = recorded.split_once('-').map(|(_, pre)| pre);
206 let binary_pre = binary.split_once('-').map(|(_, pre)| pre);
207 match (recorded_pre, binary_pre) {
208 (Some(_), None) => Alignment::BinaryNewer,
209 (None, Some(_)) => Alignment::TargetNewer,
210 (None, None) => Alignment::Aligned,
211 (Some(r), Some(b)) => match prerelease_cmp(b, r) {
212 std::cmp::Ordering::Greater => Alignment::BinaryNewer,
213 std::cmp::Ordering::Less => Alignment::TargetNewer,
214 std::cmp::Ordering::Equal => Alignment::Aligned,
215 },
216 }
217 }
218 }
219}
220
221#[must_use]
224pub fn version_is_newer(candidate: &str, pinned: &str) -> bool {
225 alignment(pinned, candidate) == Alignment::BinaryNewer
226}
227
228fn prerelease_cmp(a: &str, b: &str) -> std::cmp::Ordering {
235 let numeric = |identifier: &str| identifier.bytes().all(|byte| byte.is_ascii_digit());
236 let mut left = a.split('.');
237 let mut right = b.split('.');
238 loop {
239 match (left.next(), right.next()) {
240 (None, None) => return std::cmp::Ordering::Equal,
241 (None, Some(_)) => return std::cmp::Ordering::Less,
242 (Some(_), None) => return std::cmp::Ordering::Greater,
243 (Some(x), Some(y)) => {
244 let ordering = match (numeric(x), numeric(y)) {
245 (true, true) => x.len().cmp(&y.len()).then_with(|| x.cmp(y)),
246 (true, false) => std::cmp::Ordering::Less,
247 (false, true) => std::cmp::Ordering::Greater,
248 (false, false) => x.cmp(y),
249 };
250 if ordering != std::cmp::Ordering::Equal {
251 return ordering;
252 }
253 }
254 }
255 }
256}
257
258fn numeric_core(version: &str) -> Vec<u64> {
260 let core = version.split_once('-').map_or(version, |(core, _)| core);
261 core.split('.')
262 .map(|part| part.parse::<u64>().unwrap_or(0))
263 .collect()
264}
265
266#[cfg(test)]
267mod tests {
268 #![allow(clippy::expect_used)]
269
270 use super::{Alignment, FileRecord, Manifest, Parameters, alignment};
271 use crate::digest::Digest;
272 use crate::landing::Kind;
273
274 #[test]
278 fn the_manifest_schema_snapshot_holds() {
279 let manifest = Manifest {
280 schema_version: 1,
281 rk_version: "0.1.0".into(),
282 payload_sha256: Digest::of(b""),
283 origin: "init".into(),
284 tech: "rust".into(),
285 forge: "github".into(),
286 landed_at: "2026-08-29T00:00:00Z".into(),
287 parameters: Parameters {
288 repo: "acme/widget".into(),
289 },
290 files: vec![
291 FileRecord {
292 destination: "release-plz.toml".into(),
293 kind: Kind::Seeded,
294 sha256: Digest::of(b""),
295 baseline_sha256: Some(Digest::of(b"")),
296 },
297 FileRecord {
298 destination: "VERSION".into(),
299 kind: Kind::State,
300 sha256: Digest::of(b""),
301 baseline_sha256: None,
302 },
303 ],
304 pins: std::iter::once(("release-plz".to_owned(), "0.3.160".to_owned())).collect(),
305 };
306 let empty = Digest::of(b"").to_string();
307 assert_eq!(
308 serde_json::to_string(&manifest).expect("a manifest serializes"),
309 format!(
310 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"}},"files":[{{"destination":"release-plz.toml","kind":"seeded","sha256":"{empty}","baseline_sha256":"{empty}"}},{{"destination":"VERSION","kind":"state","sha256":"{empty}"}}],"pins":{{"release-plz":"0.3.160"}}}}"#
311 ),
312 "a state file must omit baseline_sha256 rather than serializing null"
313 );
314 }
315
316 #[test]
317 fn alignment_orders_versions_numerically() {
318 assert_eq!(alignment("0.1.0", "0.1.0"), Alignment::Aligned);
319 assert_eq!(alignment("0.1.0", "0.2.0"), Alignment::BinaryNewer);
320 assert_eq!(alignment("0.10.0", "0.9.9"), Alignment::TargetNewer);
321 assert_eq!(alignment("0.1.0-rc.1", "0.1.0"), Alignment::BinaryNewer);
322 assert_eq!(alignment("0.1.0", "0.1.0-rc.1"), Alignment::TargetNewer);
323 }
324
325 #[test]
330 fn alignment_orders_numeric_prerelease_identifiers_numerically() {
331 assert_eq!(
332 alignment("0.1.0-rc.10", "0.1.0-rc.2"),
333 Alignment::TargetNewer
334 );
335 assert_eq!(
336 alignment("0.1.0-rc.2", "0.1.0-rc.10"),
337 Alignment::BinaryNewer
338 );
339 assert_eq!(alignment("0.1.0-rc.1", "0.1.0-rc.1"), Alignment::Aligned);
340 assert_eq!(
341 alignment("0.1.0-alpha", "0.1.0-alpha.1"),
342 Alignment::BinaryNewer
343 );
344 assert_eq!(alignment("0.1.0-1", "0.1.0-alpha"), Alignment::BinaryNewer);
345 assert_eq!(
346 alignment("1.0.0-100000000000000000000", "1.0.0-99999999999999999999"),
347 Alignment::TargetNewer,
348 "identifiers past the u64 range still compare numerically"
349 );
350 assert_eq!(
351 alignment("1.0.0-99999999999999999999", "1.0.0-100000000000000000000"),
352 Alignment::BinaryNewer
353 );
354 }
355
356 #[test]
359 fn alignment_ignores_build_metadata() {
360 assert_eq!(alignment("1.2.10+build", "1.2.9"), Alignment::TargetNewer);
361 assert_eq!(alignment("1.2.9", "1.2.10+build"), Alignment::BinaryNewer);
362 assert_eq!(alignment("1.0.0+alpha", "1.0.0+beta"), Alignment::Aligned);
363 assert_eq!(
364 alignment("1.2.10-rc.1+build", "1.2.10-rc.1"),
365 Alignment::Aligned
366 );
367 assert_eq!(
368 alignment("1.2.10-rc.1+build", "1.2.10"),
369 Alignment::BinaryNewer
370 );
371 }
372}