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 = 6;
41
42const OLDEST_READABLE_SCHEMA: u64 = 1;
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "lowercase")]
49pub enum Workflow {
50 Worktree,
53 Branches,
56}
57
58impl Workflow {
59 #[must_use]
61 pub const fn as_str(self) -> &'static str {
62 match self {
63 Self::Worktree => "worktree",
64 Self::Branches => "branches",
65 }
66 }
67
68 pub fn parse(raw: &str) -> Result<Self, RkError> {
74 match raw {
75 "worktree" => Ok(Self::Worktree),
76 "branches" => Ok(Self::Branches),
77 other => Err(RkError::Usage(format!(
78 "unknown workflow '{other}'; the modes are: worktree, branches"
79 ))),
80 }
81 }
82}
83
84const fn workflow_branches() -> Workflow {
86 Workflow::Branches
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
95#[serde(rename_all = "lowercase")]
96pub enum Style {
97 Trunk,
100 Lines,
103}
104
105impl Style {
106 #[must_use]
108 pub const fn as_str(self) -> &'static str {
109 match self {
110 Self::Trunk => "trunk",
111 Self::Lines => "lines",
112 }
113 }
114
115 pub fn parse(raw: &str) -> Result<Self, RkError> {
121 match raw {
122 "trunk" => Ok(Self::Trunk),
123 "lines" => Ok(Self::Lines),
124 other => Err(RkError::Usage(format!(
125 "unknown style '{other}'; the styles are: trunk, lines"
126 ))),
127 }
128 }
129}
130
131#[derive(Debug, Serialize, Deserialize)]
133pub struct Manifest {
134 pub schema_version: u64,
136 pub rk_version: String,
138 pub payload_sha256: Digest,
141 pub origin: String,
143 pub tech: String,
145 pub forge: String,
147 pub landed_at: String,
149 pub parameters: Parameters,
152 pub files: Vec<FileRecord>,
154 pub pins: BTreeMap<String, String>,
157}
158
159#[derive(Debug, Serialize, Deserialize)]
161pub struct Parameters {
162 pub repo: String,
165 #[serde(default = "workflow_branches")]
171 pub workflow: Workflow,
172 #[serde(default, skip_serializing_if = "Option::is_none")]
178 pub style: Option<Style>,
179 #[serde(default)]
186 pub nix: bool,
187 #[serde(default = "trunk_master")]
191 pub trunk: String,
192 #[serde(default = "line_prefix_release")]
196 pub line_prefix: String,
197 #[serde(default, deserialize_with = "read_contact")]
202 pub security_contact: String,
203 #[serde(default = "response_best_effort", deserialize_with = "read_response")]
207 pub security_response: String,
208}
209
210fn trunk_master() -> String {
212 crate::config::TRUNK_DEFAULT.to_owned()
213}
214
215fn line_prefix_release() -> String {
217 crate::config::LINE_PREFIX_DEFAULT.to_owned()
218}
219
220fn response_best_effort() -> String {
222 crate::config::RESPONSE_DEFAULT.to_owned()
223}
224
225fn read_contact<'de, D: serde::Deserializer<'de>>(reader: D) -> Result<String, D::Error> {
231 canonical(reader, "security_contact", crate::config::canonical_contact)
232}
233
234fn read_response<'de, D: serde::Deserializer<'de>>(reader: D) -> Result<String, D::Error> {
236 canonical(
237 reader,
238 "security_response",
239 crate::config::canonical_response,
240 )
241}
242
243fn canonical<'de, D: serde::Deserializer<'de>>(
245 reader: D,
246 field: &str,
247 judge: impl Fn(&str) -> Result<String, String>,
248) -> Result<String, D::Error> {
249 let raw = String::deserialize(reader)?;
250 let canonical = judge(&raw)
251 .map_err(|reason| serde::de::Error::custom(format!("parameters.{field}: {reason}")))?;
252 if canonical == raw {
253 Ok(canonical)
254 } else {
255 Err(serde::de::Error::custom(format!(
256 "parameters.{field} is not canonical: the record carries {raw:?} where a landing writes {canonical:?}"
257 )))
258 }
259}
260
261#[derive(Debug, Serialize, Deserialize)]
263pub struct FileRecord {
264 pub destination: String,
266 pub kind: Kind,
268 pub sha256: Digest,
271 #[serde(skip_serializing_if = "Option::is_none")]
280 pub baseline_sha256: Option<Digest>,
281}
282
283impl Manifest {
284 #[must_use]
286 pub fn file(&self, destination: &str) -> Option<&FileRecord> {
287 self.files
288 .iter()
289 .find(|file| file.destination == destination)
290 }
291}
292
293pub fn load(target: &Utf8Path) -> Result<Option<Manifest>, RkError> {
302 let path = target.join(MANIFEST_PATH);
303 let bytes = match std::fs::read(&path) {
304 Ok(bytes) => bytes,
305 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
306 Err(e) => {
307 return Err(RkError::refusal(
308 Diagnostic::new(Reason::Io, format!("cannot read {path}: {e}"))
309 .expected("a readable landing record")
310 .target_state("unchanged"),
311 ));
312 }
313 };
314 let value: serde_json::Value = serde_json::from_slice(&bytes)
315 .map_err(|e| anyhow::anyhow!("{path} is not a landing record: {e}"))?;
316 let schema = value
322 .get("schema_version")
323 .and_then(serde_json::Value::as_u64);
324 if !schema.is_some_and(|version| (OLDEST_READABLE_SCHEMA..=SCHEMA_VERSION).contains(&version)) {
325 let found = schema.map_or_else(|| "none".to_owned(), |version| version.to_string());
326 return Err(RkError::refusal(
327 Diagnostic::new(
328 Reason::UnsupportedSchema,
329 format!(
330 "{path} declares schema_version {found}, and this binary knows only {OLDEST_READABLE_SCHEMA} through {SCHEMA_VERSION}"
331 ),
332 )
333 .expected("a record this binary can read")
334 .action("run the rk release that wrote this record, or a newer one")
335 .target_state("unchanged"),
336 ));
337 }
338 let declared = schema.unwrap_or(SCHEMA_VERSION);
339 let manifest: Manifest = serde_json::from_value(value)
340 .map_err(|e| anyhow::anyhow!("{path} does not parse at schema_version {declared}: {e}"))?;
341 Ok(Some(manifest))
342}
343
344pub fn write(target: &Utf8Path, manifest: &Manifest) -> Result<(), RkError> {
350 let path = target.join(MANIFEST_PATH);
351 atomic::write(path.as_std_path(), &render(manifest)?)?;
352 Ok(())
353}
354
355pub fn render(manifest: &Manifest) -> Result<Vec<u8>, RkError> {
362 let text = serde_json::to_string_pretty(manifest).map_err(anyhow::Error::from)?;
363 Ok(format!("{text}\n").into_bytes())
364}
365
366#[must_use]
368pub fn now() -> String {
369 humantime::format_rfc3339_seconds(std::time::SystemTime::now()).to_string()
370}
371
372#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
374#[serde(rename_all = "kebab-case")]
375pub enum Alignment {
376 Aligned,
378 BinaryNewer,
380 TargetNewer,
383}
384
385impl Alignment {
386 #[must_use]
388 pub const fn as_str(self) -> &'static str {
389 match self {
390 Self::Aligned => "aligned",
391 Self::BinaryNewer => "binary-newer",
392 Self::TargetNewer => "target-newer",
393 }
394 }
395}
396
397#[must_use]
399pub fn alignment(recorded: &str, binary: &str) -> Alignment {
400 let recorded = recorded
402 .split_once('+')
403 .map_or(recorded, |(version, _)| version);
404 let binary = binary
405 .split_once('+')
406 .map_or(binary, |(version, _)| version);
407 let recorded_core = numeric_core(recorded);
408 let binary_core = numeric_core(binary);
409 match binary_core.cmp(&recorded_core) {
410 std::cmp::Ordering::Greater => Alignment::BinaryNewer,
411 std::cmp::Ordering::Less => Alignment::TargetNewer,
412 std::cmp::Ordering::Equal => {
413 let recorded_pre = recorded.split_once('-').map(|(_, pre)| pre);
418 let binary_pre = binary.split_once('-').map(|(_, pre)| pre);
419 match (recorded_pre, binary_pre) {
420 (Some(_), None) => Alignment::BinaryNewer,
421 (None, Some(_)) => Alignment::TargetNewer,
422 (None, None) => Alignment::Aligned,
423 (Some(r), Some(b)) => match prerelease_cmp(b, r) {
424 std::cmp::Ordering::Greater => Alignment::BinaryNewer,
425 std::cmp::Ordering::Less => Alignment::TargetNewer,
426 std::cmp::Ordering::Equal => Alignment::Aligned,
427 },
428 }
429 }
430 }
431}
432
433#[must_use]
436pub fn version_is_newer(candidate: &str, pinned: &str) -> bool {
437 alignment(pinned, candidate) == Alignment::BinaryNewer
438}
439
440fn prerelease_cmp(a: &str, b: &str) -> std::cmp::Ordering {
447 let numeric = |identifier: &str| identifier.bytes().all(|byte| byte.is_ascii_digit());
448 let mut left = a.split('.');
449 let mut right = b.split('.');
450 loop {
451 match (left.next(), right.next()) {
452 (None, None) => return std::cmp::Ordering::Equal,
453 (None, Some(_)) => return std::cmp::Ordering::Less,
454 (Some(_), None) => return std::cmp::Ordering::Greater,
455 (Some(x), Some(y)) => {
456 let ordering = match (numeric(x), numeric(y)) {
457 (true, true) => x.len().cmp(&y.len()).then_with(|| x.cmp(y)),
458 (true, false) => std::cmp::Ordering::Less,
459 (false, true) => std::cmp::Ordering::Greater,
460 (false, false) => x.cmp(y),
461 };
462 if ordering != std::cmp::Ordering::Equal {
463 return ordering;
464 }
465 }
466 }
467 }
468}
469
470fn numeric_core(version: &str) -> Vec<u64> {
472 let core = version.split_once('-').map_or(version, |(core, _)| core);
473 core.split('.')
474 .map(|part| part.parse::<u64>().unwrap_or(0))
475 .collect()
476}
477
478#[cfg(test)]
479mod tests {
480 use super::{Alignment, FileRecord, Manifest, Parameters, Style, Workflow, alignment};
481 use crate::digest::Digest;
482 use crate::landing::Kind;
483
484 #[test]
488 fn the_manifest_schema_snapshot_holds() {
489 let manifest = Manifest {
490 schema_version: 6,
491 rk_version: "0.1.0".into(),
492 payload_sha256: Digest::of(b""),
493 origin: "init".into(),
494 tech: "rust".into(),
495 forge: "github".into(),
496 landed_at: "2026-08-29T00:00:00Z".into(),
497 parameters: Parameters {
498 repo: "acme/widget".into(),
499 workflow: Workflow::Worktree,
500 style: Some(Style::Trunk),
501 nix: true,
502 trunk: crate::config::TRUNK_DEFAULT.to_owned(),
503 line_prefix: crate::config::LINE_PREFIX_DEFAULT.to_owned(),
504 security_contact: String::new(),
505 security_response: crate::config::RESPONSE_DEFAULT.to_owned(),
506 },
507 files: vec![
508 FileRecord {
509 destination: "release-plz.toml".into(),
510 kind: Kind::Seeded,
511 sha256: Digest::of(b""),
512 baseline_sha256: Some(Digest::of(b"")),
513 },
514 FileRecord {
515 destination: "VERSION".into(),
516 kind: Kind::State,
517 sha256: Digest::of(b""),
518 baseline_sha256: None,
519 },
520 ],
521 pins: std::iter::once(("release-plz".to_owned(), "0.3.160".to_owned())).collect(),
522 };
523 let empty = Digest::of(b"").to_string();
524 assert_eq!(
525 serde_json::to_string(&manifest).expect("a manifest serializes"),
526 format!(
527 r#"{{"schema_version":6,"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/","security_contact":"","security_response":"best-effort"}},"files":[{{"destination":"release-plz.toml","kind":"seeded","sha256":"{empty}","baseline_sha256":"{empty}"}},{{"destination":"VERSION","kind":"state","sha256":"{empty}"}}],"pins":{{"release-plz":"0.3.160"}}}}"#
528 ),
529 "a state file must omit baseline_sha256 rather than serializing null"
530 );
531 }
532
533 #[test]
538 fn a_schema_1_record_reads_as_branches_and_a_newer_schema_refuses() {
539 let dir = tempfile::tempdir().expect("a scratch target exists");
540 let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
541 std::fs::create_dir_all(target.join(".release-kit")).expect("the record dir writes");
542 let record = |schema: u64| {
543 format!(
544 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":{{}}}}"#
545 )
546 };
547 std::fs::write(target.join(super::MANIFEST_PATH), record(1)).expect("the record writes");
548 let manifest = super::load(target)
549 .expect("a schema-1 record loads")
550 .expect("the record exists");
551 assert_eq!(manifest.parameters.workflow, Workflow::Branches);
552 assert_eq!(
553 manifest.parameters.style, None,
554 "a pre-style record carries no style; the upgrade demands one"
555 );
556 assert!(
557 !manifest.parameters.nix,
558 "a pre-nix record reads as opt-out, so an upgrade adds nothing unrequested"
559 );
560 assert_eq!(
561 manifest.parameters.security_contact, "",
562 "a pre-policy record names no contact, which is what its policy landed"
563 );
564 assert_eq!(
565 manifest.parameters.security_response,
566 crate::config::RESPONSE_DEFAULT,
567 "a pre-policy record promises no window, which is what its policy landed"
568 );
569
570 std::fs::write(target.join(super::MANIFEST_PATH), record(7)).expect("the record writes");
571 let refused = super::load(target).expect_err("a schema-7 record refuses");
572 let message = refused.to_string();
573 assert!(message.contains('7'), "{message}");
574 }
575
576 #[test]
581 fn a_record_carrying_an_uncanonical_security_parameter_refuses() {
582 let dir = tempfile::tempdir().expect("a scratch target exists");
583 let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
584 std::fs::create_dir_all(target.join(".release-kit")).expect("the record dir writes");
585 for (field, value) in [
586 ("security_contact", "team@acme.example\\nsecond line"),
589 ("security_contact", " team@acme.example "),
590 ("security_response", "90d"),
591 ("security_response", "0 days"),
592 ("security_response", "07 days"),
593 ("security_response", "1 days"),
594 ("security_response", ""),
595 ] {
596 let record = format!(
597 r#"{{"schema_version":6,"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","{field}":"{value}"}},"files":[],"pins":{{}}}}"#
598 );
599 std::fs::write(target.join(super::MANIFEST_PATH), record).expect("the record writes");
600 let refused = super::load(target).expect_err("an uncanonical record refuses");
601 assert!(refused.to_string().contains(field), "{field}: {refused}");
602 }
603 }
604
605 #[test]
606 fn alignment_orders_versions_numerically() {
607 assert_eq!(alignment("0.1.0", "0.1.0"), Alignment::Aligned);
608 assert_eq!(alignment("0.1.0", "0.2.0"), Alignment::BinaryNewer);
609 assert_eq!(alignment("0.10.0", "0.9.9"), Alignment::TargetNewer);
610 assert_eq!(alignment("0.1.0-rc.1", "0.1.0"), Alignment::BinaryNewer);
611 assert_eq!(alignment("0.1.0", "0.1.0-rc.1"), Alignment::TargetNewer);
612 }
613
614 #[test]
619 fn alignment_orders_numeric_prerelease_identifiers_numerically() {
620 assert_eq!(
621 alignment("0.1.0-rc.10", "0.1.0-rc.2"),
622 Alignment::TargetNewer
623 );
624 assert_eq!(
625 alignment("0.1.0-rc.2", "0.1.0-rc.10"),
626 Alignment::BinaryNewer
627 );
628 assert_eq!(alignment("0.1.0-rc.1", "0.1.0-rc.1"), Alignment::Aligned);
629 assert_eq!(
630 alignment("0.1.0-alpha", "0.1.0-alpha.1"),
631 Alignment::BinaryNewer
632 );
633 assert_eq!(alignment("0.1.0-1", "0.1.0-alpha"), Alignment::BinaryNewer);
634 assert_eq!(
635 alignment("1.0.0-100000000000000000000", "1.0.0-99999999999999999999"),
636 Alignment::TargetNewer,
637 "identifiers past the u64 range still compare numerically"
638 );
639 assert_eq!(
640 alignment("1.0.0-99999999999999999999", "1.0.0-100000000000000000000"),
641 Alignment::BinaryNewer
642 );
643 }
644
645 #[test]
648 fn alignment_ignores_build_metadata() {
649 assert_eq!(alignment("1.2.10+build", "1.2.9"), Alignment::TargetNewer);
650 assert_eq!(alignment("1.2.9", "1.2.10+build"), Alignment::BinaryNewer);
651 assert_eq!(alignment("1.0.0+alpha", "1.0.0+beta"), Alignment::Aligned);
652 assert_eq!(
653 alignment("1.2.10-rc.1+build", "1.2.10-rc.1"),
654 Alignment::Aligned
655 );
656 assert_eq!(
657 alignment("1.2.10-rc.1+build", "1.2.10"),
658 Alignment::BinaryNewer
659 );
660 }
661}