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 = 2;
30
31const OLDEST_READABLE_SCHEMA: u64 = 1;
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(rename_all = "lowercase")]
38pub enum Workflow {
39 Worktree,
42 Branches,
45}
46
47impl Workflow {
48 #[must_use]
50 pub const fn as_str(self) -> &'static str {
51 match self {
52 Self::Worktree => "worktree",
53 Self::Branches => "branches",
54 }
55 }
56
57 pub fn parse(raw: &str) -> Result<Self, RkError> {
63 match raw {
64 "worktree" => Ok(Self::Worktree),
65 "branches" => Ok(Self::Branches),
66 other => Err(RkError::Usage(format!(
67 "unknown workflow '{other}'; the modes are: worktree, branches"
68 ))),
69 }
70 }
71}
72
73const fn workflow_branches() -> Workflow {
75 Workflow::Branches
76}
77
78#[derive(Debug, Serialize, Deserialize)]
80pub struct Manifest {
81 pub schema_version: u64,
83 pub rk_version: String,
85 pub payload_sha256: Digest,
88 pub origin: String,
90 pub tech: String,
92 pub forge: String,
94 pub landed_at: String,
96 pub parameters: Parameters,
99 pub files: Vec<FileRecord>,
101 pub pins: BTreeMap<String, String>,
104}
105
106#[derive(Debug, Serialize, Deserialize)]
108pub struct Parameters {
109 pub repo: String,
112 #[serde(default)]
117 pub scopes: Vec<String>,
118 #[serde(default = "workflow_branches")]
124 pub workflow: Workflow,
125}
126
127#[derive(Debug, Serialize, Deserialize)]
129pub struct FileRecord {
130 pub destination: String,
132 pub kind: Kind,
134 pub sha256: Digest,
137 #[serde(skip_serializing_if = "Option::is_none")]
146 pub baseline_sha256: Option<Digest>,
147}
148
149impl Manifest {
150 #[must_use]
152 pub fn file(&self, destination: &str) -> Option<&FileRecord> {
153 self.files
154 .iter()
155 .find(|file| file.destination == destination)
156 }
157}
158
159pub fn load(target: &Utf8Path) -> Result<Option<Manifest>, RkError> {
168 let path = target.join(MANIFEST_PATH);
169 let bytes = match std::fs::read(&path) {
170 Ok(bytes) => bytes,
171 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
172 Err(e) => {
173 return Err(RkError::refusal(
174 Diagnostic::new(Reason::Io, format!("cannot read {path}: {e}"))
175 .expected("a readable landing record")
176 .target_state("unchanged"),
177 ));
178 }
179 };
180 let value: serde_json::Value = serde_json::from_slice(&bytes)
181 .map_err(|e| anyhow::anyhow!("{path} is not a landing record: {e}"))?;
182 let schema = value
188 .get("schema_version")
189 .and_then(serde_json::Value::as_u64);
190 if !schema.is_some_and(|version| (OLDEST_READABLE_SCHEMA..=SCHEMA_VERSION).contains(&version)) {
191 let found = schema.map_or_else(|| "none".to_owned(), |version| version.to_string());
192 return Err(RkError::refusal(
193 Diagnostic::new(
194 Reason::UnsupportedSchema,
195 format!(
196 "{path} declares schema_version {found}, and this binary knows only {OLDEST_READABLE_SCHEMA} through {SCHEMA_VERSION}"
197 ),
198 )
199 .expected("a record this binary can read")
200 .action("run the rk release that wrote this record, or a newer one")
201 .target_state("unchanged"),
202 ));
203 }
204 let declared = schema.unwrap_or(SCHEMA_VERSION);
205 let manifest: Manifest = serde_json::from_value(value)
206 .map_err(|e| anyhow::anyhow!("{path} does not parse at schema_version {declared}: {e}"))?;
207 Ok(Some(manifest))
208}
209
210pub fn write(target: &Utf8Path, manifest: &Manifest) -> Result<(), RkError> {
216 let text = serde_json::to_string_pretty(manifest).map_err(anyhow::Error::from)?;
217 let path = target.join(MANIFEST_PATH);
218 atomic::write(path.as_std_path(), format!("{text}\n").as_bytes())?;
219 Ok(())
220}
221
222#[must_use]
224pub fn now() -> String {
225 humantime::format_rfc3339_seconds(std::time::SystemTime::now()).to_string()
226}
227
228#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
230#[serde(rename_all = "kebab-case")]
231pub enum Alignment {
232 Aligned,
234 BinaryNewer,
236 TargetNewer,
239}
240
241impl Alignment {
242 #[must_use]
244 pub const fn as_str(self) -> &'static str {
245 match self {
246 Self::Aligned => "aligned",
247 Self::BinaryNewer => "binary-newer",
248 Self::TargetNewer => "target-newer",
249 }
250 }
251}
252
253#[must_use]
255pub fn alignment(recorded: &str, binary: &str) -> Alignment {
256 let recorded = recorded
258 .split_once('+')
259 .map_or(recorded, |(version, _)| version);
260 let binary = binary
261 .split_once('+')
262 .map_or(binary, |(version, _)| version);
263 let recorded_core = numeric_core(recorded);
264 let binary_core = numeric_core(binary);
265 match binary_core.cmp(&recorded_core) {
266 std::cmp::Ordering::Greater => Alignment::BinaryNewer,
267 std::cmp::Ordering::Less => Alignment::TargetNewer,
268 std::cmp::Ordering::Equal => {
269 let recorded_pre = recorded.split_once('-').map(|(_, pre)| pre);
274 let binary_pre = binary.split_once('-').map(|(_, pre)| pre);
275 match (recorded_pre, binary_pre) {
276 (Some(_), None) => Alignment::BinaryNewer,
277 (None, Some(_)) => Alignment::TargetNewer,
278 (None, None) => Alignment::Aligned,
279 (Some(r), Some(b)) => match prerelease_cmp(b, r) {
280 std::cmp::Ordering::Greater => Alignment::BinaryNewer,
281 std::cmp::Ordering::Less => Alignment::TargetNewer,
282 std::cmp::Ordering::Equal => Alignment::Aligned,
283 },
284 }
285 }
286 }
287}
288
289#[must_use]
292pub fn version_is_newer(candidate: &str, pinned: &str) -> bool {
293 alignment(pinned, candidate) == Alignment::BinaryNewer
294}
295
296fn prerelease_cmp(a: &str, b: &str) -> std::cmp::Ordering {
303 let numeric = |identifier: &str| identifier.bytes().all(|byte| byte.is_ascii_digit());
304 let mut left = a.split('.');
305 let mut right = b.split('.');
306 loop {
307 match (left.next(), right.next()) {
308 (None, None) => return std::cmp::Ordering::Equal,
309 (None, Some(_)) => return std::cmp::Ordering::Less,
310 (Some(_), None) => return std::cmp::Ordering::Greater,
311 (Some(x), Some(y)) => {
312 let ordering = match (numeric(x), numeric(y)) {
313 (true, true) => x.len().cmp(&y.len()).then_with(|| x.cmp(y)),
314 (true, false) => std::cmp::Ordering::Less,
315 (false, true) => std::cmp::Ordering::Greater,
316 (false, false) => x.cmp(y),
317 };
318 if ordering != std::cmp::Ordering::Equal {
319 return ordering;
320 }
321 }
322 }
323 }
324}
325
326fn numeric_core(version: &str) -> Vec<u64> {
328 let core = version.split_once('-').map_or(version, |(core, _)| core);
329 core.split('.')
330 .map(|part| part.parse::<u64>().unwrap_or(0))
331 .collect()
332}
333
334#[cfg(test)]
335mod tests {
336 #![allow(clippy::expect_used)]
337
338 use super::{Alignment, FileRecord, Manifest, Parameters, Workflow, alignment};
339 use crate::digest::Digest;
340 use crate::landing::Kind;
341
342 #[test]
346 fn the_manifest_schema_snapshot_holds() {
347 let manifest = Manifest {
348 schema_version: 2,
349 rk_version: "0.1.0".into(),
350 payload_sha256: Digest::of(b""),
351 origin: "init".into(),
352 tech: "rust".into(),
353 forge: "github".into(),
354 landed_at: "2026-08-29T00:00:00Z".into(),
355 parameters: Parameters {
356 repo: "acme/widget".into(),
357 scopes: vec!["api".into(), "cli".into()],
358 workflow: Workflow::Worktree,
359 },
360 files: vec![
361 FileRecord {
362 destination: "release-plz.toml".into(),
363 kind: Kind::Seeded,
364 sha256: Digest::of(b""),
365 baseline_sha256: Some(Digest::of(b"")),
366 },
367 FileRecord {
368 destination: "VERSION".into(),
369 kind: Kind::State,
370 sha256: Digest::of(b""),
371 baseline_sha256: None,
372 },
373 ],
374 pins: std::iter::once(("release-plz".to_owned(), "0.3.160".to_owned())).collect(),
375 };
376 let empty = Digest::of(b"").to_string();
377 assert_eq!(
378 serde_json::to_string(&manifest).expect("a manifest serializes"),
379 format!(
380 r#"{{"schema_version":2,"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"],"workflow":"worktree"}},"files":[{{"destination":"release-plz.toml","kind":"seeded","sha256":"{empty}","baseline_sha256":"{empty}"}},{{"destination":"VERSION","kind":"state","sha256":"{empty}"}}],"pins":{{"release-plz":"0.3.160"}}}}"#
381 ),
382 "a state file must omit baseline_sha256 rather than serializing null"
383 );
384 }
385
386 #[test]
390 fn a_schema_1_record_reads_as_branches_and_a_newer_schema_refuses() {
391 let dir = tempfile::tempdir().expect("a scratch target exists");
392 let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
393 std::fs::create_dir_all(target.join(".release-kit")).expect("the record dir writes");
394 let record = |schema: u64| {
395 format!(
396 r#"{{"schema_version":{schema},"rk_version":"0.1.0","payload_sha256":"0000000000000000000000000000000000000000000000000000000000000000","origin":"init","tech":"rust","forge":"github","landed_at":"2026-08-29T00:00:00Z","parameters":{{"repo":"acme/widget","scopes":["api"]}},"files":[],"pins":{{}}}}"#
397 )
398 };
399 std::fs::write(target.join(super::MANIFEST_PATH), record(1)).expect("the record writes");
400 let manifest = super::load(target)
401 .expect("a schema-1 record loads")
402 .expect("the record exists");
403 assert_eq!(manifest.parameters.workflow, Workflow::Branches);
404
405 std::fs::write(target.join(super::MANIFEST_PATH), record(3)).expect("the record writes");
406 let refused = super::load(target).expect_err("a schema-3 record refuses");
407 let message = refused.to_string();
408 assert!(message.contains('3'), "{message}");
409 }
410
411 #[test]
412 fn alignment_orders_versions_numerically() {
413 assert_eq!(alignment("0.1.0", "0.1.0"), Alignment::Aligned);
414 assert_eq!(alignment("0.1.0", "0.2.0"), Alignment::BinaryNewer);
415 assert_eq!(alignment("0.10.0", "0.9.9"), Alignment::TargetNewer);
416 assert_eq!(alignment("0.1.0-rc.1", "0.1.0"), Alignment::BinaryNewer);
417 assert_eq!(alignment("0.1.0", "0.1.0-rc.1"), Alignment::TargetNewer);
418 }
419
420 #[test]
425 fn alignment_orders_numeric_prerelease_identifiers_numerically() {
426 assert_eq!(
427 alignment("0.1.0-rc.10", "0.1.0-rc.2"),
428 Alignment::TargetNewer
429 );
430 assert_eq!(
431 alignment("0.1.0-rc.2", "0.1.0-rc.10"),
432 Alignment::BinaryNewer
433 );
434 assert_eq!(alignment("0.1.0-rc.1", "0.1.0-rc.1"), Alignment::Aligned);
435 assert_eq!(
436 alignment("0.1.0-alpha", "0.1.0-alpha.1"),
437 Alignment::BinaryNewer
438 );
439 assert_eq!(alignment("0.1.0-1", "0.1.0-alpha"), Alignment::BinaryNewer);
440 assert_eq!(
441 alignment("1.0.0-100000000000000000000", "1.0.0-99999999999999999999"),
442 Alignment::TargetNewer,
443 "identifiers past the u64 range still compare numerically"
444 );
445 assert_eq!(
446 alignment("1.0.0-99999999999999999999", "1.0.0-100000000000000000000"),
447 Alignment::BinaryNewer
448 );
449 }
450
451 #[test]
454 fn alignment_ignores_build_metadata() {
455 assert_eq!(alignment("1.2.10+build", "1.2.9"), Alignment::TargetNewer);
456 assert_eq!(alignment("1.2.9", "1.2.10+build"), Alignment::BinaryNewer);
457 assert_eq!(alignment("1.0.0+alpha", "1.0.0+beta"), Alignment::Aligned);
458 assert_eq!(
459 alignment("1.2.10-rc.1+build", "1.2.10-rc.1"),
460 Alignment::Aligned
461 );
462 assert_eq!(
463 alignment("1.2.10-rc.1+build", "1.2.10"),
464 Alignment::BinaryNewer
465 );
466 }
467}