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 = 5;
38
39const OLDEST_READABLE_SCHEMA: u64 = 1;
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(rename_all = "lowercase")]
46pub enum Workflow {
47 Worktree,
50 Branches,
53}
54
55impl Workflow {
56 #[must_use]
58 pub const fn as_str(self) -> &'static str {
59 match self {
60 Self::Worktree => "worktree",
61 Self::Branches => "branches",
62 }
63 }
64
65 pub fn parse(raw: &str) -> Result<Self, RkError> {
71 match raw {
72 "worktree" => Ok(Self::Worktree),
73 "branches" => Ok(Self::Branches),
74 other => Err(RkError::Usage(format!(
75 "unknown workflow '{other}'; the modes are: worktree, branches"
76 ))),
77 }
78 }
79}
80
81const fn workflow_branches() -> Workflow {
83 Workflow::Branches
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
92#[serde(rename_all = "lowercase")]
93pub enum Style {
94 Trunk,
97 Lines,
100}
101
102impl Style {
103 #[must_use]
105 pub const fn as_str(self) -> &'static str {
106 match self {
107 Self::Trunk => "trunk",
108 Self::Lines => "lines",
109 }
110 }
111
112 pub fn parse(raw: &str) -> Result<Self, RkError> {
118 match raw {
119 "trunk" => Ok(Self::Trunk),
120 "lines" => Ok(Self::Lines),
121 other => Err(RkError::Usage(format!(
122 "unknown style '{other}'; the styles are: trunk, lines"
123 ))),
124 }
125 }
126}
127
128#[derive(Debug, Serialize, Deserialize)]
130pub struct Manifest {
131 pub schema_version: u64,
133 pub rk_version: String,
135 pub payload_sha256: Digest,
138 pub origin: String,
140 pub tech: String,
142 pub forge: String,
144 pub landed_at: String,
146 pub parameters: Parameters,
149 pub files: Vec<FileRecord>,
151 pub pins: BTreeMap<String, String>,
154}
155
156#[derive(Debug, Serialize, Deserialize)]
158pub struct Parameters {
159 pub repo: String,
162 #[serde(default = "workflow_branches")]
168 pub workflow: Workflow,
169 #[serde(default, skip_serializing_if = "Option::is_none")]
175 pub style: Option<Style>,
176 #[serde(default)]
183 pub nix: bool,
184 #[serde(default = "trunk_master")]
188 pub trunk: String,
189 #[serde(default = "line_prefix_release")]
193 pub line_prefix: String,
194}
195
196fn trunk_master() -> String {
198 crate::config::TRUNK_DEFAULT.to_owned()
199}
200
201fn line_prefix_release() -> String {
203 crate::config::LINE_PREFIX_DEFAULT.to_owned()
204}
205
206#[derive(Debug, Serialize, Deserialize)]
208pub struct FileRecord {
209 pub destination: String,
211 pub kind: Kind,
213 pub sha256: Digest,
216 #[serde(skip_serializing_if = "Option::is_none")]
225 pub baseline_sha256: Option<Digest>,
226}
227
228impl Manifest {
229 #[must_use]
231 pub fn file(&self, destination: &str) -> Option<&FileRecord> {
232 self.files
233 .iter()
234 .find(|file| file.destination == destination)
235 }
236}
237
238pub fn load(target: &Utf8Path) -> Result<Option<Manifest>, RkError> {
247 let path = target.join(MANIFEST_PATH);
248 let bytes = match std::fs::read(&path) {
249 Ok(bytes) => bytes,
250 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
251 Err(e) => {
252 return Err(RkError::refusal(
253 Diagnostic::new(Reason::Io, format!("cannot read {path}: {e}"))
254 .expected("a readable landing record")
255 .target_state("unchanged"),
256 ));
257 }
258 };
259 let value: serde_json::Value = serde_json::from_slice(&bytes)
260 .map_err(|e| anyhow::anyhow!("{path} is not a landing record: {e}"))?;
261 let schema = value
267 .get("schema_version")
268 .and_then(serde_json::Value::as_u64);
269 if !schema.is_some_and(|version| (OLDEST_READABLE_SCHEMA..=SCHEMA_VERSION).contains(&version)) {
270 let found = schema.map_or_else(|| "none".to_owned(), |version| version.to_string());
271 return Err(RkError::refusal(
272 Diagnostic::new(
273 Reason::UnsupportedSchema,
274 format!(
275 "{path} declares schema_version {found}, and this binary knows only {OLDEST_READABLE_SCHEMA} through {SCHEMA_VERSION}"
276 ),
277 )
278 .expected("a record this binary can read")
279 .action("run the rk release that wrote this record, or a newer one")
280 .target_state("unchanged"),
281 ));
282 }
283 let declared = schema.unwrap_or(SCHEMA_VERSION);
284 let manifest: Manifest = serde_json::from_value(value)
285 .map_err(|e| anyhow::anyhow!("{path} does not parse at schema_version {declared}: {e}"))?;
286 Ok(Some(manifest))
287}
288
289pub fn write(target: &Utf8Path, manifest: &Manifest) -> Result<(), RkError> {
295 let text = serde_json::to_string_pretty(manifest).map_err(anyhow::Error::from)?;
296 let path = target.join(MANIFEST_PATH);
297 atomic::write(path.as_std_path(), format!("{text}\n").as_bytes())?;
298 Ok(())
299}
300
301#[must_use]
303pub fn now() -> String {
304 humantime::format_rfc3339_seconds(std::time::SystemTime::now()).to_string()
305}
306
307#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
309#[serde(rename_all = "kebab-case")]
310pub enum Alignment {
311 Aligned,
313 BinaryNewer,
315 TargetNewer,
318}
319
320impl Alignment {
321 #[must_use]
323 pub const fn as_str(self) -> &'static str {
324 match self {
325 Self::Aligned => "aligned",
326 Self::BinaryNewer => "binary-newer",
327 Self::TargetNewer => "target-newer",
328 }
329 }
330}
331
332#[must_use]
334pub fn alignment(recorded: &str, binary: &str) -> Alignment {
335 let recorded = recorded
337 .split_once('+')
338 .map_or(recorded, |(version, _)| version);
339 let binary = binary
340 .split_once('+')
341 .map_or(binary, |(version, _)| version);
342 let recorded_core = numeric_core(recorded);
343 let binary_core = numeric_core(binary);
344 match binary_core.cmp(&recorded_core) {
345 std::cmp::Ordering::Greater => Alignment::BinaryNewer,
346 std::cmp::Ordering::Less => Alignment::TargetNewer,
347 std::cmp::Ordering::Equal => {
348 let recorded_pre = recorded.split_once('-').map(|(_, pre)| pre);
353 let binary_pre = binary.split_once('-').map(|(_, pre)| pre);
354 match (recorded_pre, binary_pre) {
355 (Some(_), None) => Alignment::BinaryNewer,
356 (None, Some(_)) => Alignment::TargetNewer,
357 (None, None) => Alignment::Aligned,
358 (Some(r), Some(b)) => match prerelease_cmp(b, r) {
359 std::cmp::Ordering::Greater => Alignment::BinaryNewer,
360 std::cmp::Ordering::Less => Alignment::TargetNewer,
361 std::cmp::Ordering::Equal => Alignment::Aligned,
362 },
363 }
364 }
365 }
366}
367
368#[must_use]
371pub fn version_is_newer(candidate: &str, pinned: &str) -> bool {
372 alignment(pinned, candidate) == Alignment::BinaryNewer
373}
374
375fn prerelease_cmp(a: &str, b: &str) -> std::cmp::Ordering {
382 let numeric = |identifier: &str| identifier.bytes().all(|byte| byte.is_ascii_digit());
383 let mut left = a.split('.');
384 let mut right = b.split('.');
385 loop {
386 match (left.next(), right.next()) {
387 (None, None) => return std::cmp::Ordering::Equal,
388 (None, Some(_)) => return std::cmp::Ordering::Less,
389 (Some(_), None) => return std::cmp::Ordering::Greater,
390 (Some(x), Some(y)) => {
391 let ordering = match (numeric(x), numeric(y)) {
392 (true, true) => x.len().cmp(&y.len()).then_with(|| x.cmp(y)),
393 (true, false) => std::cmp::Ordering::Less,
394 (false, true) => std::cmp::Ordering::Greater,
395 (false, false) => x.cmp(y),
396 };
397 if ordering != std::cmp::Ordering::Equal {
398 return ordering;
399 }
400 }
401 }
402 }
403}
404
405fn numeric_core(version: &str) -> Vec<u64> {
407 let core = version.split_once('-').map_or(version, |(core, _)| core);
408 core.split('.')
409 .map(|part| part.parse::<u64>().unwrap_or(0))
410 .collect()
411}
412
413#[cfg(test)]
414mod tests {
415 #![allow(clippy::expect_used)]
416
417 use super::{Alignment, FileRecord, Manifest, Parameters, Style, Workflow, alignment};
418 use crate::digest::Digest;
419 use crate::landing::Kind;
420
421 #[test]
425 fn the_manifest_schema_snapshot_holds() {
426 let manifest = Manifest {
427 schema_version: 5,
428 rk_version: "0.1.0".into(),
429 payload_sha256: Digest::of(b""),
430 origin: "init".into(),
431 tech: "rust".into(),
432 forge: "github".into(),
433 landed_at: "2026-08-29T00:00:00Z".into(),
434 parameters: Parameters {
435 repo: "acme/widget".into(),
436 workflow: Workflow::Worktree,
437 style: Some(Style::Trunk),
438 nix: true,
439 trunk: crate::config::TRUNK_DEFAULT.to_owned(),
440 line_prefix: crate::config::LINE_PREFIX_DEFAULT.to_owned(),
441 },
442 files: vec![
443 FileRecord {
444 destination: "release-plz.toml".into(),
445 kind: Kind::Seeded,
446 sha256: Digest::of(b""),
447 baseline_sha256: Some(Digest::of(b"")),
448 },
449 FileRecord {
450 destination: "VERSION".into(),
451 kind: Kind::State,
452 sha256: Digest::of(b""),
453 baseline_sha256: None,
454 },
455 ],
456 pins: std::iter::once(("release-plz".to_owned(), "0.3.160".to_owned())).collect(),
457 };
458 let empty = Digest::of(b"").to_string();
459 assert_eq!(
460 serde_json::to_string(&manifest).expect("a manifest serializes"),
461 format!(
462 r#"{{"schema_version":5,"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","workflow":"worktree","style":"trunk","nix":true,"trunk":"master","line_prefix":"release/"}},"files":[{{"destination":"release-plz.toml","kind":"seeded","sha256":"{empty}","baseline_sha256":"{empty}"}},{{"destination":"VERSION","kind":"state","sha256":"{empty}"}}],"pins":{{"release-plz":"0.3.160"}}}}"#
463 ),
464 "a state file must omit baseline_sha256 rather than serializing null"
465 );
466 }
467
468 #[test]
473 fn a_schema_1_record_reads_as_branches_and_a_newer_schema_refuses() {
474 let dir = tempfile::tempdir().expect("a scratch target exists");
475 let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
476 std::fs::create_dir_all(target.join(".release-kit")).expect("the record dir writes");
477 let record = |schema: u64| {
478 format!(
479 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":{{}}}}"#
480 )
481 };
482 std::fs::write(target.join(super::MANIFEST_PATH), record(1)).expect("the record writes");
483 let manifest = super::load(target)
484 .expect("a schema-1 record loads")
485 .expect("the record exists");
486 assert_eq!(manifest.parameters.workflow, Workflow::Branches);
487 assert_eq!(
488 manifest.parameters.style, None,
489 "a pre-style record carries no style; the upgrade demands one"
490 );
491 assert!(
492 !manifest.parameters.nix,
493 "a pre-nix record reads as opt-out, so an upgrade adds nothing unrequested"
494 );
495
496 std::fs::write(target.join(super::MANIFEST_PATH), record(6)).expect("the record writes");
497 let refused = super::load(target).expect_err("a schema-6 record refuses");
498 let message = refused.to_string();
499 assert!(message.contains('6'), "{message}");
500 }
501
502 #[test]
503 fn alignment_orders_versions_numerically() {
504 assert_eq!(alignment("0.1.0", "0.1.0"), Alignment::Aligned);
505 assert_eq!(alignment("0.1.0", "0.2.0"), Alignment::BinaryNewer);
506 assert_eq!(alignment("0.10.0", "0.9.9"), Alignment::TargetNewer);
507 assert_eq!(alignment("0.1.0-rc.1", "0.1.0"), Alignment::BinaryNewer);
508 assert_eq!(alignment("0.1.0", "0.1.0-rc.1"), Alignment::TargetNewer);
509 }
510
511 #[test]
516 fn alignment_orders_numeric_prerelease_identifiers_numerically() {
517 assert_eq!(
518 alignment("0.1.0-rc.10", "0.1.0-rc.2"),
519 Alignment::TargetNewer
520 );
521 assert_eq!(
522 alignment("0.1.0-rc.2", "0.1.0-rc.10"),
523 Alignment::BinaryNewer
524 );
525 assert_eq!(alignment("0.1.0-rc.1", "0.1.0-rc.1"), Alignment::Aligned);
526 assert_eq!(
527 alignment("0.1.0-alpha", "0.1.0-alpha.1"),
528 Alignment::BinaryNewer
529 );
530 assert_eq!(alignment("0.1.0-1", "0.1.0-alpha"), Alignment::BinaryNewer);
531 assert_eq!(
532 alignment("1.0.0-100000000000000000000", "1.0.0-99999999999999999999"),
533 Alignment::TargetNewer,
534 "identifiers past the u64 range still compare numerically"
535 );
536 assert_eq!(
537 alignment("1.0.0-99999999999999999999", "1.0.0-100000000000000000000"),
538 Alignment::BinaryNewer
539 );
540 }
541
542 #[test]
545 fn alignment_ignores_build_metadata() {
546 assert_eq!(alignment("1.2.10+build", "1.2.9"), Alignment::TargetNewer);
547 assert_eq!(alignment("1.2.9", "1.2.10+build"), Alignment::BinaryNewer);
548 assert_eq!(alignment("1.0.0+alpha", "1.0.0+beta"), Alignment::Aligned);
549 assert_eq!(
550 alignment("1.2.10-rc.1+build", "1.2.10-rc.1"),
551 Alignment::Aligned
552 );
553 assert_eq!(
554 alignment("1.2.10-rc.1+build", "1.2.10"),
555 Alignment::BinaryNewer
556 );
557 }
558}