Skip to main content

release_kit/landing/
manifest.rs

1//! The landing record: `.release-kit/manifest.json`.
2//!
3//! The record is a manifest, not a stamp: `rk status` and `rk upgrade`
4//! make decisions from it, so it earns a parser that can fail and a
5//! stated schema version — an unknown shape refuses naming the record,
6//! never a best-effort read. It is written last, after every file has
7//! landed, through the temp-plus-rename writer, and it is committed:
8//! every reader it exists for sees only committed files, and it carries
9//! digests of committed files, nothing secret and nothing
10//! machine-specific.
11
12use 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
23/// Where the record lives, relative to the target root.
24pub const MANIFEST_PATH: &str = ".release-kit/manifest.json";
25
26/// The schema this binary writes.
27///
28/// It also reads schema 1 — the pre-mode record, whose absent `workflow`
29/// parameter reads as `branches` — schema 2 — the pre-style record,
30/// whose absent `style` parameter reads as none and holds an upgrade
31/// until `--style` names one — and schema 3 — the pre-nix record, whose
32/// absent `nix` parameter reads as opt-out, so an existing target's
33/// upgrade never sprouts files nobody requested — and schema 4 — the
34/// scope-vocabulary record, whose `scopes` parameter this binary renders
35/// nowhere, so a read drops it and the next rewrite lands without it —
36/// and schema 5 — the pre-policy record, whose absent security parameters
37/// read as the empty contact and the best-effort stance, which is exactly
38/// what such a landing wrote into `SECURITY.md`, so its bytes reproduce —
39/// and refuses anything else by name.
40pub const SCHEMA_VERSION: u64 = 6;
41
42/// The oldest schema this binary still reads.
43const OLDEST_READABLE_SCHEMA: u64 = 1;
44
45/// The working-copy mode a landing records: a project decision, rendered
46/// into the landed blocks and changed only through the landing verbs.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "lowercase")]
49pub enum Workflow {
50    /// Every code-changing branch lives in a linked worktree and the main
51    /// checkout commits nothing.
52    Worktree,
53    /// Branches are worked in the main checkout; worktrees stay available
54    /// beside them and nothing refuses either form.
55    Branches,
56}
57
58impl Workflow {
59    /// The flag, wire, and report form.
60    #[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    /// Parse a `--workflow` flag value.
69    ///
70    /// # Errors
71    ///
72    /// Returns [`RkError::Usage`] naming the two values.
73    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
84/// The serde default for a record from before the parameter existed.
85const fn workflow_branches() -> Workflow {
86    Workflow::Branches
87}
88
89/// The release style a landing records.
90///
91/// Whether the bot's release request stands armed to merge itself: a
92/// project decision, rendered into the landed release workflow and
93/// changed only through the landing verbs.
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
95#[serde(rename_all = "lowercase")]
96pub enum Style {
97    /// The trunk style: the release request carries auto-merge from
98    /// creation, so a green trunk ships itself.
99    Trunk,
100    /// The lines style: every request waits for a human's merge, because
101    /// a line's candidate is validated by hand.
102    Lines,
103}
104
105impl Style {
106    /// The flag, wire, and report form.
107    #[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    /// Parse a `--style` flag value.
116    ///
117    /// # Errors
118    ///
119    /// Returns [`RkError::Usage`] naming the two values.
120    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/// The record a landing writes and every target-side verb reads.
132#[derive(Debug, Serialize, Deserialize)]
133pub struct Manifest {
134    /// An integer this binary either knows or refuses on.
135    pub schema_version: u64,
136    /// The binary that produced the landing.
137    pub rk_version: String,
138    /// The aggregate payload digest from `rk payload`: which payload
139    /// actually landed, where the version alone is ambiguous.
140    pub payload_sha256: Digest,
141    /// `init` or `adopt` — how the record came to exist.
142    pub origin: String,
143    /// The technology that selected the payload.
144    pub tech: String,
145    /// The forge that selected the payload.
146    pub forge: String,
147    /// When the first landing happened; an upgrade preserves it.
148    pub landed_at: String,
149    /// Every value substituted into a `rendered` file, so a re-render is
150    /// reproducible without asking again.
151    pub parameters: Parameters,
152    /// Every landed destination with its kind and digests.
153    pub files: Vec<FileRecord>,
154    /// The registry pins the landed technology uses, copied at landing
155    /// time; `rk status` compares them offline.
156    pub pins: BTreeMap<String, String>,
157}
158
159/// The landing parameters, recorded whole.
160#[derive(Debug, Serialize, Deserialize)]
161pub struct Parameters {
162    /// The project path on the forge, recorded whole because a GitLab
163    /// project may nest below its group.
164    pub repo: String,
165    /// The working-copy mode the project chose: every code-changing branch
166    /// in a linked worktree (`worktree`), or branches worked in the main
167    /// checkout with worktrees optional beside them (`branches`). A record
168    /// predating the field reads as `branches`, so an upgrade never imposes
169    /// a guard the project did not choose.
170    #[serde(default = "workflow_branches")]
171    pub workflow: Workflow,
172    /// The release style the project chose: the bot's request armed to
173    /// merge itself (`trunk`), or every merge a human's (`lines`). A
174    /// record predating the field carries none, and an upgrade refuses
175    /// until `--style` names one: neither value is a compatibility-safe
176    /// reading of a target nobody asked.
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub style: Option<Style>,
179    /// Whether the landing carries the Nix capability: the seeded package
180    /// expression, the flake pair where the target had none, and the
181    /// workflow that proves the build. A record predating the field reads
182    /// as opt-out, so an upgrade adds nothing unrequested; the projection
183    /// stays reproducible from the record because this field is part of
184    /// it.
185    #[serde(default)]
186    pub nix: bool,
187    /// The one permanent branch, rendered into every landed artifact that
188    /// names it. A record predating the field reads as `master`, which is
189    /// what such a landing wrote, so the projection stays reproducible.
190    #[serde(default = "trunk_master")]
191    pub trunk: String,
192    /// The release-line branch prefix, rendered into the release triggers
193    /// and branch guards. A record predating the field reads as
194    /// `release/`, which is what such a landing wrote.
195    #[serde(default = "line_prefix_release")]
196    pub line_prefix: String,
197    /// The contact the landed policy names where the forge's own channel
198    /// is unavailable, empty for the forge's authored wording. A record
199    /// predating the field reads as empty, which is what such a landing
200    /// wrote.
201    #[serde(default, deserialize_with = "read_contact")]
202    pub security_contact: String,
203    /// The acknowledgment window the landed policy promises. A record
204    /// predating the field reads as `best-effort`, which is what such a
205    /// landing wrote.
206    #[serde(default = "response_best_effort", deserialize_with = "read_response")]
207    pub security_response: String,
208}
209
210/// The trunk a record predating the field carries.
211fn trunk_master() -> String {
212    crate::config::TRUNK_DEFAULT.to_owned()
213}
214
215/// The prefix a record predating the field carries.
216fn line_prefix_release() -> String {
217    crate::config::LINE_PREFIX_DEFAULT.to_owned()
218}
219
220/// The stance a record predating the field carries.
221fn response_best_effort() -> String {
222    crate::config::RESPONSE_DEFAULT.to_owned()
223}
224
225/// A recorded contact, refused where the configuration reader would refuse
226/// it or where it is not already canonical.
227///
228/// The record is the one input a re-render reads, so a hand-edited record
229/// must not reach bytes the configured path could never have produced.
230fn read_contact<'de, D: serde::Deserializer<'de>>(reader: D) -> Result<String, D::Error> {
231    canonical(reader, "security_contact", crate::config::canonical_contact)
232}
233
234/// A recorded response stance, held to the same grammar as the key.
235fn 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
243/// One recorded string held to its canonical form.
244fn 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/// One landed destination.
262#[derive(Debug, Serialize, Deserialize)]
263pub struct FileRecord {
264    /// The destination, relative to the target root.
265    pub destination: String,
266    /// The declared ownership kind.
267    pub kind: Kind,
268    /// The digest of what was written — after substitution for a
269    /// `rendered` file, of the marked block for `AGENTS.md`.
270    pub sha256: Digest,
271    /// The digest of the bytes this file's comparisons start from — what
272    /// makes the three-way comparison at upgrade possible. For a
273    /// `rendered` file, the payload as it stood at landing, before
274    /// substitution; for a `seeded` file, the starting point the target
275    /// tunes away from — the seeding payload, or, where a later payload
276    /// reclassified the file from `rendered`, the rendered bytes
277    /// release-kit last wrote. Absent for `state` files, which are never
278    /// compared.
279    #[serde(skip_serializing_if = "Option::is_none")]
280    pub baseline_sha256: Option<Digest>,
281}
282
283impl Manifest {
284    /// The recorded entry for one destination, where the record names it.
285    #[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
293/// Read the record at `target`, or `None` where no landing exists.
294///
295/// # Errors
296///
297/// The record's stated failure taxonomy: an unreadable record is a
298/// refusal naming it, a record at an unknown `schema_version` is a
299/// refusal naming the record, and one that does not parse at a known
300/// schema is a defect-class failure.
301pub 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    // Schema 1 is the pre-mode record: it parses through the same
317    // `Parameters`, whose serde default reads the absent `workflow` as
318    // `branches`. Anything past this binary's schema refuses by name —
319    // the record decides whether a guard is landed, and an older binary
320    // must never silently ignore that.
321    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
344/// Write the record, last, through the temp-plus-rename writer.
345///
346/// # Errors
347///
348/// Any write failure; the destination then holds what it held.
349pub 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
355/// The bytes [`write`] puts on disk for a record, so a planner can name
356/// the digest of the record an apply writes before anything is written.
357///
358/// # Errors
359///
360/// A serialization failure, which is a defect in this binary.
361pub 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/// The current instant in the record's RFC 3339 form.
367#[must_use]
368pub fn now() -> String {
369    humantime::format_rfc3339_seconds(std::time::SystemTime::now()).to_string()
370}
371
372/// How a record's `rk_version` stands against this binary's.
373#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
374#[serde(rename_all = "kebab-case")]
375pub enum Alignment {
376    /// The landing came from this binary's version.
377    Aligned,
378    /// The binary is newer; `rk upgrade` takes the target forward.
379    BinaryNewer,
380    /// The landing came from a newer `rk` than this one, which an upgrade
381    /// refuses rather than downgrading.
382    TargetNewer,
383}
384
385impl Alignment {
386    /// The wire form, identical to the serde rendering.
387    #[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/// Compare a record's version against this binary's.
398#[must_use]
399pub fn alignment(recorded: &str, binary: &str) -> Alignment {
400    // Build metadata after `+` carries no precedence.
401    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            // Equal numeric cores: a pre-release is older than the plain
414            // release it precedes, and two pre-releases compare by semver
415            // precedence — dot-separated identifiers, numeric ones
416            // numerically and below alphanumeric ones.
417            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/// Whether `candidate` is ahead of `pinned`, by the same ordering the
434/// alignment uses.
435#[must_use]
436pub fn version_is_newer(candidate: &str, pinned: &str) -> bool {
437    alignment(pinned, candidate) == Alignment::BinaryNewer
438}
439
440/// Semver pre-release precedence: identifier by identifier, numeric ones
441/// numerically and below any alphanumeric one, and — all preceding
442/// identifiers equal — the longer list wins. An all-digit identifier
443/// compares by digit count and then lexically, which is numeric order at
444/// any length — semver forbids leading zeroes — so no integer parse can
445/// overflow into a wrong answer.
446fn 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
470/// The dotted numeric components before any pre-release suffix.
471fn 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    /// The complete record shape at schema 6, held by snapshot: a field
485    /// rename or removal fails here and becomes a schema-version bump
486    /// instead of a silent break at every reader.
487    #[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    /// A record written before the mode existed reads as `branches`, and
534    /// its scope vocabulary drops, because this binary renders none. A
535    /// record past this binary's schema refuses by name, because the field
536    /// it cannot see decides whether a guard is landed.
537    #[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    /// The record is the one input a re-render reads, so a hand-edited
577    /// record must not reach bytes the configured path could never write:
578    /// a value the configuration reader refuses, and a value it would
579    /// canonicalize, both refuse at deserialization.
580    #[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            // A JSON escape, so the record parses and the value it decodes
587            // to is the line feed the policy could never carry.
588            ("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    /// Pre-release identifiers order by semver precedence, not by text:
615    /// `rc.10` is newer than `rc.2`, so a binary at `rc.2` must refuse a
616    /// landing from `rc.10` rather than downgrade it — at any identifier
617    /// length, so no integer width bounds the protection.
618    #[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    /// Build metadata carries no precedence: it never corrupts a numeric
646    /// component and never separates two otherwise-equal versions.
647    #[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}