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;
22pub use crate::profile::{
23    CapabilityRequests, GitWorkflow, ProfileSnapshot, ReleaseIntent, ReleaseMode,
24};
25
26/// Where the record lives, relative to the target root.
27pub const MANIFEST_PATH: &str = ".release-kit/manifest.json";
28
29/// The schema this binary writes.
30///
31/// Schema 10 is the receipt of a direct landing: the producing
32/// `rk_version`, the origin, the resolved target configuration by domain,
33/// and per destination the path, the kind, the placement where the
34/// destination is a marked region, and the digest of the bytes or region
35/// now present. It carries no bundle digest and no baseline digest,
36/// because the landing renders afresh from this binary and compares
37/// against no earlier release.
38///
39/// Schemas 1 through 8 read through one bounded conversion in
40/// [`legacy`]: the retired `payload_sha256`, per-file `baseline_sha256`,
41/// and `parameters.scopes` fields are dropped, the one technology becomes
42/// the sole technology and the automatic release driver, and the flat
43/// parameters move into their domains. The next successful landing
44/// rewrites schema 10. Anything past this schema refuses by name.
45///
46/// Schema 10 adds `git.integration`, the authority that moves an
47/// implementation onto the trunk. A record at schema 9 or below carries
48/// no such key and reads as `forge`, which is the authority it landed.
49///
50/// Schema 11 adds `capabilities.workflow_guards`. A record at schema 10 or
51/// below carries no such key: that is the pending answer, which reads as
52/// kept, because every such landing carries the guards.
53///
54/// Schema 12 adds `git.merge_form`, how the commits of a branch reach the
55/// trunk. A record at schema 11 or below carries no such key and reads as
56/// `squash`, which is the form every such landing took.
57///
58/// SATISFIES landing:a-record-states-its-schema
59/// SATISFIES git:the-merge-form-is-a-landing-parameter
60pub const SCHEMA_VERSION: u64 = 12;
61
62/// The oldest schema this binary still reads.
63const OLDEST_READABLE_SCHEMA: u64 = 1;
64
65/// The first schema that states a destination's placement. A record below
66/// it carried none, and the block destinations were regions by their names
67/// alone.
68const PLACEMENT_SCHEMA: u64 = 7;
69
70/// The checkout mode a landing records: where a topic branch opens.
71///
72/// A Git workflow parameter, rendered into the landed blocks and changed
73/// only through the landing verbs. It selects a working tree and nothing
74/// else: no branching method, no rebase policy, no merge policy.
75///
76/// SATISFIES git:checkout-mode-selects-where-a-topic-branch-opens
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
78#[serde(rename_all = "kebab-case")]
79pub enum CheckoutMode {
80    /// Every code-changing branch opens in a linked worktree and the main
81    /// checkout commits nothing.
82    LinkedWorktree,
83    /// A branch opens in the repository's original working tree, which
84    /// switches to it; worktrees stay available beside it and nothing
85    /// refuses either form.
86    MainWorktree,
87}
88
89impl CheckoutMode {
90    /// The flag, wire, and report form.
91    #[must_use]
92    pub const fn as_str(self) -> &'static str {
93        match self {
94            Self::LinkedWorktree => "linked-worktree",
95            Self::MainWorktree => "main-worktree",
96        }
97    }
98
99    /// The label the runbooks select a variant on.
100    #[must_use]
101    pub const fn runbook_label(self) -> &'static str {
102        match self {
103            Self::LinkedWorktree => "worktree",
104            Self::MainWorktree => "branches",
105        }
106    }
107
108    /// Parse a `--checkout-mode` flag value. The two names a record
109    /// carried before the vocabulary moved, `worktree` and `branches`,
110    /// still read, so an older command line and an older configuration
111    /// keep working.
112    ///
113    /// # Errors
114    ///
115    /// Returns [`RkError::Usage`] naming the two values.
116    pub fn parse(raw: &str) -> Result<Self, RkError> {
117        match raw {
118            "linked-worktree" | "worktree" => Ok(Self::LinkedWorktree),
119            "main-worktree" | "branches" => Ok(Self::MainWorktree),
120            other => Err(RkError::Usage(format!(
121                "unknown checkout mode '{other}'; the modes are: linked-worktree, main-worktree"
122            ))),
123        }
124    }
125}
126
127impl<'de> serde::Deserialize<'de> for CheckoutMode {
128    fn deserialize<D: serde::Deserializer<'de>>(reader: D) -> Result<Self, D::Error> {
129        let raw = String::deserialize(reader)?;
130        Self::parse(&raw).map_err(|error| serde::de::Error::custom(error.to_string()))
131    }
132}
133
134/// The integration mode a landing records: which authority moves an
135/// implementation onto the trunk.
136///
137/// A Git workflow parameter, rendered into the landed blocks and changed
138/// only through the landing verbs. It selects an authority and nothing
139/// else: no branching method, no checkout mode, and no release path.
140///
141/// SATISFIES git:integration-mode-selects-the-authority-that-squashes
142#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
143#[serde(rename_all = "lowercase")]
144pub enum Integration {
145    /// The checkout performs and records the integration, through
146    /// `rk integrate`, and the operator pushes the trunk separately.
147    Local,
148    /// The forge performs and records the integration, through a pull
149    /// request or a merge request behind its required check.
150    Forge,
151}
152
153impl Integration {
154    /// The flag, wire, and report form.
155    #[must_use]
156    pub const fn as_str(self) -> &'static str {
157        match self {
158            Self::Local => "local",
159            Self::Forge => "forge",
160        }
161    }
162
163    /// Parse an `--integration` flag value.
164    ///
165    /// # Errors
166    ///
167    /// Returns [`RkError::Usage`] naming the two values.
168    pub fn parse(raw: &str) -> Result<Self, RkError> {
169        match raw {
170            "local" => Ok(Self::Local),
171            "forge" => Ok(Self::Forge),
172            other => Err(RkError::Usage(format!(
173                "unknown integration mode '{other}'; the modes are: local, forge"
174            ))),
175        }
176    }
177}
178
179impl<'de> serde::Deserialize<'de> for Integration {
180    fn deserialize<D: serde::Deserializer<'de>>(reader: D) -> Result<Self, D::Error> {
181        let raw = String::deserialize(reader)?;
182        Self::parse(&raw).map_err(|error| serde::de::Error::custom(error.to_string()))
183    }
184}
185
186/// The compatibility answer for a record written before the integration
187/// axis existed: such a target landed the forge blocks and the forge
188/// protections, so reading it as `local` would tell it that its trunk
189/// takes direct pushes.
190pub(crate) const fn integration_forge() -> Integration {
191    Integration::Forge
192}
193
194/// The merge form a landing records: how the commits of a branch reach
195/// the trunk.
196///
197/// A Git workflow parameter, changed only through the landing verbs. It
198/// selects a form and nothing else: no authority, no checkout mode, and no
199/// release path. Both forms keep the trunk linear and write no merge
200/// commit.
201///
202/// SATISFIES git:the-merge-form-is-a-landing-parameter
203#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
204#[serde(rename_all = "lowercase")]
205pub enum MergeForm {
206    /// The branch arrives as one commit, so one implementation is one
207    /// trunk commit.
208    Squash,
209    /// The branch is rebased on the trunk tip and the trunk fast-forwards
210    /// to it, so every commit arrives with its own message.
211    Rebase,
212}
213
214impl MergeForm {
215    /// The flag, wire, and report form.
216    #[must_use]
217    pub const fn as_str(self) -> &'static str {
218        match self {
219            Self::Squash => "squash",
220            Self::Rebase => "rebase",
221        }
222    }
223
224    /// Parse a `--merge-form` flag value.
225    ///
226    /// # Errors
227    ///
228    /// Returns [`RkError::Usage`] naming the two values.
229    pub fn parse(raw: &str) -> Result<Self, RkError> {
230        match raw {
231            "squash" => Ok(Self::Squash),
232            "rebase" => Ok(Self::Rebase),
233            other => Err(RkError::Usage(format!(
234                "unknown merge form '{other}'; the forms are: squash, rebase"
235            ))),
236        }
237    }
238}
239
240impl<'de> serde::Deserialize<'de> for MergeForm {
241    fn deserialize<D: serde::Deserializer<'de>>(reader: D) -> Result<Self, D::Error> {
242        let raw = String::deserialize(reader)?;
243        Self::parse(&raw).map_err(|error| serde::de::Error::custom(error.to_string()))
244    }
245}
246
247/// The compiled default, and the answer for a record written before the
248/// merge form existed.
249///
250/// Every such landing took the squash form, so reading it as anything
251/// else would change how its branches reach the trunk without a question.
252#[must_use]
253pub const fn merge_form_squash() -> MergeForm {
254    MergeForm::Squash
255}
256
257/// The release style a landing records.
258///
259/// Whether the bot's release request stands armed to merge itself: a
260/// project decision, rendered into the landed release workflow and
261/// changed only through the landing verbs.
262#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
263#[serde(rename_all = "lowercase")]
264pub enum Style {
265    /// The trunk style: the release request carries auto-merge from
266    /// creation, so a green trunk ships itself.
267    Trunk,
268    /// The lines style: every request waits for a human's merge, because
269    /// a line's candidate is validated by hand.
270    Lines,
271}
272
273impl Style {
274    /// The flag, wire, and report form.
275    #[must_use]
276    pub const fn as_str(self) -> &'static str {
277        match self {
278            Self::Trunk => "trunk",
279            Self::Lines => "lines",
280        }
281    }
282
283    /// Parse a `--style` flag value.
284    ///
285    /// # Errors
286    ///
287    /// Returns [`RkError::Usage`] naming the two values.
288    pub fn parse(raw: &str) -> Result<Self, RkError> {
289        match raw {
290            "trunk" => Ok(Self::Trunk),
291            "lines" => Ok(Self::Lines),
292            other => Err(RkError::Usage(format!(
293                "unknown style '{other}'; the styles are: trunk, lines"
294            ))),
295        }
296    }
297}
298
299/// The code scanning provider a landing records.
300///
301/// A project decision: which analyzer the landed workflow runs, and with it
302/// whether the landing carries a licence condition at all.
303#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
304#[serde(rename_all = "lowercase")]
305pub enum Provider {
306    /// GitHub's own analyzer. Free under terms that cover an open-source
307    /// codebase alone, so a landing reads the binding's declared licence
308    /// first and refuses the pair where it states no such codebase.
309    CodeQl,
310    /// Semgrep Community Edition, which carries no licence condition on the
311    /// codebase it scans and runs on either forge.
312    Semgrep,
313}
314
315impl Provider {
316    /// The flag, wire, and report form.
317    #[must_use]
318    pub const fn as_str(self) -> &'static str {
319        match self {
320            Self::CodeQl => "codeql",
321            Self::Semgrep => "semgrep",
322        }
323    }
324
325    /// Parse a `--code-scanning` flag value.
326    ///
327    /// # Errors
328    ///
329    /// Returns [`RkError::Usage`] naming the providers and the word that
330    /// turns the capability off.
331    pub fn parse(raw: &str) -> Result<Option<Self>, RkError> {
332        match raw {
333            "codeql" => Ok(Some(Self::CodeQl)),
334            "semgrep" => Ok(Some(Self::Semgrep)),
335            "off" => Ok(None),
336            other => Err(RkError::Usage(format!(
337                "unknown code scanning provider '{other}'; the providers are: codeql, semgrep, and off turns the capability off"
338            ))),
339        }
340    }
341}
342
343/// The record a landing writes and every target-side verb reads.
344///
345/// Schema 9 records the resolved target configuration by domain: the
346/// source-free profile snapshot, the Git workflow parameters, the
347/// capability requests, and the render parameters no domain owns.
348#[derive(Debug, Serialize, Deserialize)]
349pub struct Manifest {
350    /// An integer this binary either knows or refuses on.
351    pub schema_version: u64,
352    /// The binary that produced the landing.
353    pub rk_version: String,
354    /// `init` or `adopt` — how the record came to exist.
355    pub origin: String,
356    /// When the first landing happened; an upgrade preserves it.
357    pub landed_at: String,
358    /// What the project is: its technologies, its forge where it has one,
359    /// and its release intent. Values alone, no precedence source.
360    pub profile: ProfileSnapshot,
361    /// How topic branches reach the trunk: the trunk's name and the
362    /// checkout mode.
363    pub git: GitWorkflow,
364    /// Which optional products the target requested.
365    pub capabilities: CapabilityRequests,
366    /// Every remaining value substituted into a `rendered` file, so a
367    /// re-render is reproducible without asking again.
368    pub parameters: Parameters,
369    /// Every landed destination with its kind and digests.
370    pub files: Vec<FileRecord>,
371    /// The registry pins the selected capabilities use, copied at landing
372    /// time; `rk status` compares them offline.
373    pub pins: BTreeMap<String, String>,
374}
375
376/// The render parameters no domain table owns, recorded whole.
377#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
378pub struct Parameters {
379    /// The project path on the forge, recorded whole because a GitLab
380    /// project may nest below its group. Empty where the target has no
381    /// forge repository.
382    #[serde(default)]
383    pub repo: String,
384    /// The contact the landed policy names where the forge's own channel
385    /// is unavailable, empty for the forge's authored wording. A record
386    /// predating the field reads as empty, which is what such a landing
387    /// wrote.
388    #[serde(default, deserialize_with = "read_contact")]
389    pub security_contact: String,
390    /// The acknowledgment window the landed policy promises. A record
391    /// predating the field reads as `best-effort`, which is what such a
392    /// landing wrote.
393    #[serde(default = "response_best_effort", deserialize_with = "read_response")]
394    pub security_response: String,
395    /// The paragraph the landed policy states about what the project does
396    /// not own, empty where it states none. A record predating the field
397    /// reads as empty, which is what such a landing wrote, and an empty
398    /// answer omits the key, so the record's bytes do not change.
399    #[serde(
400        default,
401        skip_serializing_if = "String::is_empty",
402        deserialize_with = "read_scope"
403    )]
404    pub security_scope: String,
405    /// The check the release gate believes. A record predating the field
406    /// reads as empty, which is what such a landing wrote: it rendered a
407    /// standing arm that named no check.
408    #[serde(default)]
409    pub required_check: String,
410    /// The workflow whose completion wakes the release gate. A record
411    /// predating the field reads as empty, for the same reason.
412    #[serde(default)]
413    pub required_workflow: String,
414    /// How a command runs in the project's own development environment,
415    /// absent where the project gives no answer. A record predating the
416    /// field reads as absent, which is what such a landing rendered, and a
417    /// record with no answer omits the key, so its bytes do not change.
418    #[serde(
419        default,
420        skip_serializing_if = "Option::is_none",
421        deserialize_with = "read_dev_environment"
422    )]
423    pub dev_environment: Option<crate::config::dev_environment::DevEnvironment>,
424}
425
426/// A recorded development environment, held to the configuration's own
427/// rules, because the record is the one input a re-render reads.
428fn read_dev_environment<'de, D: serde::Deserializer<'de>>(
429    reader: D,
430) -> Result<Option<crate::config::dev_environment::DevEnvironment>, D::Error> {
431    let answer = crate::config::dev_environment::DevEnvironment::deserialize(reader)?;
432    answer.check().map_err(|reason| {
433        serde::de::Error::custom(format!("parameters.dev_environment: {reason}"))
434    })?;
435    answer.answered().map_or_else(
436        || {
437            Err(serde::de::Error::custom(
438                "parameters.dev_environment: a recorded answer names a run prefix",
439            ))
440        },
441        |answer| Ok(Some(answer)),
442    )
443}
444
445/// The stance a record predating the field carries.
446fn response_best_effort() -> String {
447    crate::config::RESPONSE_DEFAULT.to_owned()
448}
449
450/// A recorded contact, refused where the configuration reader would refuse
451/// it or where it is not already canonical.
452///
453/// The record is the one input a re-render reads, so a hand-edited record
454/// must not reach bytes the configured path could never have produced.
455fn read_contact<'de, D: serde::Deserializer<'de>>(reader: D) -> Result<String, D::Error> {
456    canonical(reader, "security_contact", crate::config::canonical_contact)
457}
458
459/// A recorded response stance, held to the same grammar as the key.
460fn read_response<'de, D: serde::Deserializer<'de>>(reader: D) -> Result<String, D::Error> {
461    canonical(
462        reader,
463        "security_response",
464        crate::config::canonical_response,
465    )
466}
467
468/// A recorded scope paragraph, held to the same grammar as the key.
469fn read_scope<'de, D: serde::Deserializer<'de>>(reader: D) -> Result<String, D::Error> {
470    canonical(reader, "security_scope", crate::config::canonical_scope)
471}
472
473/// One recorded string held to its canonical form.
474fn canonical<'de, D: serde::Deserializer<'de>>(
475    reader: D,
476    field: &str,
477    judge: impl Fn(&str) -> Result<String, String>,
478) -> Result<String, D::Error> {
479    let raw = String::deserialize(reader)?;
480    let canonical = judge(&raw)
481        .map_err(|reason| serde::de::Error::custom(format!("parameters.{field}: {reason}")))?;
482    if canonical == raw {
483        Ok(canonical)
484    } else {
485        Err(serde::de::Error::custom(format!(
486            "parameters.{field} is not canonical: the record carries {raw:?} where a landing writes {canonical:?}"
487        )))
488    }
489}
490
491/// One landed destination.
492#[derive(Debug, Serialize, Deserialize)]
493pub struct FileRecord {
494    /// The destination, relative to the target root.
495    pub destination: String,
496    /// The declared ownership kind.
497    pub kind: Kind,
498    /// The digest of what the destination holds: the bytes now present
499    /// for a whole file, the marked region alone for a region destination.
500    /// One exception: a rendered file preserved under an accepted-drift
501    /// entry records the candidate's digest, not the accepted bytes on
502    /// disk, so the receipt goes on saying what the tool would write.
503    pub sha256: Digest,
504    /// How the landing occupies the destination: the whole file, which
505    /// the record omits, or one marked region inside a document the
506    /// target owns.
507    #[serde(default, skip_serializing_if = "Placement::is_whole")]
508    pub placement: Placement,
509}
510
511/// How a recorded destination is occupied.
512#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
513#[serde(rename_all = "lowercase")]
514pub enum Placement {
515    /// The landing owns the whole file.
516    #[default]
517    Whole,
518    /// The landing owns the one marked region; every byte outside it is
519    /// the target's.
520    Region,
521}
522
523impl Placement {
524    /// Whether this is the default the record omits.
525    #[must_use]
526    pub const fn is_whole(&self) -> bool {
527        matches!(self, Self::Whole)
528    }
529
530    /// The report form.
531    #[must_use]
532    pub const fn as_str(self) -> &'static str {
533        match self {
534            Self::Whole => "whole",
535            Self::Region => "region",
536        }
537    }
538}
539
540/// The one bounded conversion from a record at schemas 1 through 8 to the
541/// current shape.
542///
543/// It reads no other release and interprets no other release's sources:
544/// it drops the fields the direct landing retired, and it moves the one
545/// technology, the forge, and the flat parameters an older record carried
546/// into the domains schema 9 states. Every older record described an
547/// automatic release driven by its one technology, with the reporting
548/// policy landed, so that is what the conversion says.
549pub mod legacy {
550    use serde_json::{Map, Value, json};
551
552    /// Take a field out of a JSON object, where it is one.
553    fn take(object: &mut Map<String, Value>, key: &str) -> Option<Value> {
554        object.remove(key)
555    }
556
557    /// Rewrite a record value at a schema before this binary's so it
558    /// deserializes as the current shape.
559    ///
560    /// `payload_sha256` named a bundle digest no comparison reads any
561    /// more; per-file `baseline_sha256` fed a three-way comparison that
562    /// no longer exists; `parameters.scopes` was a vocabulary this binary
563    /// renders nowhere. `tech`, `forge`, and the flat `parameters` become
564    /// the `profile`, `git`, and `capabilities` domains, with the old
565    /// `worktree` and `branches` mode names read as `linked-worktree` and
566    /// `main-worktree`.
567    #[must_use]
568    pub fn convert(mut value: Value) -> Value {
569        let Some(record) = value.as_object_mut() else {
570            return value;
571        };
572        record.remove("payload_sha256");
573        if let Some(files) = record.get_mut("files").and_then(Value::as_array_mut) {
574            for file in files.iter_mut().filter_map(Value::as_object_mut) {
575                file.remove("baseline_sha256");
576            }
577        }
578        if record.contains_key("profile") {
579            return value;
580        }
581        let tech = take(record, "tech").and_then(|v| v.as_str().map(str::to_owned));
582        let forge = take(record, "forge").and_then(|v| v.as_str().map(str::to_owned));
583        let mut parameters = take(record, "parameters")
584            .and_then(|v| v.as_object().cloned())
585            .unwrap_or_default();
586        parameters.remove("scopes");
587        let checkout_mode = match parameters
588            .remove("workflow")
589            .and_then(|v| v.as_str().map(str::to_owned))
590            .as_deref()
591        {
592            Some("worktree") => "linked-worktree",
593            // A record predating the mode carried none, and such a
594            // landing wrote the blocks without the guard.
595            _ => "main-worktree",
596        };
597        let style = parameters.remove("style").unwrap_or(Value::Null);
598        let trunk = parameters
599            .remove("trunk")
600            .unwrap_or_else(|| json!(crate::config::TRUNK_DEFAULT));
601        let line_prefix = parameters
602            .remove("line_prefix")
603            .unwrap_or_else(|| json!(crate::config::LINE_PREFIX_DEFAULT));
604        let nix = parameters.remove("nix").unwrap_or(json!(false));
605        let scorecard = parameters.remove("scorecard").unwrap_or(json!(false));
606        let code_scanning = parameters.remove("code_scanning").unwrap_or(Value::Null);
607        let mut release = Map::new();
608        release.insert("mode".into(), json!("automatic"));
609        if let Some(tech) = &tech {
610            release.insert("driver".into(), json!(tech));
611        }
612        if !style.is_null() {
613            release.insert("style".into(), style);
614        }
615        release.insert("line_prefix".into(), line_prefix);
616        let technologies: Vec<Value> = tech.iter().map(|t| json!(t)).collect();
617        record.insert(
618            "profile".into(),
619            json!({
620                "technologies": technologies,
621                "forge": forge,
622                "release": Value::Object(release),
623            }),
624        );
625        record.insert(
626            "git".into(),
627            json!({ "trunk": trunk, "checkout_mode": checkout_mode }),
628        );
629        record.insert(
630            "capabilities".into(),
631            json!({
632                "nix_packaging": nix,
633                "reporting_policy": true,
634                "scorecard": scorecard,
635                "code_scanning": code_scanning,
636            }),
637        );
638        record.insert("parameters".into(), Value::Object(parameters));
639        value
640    }
641}
642
643impl Manifest {
644    /// The recorded entry for one destination, where the record names it.
645    #[must_use]
646    pub fn file(&self, destination: &str) -> Option<&FileRecord> {
647        self.files
648            .iter()
649            .find(|file| file.destination == destination)
650    }
651}
652
653/// Read the record at `target`, or `None` where no landing exists.
654///
655/// # Errors
656///
657/// The record's stated failure taxonomy: an unreadable record is a
658/// refusal naming it, a record at an unknown `schema_version` is a
659/// refusal naming the record, and one that does not parse at a known
660/// schema is a defect-class failure.
661pub fn load(target: &Utf8Path) -> Result<Option<Manifest>, RkError> {
662    let path = target.join(MANIFEST_PATH);
663    let bytes = match std::fs::read(&path) {
664        Ok(bytes) => bytes,
665        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
666        Err(e) => {
667            return Err(RkError::refusal(
668                Diagnostic::new(Reason::Io, format!("cannot read {path}: {e}"))
669                    .expected("a readable landing record")
670                    .target_state("unchanged"),
671            ));
672        }
673    };
674    let value: serde_json::Value = serde_json::from_slice(&bytes)
675        .map_err(|e| record_invalid(&path, &format!("{path} is not a landing record: {e}")))?;
676    // A record at an earlier schema converts through the one legacy
677    // conversion. Anything past this binary's schema refuses by the
678    // record schema alone: the record decides whether a guard is landed,
679    // and an older binary must never silently ignore that.
680    let schema = value
681        .get("schema_version")
682        .and_then(serde_json::Value::as_u64);
683    if !schema.is_some_and(|version| (OLDEST_READABLE_SCHEMA..=SCHEMA_VERSION).contains(&version)) {
684        let found = schema.map_or_else(|| "none".to_owned(), |version| version.to_string());
685        return Err(RkError::refusal(
686            Diagnostic::new(
687                Reason::UnsupportedSchema,
688                format!(
689                    "{path} declares schema_version {found}, and this binary knows only {OLDEST_READABLE_SCHEMA} through {SCHEMA_VERSION}"
690                ),
691            )
692            .expected("a landing record at a schema this binary knows")
693            .action("install the rk release that wrote this record, or a newer one")
694            .target_state("unchanged"),
695        ));
696    }
697    let declared = schema.unwrap_or(SCHEMA_VERSION);
698    let value = if declared < SCHEMA_VERSION {
699        legacy::convert(value)
700    } else {
701        value
702    };
703    let mut manifest: Manifest = serde_json::from_value(value).map_err(|e| {
704        record_invalid(
705            &path,
706            &format!("{path} does not parse at schema_version {declared}: {e}"),
707        )
708    })?;
709    // A record below the placement schema stated none: the block
710    // destinations were regions by their names alone, and the loaded shape
711    // says so.
712    for file in &mut manifest.files {
713        if declared < PLACEMENT_SCHEMA && crate::landing::block_markers(&file.destination).is_some()
714        {
715            file.placement = Placement::Region;
716        }
717    }
718    Ok(Some(manifest))
719}
720
721/// The refusal a record that exists and does not parse answers with.
722///
723/// It is the target's file that is wrong, not this binary, so it carries
724/// the same parts a `config-invalid` refusal does: what to change, and
725/// that nothing was written. Git usually holds the last good copy, which
726/// is why the action names the restore first.
727fn record_invalid(path: &Utf8Path, message: &str) -> RkError {
728    RkError::refusal(
729        Diagnostic::new(Reason::RecordInvalid, message)
730            .expected("a landing record this binary wrote, or one it can convert")
731            .action(format!(
732                "restore {path} from Git, or repair the file it names, and retry"
733            ))
734            .target_state("nothing was written"),
735    )
736}
737
738/// Write the record, last, through the temp-plus-rename writer.
739///
740/// # Errors
741///
742/// Any write failure; the destination then holds what it held.
743pub fn write(target: &Utf8Path, manifest: &Manifest) -> Result<(), RkError> {
744    let path = target.join(MANIFEST_PATH);
745    atomic::write(path.as_std_path(), &render(manifest)?)?;
746    Ok(())
747}
748
749/// The bytes [`write`] puts on disk for a record.
750///
751/// # Errors
752///
753/// A serialization failure, which is a defect in this binary.
754pub fn render(manifest: &Manifest) -> Result<Vec<u8>, RkError> {
755    let text = serde_json::to_string_pretty(manifest).map_err(anyhow::Error::from)?;
756    Ok(format!("{text}\n").into_bytes())
757}
758
759/// The current instant in the record's RFC 3339 form.
760#[must_use]
761pub fn now() -> String {
762    humantime::format_rfc3339_seconds(std::time::SystemTime::now()).to_string()
763}
764
765/// How a record's `rk_version` stands against this binary's.
766#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
767#[serde(rename_all = "kebab-case")]
768pub enum Alignment {
769    /// The landing came from this binary's version.
770    Aligned,
771    /// The binary is newer; `rk upgrade` takes the target forward.
772    BinaryNewer,
773    /// The landing came from a newer `rk` than this one, which an upgrade
774    /// refuses rather than downgrading.
775    TargetNewer,
776}
777
778impl Alignment {
779    /// The wire form, identical to the serde rendering.
780    #[must_use]
781    pub const fn as_str(self) -> &'static str {
782        match self {
783            Self::Aligned => "aligned",
784            Self::BinaryNewer => "binary-newer",
785            Self::TargetNewer => "target-newer",
786        }
787    }
788}
789
790/// Compare a record's version against this binary's.
791#[must_use]
792pub fn alignment(recorded: &str, binary: &str) -> Alignment {
793    // Build metadata after `+` carries no precedence.
794    let recorded = recorded
795        .split_once('+')
796        .map_or(recorded, |(version, _)| version);
797    let binary = binary
798        .split_once('+')
799        .map_or(binary, |(version, _)| version);
800    let recorded_core = numeric_core(recorded);
801    let binary_core = numeric_core(binary);
802    match binary_core.cmp(&recorded_core) {
803        std::cmp::Ordering::Greater => Alignment::BinaryNewer,
804        std::cmp::Ordering::Less => Alignment::TargetNewer,
805        std::cmp::Ordering::Equal => {
806            // Equal numeric cores: a pre-release is older than the plain
807            // release it precedes, and two pre-releases compare by semver
808            // precedence — dot-separated identifiers, numeric ones
809            // numerically and below alphanumeric ones.
810            let recorded_pre = recorded.split_once('-').map(|(_, pre)| pre);
811            let binary_pre = binary.split_once('-').map(|(_, pre)| pre);
812            match (recorded_pre, binary_pre) {
813                (Some(_), None) => Alignment::BinaryNewer,
814                (None, Some(_)) => Alignment::TargetNewer,
815                (None, None) => Alignment::Aligned,
816                (Some(r), Some(b)) => match prerelease_cmp(b, r) {
817                    std::cmp::Ordering::Greater => Alignment::BinaryNewer,
818                    std::cmp::Ordering::Less => Alignment::TargetNewer,
819                    std::cmp::Ordering::Equal => Alignment::Aligned,
820                },
821            }
822        }
823    }
824}
825
826/// Whether `candidate` is ahead of `pinned`, by the same ordering the
827/// alignment uses.
828#[must_use]
829pub fn version_is_newer(candidate: &str, pinned: &str) -> bool {
830    alignment(pinned, candidate) == Alignment::BinaryNewer
831}
832
833/// Semver pre-release precedence: identifier by identifier, numeric ones
834/// numerically and below any alphanumeric one, and — all preceding
835/// identifiers equal — the longer list wins. An all-digit identifier
836/// compares by digit count and then lexically, which is numeric order at
837/// any length — semver forbids leading zeroes — so no integer parse can
838/// overflow into a wrong answer.
839fn prerelease_cmp(a: &str, b: &str) -> std::cmp::Ordering {
840    let numeric = |identifier: &str| identifier.bytes().all(|byte| byte.is_ascii_digit());
841    let mut left = a.split('.');
842    let mut right = b.split('.');
843    loop {
844        match (left.next(), right.next()) {
845            (None, None) => return std::cmp::Ordering::Equal,
846            (None, Some(_)) => return std::cmp::Ordering::Less,
847            (Some(_), None) => return std::cmp::Ordering::Greater,
848            (Some(x), Some(y)) => {
849                let ordering = match (numeric(x), numeric(y)) {
850                    (true, true) => x.len().cmp(&y.len()).then_with(|| x.cmp(y)),
851                    (true, false) => std::cmp::Ordering::Less,
852                    (false, true) => std::cmp::Ordering::Greater,
853                    (false, false) => x.cmp(y),
854                };
855                if ordering != std::cmp::Ordering::Equal {
856                    return ordering;
857                }
858            }
859        }
860    }
861}
862
863/// The dotted numeric components before any pre-release suffix.
864fn numeric_core(version: &str) -> Vec<u64> {
865    let core = version.split_once('-').map_or(version, |(core, _)| core);
866    core.split('.')
867        .map(|part| part.parse::<u64>().unwrap_or(0))
868        .collect()
869}
870
871#[cfg(test)]
872mod tests {
873    use super::{
874        Alignment, CapabilityRequests, CheckoutMode, FileRecord, GitWorkflow, Manifest, Parameters,
875        Placement, ProfileSnapshot, Provider, ReleaseIntent, ReleaseMode, Style, alignment,
876    };
877    use crate::digest::Digest;
878    use crate::landing::Integration;
879    use crate::landing::Kind;
880
881    /// The complete record shape at schema 9, held by snapshot: a field
882    /// rename or removal fails here and becomes a schema-version bump
883    /// instead of a silent break at every reader.
884    #[test]
885    #[allow(
886        clippy::too_many_lines,
887        reason = "two whole records, built field by field, are what the snapshot holds"
888    )]
889    fn the_manifest_schema_snapshot_holds() {
890        let manifest = Manifest {
891            schema_version: super::SCHEMA_VERSION,
892            rk_version: "0.1.0".into(),
893            origin: "init".into(),
894            landed_at: "2026-08-29T00:00:00Z".into(),
895            profile: ProfileSnapshot {
896                technologies: vec!["rust".into()],
897                forge: Some("github".into()),
898                release: ReleaseIntent {
899                    mode: ReleaseMode::Automatic,
900                    driver: Some("rust".into()),
901                    style: Some(Style::Trunk),
902                    line_prefix: Some(crate::config::LINE_PREFIX_DEFAULT.to_owned()),
903                    registry: None,
904                    build: None,
905                },
906            },
907            git: GitWorkflow {
908                trunk: crate::config::TRUNK_DEFAULT.to_owned(),
909                checkout_mode: CheckoutMode::LinkedWorktree,
910                integration: Integration::Local,
911                merge_form: crate::landing::MergeForm::Squash,
912            },
913            capabilities: CapabilityRequests {
914                nix_packaging: true,
915                reporting_policy: true,
916                scorecard: true,
917                code_scanning: Some(Provider::Semgrep),
918                workflow_guards: Some(false),
919            },
920            parameters: Parameters {
921                repo: "acme/widget".into(),
922                security_contact: String::new(),
923                security_response: crate::config::RESPONSE_DEFAULT.to_owned(),
924                security_scope: String::new(),
925                required_check: String::new(),
926                required_workflow: String::new(),
927                dev_environment: None,
928            },
929            files: vec![
930                FileRecord {
931                    destination: "release-plz.toml".into(),
932                    kind: Kind::Seeded,
933                    sha256: Digest::of(b""),
934                    placement: Placement::Whole,
935                },
936                FileRecord {
937                    destination: "AGENTS.md".into(),
938                    kind: Kind::Rendered,
939                    sha256: Digest::of(b""),
940                    placement: Placement::Region,
941                },
942            ],
943            pins: std::iter::once(("release-plz".to_owned(), "0.3.160".to_owned())).collect(),
944        };
945        let empty = Digest::of(b"").to_string();
946        let text = serde_json::to_string(&manifest).expect("a manifest serializes");
947        assert_eq!(
948            text,
949            format!(
950                r#"{{"schema_version":12,"rk_version":"0.1.0","origin":"init","landed_at":"2026-08-29T00:00:00Z","profile":{{"technologies":["rust"],"forge":"github","release":{{"mode":"automatic","driver":"rust","style":"trunk","line_prefix":"release/"}}}},"git":{{"trunk":"master","checkout_mode":"linked-worktree","integration":"local","merge_form":"squash"}},"capabilities":{{"nix_packaging":true,"reporting_policy":true,"scorecard":true,"code_scanning":"semgrep","workflow_guards":false}},"parameters":{{"repo":"acme/widget","security_contact":"","security_response":"best-effort","required_check":"","required_workflow":""}},"files":[{{"destination":"release-plz.toml","kind":"seeded","sha256":"{empty}"}},{{"destination":"AGENTS.md","kind":"rendered","sha256":"{empty}","placement":"region"}}],"pins":{{"release-plz":"0.3.160"}}}}"#
951            ),
952            "a whole file omits its placement, and no retired digest field survives"
953        );
954        assert!(!text.contains("payload_sha256") && !text.contains("baseline_sha256"));
955        // A release-less record omits the automatic-only keys and the forge.
956        let release_less = Manifest {
957            profile: ProfileSnapshot {
958                technologies: vec![],
959                forge: None,
960                release: ReleaseIntent {
961                    mode: ReleaseMode::None,
962                    driver: None,
963                    style: None,
964                    line_prefix: None,
965                    registry: None,
966                    build: None,
967                },
968            },
969            capabilities: CapabilityRequests {
970                nix_packaging: false,
971                reporting_policy: false,
972                scorecard: false,
973                code_scanning: None,
974                workflow_guards: Some(false),
975            },
976            parameters: Parameters {
977                repo: String::new(),
978                security_contact: String::new(),
979                security_response: crate::config::RESPONSE_DEFAULT.to_owned(),
980                security_scope: String::new(),
981                required_check: String::new(),
982                dev_environment: None,
983                required_workflow: String::new(),
984            },
985            files: vec![],
986            pins: std::collections::BTreeMap::new(),
987            ..manifest
988        };
989        assert_eq!(
990            serde_json::to_string(&release_less).expect("serializes"),
991            r#"{"schema_version":12,"rk_version":"0.1.0","origin":"init","landed_at":"2026-08-29T00:00:00Z","profile":{"technologies":[],"release":{"mode":"none"}},"git":{"trunk":"master","checkout_mode":"linked-worktree","integration":"local","merge_form":"squash"},"capabilities":{"nix_packaging":false,"reporting_policy":false,"scorecard":false,"workflow_guards":false},"parameters":{"repo":"","security_contact":"","security_response":"best-effort","required_check":"","required_workflow":""},"files":[],"pins":{}}"#
992        );
993    }
994
995    /// A record written before the domains existed reads as an automatic
996    /// release driven by its one technology, in the main-worktree mode,
997    /// with the reporting policy it landed, and its scope vocabulary
998    /// drops, because this binary renders none. Every earlier schema
999    /// converts through the one legacy path with its retired digests
1000    /// ignored, and a record past this binary's schema refuses by the
1001    /// record schema alone, naming no other schema.
1002    #[test]
1003    fn a_schema_1_record_reads_as_branches_and_a_newer_schema_refuses() {
1004        let dir = tempfile::tempdir().expect("a scratch target exists");
1005        let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
1006        std::fs::create_dir_all(target.join(".release-kit")).expect("the record dir writes");
1007        let record = |schema: u64| {
1008            format!(
1009                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":{{}}}}"#
1010            )
1011        };
1012        std::fs::write(target.join(super::MANIFEST_PATH), record(1)).expect("the record writes");
1013        let manifest = super::load(target)
1014            .expect("a schema-1 record loads")
1015            .expect("the record exists");
1016        assert_eq!(manifest.git.checkout_mode, CheckoutMode::MainWorktree);
1017        assert_eq!(manifest.profile.technologies, vec!["rust".to_owned()]);
1018        assert_eq!(manifest.profile.forge.as_deref(), Some("github"));
1019        assert_eq!(manifest.profile.release.mode, ReleaseMode::Automatic);
1020        assert_eq!(manifest.profile.release.driver.as_deref(), Some("rust"));
1021        assert_eq!(
1022            manifest.profile.release.style, None,
1023            "a pre-style record carries no style; the upgrade demands one"
1024        );
1025        assert_eq!(
1026            manifest.profile.release.line_prefix.as_deref(),
1027            Some(crate::config::LINE_PREFIX_DEFAULT)
1028        );
1029        assert_eq!(manifest.git.trunk, crate::config::TRUNK_DEFAULT);
1030        assert!(
1031            !manifest.capabilities.nix_packaging,
1032            "a pre-nix record reads as opt-out, so an upgrade adds nothing unrequested"
1033        );
1034        assert!(
1035            manifest.capabilities.reporting_policy,
1036            "an older landing carried the policy, so the record says so"
1037        );
1038        assert_eq!(
1039            manifest.parameters.security_contact, "",
1040            "a pre-policy record names no contact, which is what its policy landed"
1041        );
1042        assert_eq!(
1043            manifest.parameters.security_response,
1044            crate::config::RESPONSE_DEFAULT,
1045            "a pre-policy record promises no window, which is what its policy landed"
1046        );
1047
1048        for schema in 2..=8 {
1049            std::fs::write(
1050                target.join(super::MANIFEST_PATH),
1051                format!(
1052                    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","workflow":"worktree","style":"lines","nix":true,"scorecard":true,"code_scanning":"semgrep","trunk":"main","line_prefix":"stable/"}},"files":[{{"destination":"AGENTS.md","kind":"rendered","sha256":"0000000000000000000000000000000000000000000000000000000000000000","baseline_sha256":"0000000000000000000000000000000000000000000000000000000000000000"}}],"pins":{{}}}}"#
1053                ),
1054            )
1055            .expect("the record writes");
1056            let manifest = super::load(target)
1057                .expect("an earlier record loads")
1058                .expect("the record exists");
1059            assert_eq!(manifest.schema_version, schema);
1060            assert_eq!(manifest.git.checkout_mode, CheckoutMode::LinkedWorktree);
1061            // A record below the placement schema stated none, so the
1062            // block destinations are regions by their names alone; from
1063            // that schema on the record says so itself.
1064            assert_eq!(manifest.git.trunk, "main");
1065            assert_eq!(manifest.profile.release.style, Some(Style::Lines));
1066            assert_eq!(
1067                manifest.profile.release.line_prefix.as_deref(),
1068                Some("stable/")
1069            );
1070            assert!(manifest.capabilities.nix_packaging && manifest.capabilities.scorecard);
1071            assert_eq!(manifest.capabilities.code_scanning, Some(Provider::Semgrep));
1072            assert_eq!(
1073                manifest.files[0].placement,
1074                if schema < super::PLACEMENT_SCHEMA {
1075                    Placement::Region
1076                } else {
1077                    Placement::Whole
1078                },
1079                "a block destination below the placement schema reads as a region"
1080            );
1081            let rewritten = super::render(&manifest).expect("renders");
1082            let text = String::from_utf8(rewritten).expect("text");
1083            assert!(!text.contains("baseline_sha256"), "{text}");
1084            assert!(
1085                !text.contains("\"tech\""),
1086                "the flat identity moved: {text}"
1087            );
1088        }
1089
1090        std::fs::write(target.join(super::MANIFEST_PATH), record(999)).expect("the record writes");
1091        let refused = super::load(target).expect_err("a schema-999 record refuses");
1092        assert_eq!(
1093            refused.reason(),
1094            crate::diagnostic::Reason::UnsupportedSchema
1095        );
1096        let message = refused.to_string();
1097        assert!(message.contains("999"), "{message}");
1098        assert!(message.contains(super::MANIFEST_PATH), "{message}");
1099        assert!(
1100            !message.to_lowercase().contains("bundle"),
1101            "the record schema stands alone: {message}"
1102        );
1103    }
1104
1105    /// A record at schema 11 predates the merge form and carries no key,
1106    /// so it reads as `squash`, the form every such landing took, and the
1107    /// next write states the form at the current schema. A record that
1108    /// names `rebase` keeps it, and a form outside the two refuses as an
1109    /// invalid record rather than reading as either.
1110    ///
1111    /// SATISFIES git:the-merge-form-is-a-landing-parameter
1112    #[test]
1113    fn merge_form_of_a_schema_11_record_reads_as_squash() {
1114        let dir = tempfile::tempdir().expect("a scratch target exists");
1115        let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
1116        std::fs::create_dir_all(target.join(".release-kit")).expect("the record dir writes");
1117        let record = |schema: u64, git: &str| {
1118            format!(
1119                r#"{{"schema_version":{schema},"rk_version":"0.1.0","origin":"init","landed_at":"2026-08-29T00:00:00Z","profile":{{"technologies":[],"release":{{"mode":"none"}}}},"git":{{"trunk":"master","checkout_mode":"linked-worktree","integration":"local"{git}}},"capabilities":{{"nix_packaging":false,"reporting_policy":false,"scorecard":false,"workflow_guards":true}},"parameters":{{"repo":""}},"files":[],"pins":{{}}}}"#
1120            )
1121        };
1122        std::fs::write(target.join(super::MANIFEST_PATH), record(11, "")).expect("writes");
1123        let manifest = super::load(target)
1124            .expect("a schema-11 record loads")
1125            .expect("the record exists");
1126        assert_eq!(manifest.schema_version, 11);
1127        assert_eq!(manifest.git.merge_form, super::MergeForm::Squash);
1128        let text = String::from_utf8(super::render(&manifest).expect("renders")).expect("text");
1129        assert!(text.contains("\"merge_form\": \"squash\""), "{text}");
1130
1131        std::fs::write(
1132            target.join(super::MANIFEST_PATH),
1133            record(super::SCHEMA_VERSION, r#","merge_form":"rebase""#),
1134        )
1135        .expect("writes");
1136        let manifest = super::load(target)
1137            .expect("a current record loads")
1138            .expect("the record exists");
1139        assert_eq!(manifest.git.merge_form, super::MergeForm::Rebase);
1140
1141        std::fs::write(
1142            target.join(super::MANIFEST_PATH),
1143            record(super::SCHEMA_VERSION, r#","merge_form":"merge""#),
1144        )
1145        .expect("writes");
1146        let refused = super::load(target).expect_err("an unknown form refuses");
1147        assert_eq!(refused.reason(), crate::diagnostic::Reason::RecordInvalid);
1148        assert!(refused.to_string().contains("merge form"), "{refused}");
1149    }
1150
1151    /// The record is the one input a re-render reads, so a hand-edited
1152    /// record must not reach bytes the configured path could never write:
1153    /// a value the configuration reader refuses, and a value it would
1154    /// canonicalize, both refuse at deserialization.
1155    #[test]
1156    fn a_record_carrying_an_uncanonical_security_parameter_refuses() {
1157        let dir = tempfile::tempdir().expect("a scratch target exists");
1158        let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
1159        std::fs::create_dir_all(target.join(".release-kit")).expect("the record dir writes");
1160        for (field, value) in [
1161            // A JSON escape, so the record parses and the value it decodes
1162            // to is the line feed the policy could never carry.
1163            ("security_contact", "team@acme.example\\nsecond line"),
1164            ("security_contact", "  team@acme.example  "),
1165            ("security_response", "90d"),
1166            ("security_response", "0 days"),
1167            ("security_response", "07 days"),
1168            ("security_response", "1 days"),
1169            ("security_response", ""),
1170            ("security_scope", "one\\n\\ntwo"),
1171            ("security_scope", "# Out of scope"),
1172            ("security_scope", "Out of scope\\n---"),
1173            ("security_scope", "see <!-- here"),
1174            ("security_scope", "  padded  "),
1175        ] {
1176            let record = format!(
1177                r#"{{"schema_version":10,"rk_version":"0.1.0","origin":"init","landed_at":"2026-08-29T00:00:00Z","profile":{{"technologies":["rust"],"forge":"github","release":{{"mode":"automatic","driver":"rust","style":"trunk","line_prefix":"release/"}}}},"git":{{"trunk":"master","checkout_mode":"linked-worktree","integration":"local"}},"capabilities":{{"nix_packaging":false,"reporting_policy":true,"scorecard":false}},"parameters":{{"repo":"acme/widget","{field}":"{value}"}},"files":[],"pins":{{}}}}"#
1178            );
1179            std::fs::write(target.join(super::MANIFEST_PATH), record).expect("the record writes");
1180            let refused = super::load(target).expect_err("an uncanonical record refuses");
1181            assert!(refused.to_string().contains(field), "{field}: {refused}");
1182        }
1183    }
1184
1185    #[test]
1186    fn alignment_orders_versions_numerically() {
1187        assert_eq!(alignment("0.1.0", "0.1.0"), Alignment::Aligned);
1188        assert_eq!(alignment("0.1.0", "0.2.0"), Alignment::BinaryNewer);
1189        assert_eq!(alignment("0.10.0", "0.9.9"), Alignment::TargetNewer);
1190        assert_eq!(alignment("0.1.0-rc.1", "0.1.0"), Alignment::BinaryNewer);
1191        assert_eq!(alignment("0.1.0", "0.1.0-rc.1"), Alignment::TargetNewer);
1192    }
1193
1194    /// Pre-release identifiers order by semver precedence, not by text:
1195    /// `rc.10` is newer than `rc.2`, so a binary at `rc.2` must refuse a
1196    /// landing from `rc.10` rather than downgrade it — at any identifier
1197    /// length, so no integer width bounds the protection.
1198    #[test]
1199    fn alignment_orders_numeric_prerelease_identifiers_numerically() {
1200        assert_eq!(
1201            alignment("0.1.0-rc.10", "0.1.0-rc.2"),
1202            Alignment::TargetNewer
1203        );
1204        assert_eq!(
1205            alignment("0.1.0-rc.2", "0.1.0-rc.10"),
1206            Alignment::BinaryNewer
1207        );
1208        assert_eq!(alignment("0.1.0-rc.1", "0.1.0-rc.1"), Alignment::Aligned);
1209        assert_eq!(
1210            alignment("0.1.0-alpha", "0.1.0-alpha.1"),
1211            Alignment::BinaryNewer
1212        );
1213        assert_eq!(alignment("0.1.0-1", "0.1.0-alpha"), Alignment::BinaryNewer);
1214        assert_eq!(
1215            alignment("1.0.0-100000000000000000000", "1.0.0-99999999999999999999"),
1216            Alignment::TargetNewer,
1217            "identifiers past the u64 range still compare numerically"
1218        );
1219        assert_eq!(
1220            alignment("1.0.0-99999999999999999999", "1.0.0-100000000000000000000"),
1221            Alignment::BinaryNewer
1222        );
1223    }
1224
1225    /// Build metadata carries no precedence: it never corrupts a numeric
1226    /// component and never separates two otherwise-equal versions.
1227    #[test]
1228    fn alignment_ignores_build_metadata() {
1229        assert_eq!(alignment("1.2.10+build", "1.2.9"), Alignment::TargetNewer);
1230        assert_eq!(alignment("1.2.9", "1.2.10+build"), Alignment::BinaryNewer);
1231        assert_eq!(alignment("1.0.0+alpha", "1.0.0+beta"), Alignment::Aligned);
1232        assert_eq!(
1233            alignment("1.2.10-rc.1+build", "1.2.10-rc.1"),
1234            Alignment::Aligned
1235        );
1236        assert_eq!(
1237            alignment("1.2.10-rc.1+build", "1.2.10"),
1238            Alignment::BinaryNewer
1239        );
1240    }
1241}