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 #[serde(default)]
68 pub scopes: Vec<String>,
69}
70
71#[derive(Debug, Serialize, Deserialize)]
73pub struct FileRecord {
74 pub destination: String,
76 pub kind: Kind,
78 pub sha256: Digest,
81 #[serde(skip_serializing_if = "Option::is_none")]
90 pub baseline_sha256: Option<Digest>,
91}
92
93impl Manifest {
94 #[must_use]
96 pub fn file(&self, destination: &str) -> Option<&FileRecord> {
97 self.files
98 .iter()
99 .find(|file| file.destination == destination)
100 }
101}
102
103pub fn load(target: &Utf8Path) -> Result<Option<Manifest>, RkError> {
112 let path = target.join(MANIFEST_PATH);
113 let bytes = match std::fs::read(&path) {
114 Ok(bytes) => bytes,
115 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
116 Err(e) => {
117 return Err(RkError::refusal(
118 Diagnostic::new(Reason::Io, format!("cannot read {path}: {e}"))
119 .expected("a readable landing record")
120 .target_state("unchanged"),
121 ));
122 }
123 };
124 let value: serde_json::Value = serde_json::from_slice(&bytes)
125 .map_err(|e| anyhow::anyhow!("{path} is not a landing record: {e}"))?;
126 let schema = value
127 .get("schema_version")
128 .and_then(serde_json::Value::as_u64);
129 if schema != Some(SCHEMA_VERSION) {
130 let found = schema.map_or_else(|| "none".to_owned(), |version| version.to_string());
131 return Err(RkError::refusal(
132 Diagnostic::new(
133 Reason::UnsupportedSchema,
134 format!(
135 "{path} declares schema_version {found}, and this binary knows only {SCHEMA_VERSION}"
136 ),
137 )
138 .expected("a record this binary can read")
139 .action("run the rk release that wrote this record, or a newer one")
140 .target_state("unchanged"),
141 ));
142 }
143 let manifest: Manifest = serde_json::from_value(value)
144 .map_err(|e| anyhow::anyhow!("{path} does not parse at schema_version 1: {e}"))?;
145 Ok(Some(manifest))
146}
147
148pub fn write(target: &Utf8Path, manifest: &Manifest) -> Result<(), RkError> {
154 let text = serde_json::to_string_pretty(manifest).map_err(anyhow::Error::from)?;
155 let path = target.join(MANIFEST_PATH);
156 atomic::write(path.as_std_path(), format!("{text}\n").as_bytes())?;
157 Ok(())
158}
159
160#[must_use]
162pub fn now() -> String {
163 humantime::format_rfc3339_seconds(std::time::SystemTime::now()).to_string()
164}
165
166#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
168#[serde(rename_all = "kebab-case")]
169pub enum Alignment {
170 Aligned,
172 BinaryNewer,
174 TargetNewer,
177}
178
179impl Alignment {
180 #[must_use]
182 pub const fn as_str(self) -> &'static str {
183 match self {
184 Self::Aligned => "aligned",
185 Self::BinaryNewer => "binary-newer",
186 Self::TargetNewer => "target-newer",
187 }
188 }
189}
190
191#[must_use]
193pub fn alignment(recorded: &str, binary: &str) -> Alignment {
194 let recorded = recorded
196 .split_once('+')
197 .map_or(recorded, |(version, _)| version);
198 let binary = binary
199 .split_once('+')
200 .map_or(binary, |(version, _)| version);
201 let recorded_core = numeric_core(recorded);
202 let binary_core = numeric_core(binary);
203 match binary_core.cmp(&recorded_core) {
204 std::cmp::Ordering::Greater => Alignment::BinaryNewer,
205 std::cmp::Ordering::Less => Alignment::TargetNewer,
206 std::cmp::Ordering::Equal => {
207 let recorded_pre = recorded.split_once('-').map(|(_, pre)| pre);
212 let binary_pre = binary.split_once('-').map(|(_, pre)| pre);
213 match (recorded_pre, binary_pre) {
214 (Some(_), None) => Alignment::BinaryNewer,
215 (None, Some(_)) => Alignment::TargetNewer,
216 (None, None) => Alignment::Aligned,
217 (Some(r), Some(b)) => match prerelease_cmp(b, r) {
218 std::cmp::Ordering::Greater => Alignment::BinaryNewer,
219 std::cmp::Ordering::Less => Alignment::TargetNewer,
220 std::cmp::Ordering::Equal => Alignment::Aligned,
221 },
222 }
223 }
224 }
225}
226
227#[must_use]
230pub fn version_is_newer(candidate: &str, pinned: &str) -> bool {
231 alignment(pinned, candidate) == Alignment::BinaryNewer
232}
233
234fn prerelease_cmp(a: &str, b: &str) -> std::cmp::Ordering {
241 let numeric = |identifier: &str| identifier.bytes().all(|byte| byte.is_ascii_digit());
242 let mut left = a.split('.');
243 let mut right = b.split('.');
244 loop {
245 match (left.next(), right.next()) {
246 (None, None) => return std::cmp::Ordering::Equal,
247 (None, Some(_)) => return std::cmp::Ordering::Less,
248 (Some(_), None) => return std::cmp::Ordering::Greater,
249 (Some(x), Some(y)) => {
250 let ordering = match (numeric(x), numeric(y)) {
251 (true, true) => x.len().cmp(&y.len()).then_with(|| x.cmp(y)),
252 (true, false) => std::cmp::Ordering::Less,
253 (false, true) => std::cmp::Ordering::Greater,
254 (false, false) => x.cmp(y),
255 };
256 if ordering != std::cmp::Ordering::Equal {
257 return ordering;
258 }
259 }
260 }
261 }
262}
263
264fn numeric_core(version: &str) -> Vec<u64> {
266 let core = version.split_once('-').map_or(version, |(core, _)| core);
267 core.split('.')
268 .map(|part| part.parse::<u64>().unwrap_or(0))
269 .collect()
270}
271
272#[cfg(test)]
273mod tests {
274 #![allow(clippy::expect_used)]
275
276 use super::{Alignment, FileRecord, Manifest, Parameters, alignment};
277 use crate::digest::Digest;
278 use crate::landing::Kind;
279
280 #[test]
284 fn the_manifest_schema_snapshot_holds() {
285 let manifest = Manifest {
286 schema_version: 1,
287 rk_version: "0.1.0".into(),
288 payload_sha256: Digest::of(b""),
289 origin: "init".into(),
290 tech: "rust".into(),
291 forge: "github".into(),
292 landed_at: "2026-08-29T00:00:00Z".into(),
293 parameters: Parameters {
294 repo: "acme/widget".into(),
295 scopes: vec!["api".into(), "cli".into()],
296 },
297 files: vec![
298 FileRecord {
299 destination: "release-plz.toml".into(),
300 kind: Kind::Seeded,
301 sha256: Digest::of(b""),
302 baseline_sha256: Some(Digest::of(b"")),
303 },
304 FileRecord {
305 destination: "VERSION".into(),
306 kind: Kind::State,
307 sha256: Digest::of(b""),
308 baseline_sha256: None,
309 },
310 ],
311 pins: std::iter::once(("release-plz".to_owned(), "0.3.160".to_owned())).collect(),
312 };
313 let empty = Digest::of(b"").to_string();
314 assert_eq!(
315 serde_json::to_string(&manifest).expect("a manifest serializes"),
316 format!(
317 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"}}}}"#
318 ),
319 "a state file must omit baseline_sha256 rather than serializing null"
320 );
321 }
322
323 #[test]
324 fn alignment_orders_versions_numerically() {
325 assert_eq!(alignment("0.1.0", "0.1.0"), Alignment::Aligned);
326 assert_eq!(alignment("0.1.0", "0.2.0"), Alignment::BinaryNewer);
327 assert_eq!(alignment("0.10.0", "0.9.9"), Alignment::TargetNewer);
328 assert_eq!(alignment("0.1.0-rc.1", "0.1.0"), Alignment::BinaryNewer);
329 assert_eq!(alignment("0.1.0", "0.1.0-rc.1"), Alignment::TargetNewer);
330 }
331
332 #[test]
337 fn alignment_orders_numeric_prerelease_identifiers_numerically() {
338 assert_eq!(
339 alignment("0.1.0-rc.10", "0.1.0-rc.2"),
340 Alignment::TargetNewer
341 );
342 assert_eq!(
343 alignment("0.1.0-rc.2", "0.1.0-rc.10"),
344 Alignment::BinaryNewer
345 );
346 assert_eq!(alignment("0.1.0-rc.1", "0.1.0-rc.1"), Alignment::Aligned);
347 assert_eq!(
348 alignment("0.1.0-alpha", "0.1.0-alpha.1"),
349 Alignment::BinaryNewer
350 );
351 assert_eq!(alignment("0.1.0-1", "0.1.0-alpha"), Alignment::BinaryNewer);
352 assert_eq!(
353 alignment("1.0.0-100000000000000000000", "1.0.0-99999999999999999999"),
354 Alignment::TargetNewer,
355 "identifiers past the u64 range still compare numerically"
356 );
357 assert_eq!(
358 alignment("1.0.0-99999999999999999999", "1.0.0-100000000000000000000"),
359 Alignment::BinaryNewer
360 );
361 }
362
363 #[test]
366 fn alignment_ignores_build_metadata() {
367 assert_eq!(alignment("1.2.10+build", "1.2.9"), Alignment::TargetNewer);
368 assert_eq!(alignment("1.2.9", "1.2.10+build"), Alignment::BinaryNewer);
369 assert_eq!(alignment("1.0.0+alpha", "1.0.0+beta"), Alignment::Aligned);
370 assert_eq!(
371 alignment("1.2.10-rc.1+build", "1.2.10-rc.1"),
372 Alignment::Aligned
373 );
374 assert_eq!(
375 alignment("1.2.10-rc.1+build", "1.2.10"),
376 Alignment::BinaryNewer
377 );
378 }
379}