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    pub sha256: Digest,
501    /// How the landing occupies the destination: the whole file, which
502    /// the record omits, or one marked region inside a document the
503    /// target owns.
504    #[serde(default, skip_serializing_if = "Placement::is_whole")]
505    pub placement: Placement,
506}
507
508/// How a recorded destination is occupied.
509#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
510#[serde(rename_all = "lowercase")]
511pub enum Placement {
512    /// The landing owns the whole file.
513    #[default]
514    Whole,
515    /// The landing owns the one marked region; every byte outside it is
516    /// the target's.
517    Region,
518}
519
520impl Placement {
521    /// Whether this is the default the record omits.
522    #[must_use]
523    pub const fn is_whole(&self) -> bool {
524        matches!(self, Self::Whole)
525    }
526
527    /// The report form.
528    #[must_use]
529    pub const fn as_str(self) -> &'static str {
530        match self {
531            Self::Whole => "whole",
532            Self::Region => "region",
533        }
534    }
535}
536
537/// The one bounded conversion from a record at schemas 1 through 8 to the
538/// current shape.
539///
540/// It reads no other release and interprets no other release's sources:
541/// it drops the fields the direct landing retired, and it moves the one
542/// technology, the forge, and the flat parameters an older record carried
543/// into the domains schema 9 states. Every older record described an
544/// automatic release driven by its one technology, with the reporting
545/// policy landed, so that is what the conversion says.
546pub mod legacy {
547    use serde_json::{Map, Value, json};
548
549    /// Take a field out of a JSON object, where it is one.
550    fn take(object: &mut Map<String, Value>, key: &str) -> Option<Value> {
551        object.remove(key)
552    }
553
554    /// Rewrite a record value at a schema before this binary's so it
555    /// deserializes as the current shape.
556    ///
557    /// `payload_sha256` named a bundle digest no comparison reads any
558    /// more; per-file `baseline_sha256` fed a three-way comparison that
559    /// no longer exists; `parameters.scopes` was a vocabulary this binary
560    /// renders nowhere. `tech`, `forge`, and the flat `parameters` become
561    /// the `profile`, `git`, and `capabilities` domains, with the old
562    /// `worktree` and `branches` mode names read as `linked-worktree` and
563    /// `main-worktree`.
564    #[must_use]
565    pub fn convert(mut value: Value) -> Value {
566        let Some(record) = value.as_object_mut() else {
567            return value;
568        };
569        record.remove("payload_sha256");
570        if let Some(files) = record.get_mut("files").and_then(Value::as_array_mut) {
571            for file in files.iter_mut().filter_map(Value::as_object_mut) {
572                file.remove("baseline_sha256");
573            }
574        }
575        if record.contains_key("profile") {
576            return value;
577        }
578        let tech = take(record, "tech").and_then(|v| v.as_str().map(str::to_owned));
579        let forge = take(record, "forge").and_then(|v| v.as_str().map(str::to_owned));
580        let mut parameters = take(record, "parameters")
581            .and_then(|v| v.as_object().cloned())
582            .unwrap_or_default();
583        parameters.remove("scopes");
584        let checkout_mode = match parameters
585            .remove("workflow")
586            .and_then(|v| v.as_str().map(str::to_owned))
587            .as_deref()
588        {
589            Some("worktree") => "linked-worktree",
590            // A record predating the mode carried none, and such a
591            // landing wrote the blocks without the guard.
592            _ => "main-worktree",
593        };
594        let style = parameters.remove("style").unwrap_or(Value::Null);
595        let trunk = parameters
596            .remove("trunk")
597            .unwrap_or_else(|| json!(crate::config::TRUNK_DEFAULT));
598        let line_prefix = parameters
599            .remove("line_prefix")
600            .unwrap_or_else(|| json!(crate::config::LINE_PREFIX_DEFAULT));
601        let nix = parameters.remove("nix").unwrap_or(json!(false));
602        let scorecard = parameters.remove("scorecard").unwrap_or(json!(false));
603        let code_scanning = parameters.remove("code_scanning").unwrap_or(Value::Null);
604        let mut release = Map::new();
605        release.insert("mode".into(), json!("automatic"));
606        if let Some(tech) = &tech {
607            release.insert("driver".into(), json!(tech));
608        }
609        if !style.is_null() {
610            release.insert("style".into(), style);
611        }
612        release.insert("line_prefix".into(), line_prefix);
613        let technologies: Vec<Value> = tech.iter().map(|t| json!(t)).collect();
614        record.insert(
615            "profile".into(),
616            json!({
617                "technologies": technologies,
618                "forge": forge,
619                "release": Value::Object(release),
620            }),
621        );
622        record.insert(
623            "git".into(),
624            json!({ "trunk": trunk, "checkout_mode": checkout_mode }),
625        );
626        record.insert(
627            "capabilities".into(),
628            json!({
629                "nix_packaging": nix,
630                "reporting_policy": true,
631                "scorecard": scorecard,
632                "code_scanning": code_scanning,
633            }),
634        );
635        record.insert("parameters".into(), Value::Object(parameters));
636        value
637    }
638}
639
640impl Manifest {
641    /// The recorded entry for one destination, where the record names it.
642    #[must_use]
643    pub fn file(&self, destination: &str) -> Option<&FileRecord> {
644        self.files
645            .iter()
646            .find(|file| file.destination == destination)
647    }
648}
649
650/// Read the record at `target`, or `None` where no landing exists.
651///
652/// # Errors
653///
654/// The record's stated failure taxonomy: an unreadable record is a
655/// refusal naming it, a record at an unknown `schema_version` is a
656/// refusal naming the record, and one that does not parse at a known
657/// schema is a defect-class failure.
658pub fn load(target: &Utf8Path) -> Result<Option<Manifest>, RkError> {
659    let path = target.join(MANIFEST_PATH);
660    let bytes = match std::fs::read(&path) {
661        Ok(bytes) => bytes,
662        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
663        Err(e) => {
664            return Err(RkError::refusal(
665                Diagnostic::new(Reason::Io, format!("cannot read {path}: {e}"))
666                    .expected("a readable landing record")
667                    .target_state("unchanged"),
668            ));
669        }
670    };
671    let value: serde_json::Value = serde_json::from_slice(&bytes)
672        .map_err(|e| record_invalid(&path, &format!("{path} is not a landing record: {e}")))?;
673    // A record at an earlier schema converts through the one legacy
674    // conversion. Anything past this binary's schema refuses by the
675    // record schema alone: the record decides whether a guard is landed,
676    // and an older binary must never silently ignore that.
677    let schema = value
678        .get("schema_version")
679        .and_then(serde_json::Value::as_u64);
680    if !schema.is_some_and(|version| (OLDEST_READABLE_SCHEMA..=SCHEMA_VERSION).contains(&version)) {
681        let found = schema.map_or_else(|| "none".to_owned(), |version| version.to_string());
682        return Err(RkError::refusal(
683            Diagnostic::new(
684                Reason::UnsupportedSchema,
685                format!(
686                    "{path} declares schema_version {found}, and this binary knows only {OLDEST_READABLE_SCHEMA} through {SCHEMA_VERSION}"
687                ),
688            )
689            .expected("a landing record at a schema this binary knows")
690            .action("install the rk release that wrote this record, or a newer one")
691            .target_state("unchanged"),
692        ));
693    }
694    let declared = schema.unwrap_or(SCHEMA_VERSION);
695    let value = if declared < SCHEMA_VERSION {
696        legacy::convert(value)
697    } else {
698        value
699    };
700    let mut manifest: Manifest = serde_json::from_value(value).map_err(|e| {
701        record_invalid(
702            &path,
703            &format!("{path} does not parse at schema_version {declared}: {e}"),
704        )
705    })?;
706    // A record below the placement schema stated none: the block
707    // destinations were regions by their names alone, and the loaded shape
708    // says so.
709    for file in &mut manifest.files {
710        if declared < PLACEMENT_SCHEMA && crate::landing::block_markers(&file.destination).is_some()
711        {
712            file.placement = Placement::Region;
713        }
714    }
715    Ok(Some(manifest))
716}
717
718/// The refusal a record that exists and does not parse answers with.
719///
720/// It is the target's file that is wrong, not this binary, so it carries
721/// the same parts a `config-invalid` refusal does: what to change, and
722/// that nothing was written. Git usually holds the last good copy, which
723/// is why the action names the restore first.
724fn record_invalid(path: &Utf8Path, message: &str) -> RkError {
725    RkError::refusal(
726        Diagnostic::new(Reason::RecordInvalid, message)
727            .expected("a landing record this binary wrote, or one it can convert")
728            .action(format!(
729                "restore {path} from Git, or repair the file it names, and retry"
730            ))
731            .target_state("nothing was written"),
732    )
733}
734
735/// Write the record, last, through the temp-plus-rename writer.
736///
737/// # Errors
738///
739/// Any write failure; the destination then holds what it held.
740pub fn write(target: &Utf8Path, manifest: &Manifest) -> Result<(), RkError> {
741    let path = target.join(MANIFEST_PATH);
742    atomic::write(path.as_std_path(), &render(manifest)?)?;
743    Ok(())
744}
745
746/// The bytes [`write`] puts on disk for a record.
747///
748/// # Errors
749///
750/// A serialization failure, which is a defect in this binary.
751pub fn render(manifest: &Manifest) -> Result<Vec<u8>, RkError> {
752    let text = serde_json::to_string_pretty(manifest).map_err(anyhow::Error::from)?;
753    Ok(format!("{text}\n").into_bytes())
754}
755
756/// The current instant in the record's RFC 3339 form.
757#[must_use]
758pub fn now() -> String {
759    humantime::format_rfc3339_seconds(std::time::SystemTime::now()).to_string()
760}
761
762/// How a record's `rk_version` stands against this binary's.
763#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
764#[serde(rename_all = "kebab-case")]
765pub enum Alignment {
766    /// The landing came from this binary's version.
767    Aligned,
768    /// The binary is newer; `rk upgrade` takes the target forward.
769    BinaryNewer,
770    /// The landing came from a newer `rk` than this one, which an upgrade
771    /// refuses rather than downgrading.
772    TargetNewer,
773}
774
775impl Alignment {
776    /// The wire form, identical to the serde rendering.
777    #[must_use]
778    pub const fn as_str(self) -> &'static str {
779        match self {
780            Self::Aligned => "aligned",
781            Self::BinaryNewer => "binary-newer",
782            Self::TargetNewer => "target-newer",
783        }
784    }
785}
786
787/// Compare a record's version against this binary's.
788#[must_use]
789pub fn alignment(recorded: &str, binary: &str) -> Alignment {
790    // Build metadata after `+` carries no precedence.
791    let recorded = recorded
792        .split_once('+')
793        .map_or(recorded, |(version, _)| version);
794    let binary = binary
795        .split_once('+')
796        .map_or(binary, |(version, _)| version);
797    let recorded_core = numeric_core(recorded);
798    let binary_core = numeric_core(binary);
799    match binary_core.cmp(&recorded_core) {
800        std::cmp::Ordering::Greater => Alignment::BinaryNewer,
801        std::cmp::Ordering::Less => Alignment::TargetNewer,
802        std::cmp::Ordering::Equal => {
803            // Equal numeric cores: a pre-release is older than the plain
804            // release it precedes, and two pre-releases compare by semver
805            // precedence — dot-separated identifiers, numeric ones
806            // numerically and below alphanumeric ones.
807            let recorded_pre = recorded.split_once('-').map(|(_, pre)| pre);
808            let binary_pre = binary.split_once('-').map(|(_, pre)| pre);
809            match (recorded_pre, binary_pre) {
810                (Some(_), None) => Alignment::BinaryNewer,
811                (None, Some(_)) => Alignment::TargetNewer,
812                (None, None) => Alignment::Aligned,
813                (Some(r), Some(b)) => match prerelease_cmp(b, r) {
814                    std::cmp::Ordering::Greater => Alignment::BinaryNewer,
815                    std::cmp::Ordering::Less => Alignment::TargetNewer,
816                    std::cmp::Ordering::Equal => Alignment::Aligned,
817                },
818            }
819        }
820    }
821}
822
823/// Whether `candidate` is ahead of `pinned`, by the same ordering the
824/// alignment uses.
825#[must_use]
826pub fn version_is_newer(candidate: &str, pinned: &str) -> bool {
827    alignment(pinned, candidate) == Alignment::BinaryNewer
828}
829
830/// Semver pre-release precedence: identifier by identifier, numeric ones
831/// numerically and below any alphanumeric one, and — all preceding
832/// identifiers equal — the longer list wins. An all-digit identifier
833/// compares by digit count and then lexically, which is numeric order at
834/// any length — semver forbids leading zeroes — so no integer parse can
835/// overflow into a wrong answer.
836fn prerelease_cmp(a: &str, b: &str) -> std::cmp::Ordering {
837    let numeric = |identifier: &str| identifier.bytes().all(|byte| byte.is_ascii_digit());
838    let mut left = a.split('.');
839    let mut right = b.split('.');
840    loop {
841        match (left.next(), right.next()) {
842            (None, None) => return std::cmp::Ordering::Equal,
843            (None, Some(_)) => return std::cmp::Ordering::Less,
844            (Some(_), None) => return std::cmp::Ordering::Greater,
845            (Some(x), Some(y)) => {
846                let ordering = match (numeric(x), numeric(y)) {
847                    (true, true) => x.len().cmp(&y.len()).then_with(|| x.cmp(y)),
848                    (true, false) => std::cmp::Ordering::Less,
849                    (false, true) => std::cmp::Ordering::Greater,
850                    (false, false) => x.cmp(y),
851                };
852                if ordering != std::cmp::Ordering::Equal {
853                    return ordering;
854                }
855            }
856        }
857    }
858}
859
860/// The dotted numeric components before any pre-release suffix.
861fn numeric_core(version: &str) -> Vec<u64> {
862    let core = version.split_once('-').map_or(version, |(core, _)| core);
863    core.split('.')
864        .map(|part| part.parse::<u64>().unwrap_or(0))
865        .collect()
866}
867
868#[cfg(test)]
869mod tests {
870    use super::{
871        Alignment, CapabilityRequests, CheckoutMode, FileRecord, GitWorkflow, Manifest, Parameters,
872        Placement, ProfileSnapshot, Provider, ReleaseIntent, ReleaseMode, Style, alignment,
873    };
874    use crate::digest::Digest;
875    use crate::landing::Integration;
876    use crate::landing::Kind;
877
878    /// The complete record shape at schema 9, held by snapshot: a field
879    /// rename or removal fails here and becomes a schema-version bump
880    /// instead of a silent break at every reader.
881    #[test]
882    fn the_manifest_schema_snapshot_holds() {
883        let manifest = Manifest {
884            schema_version: super::SCHEMA_VERSION,
885            rk_version: "0.1.0".into(),
886            origin: "init".into(),
887            landed_at: "2026-08-29T00:00:00Z".into(),
888            profile: ProfileSnapshot {
889                technologies: vec!["rust".into()],
890                forge: Some("github".into()),
891                release: ReleaseIntent {
892                    mode: ReleaseMode::Automatic,
893                    driver: Some("rust".into()),
894                    style: Some(Style::Trunk),
895                    line_prefix: Some(crate::config::LINE_PREFIX_DEFAULT.to_owned()),
896                    registry: None,
897                },
898            },
899            git: GitWorkflow {
900                trunk: crate::config::TRUNK_DEFAULT.to_owned(),
901                checkout_mode: CheckoutMode::LinkedWorktree,
902                integration: Integration::Local,
903                merge_form: crate::landing::MergeForm::Squash,
904            },
905            capabilities: CapabilityRequests {
906                nix_packaging: true,
907                reporting_policy: true,
908                scorecard: true,
909                code_scanning: Some(Provider::Semgrep),
910                workflow_guards: Some(false),
911            },
912            parameters: Parameters {
913                repo: "acme/widget".into(),
914                security_contact: String::new(),
915                security_response: crate::config::RESPONSE_DEFAULT.to_owned(),
916                security_scope: String::new(),
917                required_check: String::new(),
918                required_workflow: String::new(),
919                dev_environment: None,
920            },
921            files: vec![
922                FileRecord {
923                    destination: "release-plz.toml".into(),
924                    kind: Kind::Seeded,
925                    sha256: Digest::of(b""),
926                    placement: Placement::Whole,
927                },
928                FileRecord {
929                    destination: "AGENTS.md".into(),
930                    kind: Kind::Rendered,
931                    sha256: Digest::of(b""),
932                    placement: Placement::Region,
933                },
934            ],
935            pins: std::iter::once(("release-plz".to_owned(), "0.3.160".to_owned())).collect(),
936        };
937        let empty = Digest::of(b"").to_string();
938        let text = serde_json::to_string(&manifest).expect("a manifest serializes");
939        assert_eq!(
940            text,
941            format!(
942                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"}}}}"#
943            ),
944            "a whole file omits its placement, and no retired digest field survives"
945        );
946        assert!(!text.contains("payload_sha256") && !text.contains("baseline_sha256"));
947        // A release-less record omits the automatic-only keys and the forge.
948        let release_less = Manifest {
949            profile: ProfileSnapshot {
950                technologies: vec![],
951                forge: None,
952                release: ReleaseIntent {
953                    mode: ReleaseMode::None,
954                    driver: None,
955                    style: None,
956                    line_prefix: None,
957                    registry: None,
958                },
959            },
960            capabilities: CapabilityRequests {
961                nix_packaging: false,
962                reporting_policy: false,
963                scorecard: false,
964                code_scanning: None,
965                workflow_guards: Some(false),
966            },
967            parameters: Parameters {
968                repo: String::new(),
969                security_contact: String::new(),
970                security_response: crate::config::RESPONSE_DEFAULT.to_owned(),
971                security_scope: String::new(),
972                required_check: String::new(),
973                dev_environment: None,
974                required_workflow: String::new(),
975            },
976            files: vec![],
977            pins: std::collections::BTreeMap::new(),
978            ..manifest
979        };
980        assert_eq!(
981            serde_json::to_string(&release_less).expect("serializes"),
982            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":{}}"#
983        );
984    }
985
986    /// A record written before the domains existed reads as an automatic
987    /// release driven by its one technology, in the main-worktree mode,
988    /// with the reporting policy it landed, and its scope vocabulary
989    /// drops, because this binary renders none. Every earlier schema
990    /// converts through the one legacy path with its retired digests
991    /// ignored, and a record past this binary's schema refuses by the
992    /// record schema alone, naming no other schema.
993    #[test]
994    fn a_schema_1_record_reads_as_branches_and_a_newer_schema_refuses() {
995        let dir = tempfile::tempdir().expect("a scratch target exists");
996        let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
997        std::fs::create_dir_all(target.join(".release-kit")).expect("the record dir writes");
998        let record = |schema: u64| {
999            format!(
1000                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":{{}}}}"#
1001            )
1002        };
1003        std::fs::write(target.join(super::MANIFEST_PATH), record(1)).expect("the record writes");
1004        let manifest = super::load(target)
1005            .expect("a schema-1 record loads")
1006            .expect("the record exists");
1007        assert_eq!(manifest.git.checkout_mode, CheckoutMode::MainWorktree);
1008        assert_eq!(manifest.profile.technologies, vec!["rust".to_owned()]);
1009        assert_eq!(manifest.profile.forge.as_deref(), Some("github"));
1010        assert_eq!(manifest.profile.release.mode, ReleaseMode::Automatic);
1011        assert_eq!(manifest.profile.release.driver.as_deref(), Some("rust"));
1012        assert_eq!(
1013            manifest.profile.release.style, None,
1014            "a pre-style record carries no style; the upgrade demands one"
1015        );
1016        assert_eq!(
1017            manifest.profile.release.line_prefix.as_deref(),
1018            Some(crate::config::LINE_PREFIX_DEFAULT)
1019        );
1020        assert_eq!(manifest.git.trunk, crate::config::TRUNK_DEFAULT);
1021        assert!(
1022            !manifest.capabilities.nix_packaging,
1023            "a pre-nix record reads as opt-out, so an upgrade adds nothing unrequested"
1024        );
1025        assert!(
1026            manifest.capabilities.reporting_policy,
1027            "an older landing carried the policy, so the record says so"
1028        );
1029        assert_eq!(
1030            manifest.parameters.security_contact, "",
1031            "a pre-policy record names no contact, which is what its policy landed"
1032        );
1033        assert_eq!(
1034            manifest.parameters.security_response,
1035            crate::config::RESPONSE_DEFAULT,
1036            "a pre-policy record promises no window, which is what its policy landed"
1037        );
1038
1039        for schema in 2..=8 {
1040            std::fs::write(
1041                target.join(super::MANIFEST_PATH),
1042                format!(
1043                    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":{{}}}}"#
1044                ),
1045            )
1046            .expect("the record writes");
1047            let manifest = super::load(target)
1048                .expect("an earlier record loads")
1049                .expect("the record exists");
1050            assert_eq!(manifest.schema_version, schema);
1051            assert_eq!(manifest.git.checkout_mode, CheckoutMode::LinkedWorktree);
1052            // A record below the placement schema stated none, so the
1053            // block destinations are regions by their names alone; from
1054            // that schema on the record says so itself.
1055            assert_eq!(manifest.git.trunk, "main");
1056            assert_eq!(manifest.profile.release.style, Some(Style::Lines));
1057            assert_eq!(
1058                manifest.profile.release.line_prefix.as_deref(),
1059                Some("stable/")
1060            );
1061            assert!(manifest.capabilities.nix_packaging && manifest.capabilities.scorecard);
1062            assert_eq!(manifest.capabilities.code_scanning, Some(Provider::Semgrep));
1063            assert_eq!(
1064                manifest.files[0].placement,
1065                if schema < super::PLACEMENT_SCHEMA {
1066                    Placement::Region
1067                } else {
1068                    Placement::Whole
1069                },
1070                "a block destination below the placement schema reads as a region"
1071            );
1072            let rewritten = super::render(&manifest).expect("renders");
1073            let text = String::from_utf8(rewritten).expect("text");
1074            assert!(!text.contains("baseline_sha256"), "{text}");
1075            assert!(
1076                !text.contains("\"tech\""),
1077                "the flat identity moved: {text}"
1078            );
1079        }
1080
1081        std::fs::write(target.join(super::MANIFEST_PATH), record(999)).expect("the record writes");
1082        let refused = super::load(target).expect_err("a schema-999 record refuses");
1083        assert_eq!(
1084            refused.reason(),
1085            crate::diagnostic::Reason::UnsupportedSchema
1086        );
1087        let message = refused.to_string();
1088        assert!(message.contains("999"), "{message}");
1089        assert!(message.contains(super::MANIFEST_PATH), "{message}");
1090        assert!(
1091            !message.to_lowercase().contains("bundle"),
1092            "the record schema stands alone: {message}"
1093        );
1094    }
1095
1096    /// A record at schema 11 predates the merge form and carries no key,
1097    /// so it reads as `squash`, the form every such landing took, and the
1098    /// next write states the form at the current schema. A record that
1099    /// names `rebase` keeps it, and a form outside the two refuses as an
1100    /// invalid record rather than reading as either.
1101    ///
1102    /// SATISFIES git:the-merge-form-is-a-landing-parameter
1103    #[test]
1104    fn merge_form_of_a_schema_11_record_reads_as_squash() {
1105        let dir = tempfile::tempdir().expect("a scratch target exists");
1106        let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
1107        std::fs::create_dir_all(target.join(".release-kit")).expect("the record dir writes");
1108        let record = |schema: u64, git: &str| {
1109            format!(
1110                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":{{}}}}"#
1111            )
1112        };
1113        std::fs::write(target.join(super::MANIFEST_PATH), record(11, "")).expect("writes");
1114        let manifest = super::load(target)
1115            .expect("a schema-11 record loads")
1116            .expect("the record exists");
1117        assert_eq!(manifest.schema_version, 11);
1118        assert_eq!(manifest.git.merge_form, super::MergeForm::Squash);
1119        let text = String::from_utf8(super::render(&manifest).expect("renders")).expect("text");
1120        assert!(text.contains("\"merge_form\": \"squash\""), "{text}");
1121
1122        std::fs::write(
1123            target.join(super::MANIFEST_PATH),
1124            record(super::SCHEMA_VERSION, r#","merge_form":"rebase""#),
1125        )
1126        .expect("writes");
1127        let manifest = super::load(target)
1128            .expect("a current record loads")
1129            .expect("the record exists");
1130        assert_eq!(manifest.git.merge_form, super::MergeForm::Rebase);
1131
1132        std::fs::write(
1133            target.join(super::MANIFEST_PATH),
1134            record(super::SCHEMA_VERSION, r#","merge_form":"merge""#),
1135        )
1136        .expect("writes");
1137        let refused = super::load(target).expect_err("an unknown form refuses");
1138        assert_eq!(refused.reason(), crate::diagnostic::Reason::RecordInvalid);
1139        assert!(refused.to_string().contains("merge form"), "{refused}");
1140    }
1141
1142    /// The record is the one input a re-render reads, so a hand-edited
1143    /// record must not reach bytes the configured path could never write:
1144    /// a value the configuration reader refuses, and a value it would
1145    /// canonicalize, both refuse at deserialization.
1146    #[test]
1147    fn a_record_carrying_an_uncanonical_security_parameter_refuses() {
1148        let dir = tempfile::tempdir().expect("a scratch target exists");
1149        let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
1150        std::fs::create_dir_all(target.join(".release-kit")).expect("the record dir writes");
1151        for (field, value) in [
1152            // A JSON escape, so the record parses and the value it decodes
1153            // to is the line feed the policy could never carry.
1154            ("security_contact", "team@acme.example\\nsecond line"),
1155            ("security_contact", "  team@acme.example  "),
1156            ("security_response", "90d"),
1157            ("security_response", "0 days"),
1158            ("security_response", "07 days"),
1159            ("security_response", "1 days"),
1160            ("security_response", ""),
1161            ("security_scope", "one\\n\\ntwo"),
1162            ("security_scope", "# Out of scope"),
1163            ("security_scope", "Out of scope\\n---"),
1164            ("security_scope", "see <!-- here"),
1165            ("security_scope", "  padded  "),
1166        ] {
1167            let record = format!(
1168                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":{{}}}}"#
1169            );
1170            std::fs::write(target.join(super::MANIFEST_PATH), record).expect("the record writes");
1171            let refused = super::load(target).expect_err("an uncanonical record refuses");
1172            assert!(refused.to_string().contains(field), "{field}: {refused}");
1173        }
1174    }
1175
1176    #[test]
1177    fn alignment_orders_versions_numerically() {
1178        assert_eq!(alignment("0.1.0", "0.1.0"), Alignment::Aligned);
1179        assert_eq!(alignment("0.1.0", "0.2.0"), Alignment::BinaryNewer);
1180        assert_eq!(alignment("0.10.0", "0.9.9"), Alignment::TargetNewer);
1181        assert_eq!(alignment("0.1.0-rc.1", "0.1.0"), Alignment::BinaryNewer);
1182        assert_eq!(alignment("0.1.0", "0.1.0-rc.1"), Alignment::TargetNewer);
1183    }
1184
1185    /// Pre-release identifiers order by semver precedence, not by text:
1186    /// `rc.10` is newer than `rc.2`, so a binary at `rc.2` must refuse a
1187    /// landing from `rc.10` rather than downgrade it — at any identifier
1188    /// length, so no integer width bounds the protection.
1189    #[test]
1190    fn alignment_orders_numeric_prerelease_identifiers_numerically() {
1191        assert_eq!(
1192            alignment("0.1.0-rc.10", "0.1.0-rc.2"),
1193            Alignment::TargetNewer
1194        );
1195        assert_eq!(
1196            alignment("0.1.0-rc.2", "0.1.0-rc.10"),
1197            Alignment::BinaryNewer
1198        );
1199        assert_eq!(alignment("0.1.0-rc.1", "0.1.0-rc.1"), Alignment::Aligned);
1200        assert_eq!(
1201            alignment("0.1.0-alpha", "0.1.0-alpha.1"),
1202            Alignment::BinaryNewer
1203        );
1204        assert_eq!(alignment("0.1.0-1", "0.1.0-alpha"), Alignment::BinaryNewer);
1205        assert_eq!(
1206            alignment("1.0.0-100000000000000000000", "1.0.0-99999999999999999999"),
1207            Alignment::TargetNewer,
1208            "identifiers past the u64 range still compare numerically"
1209        );
1210        assert_eq!(
1211            alignment("1.0.0-99999999999999999999", "1.0.0-100000000000000000000"),
1212            Alignment::BinaryNewer
1213        );
1214    }
1215
1216    /// Build metadata carries no precedence: it never corrupts a numeric
1217    /// component and never separates two otherwise-equal versions.
1218    #[test]
1219    fn alignment_ignores_build_metadata() {
1220        assert_eq!(alignment("1.2.10+build", "1.2.9"), Alignment::TargetNewer);
1221        assert_eq!(alignment("1.2.9", "1.2.10+build"), Alignment::BinaryNewer);
1222        assert_eq!(alignment("1.0.0+alpha", "1.0.0+beta"), Alignment::Aligned);
1223        assert_eq!(
1224            alignment("1.2.10-rc.1+build", "1.2.10-rc.1"),
1225            Alignment::Aligned
1226        );
1227        assert_eq!(
1228            alignment("1.2.10-rc.1+build", "1.2.10"),
1229            Alignment::BinaryNewer
1230        );
1231    }
1232}