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