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 = 3;
33
34const OLDEST_READABLE_SCHEMA: u64 = 1;
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "lowercase")]
41pub enum Workflow {
42 Worktree,
45 Branches,
48}
49
50impl Workflow {
51 #[must_use]
53 pub const fn as_str(self) -> &'static str {
54 match self {
55 Self::Worktree => "worktree",
56 Self::Branches => "branches",
57 }
58 }
59
60 pub fn parse(raw: &str) -> Result<Self, RkError> {
66 match raw {
67 "worktree" => Ok(Self::Worktree),
68 "branches" => Ok(Self::Branches),
69 other => Err(RkError::Usage(format!(
70 "unknown workflow '{other}'; the modes are: worktree, branches"
71 ))),
72 }
73 }
74}
75
76const fn workflow_branches() -> Workflow {
78 Workflow::Branches
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
87#[serde(rename_all = "lowercase")]
88pub enum Style {
89 Trunk,
92 Lines,
95}
96
97impl Style {
98 #[must_use]
100 pub const fn as_str(self) -> &'static str {
101 match self {
102 Self::Trunk => "trunk",
103 Self::Lines => "lines",
104 }
105 }
106
107 pub fn parse(raw: &str) -> Result<Self, RkError> {
113 match raw {
114 "trunk" => Ok(Self::Trunk),
115 "lines" => Ok(Self::Lines),
116 other => Err(RkError::Usage(format!(
117 "unknown style '{other}'; the styles are: trunk, lines"
118 ))),
119 }
120 }
121}
122
123#[derive(Debug, Serialize, Deserialize)]
125pub struct Manifest {
126 pub schema_version: u64,
128 pub rk_version: String,
130 pub payload_sha256: Digest,
133 pub origin: String,
135 pub tech: String,
137 pub forge: String,
139 pub landed_at: String,
141 pub parameters: Parameters,
144 pub files: Vec<FileRecord>,
146 pub pins: BTreeMap<String, String>,
149}
150
151#[derive(Debug, Serialize, Deserialize)]
153pub struct Parameters {
154 pub repo: String,
157 #[serde(default)]
162 pub scopes: Vec<String>,
163 #[serde(default = "workflow_branches")]
169 pub workflow: Workflow,
170 #[serde(default, skip_serializing_if = "Option::is_none")]
176 pub style: Option<Style>,
177}
178
179#[derive(Debug, Serialize, Deserialize)]
181pub struct FileRecord {
182 pub destination: String,
184 pub kind: Kind,
186 pub sha256: Digest,
189 #[serde(skip_serializing_if = "Option::is_none")]
198 pub baseline_sha256: Option<Digest>,
199}
200
201impl Manifest {
202 #[must_use]
204 pub fn file(&self, destination: &str) -> Option<&FileRecord> {
205 self.files
206 .iter()
207 .find(|file| file.destination == destination)
208 }
209}
210
211pub fn load(target: &Utf8Path) -> Result<Option<Manifest>, RkError> {
220 let path = target.join(MANIFEST_PATH);
221 let bytes = match std::fs::read(&path) {
222 Ok(bytes) => bytes,
223 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
224 Err(e) => {
225 return Err(RkError::refusal(
226 Diagnostic::new(Reason::Io, format!("cannot read {path}: {e}"))
227 .expected("a readable landing record")
228 .target_state("unchanged"),
229 ));
230 }
231 };
232 let value: serde_json::Value = serde_json::from_slice(&bytes)
233 .map_err(|e| anyhow::anyhow!("{path} is not a landing record: {e}"))?;
234 let schema = value
240 .get("schema_version")
241 .and_then(serde_json::Value::as_u64);
242 if !schema.is_some_and(|version| (OLDEST_READABLE_SCHEMA..=SCHEMA_VERSION).contains(&version)) {
243 let found = schema.map_or_else(|| "none".to_owned(), |version| version.to_string());
244 return Err(RkError::refusal(
245 Diagnostic::new(
246 Reason::UnsupportedSchema,
247 format!(
248 "{path} declares schema_version {found}, and this binary knows only {OLDEST_READABLE_SCHEMA} through {SCHEMA_VERSION}"
249 ),
250 )
251 .expected("a record this binary can read")
252 .action("run the rk release that wrote this record, or a newer one")
253 .target_state("unchanged"),
254 ));
255 }
256 let declared = schema.unwrap_or(SCHEMA_VERSION);
257 let manifest: Manifest = serde_json::from_value(value)
258 .map_err(|e| anyhow::anyhow!("{path} does not parse at schema_version {declared}: {e}"))?;
259 Ok(Some(manifest))
260}
261
262pub fn write(target: &Utf8Path, manifest: &Manifest) -> Result<(), RkError> {
268 let text = serde_json::to_string_pretty(manifest).map_err(anyhow::Error::from)?;
269 let path = target.join(MANIFEST_PATH);
270 atomic::write(path.as_std_path(), format!("{text}\n").as_bytes())?;
271 Ok(())
272}
273
274#[must_use]
276pub fn now() -> String {
277 humantime::format_rfc3339_seconds(std::time::SystemTime::now()).to_string()
278}
279
280#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
282#[serde(rename_all = "kebab-case")]
283pub enum Alignment {
284 Aligned,
286 BinaryNewer,
288 TargetNewer,
291}
292
293impl Alignment {
294 #[must_use]
296 pub const fn as_str(self) -> &'static str {
297 match self {
298 Self::Aligned => "aligned",
299 Self::BinaryNewer => "binary-newer",
300 Self::TargetNewer => "target-newer",
301 }
302 }
303}
304
305#[must_use]
307pub fn alignment(recorded: &str, binary: &str) -> Alignment {
308 let recorded = recorded
310 .split_once('+')
311 .map_or(recorded, |(version, _)| version);
312 let binary = binary
313 .split_once('+')
314 .map_or(binary, |(version, _)| version);
315 let recorded_core = numeric_core(recorded);
316 let binary_core = numeric_core(binary);
317 match binary_core.cmp(&recorded_core) {
318 std::cmp::Ordering::Greater => Alignment::BinaryNewer,
319 std::cmp::Ordering::Less => Alignment::TargetNewer,
320 std::cmp::Ordering::Equal => {
321 let recorded_pre = recorded.split_once('-').map(|(_, pre)| pre);
326 let binary_pre = binary.split_once('-').map(|(_, pre)| pre);
327 match (recorded_pre, binary_pre) {
328 (Some(_), None) => Alignment::BinaryNewer,
329 (None, Some(_)) => Alignment::TargetNewer,
330 (None, None) => Alignment::Aligned,
331 (Some(r), Some(b)) => match prerelease_cmp(b, r) {
332 std::cmp::Ordering::Greater => Alignment::BinaryNewer,
333 std::cmp::Ordering::Less => Alignment::TargetNewer,
334 std::cmp::Ordering::Equal => Alignment::Aligned,
335 },
336 }
337 }
338 }
339}
340
341#[must_use]
344pub fn version_is_newer(candidate: &str, pinned: &str) -> bool {
345 alignment(pinned, candidate) == Alignment::BinaryNewer
346}
347
348fn prerelease_cmp(a: &str, b: &str) -> std::cmp::Ordering {
355 let numeric = |identifier: &str| identifier.bytes().all(|byte| byte.is_ascii_digit());
356 let mut left = a.split('.');
357 let mut right = b.split('.');
358 loop {
359 match (left.next(), right.next()) {
360 (None, None) => return std::cmp::Ordering::Equal,
361 (None, Some(_)) => return std::cmp::Ordering::Less,
362 (Some(_), None) => return std::cmp::Ordering::Greater,
363 (Some(x), Some(y)) => {
364 let ordering = match (numeric(x), numeric(y)) {
365 (true, true) => x.len().cmp(&y.len()).then_with(|| x.cmp(y)),
366 (true, false) => std::cmp::Ordering::Less,
367 (false, true) => std::cmp::Ordering::Greater,
368 (false, false) => x.cmp(y),
369 };
370 if ordering != std::cmp::Ordering::Equal {
371 return ordering;
372 }
373 }
374 }
375 }
376}
377
378fn numeric_core(version: &str) -> Vec<u64> {
380 let core = version.split_once('-').map_or(version, |(core, _)| core);
381 core.split('.')
382 .map(|part| part.parse::<u64>().unwrap_or(0))
383 .collect()
384}
385
386#[cfg(test)]
387mod tests {
388 #![allow(clippy::expect_used)]
389
390 use super::{Alignment, FileRecord, Manifest, Parameters, Style, Workflow, alignment};
391 use crate::digest::Digest;
392 use crate::landing::Kind;
393
394 #[test]
398 fn the_manifest_schema_snapshot_holds() {
399 let manifest = Manifest {
400 schema_version: 3,
401 rk_version: "0.1.0".into(),
402 payload_sha256: Digest::of(b""),
403 origin: "init".into(),
404 tech: "rust".into(),
405 forge: "github".into(),
406 landed_at: "2026-08-29T00:00:00Z".into(),
407 parameters: Parameters {
408 repo: "acme/widget".into(),
409 scopes: vec!["api".into(), "cli".into()],
410 workflow: Workflow::Worktree,
411 style: Some(Style::Trunk),
412 },
413 files: vec![
414 FileRecord {
415 destination: "release-plz.toml".into(),
416 kind: Kind::Seeded,
417 sha256: Digest::of(b""),
418 baseline_sha256: Some(Digest::of(b"")),
419 },
420 FileRecord {
421 destination: "VERSION".into(),
422 kind: Kind::State,
423 sha256: Digest::of(b""),
424 baseline_sha256: None,
425 },
426 ],
427 pins: std::iter::once(("release-plz".to_owned(), "0.3.160".to_owned())).collect(),
428 };
429 let empty = Digest::of(b"").to_string();
430 assert_eq!(
431 serde_json::to_string(&manifest).expect("a manifest serializes"),
432 format!(
433 r#"{{"schema_version":3,"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","style":"trunk"}},"files":[{{"destination":"release-plz.toml","kind":"seeded","sha256":"{empty}","baseline_sha256":"{empty}"}},{{"destination":"VERSION","kind":"state","sha256":"{empty}"}}],"pins":{{"release-plz":"0.3.160"}}}}"#
434 ),
435 "a state file must omit baseline_sha256 rather than serializing null"
436 );
437 }
438
439 #[test]
443 fn a_schema_1_record_reads_as_branches_and_a_newer_schema_refuses() {
444 let dir = tempfile::tempdir().expect("a scratch target exists");
445 let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
446 std::fs::create_dir_all(target.join(".release-kit")).expect("the record dir writes");
447 let record = |schema: u64| {
448 format!(
449 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":{{}}}}"#
450 )
451 };
452 std::fs::write(target.join(super::MANIFEST_PATH), record(1)).expect("the record writes");
453 let manifest = super::load(target)
454 .expect("a schema-1 record loads")
455 .expect("the record exists");
456 assert_eq!(manifest.parameters.workflow, Workflow::Branches);
457 assert_eq!(
458 manifest.parameters.style, None,
459 "a pre-style record carries no style; the upgrade demands one"
460 );
461
462 std::fs::write(target.join(super::MANIFEST_PATH), record(4)).expect("the record writes");
463 let refused = super::load(target).expect_err("a schema-4 record refuses");
464 let message = refused.to_string();
465 assert!(message.contains('4'), "{message}");
466 }
467
468 #[test]
469 fn alignment_orders_versions_numerically() {
470 assert_eq!(alignment("0.1.0", "0.1.0"), Alignment::Aligned);
471 assert_eq!(alignment("0.1.0", "0.2.0"), Alignment::BinaryNewer);
472 assert_eq!(alignment("0.10.0", "0.9.9"), Alignment::TargetNewer);
473 assert_eq!(alignment("0.1.0-rc.1", "0.1.0"), Alignment::BinaryNewer);
474 assert_eq!(alignment("0.1.0", "0.1.0-rc.1"), Alignment::TargetNewer);
475 }
476
477 #[test]
482 fn alignment_orders_numeric_prerelease_identifiers_numerically() {
483 assert_eq!(
484 alignment("0.1.0-rc.10", "0.1.0-rc.2"),
485 Alignment::TargetNewer
486 );
487 assert_eq!(
488 alignment("0.1.0-rc.2", "0.1.0-rc.10"),
489 Alignment::BinaryNewer
490 );
491 assert_eq!(alignment("0.1.0-rc.1", "0.1.0-rc.1"), Alignment::Aligned);
492 assert_eq!(
493 alignment("0.1.0-alpha", "0.1.0-alpha.1"),
494 Alignment::BinaryNewer
495 );
496 assert_eq!(alignment("0.1.0-1", "0.1.0-alpha"), Alignment::BinaryNewer);
497 assert_eq!(
498 alignment("1.0.0-100000000000000000000", "1.0.0-99999999999999999999"),
499 Alignment::TargetNewer,
500 "identifiers past the u64 range still compare numerically"
501 );
502 assert_eq!(
503 alignment("1.0.0-99999999999999999999", "1.0.0-100000000000000000000"),
504 Alignment::BinaryNewer
505 );
506 }
507
508 #[test]
511 fn alignment_ignores_build_metadata() {
512 assert_eq!(alignment("1.2.10+build", "1.2.9"), Alignment::TargetNewer);
513 assert_eq!(alignment("1.2.9", "1.2.10+build"), Alignment::BinaryNewer);
514 assert_eq!(alignment("1.0.0+alpha", "1.0.0+beta"), Alignment::Aligned);
515 assert_eq!(
516 alignment("1.2.10-rc.1+build", "1.2.10-rc.1"),
517 Alignment::Aligned
518 );
519 assert_eq!(
520 alignment("1.2.10-rc.1+build", "1.2.10"),
521 Alignment::BinaryNewer
522 );
523 }
524}