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 = 4;
36
37const OLDEST_READABLE_SCHEMA: u64 = 1;
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(rename_all = "lowercase")]
44pub enum Workflow {
45 Worktree,
48 Branches,
51}
52
53impl Workflow {
54 #[must_use]
56 pub const fn as_str(self) -> &'static str {
57 match self {
58 Self::Worktree => "worktree",
59 Self::Branches => "branches",
60 }
61 }
62
63 pub fn parse(raw: &str) -> Result<Self, RkError> {
69 match raw {
70 "worktree" => Ok(Self::Worktree),
71 "branches" => Ok(Self::Branches),
72 other => Err(RkError::Usage(format!(
73 "unknown workflow '{other}'; the modes are: worktree, branches"
74 ))),
75 }
76 }
77}
78
79const fn workflow_branches() -> Workflow {
81 Workflow::Branches
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(rename_all = "lowercase")]
91pub enum Style {
92 Trunk,
95 Lines,
98}
99
100impl Style {
101 #[must_use]
103 pub const fn as_str(self) -> &'static str {
104 match self {
105 Self::Trunk => "trunk",
106 Self::Lines => "lines",
107 }
108 }
109
110 pub fn parse(raw: &str) -> Result<Self, RkError> {
116 match raw {
117 "trunk" => Ok(Self::Trunk),
118 "lines" => Ok(Self::Lines),
119 other => Err(RkError::Usage(format!(
120 "unknown style '{other}'; the styles are: trunk, lines"
121 ))),
122 }
123 }
124}
125
126#[derive(Debug, Serialize, Deserialize)]
128pub struct Manifest {
129 pub schema_version: u64,
131 pub rk_version: String,
133 pub payload_sha256: Digest,
136 pub origin: String,
138 pub tech: String,
140 pub forge: String,
142 pub landed_at: String,
144 pub parameters: Parameters,
147 pub files: Vec<FileRecord>,
149 pub pins: BTreeMap<String, String>,
152}
153
154#[derive(Debug, Serialize, Deserialize)]
156pub struct Parameters {
157 pub repo: String,
160 #[serde(default)]
165 pub scopes: Vec<String>,
166 #[serde(default = "workflow_branches")]
172 pub workflow: Workflow,
173 #[serde(default, skip_serializing_if = "Option::is_none")]
179 pub style: Option<Style>,
180 #[serde(default)]
187 pub nix: bool,
188}
189
190#[derive(Debug, Serialize, Deserialize)]
192pub struct FileRecord {
193 pub destination: String,
195 pub kind: Kind,
197 pub sha256: Digest,
200 #[serde(skip_serializing_if = "Option::is_none")]
209 pub baseline_sha256: Option<Digest>,
210}
211
212impl Manifest {
213 #[must_use]
215 pub fn file(&self, destination: &str) -> Option<&FileRecord> {
216 self.files
217 .iter()
218 .find(|file| file.destination == destination)
219 }
220}
221
222pub fn load(target: &Utf8Path) -> Result<Option<Manifest>, RkError> {
231 let path = target.join(MANIFEST_PATH);
232 let bytes = match std::fs::read(&path) {
233 Ok(bytes) => bytes,
234 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
235 Err(e) => {
236 return Err(RkError::refusal(
237 Diagnostic::new(Reason::Io, format!("cannot read {path}: {e}"))
238 .expected("a readable landing record")
239 .target_state("unchanged"),
240 ));
241 }
242 };
243 let value: serde_json::Value = serde_json::from_slice(&bytes)
244 .map_err(|e| anyhow::anyhow!("{path} is not a landing record: {e}"))?;
245 let schema = value
251 .get("schema_version")
252 .and_then(serde_json::Value::as_u64);
253 if !schema.is_some_and(|version| (OLDEST_READABLE_SCHEMA..=SCHEMA_VERSION).contains(&version)) {
254 let found = schema.map_or_else(|| "none".to_owned(), |version| version.to_string());
255 return Err(RkError::refusal(
256 Diagnostic::new(
257 Reason::UnsupportedSchema,
258 format!(
259 "{path} declares schema_version {found}, and this binary knows only {OLDEST_READABLE_SCHEMA} through {SCHEMA_VERSION}"
260 ),
261 )
262 .expected("a record this binary can read")
263 .action("run the rk release that wrote this record, or a newer one")
264 .target_state("unchanged"),
265 ));
266 }
267 let declared = schema.unwrap_or(SCHEMA_VERSION);
268 let manifest: Manifest = serde_json::from_value(value)
269 .map_err(|e| anyhow::anyhow!("{path} does not parse at schema_version {declared}: {e}"))?;
270 Ok(Some(manifest))
271}
272
273pub fn write(target: &Utf8Path, manifest: &Manifest) -> Result<(), RkError> {
279 let text = serde_json::to_string_pretty(manifest).map_err(anyhow::Error::from)?;
280 let path = target.join(MANIFEST_PATH);
281 atomic::write(path.as_std_path(), format!("{text}\n").as_bytes())?;
282 Ok(())
283}
284
285#[must_use]
287pub fn now() -> String {
288 humantime::format_rfc3339_seconds(std::time::SystemTime::now()).to_string()
289}
290
291#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
293#[serde(rename_all = "kebab-case")]
294pub enum Alignment {
295 Aligned,
297 BinaryNewer,
299 TargetNewer,
302}
303
304impl Alignment {
305 #[must_use]
307 pub const fn as_str(self) -> &'static str {
308 match self {
309 Self::Aligned => "aligned",
310 Self::BinaryNewer => "binary-newer",
311 Self::TargetNewer => "target-newer",
312 }
313 }
314}
315
316#[must_use]
318pub fn alignment(recorded: &str, binary: &str) -> Alignment {
319 let recorded = recorded
321 .split_once('+')
322 .map_or(recorded, |(version, _)| version);
323 let binary = binary
324 .split_once('+')
325 .map_or(binary, |(version, _)| version);
326 let recorded_core = numeric_core(recorded);
327 let binary_core = numeric_core(binary);
328 match binary_core.cmp(&recorded_core) {
329 std::cmp::Ordering::Greater => Alignment::BinaryNewer,
330 std::cmp::Ordering::Less => Alignment::TargetNewer,
331 std::cmp::Ordering::Equal => {
332 let recorded_pre = recorded.split_once('-').map(|(_, pre)| pre);
337 let binary_pre = binary.split_once('-').map(|(_, pre)| pre);
338 match (recorded_pre, binary_pre) {
339 (Some(_), None) => Alignment::BinaryNewer,
340 (None, Some(_)) => Alignment::TargetNewer,
341 (None, None) => Alignment::Aligned,
342 (Some(r), Some(b)) => match prerelease_cmp(b, r) {
343 std::cmp::Ordering::Greater => Alignment::BinaryNewer,
344 std::cmp::Ordering::Less => Alignment::TargetNewer,
345 std::cmp::Ordering::Equal => Alignment::Aligned,
346 },
347 }
348 }
349 }
350}
351
352#[must_use]
355pub fn version_is_newer(candidate: &str, pinned: &str) -> bool {
356 alignment(pinned, candidate) == Alignment::BinaryNewer
357}
358
359fn prerelease_cmp(a: &str, b: &str) -> std::cmp::Ordering {
366 let numeric = |identifier: &str| identifier.bytes().all(|byte| byte.is_ascii_digit());
367 let mut left = a.split('.');
368 let mut right = b.split('.');
369 loop {
370 match (left.next(), right.next()) {
371 (None, None) => return std::cmp::Ordering::Equal,
372 (None, Some(_)) => return std::cmp::Ordering::Less,
373 (Some(_), None) => return std::cmp::Ordering::Greater,
374 (Some(x), Some(y)) => {
375 let ordering = match (numeric(x), numeric(y)) {
376 (true, true) => x.len().cmp(&y.len()).then_with(|| x.cmp(y)),
377 (true, false) => std::cmp::Ordering::Less,
378 (false, true) => std::cmp::Ordering::Greater,
379 (false, false) => x.cmp(y),
380 };
381 if ordering != std::cmp::Ordering::Equal {
382 return ordering;
383 }
384 }
385 }
386 }
387}
388
389fn numeric_core(version: &str) -> Vec<u64> {
391 let core = version.split_once('-').map_or(version, |(core, _)| core);
392 core.split('.')
393 .map(|part| part.parse::<u64>().unwrap_or(0))
394 .collect()
395}
396
397#[cfg(test)]
398mod tests {
399 #![allow(clippy::expect_used)]
400
401 use super::{Alignment, FileRecord, Manifest, Parameters, Style, Workflow, alignment};
402 use crate::digest::Digest;
403 use crate::landing::Kind;
404
405 #[test]
409 fn the_manifest_schema_snapshot_holds() {
410 let manifest = Manifest {
411 schema_version: 4,
412 rk_version: "0.1.0".into(),
413 payload_sha256: Digest::of(b""),
414 origin: "init".into(),
415 tech: "rust".into(),
416 forge: "github".into(),
417 landed_at: "2026-08-29T00:00:00Z".into(),
418 parameters: Parameters {
419 repo: "acme/widget".into(),
420 scopes: vec!["api".into(), "cli".into()],
421 workflow: Workflow::Worktree,
422 style: Some(Style::Trunk),
423 nix: true,
424 },
425 files: vec![
426 FileRecord {
427 destination: "release-plz.toml".into(),
428 kind: Kind::Seeded,
429 sha256: Digest::of(b""),
430 baseline_sha256: Some(Digest::of(b"")),
431 },
432 FileRecord {
433 destination: "VERSION".into(),
434 kind: Kind::State,
435 sha256: Digest::of(b""),
436 baseline_sha256: None,
437 },
438 ],
439 pins: std::iter::once(("release-plz".to_owned(), "0.3.160".to_owned())).collect(),
440 };
441 let empty = Digest::of(b"").to_string();
442 assert_eq!(
443 serde_json::to_string(&manifest).expect("a manifest serializes"),
444 format!(
445 r#"{{"schema_version":4,"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","nix":true}},"files":[{{"destination":"release-plz.toml","kind":"seeded","sha256":"{empty}","baseline_sha256":"{empty}"}},{{"destination":"VERSION","kind":"state","sha256":"{empty}"}}],"pins":{{"release-plz":"0.3.160"}}}}"#
446 ),
447 "a state file must omit baseline_sha256 rather than serializing null"
448 );
449 }
450
451 #[test]
455 fn a_schema_1_record_reads_as_branches_and_a_newer_schema_refuses() {
456 let dir = tempfile::tempdir().expect("a scratch target exists");
457 let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
458 std::fs::create_dir_all(target.join(".release-kit")).expect("the record dir writes");
459 let record = |schema: u64| {
460 format!(
461 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":{{}}}}"#
462 )
463 };
464 std::fs::write(target.join(super::MANIFEST_PATH), record(1)).expect("the record writes");
465 let manifest = super::load(target)
466 .expect("a schema-1 record loads")
467 .expect("the record exists");
468 assert_eq!(manifest.parameters.workflow, Workflow::Branches);
469 assert_eq!(
470 manifest.parameters.style, None,
471 "a pre-style record carries no style; the upgrade demands one"
472 );
473 assert!(
474 !manifest.parameters.nix,
475 "a pre-nix record reads as opt-out, so an upgrade adds nothing unrequested"
476 );
477
478 std::fs::write(target.join(super::MANIFEST_PATH), record(5)).expect("the record writes");
479 let refused = super::load(target).expect_err("a schema-5 record refuses");
480 let message = refused.to_string();
481 assert!(message.contains('5'), "{message}");
482 }
483
484 #[test]
485 fn alignment_orders_versions_numerically() {
486 assert_eq!(alignment("0.1.0", "0.1.0"), Alignment::Aligned);
487 assert_eq!(alignment("0.1.0", "0.2.0"), Alignment::BinaryNewer);
488 assert_eq!(alignment("0.10.0", "0.9.9"), Alignment::TargetNewer);
489 assert_eq!(alignment("0.1.0-rc.1", "0.1.0"), Alignment::BinaryNewer);
490 assert_eq!(alignment("0.1.0", "0.1.0-rc.1"), Alignment::TargetNewer);
491 }
492
493 #[test]
498 fn alignment_orders_numeric_prerelease_identifiers_numerically() {
499 assert_eq!(
500 alignment("0.1.0-rc.10", "0.1.0-rc.2"),
501 Alignment::TargetNewer
502 );
503 assert_eq!(
504 alignment("0.1.0-rc.2", "0.1.0-rc.10"),
505 Alignment::BinaryNewer
506 );
507 assert_eq!(alignment("0.1.0-rc.1", "0.1.0-rc.1"), Alignment::Aligned);
508 assert_eq!(
509 alignment("0.1.0-alpha", "0.1.0-alpha.1"),
510 Alignment::BinaryNewer
511 );
512 assert_eq!(alignment("0.1.0-1", "0.1.0-alpha"), Alignment::BinaryNewer);
513 assert_eq!(
514 alignment("1.0.0-100000000000000000000", "1.0.0-99999999999999999999"),
515 Alignment::TargetNewer,
516 "identifiers past the u64 range still compare numerically"
517 );
518 assert_eq!(
519 alignment("1.0.0-99999999999999999999", "1.0.0-100000000000000000000"),
520 Alignment::BinaryNewer
521 );
522 }
523
524 #[test]
527 fn alignment_ignores_build_metadata() {
528 assert_eq!(alignment("1.2.10+build", "1.2.9"), Alignment::TargetNewer);
529 assert_eq!(alignment("1.2.9", "1.2.10+build"), Alignment::BinaryNewer);
530 assert_eq!(alignment("1.0.0+alpha", "1.0.0+beta"), Alignment::Aligned);
531 assert_eq!(
532 alignment("1.2.10-rc.1+build", "1.2.10-rc.1"),
533 Alignment::Aligned
534 );
535 assert_eq!(
536 alignment("1.2.10-rc.1+build", "1.2.10"),
537 Alignment::BinaryNewer
538 );
539 }
540}