Skip to main content

release_kit/
projection.rs

1//! The one pure target-specific projection over this binary's embedded
2//! sources.
3//!
4//! [`Projection::compute`] takes a [`ProjectionInput`], values alone, and
5//! answers the complete candidate artifact tree this installed binary
6//! would land in one target: every destination with its ownership
7//! [`Kind`], its [`Placement`], the complete proposed bytes, the rendered
8//! region where the destination is a marked region, and the embedded
9//! source paths it was rendered from. Staging and production landing
10//! share it byte for byte, so what an agent studies in a stage is what a
11//! landing writes.
12//!
13//! This module reads snippets and blocks through `src/embedded.rs`
14//! directly and reads nothing else: no target file, no environment, no
15//! Git state, no clock, no registry, no network. Whatever a projection
16//! needs from the target arrives as [`TargetEvidence`], gathered before
17//! construction by [`evidence::gather`], which lives in its own file so a
18//! source scan can hold this one to the pure boundary.
19//!
20//! Every pure piece of the landing model has one implementation here: the
21//! kind table, the token substitution, the block templating, the splice
22//! and marker judgments, and the Nix crate-shape judgment. Which embedded
23//! source belongs to which capability, and whether a capability is
24//! available at the target's dimensions, is the catalog's answer in
25//! `crate::profile::catalog`, and this module renders what the catalog
26//! selects. `src/landing.rs` re-exports the pieces under their old names.
27//!
28//! One input type, one compute function, one candidate shape: staging and
29//! every landing verb consume this one projection.
30
31pub mod evidence;
32
33use std::collections::BTreeMap;
34
35use serde::{Deserialize, Serialize};
36
37use crate::embedded;
38use crate::error::RkError;
39pub use crate::landing::manifest::Provider;
40use crate::landing::{CheckoutMode, Integration, Params};
41use crate::profile::ReleaseMode;
42use crate::profile::catalog::{self, Availability, Selection, Status};
43
44/// The complete input to one projection: the resolved landing parameters
45/// and the typed evidence read from the target beforehand.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct ProjectionInput {
48    /// The resolved landing parameters.
49    pub params: Params,
50    /// What the target already holds, as values.
51    pub evidence: TargetEvidence,
52}
53
54/// What a projection needs to know about the target, gathered before the
55/// projection runs and carried as values.
56#[derive(Debug, Clone, PartialEq, Eq, Default)]
57#[allow(
58    clippy::struct_excessive_bools,
59    reason = "each field is one independent fact read off the target, not a state a smaller type could carry"
60)]
61pub struct TargetEvidence {
62    /// The complete existing document at each block destination that
63    /// exists on disk, keyed by destination. An absent key is an absent
64    /// file.
65    pub documents: BTreeMap<String, Vec<u8>>,
66    /// The crate facts the Nix seed relies on.
67    pub crate_shape: CrateShape,
68    /// Whether `flake.nix` is present at the target, a link included.
69    pub flake_nix_present: bool,
70    /// Whether `flake.lock` is present at the target, a link included.
71    pub flake_lock_present: bool,
72    /// Whether the receipt already records `flake.nix`: a pair release-kit
73    /// landed is its own and is never withheld.
74    pub flake_recorded: bool,
75    /// Whether `.gitlab-ci.yml` is present at the target, a link included.
76    pub root_pipeline_present: bool,
77    /// Whether the receipt already records `.gitlab-ci.yml`: a root
78    /// pipeline release-kit landed is its own and is never withheld.
79    pub root_pipeline_recorded: bool,
80}
81
82impl TargetEvidence {
83    /// The existing document at one block destination.
84    #[must_use]
85    pub fn document(&self, destination: &str) -> Option<&[u8]> {
86        self.documents.get(destination).map(Vec::as_slice)
87    }
88}
89
90/// The structural facts of the target's crate that the seeded Nix
91/// package expression and the seed flake's smoke check rely on.
92#[derive(Debug, Clone, PartialEq, Eq, Default)]
93pub struct CrateShape {
94    /// The text of the target's `Cargo.toml`, or `None` where none reads.
95    pub cargo_toml: Option<String>,
96    /// Whether `Cargo.lock` is a file at the target.
97    pub cargo_lock: bool,
98    /// Whether `src/main.rs` is a file at the target.
99    pub main_rs: bool,
100}
101
102/// The complete candidate artifact tree for one target.
103///
104/// A value, never a record: it carries no operation, no decision, and no
105/// apply state, and it is not serializable. A consumer renders it again
106/// from scratch rather than reading a saved copy.
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct Projection {
109    /// Every capability the catalog answered for this target, in catalog
110    /// order: selected, withheld, or omitted with its reason.
111    pub capabilities: Vec<Selection>,
112    /// Every candidate, sorted by destination.
113    pub candidates: Vec<Candidate>,
114    /// The destinations the target's own state withholds, each with its
115    /// one reason and, where one exists, the operator's one edit. A
116    /// destination no selected capability ships is absent, never
117    /// omitted.
118    pub omissions: Vec<Omission>,
119    /// The block destinations whose existing document offers the block no
120    /// place, a target-side defect staging explains and landing refuses.
121    pub collisions: Vec<Collision>,
122    /// Why the recorded code scanning provider's licence condition refuses
123    /// this target, or `None` where no condition applies or the licence
124    /// satisfies it. A landing verb refuses on it and writes nothing; `rk
125    /// status` reports it as a warning and still exits 0, because the
126    /// target is not broken and the operator owns the licensing decision.
127    pub licence_refusal: Option<String>,
128    /// Why a recorded capability cannot run at this target at all, which is a
129    /// receipt nothing can honour rather than a decision anyone made. Empty on
130    /// every target whose parameters came through resolution, because
131    /// resolution refuses these before they are recorded.
132    pub record_defects: Vec<String>,
133}
134
135/// One proposed destination.
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct Candidate {
138    /// The capability that lands it.
139    pub capability: &'static str,
140    /// The destination, relative to the target root.
141    pub destination: String,
142    /// Who owns the bytes after landing.
143    pub kind: Kind,
144    /// The whole file, or the one marked region.
145    pub placement: Placement,
146    /// The complete proposed destination bytes. For a region this is the
147    /// complete spliced document, computed from the existing document in
148    /// the evidence, so a staged view equals what production writes.
149    pub bytes: Vec<u8>,
150    /// The rendered block alone for a region destination: what the
151    /// receipt digests. `None` for a whole file.
152    pub region: Option<Vec<u8>>,
153    /// The embedded source paths the candidate was rendered from, each
154    /// carrying its distribution root as the first segment.
155    pub sources: Vec<String>,
156}
157
158/// How a candidate occupies its destination.
159#[derive(Debug, Clone, Copy, PartialEq, Eq)]
160pub enum Placement {
161    /// The candidate is the whole file.
162    Whole,
163    /// The candidate is the one marked region between these markers; the
164    /// bytes outside them belong to the target.
165    Region {
166        /// The opening marker.
167        begin: &'static str,
168        /// The closing marker.
169        end: &'static str,
170    },
171}
172
173/// One destination withheld from this target, with why.
174#[derive(Debug, Clone, PartialEq, Eq)]
175pub struct Omission {
176    /// The destination that stays out.
177    pub destination: String,
178    /// The reason, stated once per destination.
179    pub reason: String,
180    /// The one edit the operator makes to activate what release-kit could
181    /// not, where the omission is a withheld activation rather than a
182    /// shape the target cannot take.
183    pub action: Option<String>,
184}
185
186/// One block destination the target's document cannot take.
187#[derive(Debug, Clone, PartialEq, Eq)]
188pub struct Collision {
189    /// The destination whose document offers the block no place.
190    pub destination: String,
191    /// The reason, for staging to explain and landing to refuse with.
192    pub reason: String,
193}
194
195impl Projection {
196    /// The complete candidate tree for `input`, from this binary's
197    /// embedded sources alone.
198    ///
199    /// # Errors
200    ///
201    /// A source defect in this binary: an unknown technology or an
202    /// unsupported pair as [`RkError::Usage`], and as [`RkError::Other`] a
203    /// destination two sources ship, a snippet the kind table does not
204    /// classify, or a block this binary does not embed.
205    pub fn compute(input: &ProjectionInput) -> Result<Self, RkError> {
206        Self::compute_over(&embedded_snippets(), input)
207    }
208
209    /// [`Self::compute`] over an explicit snippet list, whose paths carry
210    /// the `snippets/` root; the embedded tree in production, an injected
211    /// one under test.
212    #[allow(
213        clippy::too_many_lines,
214        reason = "one pass selects, renders, splices, and withholds; splitting it would separate a withheld destination from the selection that offered it"
215    )]
216    fn compute_over(files: &[(String, &[u8])], input: &ProjectionInput) -> Result<Self, RkError> {
217        let params = &input.params;
218        let evidence = &input.evidence;
219        let availability = Availability::over(
220            files
221                .iter()
222                .filter_map(|(path, _)| path.strip_prefix("snippets/").map(str::to_owned))
223                .collect(),
224        );
225        let mut capabilities = catalog::select(params, &availability);
226        let mut candidates: Vec<Candidate> = Vec::new();
227        for selection in &capabilities {
228            if !selection.lands() {
229                continue;
230            }
231            for source in &selection.sources {
232                let destination = source
233                    .splitn(3, '/')
234                    .nth(2)
235                    .ok_or_else(|| {
236                        anyhow::anyhow!(
237                            "{source}: a snippet path has a zone, a forge, and a destination"
238                        )
239                    })?
240                    .to_owned();
241                let path = format!("snippets/{source}");
242                let bytes = files
243                    .iter()
244                    .find(|(candidate, _)| *candidate == path)
245                    .map(|(_, bytes)| *bytes)
246                    .ok_or_else(|| {
247                        anyhow::anyhow!(
248                            "{path}: the catalog selected a source the tree does not carry"
249                        )
250                    })?;
251                if let Some(existing) = candidates
252                    .iter()
253                    .find(|candidate| candidate.destination == destination)
254                {
255                    return Err(anyhow::anyhow!(
256                        "{} and {} both ship {destination}: {} and {path}; the embedded sources are defective",
257                        existing.capability,
258                        selection.id,
259                        existing.sources.join(", ")
260                    )
261                    .into());
262                }
263                let kind = kind_of(&destination).ok_or_else(|| {
264                    anyhow::anyhow!(
265                        "the embedded sources do not classify {destination}; the kind table is stale"
266                    )
267                })?;
268                let rendered = match kind {
269                    Kind::Rendered => render(bytes, params),
270                    Kind::Seeded | Kind::State => bytes.to_vec(),
271                };
272                candidates.push(Candidate {
273                    capability: selection.id,
274                    destination,
275                    kind,
276                    placement: Placement::Whole,
277                    bytes: rendered,
278                    region: None,
279                    sources: vec![path],
280                });
281            }
282        }
283        let mut collisions = Vec::new();
284        for destination in BLOCK_DESTINATIONS {
285            let (template, sources) =
286                block_template(destination, params.checkout_mode(), params.integration())?;
287            if let Some(whole) = candidates
288                .iter()
289                .find(|candidate| candidate.destination == destination)
290            {
291                return Err(anyhow::anyhow!(
292                    "{destination} is both a whole file from {} and a marked region from {}; the embedded sources are defective",
293                    whole.sources.join(", "),
294                    sources.join(", ")
295                )
296                .into());
297            }
298            let region = render(template.as_bytes(), params);
299            let (begin, end) = block_markers(destination).ok_or_else(|| {
300                anyhow::anyhow!("{destination} is a block destination with no markers")
301            })?;
302            match propose_document(destination, evidence.document(destination), &region) {
303                Ok(bytes) => candidates.push(Candidate {
304                    capability: catalog::GUARDS,
305                    destination: destination.to_owned(),
306                    kind: Kind::Rendered,
307                    placement: Placement::Region { begin, end },
308                    bytes,
309                    region: Some(region),
310                    sources,
311                }),
312                Err(reason) => collisions.push(Collision {
313                    destination: destination.to_owned(),
314                    reason,
315                }),
316            }
317        }
318        let mut omissions = Vec::new();
319        if let Some((set, reason)) = nix_withholding(params.nix_packaging(), evidence)
320            && candidates
321                .iter()
322                .any(|candidate| candidate.capability == catalog::PACKAGING_NIX)
323        {
324            candidates.retain(|candidate| {
325                if set.contains(&candidate.destination.as_str()) {
326                    omissions.push(Omission {
327                        destination: candidate.destination.clone(),
328                        reason: reason.clone(),
329                        action: None,
330                    });
331                    false
332                } else {
333                    true
334                }
335            });
336            withhold(&mut capabilities, catalog::PACKAGING_NIX, &reason, None);
337        }
338        // The release-less GitLab root pipeline: where the target owns a
339        // root pipeline the receipt does not attribute, the fragment still
340        // lands as the gate's prerequisite, and the activation is the
341        // operator's one include line rather than a refusal of the whole
342        // landing.
343        let title_root = candidates.iter().position(|candidate| {
344            candidate.capability == catalog::TITLE_CHECK
345                && candidate.destination == GITLAB_ROOT_PIPELINE
346        });
347        if let Some(index) = title_root
348            && evidence.root_pipeline_present
349            && !evidence.root_pipeline_recorded
350        {
351            let candidate = candidates.remove(index);
352            let reason = format!(
353                "the target owns {GITLAB_ROOT_PIPELINE}, so the release-less root pipeline that would activate the title gate stays out; the fragment lands as the gate's prerequisite"
354            );
355            let action = format!(
356                "add the include to the target's own {GITLAB_ROOT_PIPELINE}: include:\n  - local: {GITLAB_TITLE_FRAGMENT}"
357            );
358            omissions.push(Omission {
359                destination: candidate.destination,
360                reason: reason.clone(),
361                action: Some(action.clone()),
362            });
363            withhold(
364                &mut capabilities,
365                catalog::TITLE_CHECK,
366                &reason,
367                Some(action),
368            );
369        }
370        candidates.sort_by(|a, b| a.destination.cmp(&b.destination));
371        omissions.sort_by(|a, b| a.destination.cmp(&b.destination));
372        // The licence judgment belongs to a workflow this landing will
373        // actually write. A provider the target's dimensions cannot run is
374        // already an unavailable optional capability, reported and omitted,
375        // and refusing it on its terms would refuse a landing over a file
376        // that was never going to land.
377        let scanning_selected = capabilities.iter().any(|selection| {
378            selection.id == catalog::CODE_SCANNING && selection.status == Status::Selected
379        });
380        let licence_refusal = scanning_selected
381            .then(|| {
382                code_scanning_licence_refusal(
383                    params.code_scanning(),
384                    params.driver(),
385                    &evidence.crate_shape,
386                )
387            })
388            .flatten();
389        // A scanner the target's dimensions cannot run is an unavailable
390        // optional capability, which the catalog already reports and this
391        // projection already omits. Naming it a record defect too would
392        // make a landing this binary itself wrote fail its own check.
393        let mut record_defects: Vec<String> = Vec::new();
394        // A recorded automatic release whose automation this release cannot
395        // land is a receipt nothing can honour: named here, so a
396        // record-only reader sees it.
397        if params.release_mode() == ReleaseMode::Automatic
398            && let Some(selection) = capabilities
399                .iter()
400                .find(|selection| selection.id == catalog::RELEASE_AUTOMATION)
401            && matches!(selection.status, Status::Unavailable | Status::Unknown)
402            && let Some(reason) = &selection.reason
403        {
404            record_defects.push(reason.clone());
405        }
406        Ok(Self {
407            capabilities,
408            candidates,
409            omissions,
410            collisions,
411            licence_refusal,
412            record_defects,
413        })
414    }
415
416    /// Why the selected release automation cannot land, where the profile
417    /// asked for one this release does not carry at its dimensions. An
418    /// apply refuses on it before any write; a preview reports it.
419    #[must_use]
420    pub fn release_unavailable(&self) -> Option<&str> {
421        self.capabilities
422            .iter()
423            .find(|selection| {
424                selection.id == catalog::RELEASE_AUTOMATION
425                    && matches!(selection.status, Status::Unavailable | Status::Unknown)
426            })
427            .and_then(|selection| selection.reason.as_deref())
428    }
429
430    /// The one selection for a capability.
431    #[must_use]
432    pub fn capability(&self, id: &str) -> Option<&Selection> {
433        self.capabilities
434            .iter()
435            .find(|selection| selection.id == id)
436    }
437}
438
439/// Mark one capability withheld with its reason and the operator's edit.
440fn withhold(capabilities: &mut [Selection], id: &str, reason: &str, action: Option<String>) {
441    if let Some(selection) = capabilities.iter_mut().find(|selection| selection.id == id) {
442        selection.status = Status::Withheld;
443        selection.reason = Some(reason.to_owned());
444        selection.action = action;
445    }
446}
447
448/// The GitLab root pipeline, the one destination a release-less landing
449/// withholds where the target owns it.
450pub const GITLAB_ROOT_PIPELINE: &str = ".gitlab-ci.yml";
451
452/// The GitLab title fragment the root pipeline includes.
453pub const GITLAB_TITLE_FRAGMENT: &str = ".gitlab/ci/mr-title.yml";
454
455/// Every snippet this binary embeds, as `(path, bytes)` with the path
456/// carrying the `snippets/` root, sorted by path.
457fn embedded_snippets() -> Vec<(String, &'static [u8])> {
458    embedded::walk(&embedded::SNIPPETS)
459        .into_iter()
460        .map(|(path, bytes)| (format!("snippets/{path}"), bytes))
461        .collect()
462}
463
464/// Every `(driver, forge)` pair at which the release automation is
465/// available, in path order.
466#[must_use]
467pub fn supported_pairs() -> Vec<(String, String)> {
468    Availability::embedded()
469        .automation_tuples()
470        .into_iter()
471        .filter_map(|entry| {
472            entry
473                .split_once(", ")
474                .map(|(driver, forge)| (driver.to_owned(), forge.to_owned()))
475        })
476        .collect()
477}
478
479/// Who owns a landed file's bytes after landing.
480#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
481#[serde(rename_all = "lowercase")]
482pub enum Kind {
483    /// release-kit owns it: a newer binary re-renders it, and a target
484    /// edit is a conflict.
485    Rendered,
486    /// The target owns it: a starting point the project tunes, reported
487    /// and never rewritten.
488    Seeded,
489    /// The release automation owns it: never written after the first
490    /// landing, never compared.
491    State,
492}
493
494impl Kind {
495    /// The wire and report form.
496    #[must_use]
497    pub const fn as_str(self) -> &'static str {
498        match self {
499            Self::Rendered => "rendered",
500            Self::Seeded => "seeded",
501            Self::State => "state",
502        }
503    }
504}
505
506/// The declared classification: every landable destination and its kind.
507/// The workflow and pipeline files carry the release automation and the
508/// OIDC permission, so release-kit owns them; the tool configurations are
509/// per-project judgment; the two state files are rewritten by the release
510/// automation itself.
511const KINDS: [(&str, Kind); 20] = [
512    (".github/workflows/release-plz.yml", Kind::Rendered),
513    (".github/workflows/release-please.yml", Kind::Rendered),
514    (".github/workflows/release.yml", Kind::Rendered),
515    (".github/workflows/pr-title.yml", Kind::Rendered),
516    (".github/workflows/scorecard.yml", Kind::Rendered),
517    (".github/workflows/code-scanning-codeql.yml", Kind::Rendered),
518    (
519        ".github/workflows/code-scanning-semgrep.yml",
520        Kind::Rendered,
521    ),
522    (".gitlab/ci/code-scanning-semgrep.yml", Kind::Rendered),
523    (".gitlab-ci.yml", Kind::Rendered),
524    ("SECURITY.md", Kind::Rendered),
525    (".gitlab/ci/mr-title.yml", Kind::Rendered),
526    ("release-plz.toml", Kind::Seeded),
527    ("dist-workspace.toml", Kind::Seeded),
528    ("release-please-config.json", Kind::Seeded),
529    ("cliff.toml", Kind::Seeded),
530    ("nix/package.nix", Kind::Seeded),
531    ("flake.nix", Kind::Seeded),
532    (".release-please-manifest.json", Kind::State),
533    ("VERSION", Kind::State),
534    ("flake.lock", Kind::State),
535];
536
537/// The destinations of the opt-in Nix capability, present in a projection
538/// only where the landing's `nix` parameter is on.
539///
540/// The parameter is recorded, so `status`, `upgrade`, and `adopt` can
541/// reconstruct whether these files are supposed to exist: an absent file
542/// under `nix = false` is not wanted, never drifted.
543///
544/// The capability lands no workflow, on either forge, and each forge's
545/// reason is its own. On GitHub a job gates the merge only inside the
546/// workflow the required check needs, and that workflow is the target's
547/// own. On GitLab the merge check is the whole pipeline, and a target's
548/// jobs live in the child pipeline the rendered parent triggers, which the
549/// target owns. The bindings serve the job for both.
550pub const NIX_DESTINATIONS: [&str; 3] = ["nix/package.nix", "flake.nix", "flake.lock"];
551
552/// The subset a target with a flake of its own keeps out: the seed pair,
553/// whose files would sit beside a flake release-kit did not author.
554///
555/// The seeded package expression is not in it: it lands either way, as
556/// the starting point the target integrates by hand.
557pub const NIX_WITHHOLDABLE: [&str; 2] = ["flake.nix", "flake.lock"];
558
559/// The destinations of the opt-in Scorecard capability, present in a
560/// projection only where the landing's `scorecard` parameter is on.
561///
562/// The parameter is recorded for the reason [`NIX_DESTINATIONS`] states:
563/// an absent file under `scorecard = false` is not wanted, never drifted.
564/// The capability is GitHub's alone, so the shared GitLab zone ships no
565/// counterpart and a GitLab landing projects nothing for it, whatever the
566/// parameter says. A Scorecard run needs no forge setting on a public
567/// repository, so the capability adds no setup step and cannot conflict
568/// with `forge-setup:every-supported-forge-runs-every-step`.
569///
570/// The file is `rendered`: its bytes carry the recorded trunk and nothing a
571/// target is expected to tune, so release-kit owns them and an edit is
572/// drift.
573pub const SCORECARD_DESTINATIONS: [&str; 1] = [".github/workflows/scorecard.yml"];
574
575/// Every destination the opt-in code scanning capability can land, against
576/// the provider that lands it.
577///
578/// One source owns one destination, so the provider is in the name: a
579/// landing writes exactly the entry its recorded provider names and the
580/// forge ships. Switching provider retires one destination and adds
581/// another, which `landing:a-dropped-file-stays` already answers.
582///
583/// `codeql` is GitHub's own analyzer and has no GitLab entry, so a GitLab
584/// landing that names it lands nothing; the catalog reports that pair
585/// unavailable by name, as it reports a binding outside
586/// [`CODE_SCANNING_TECHS`], and the landing omits the destination and
587/// proceeds.
588pub const CODE_SCANNING_DESTINATIONS: [(&str, Provider); 3] = [
589    (
590        ".github/workflows/code-scanning-codeql.yml",
591        Provider::CodeQl,
592    ),
593    (
594        ".github/workflows/code-scanning-semgrep.yml",
595        Provider::Semgrep,
596    ),
597    (".gitlab/ci/code-scanning-semgrep.yml", Provider::Semgrep),
598];
599
600/// The bindings that ship a code scanning workflow.
601///
602/// A scanner reads one language: the `CodeQL` arm fixes `languages: rust` and
603/// both Semgrep arms name the `p/rust` ruleset, so the sources live in the
604/// rust pairs rather than in a forge's technology-independent shared zone.
605/// A landing for any other binding reports the capability unavailable by
606/// name and omits its destinations, rather than writing nothing silently.
607pub const CODE_SCANNING_TECHS: [&str; 1] = ["rust"];
608
609/// Why the named code scanning provider cannot run at this
610/// `(technology, forge)`, or `None` where it can or none is named.
611///
612/// One owner for the question, because two callers ask it and they must not
613/// disagree. The reason is informational: it becomes the catalog's
614/// `Unavailable` status, and nothing refuses on it. An optional capability
615/// this release cannot build at the target's dimensions is reported and
616/// omitted, which is what
617/// `project-profile:an-operation-refuses-only-what-it-requires` states.
618///
619/// A receipt asks the same question and gets the same answer, because
620/// `Params::from_record` cannot fail and a hand-edited or foreign receipt
621/// reaches `rk status` through it: without this, a receipt naming a provider
622/// whose pair ships nothing would project nothing, name nothing, and read as
623/// clean. It is a report about the pair, not a record defect, so a landing
624/// this binary wrote still answers its own check.
625#[must_use]
626pub fn code_scanning_incompatibility(
627    provider: Option<Provider>,
628    driver: Option<&str>,
629    forge: Option<&str>,
630) -> Option<String> {
631    let named = provider?;
632    let Some(driver) = driver else {
633        return Some(format!(
634            "a scanner reads the release driver's language, and the profile names no automatic release driver; the bindings that carry one are: {}",
635            CODE_SCANNING_TECHS.join(", ")
636        ));
637    };
638    if !CODE_SCANNING_TECHS.contains(&driver) {
639        return Some(format!(
640            "the {driver} binding ships no code scanning workflow; a scanner reads one language, and the bindings that carry one are: {}",
641            CODE_SCANNING_TECHS.join(", ")
642        ));
643    }
644    let Some(forge) = forge else {
645        return Some(
646            "a code scanning workflow runs at a forge, and the profile names none".to_owned(),
647        );
648    };
649    if named == Provider::CodeQl && forge != "github" {
650        return Some(format!(
651            "codeql is GitHub's own analyzer and the {forge} pair ships no workflow for it; pass --code-scanning semgrep"
652        ));
653    }
654    None
655}
656
657/// The SPDX identifiers this convention recognizes as OSI-approved, sorted.
658///
659/// Lowercase, because the comparison is case-insensitive: SPDX asks for that
660/// and cargo accepts any casing.
661///
662/// A closed list rather than a parse of the OSI register: the register moves
663/// and this binary reads no network, so an identifier absent here is
664/// unrecognized rather than rejected, and the refusal says so. Every entry
665/// is an OSI-approved licence that appears on published Rust crates.
666const OSI_APPROVED: [&str; 18] = [
667    "0bsd",
668    "agpl-3.0",
669    "agpl-3.0-only",
670    "agpl-3.0-or-later",
671    "apache-2.0",
672    "bsd-2-clause",
673    "bsd-3-clause",
674    "bsl-1.0",
675    "epl-2.0",
676    "gpl-2.0",
677    "gpl-2.0-only",
678    "gpl-2.0-or-later",
679    "gpl-3.0",
680    "gpl-3.0-only",
681    "gpl-3.0-or-later",
682    "isc",
683    "mit",
684    "mpl-2.0",
685];
686
687/// The SPDX identifiers this convention recognizes as terms over material
688/// that is not code, sorted.
689///
690/// Lowercase, for the reason [`OSI_APPROVED`] is.
691///
692/// None of these is OSI-approved, and none of them grants a licence over
693/// code, so none of them states an open-source codebase on its own. They are
694/// recognized because a project that licenses its prose apart from its source
695/// declares both in one expression, and a conjunction naming one of these
696/// beside an OSI-approved licence still hands every reader the approved
697/// terms. The entries are the attribution and share-alike families at the two
698/// versions still in use, which is what a crate shipping documentation beside
699/// its source declares.
700///
701/// `CC0-1.0` is deliberately absent. It dedicates any material to the public
702/// domain, code included, so it is not a term over material that is not code
703/// and it belongs to a question this list does not answer.
704const CONTENT_LICENCES: [&str; 4] = ["cc-by-3.0", "cc-by-4.0", "cc-by-sa-3.0", "cc-by-sa-4.0"];
705
706/// Every SPDX exception identifier, lowercase.
707///
708/// SPDX requires the right operand of `WITH` to be a `<license-exception-id>`,
709/// so an operand outside this list makes the expression malformed and the
710/// licence unread. The whole register rather than a subset, because a subset
711/// refuses a legitimate crate: `GPL-2.0-only WITH GCC-exception-2.0` names a
712/// real exception, and a reader carrying only the newer `GCC-exception-3.1`
713/// would refuse it.
714///
715/// An exception grants permission rather than withdrawing it, so none of these
716/// changes whether the left operand is OSI-approved. It is validated because a
717/// reader that cannot parse the expression has not read the licence.
718///
719/// Taken from the SPDX license-list-data exception register, 86 identifiers, on
720/// 2026-09-15. A later addition upstream is a patch here, and the refusal names
721/// the operand it did not recognize.
722const SPDX_EXCEPTIONS: [&str; 86] = [
723    "389-exception",
724    "asterisk-exception",
725    "asterisk-linking-protocols-exception",
726    "autoconf-exception-2.0",
727    "autoconf-exception-3.0",
728    "autoconf-exception-generic",
729    "autoconf-exception-generic-3.0",
730    "autoconf-exception-macro",
731    "bison-exception-1.24",
732    "bison-exception-2.2",
733    "bootloader-exception",
734    "cgal-linking-exception",
735    "classpath-exception-2.0",
736    "classpath-exception-2.0-short",
737    "clisp-exception-2.0",
738    "cryptsetup-openssl-exception",
739    "digia-qt-lgpl-exception-1.1",
740    "digirule-foss-exception",
741    "ecos-exception-2.0",
742    "erlang-otp-linking-exception",
743    "fawkes-runtime-exception",
744    "fltk-exception",
745    "fmt-exception",
746    "font-exception-2.0",
747    "freertos-exception-2.0",
748    "gcc-exception-2.0",
749    "gcc-exception-2.0-note",
750    "gcc-exception-3.1",
751    "gmsh-exception",
752    "gnat-exception",
753    "gnome-examples-exception",
754    "gnu-compiler-exception",
755    "gnu-javamail-exception",
756    "google-patent-webm",
757    "gpl-3.0-389-ds-base-exception",
758    "gpl-3.0-interface-exception",
759    "gpl-3.0-linking-exception",
760    "gpl-3.0-linking-source-exception",
761    "gpl-cc-1.0",
762    "gstreamer-exception-2005",
763    "gstreamer-exception-2008",
764    "harbour-exception",
765    "i2p-gpl-java-exception",
766    "independent-modules-exception",
767    "kicad-libraries-exception",
768    "kvirc-openssl-exception",
769    "lgpl-3.0-linking-exception",
770    "libpri-openh323-exception",
771    "libtool-exception",
772    "linux-syscall-note",
773    "llgpl",
774    "llvm-exception",
775    "lzma-exception",
776    "mif-exception",
777    "mxml-exception",
778    "nokia-qt-exception-1.1",
779    "ocaml-lgpl-linking-exception",
780    "occt-exception-1.0",
781    "openjdk-assembly-exception-1.0",
782    "openvpn-openssl-exception",
783    "pcre2-exception",
784    "polyparse-exception",
785    "ps-or-pdf-font-exception-20170817",
786    "qpl-1.0-inria-2004-exception",
787    "qt-gpl-exception-1.0",
788    "qt-lgpl-exception-1.1",
789    "qwt-exception-1.0",
790    "romic-exception",
791    "rrdtool-floss-exception-2.0",
792    "rsync-linking-exception",
793    "sane-exception",
794    "shl-2.0",
795    "shl-2.1",
796    "simple-library-usage-exception",
797    "spelling-provider-lgpl-exception",
798    "sqlitestudio-openssl-exception",
799    "stunnel-exception",
800    "swi-exception",
801    "swift-exception",
802    "texinfo-exception",
803    "u-boot-exception-2.0",
804    "ubdl-exception",
805    "universal-foss-exception-1.0",
806    "vsftpd-openssl-exception",
807    "wxwindows-exception-3.1",
808    "x11vnc-openssl-exception",
809];
810
811/// Whether `identifier` is in `list`, matched the way SPDX asks for it.
812///
813/// Without regard to case: SPDX states that an identifier "should be matched
814/// in a case-insensitive manner", and cargo accepts `license = "mit"` without
815/// complaint, so a real crate can carry any casing and a case-sensitive
816/// comparison would refuse a licence it recognizes.
817fn listed(list: &[&str], identifier: &str) -> bool {
818    let lowered = identifier.to_ascii_lowercase();
819    list.contains(&lowered.as_str())
820}
821
822/// One token of an SPDX licence expression.
823#[derive(Debug, Clone, Copy, PartialEq, Eq)]
824enum Token<'a> {
825    /// An opening parenthesis.
826    Open,
827    /// A closing parenthesis.
828    Close,
829    /// The conjunction: the codebase is offered under both terms at once.
830    And,
831    /// The disjunction: the reader chooses one term.
832    Or,
833    /// The exception operator, whose right side names an exception rather
834    /// than a licence.
835    With,
836    /// A licence identifier, a licence reference, or an exception
837    /// identifier. Which one it is depends on its position.
838    Identifier(&'a str),
839}
840
841/// The tokens of one SPDX expression, or `None` where a character can begin
842/// no token.
843///
844/// The identifier alphabet is SPDX's own plus `:`, which a
845/// `DocumentRef-...:LicenseRef-...` reference carries. A reference
846/// tokenizes and then simply matches no approved identifier, which is the
847/// honest answer: it names a licence whose text lives outside the register.
848fn tokenize(expression: &str) -> Option<Vec<Token<'_>>> {
849    let mut tokens = Vec::new();
850    let bytes = expression.as_bytes();
851    let mut at = 0;
852    while at < bytes.len() {
853        let byte = bytes[at];
854        if byte.is_ascii_whitespace() {
855            at += 1;
856            continue;
857        }
858        if byte == b'(' {
859            tokens.push(Token::Open);
860            at += 1;
861            continue;
862        }
863        if byte == b')' {
864            tokens.push(Token::Close);
865            at += 1;
866            continue;
867        }
868        let start = at;
869        while at < bytes.len() {
870            let byte = bytes[at];
871            if byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'+' | b':') {
872                at += 1;
873            } else {
874                break;
875            }
876        }
877        if at == start {
878            return None;
879        }
880        let word = &expression[start..at];
881        tokens.push(match word {
882            "AND" => Token::And,
883            "OR" => Token::Or,
884            "WITH" => Token::With,
885            other => Token::Identifier(other),
886        });
887    }
888    Some(tokens)
889}
890
891/// What one node of an SPDX expression states.
892///
893/// Two facts rather than one, because a single bit cannot answer the
894/// question. `MIT AND (CC-BY-4.0 OR CC-BY-SA-4.0)` needs an inner node that
895/// states no open-source codebase on its own and still leaves the conjunction
896/// around it stating one.
897#[derive(Debug, Clone, Copy)]
898struct Verdict {
899    /// Every reader of this node obtains an OSI-approved grant.
900    grants: bool,
901    /// Every licence this node names is one this release recognizes.
902    clean: bool,
903}
904
905/// A recursive-descent reader over one tokenized SPDX expression.
906///
907/// It answers three questions in one pass, and all three must hold: whether
908/// the expression is well formed, whether every reader of it obtains an
909/// OSI-approved grant, and whether every licence it names is one this release
910/// recognizes. A malformed expression answers `None` rather than falling
911/// back on the identifiers it happened to contain, because an expression
912/// nobody can parse states no terms at all.
913struct Spdx<'a> {
914    tokens: &'a [Token<'a>],
915    at: usize,
916}
917
918impl<'a> Spdx<'a> {
919    /// The token at the cursor, without consuming it.
920    fn peek(&self) -> Option<Token<'a>> {
921        self.tokens.get(self.at).copied()
922    }
923
924    /// The token at the cursor, consumed.
925    fn bump(&mut self) -> Option<Token<'a>> {
926        let token = self.peek()?;
927        self.at += 1;
928        Some(token)
929    }
930
931    /// One expression: conjunctions joined by `OR`.
932    ///
933    /// `OR` binds loosest, which is what SPDX states, and the precedence is
934    /// load-bearing here rather than decorative. A disjunction offers the
935    /// reader a choice, so it grants the approved terms only where every arm
936    /// does: one unapproved arm is an arm the reader may take. Read with the
937    /// operators flattened instead, `CC-BY-4.0 OR CC-BY-SA-4.0 AND MIT` would
938    /// state an open-source codebase, and its reader may hold a term over
939    /// prose alone.
940    fn expression(&mut self) -> Option<Verdict> {
941        let mut verdict = self.conjunction()?;
942        while self.peek() == Some(Token::Or) {
943            self.bump();
944            let right = self.conjunction()?;
945            verdict = Verdict {
946                grants: verdict.grants && right.grants,
947                clean: verdict.clean && right.clean,
948            };
949        }
950        Some(verdict)
951    }
952
953    /// One conjunction: operands joined by `AND`, each operand a
954    /// parenthesized expression or a simple licence.
955    ///
956    /// `AND` binds tighter than `OR`. A conjunction offers every term at
957    /// once, so it grants the approved terms where any operand does: a reader
958    /// who must accept both terms has accepted the approved one. What the
959    /// other operand may be is held by `clean` rather than by this field,
960    /// because a licence nobody here can read establishes nothing, while a
961    /// Creative Commons term over prose withdraws no grant over code.
962    fn conjunction(&mut self) -> Option<Verdict> {
963        let mut verdict = self.operand()?;
964        while self.peek() == Some(Token::And) {
965            self.bump();
966            let right = self.operand()?;
967            verdict = Verdict {
968                grants: verdict.grants || right.grants,
969                clean: verdict.clean && right.clean,
970            };
971        }
972        Some(verdict)
973    }
974
975    /// One operand: a parenthesized expression, or a licence identifier
976    /// optionally carrying `WITH` and an exception identifier.
977    ///
978    /// A `+` suffix reads as the bare identifier, which is what the
979    /// deprecated `GPL-3.0+` form means. The operand after `WITH` must be an
980    /// exception identifier this release recognizes, and it changes no
981    /// verdict: an exception grants permission rather than withdrawing it, so
982    /// which licence the codebase is offered under is answered by the left
983    /// operand alone.
984    fn operand(&mut self) -> Option<Verdict> {
985        match self.bump()? {
986            Token::Open => {
987                // A parenthesis restarts the whole grammar, so the loosest
988                // operator binds inside it too.
989                let inner = self.expression()?;
990                (self.bump()? == Token::Close).then_some(inner)
991            }
992            Token::Identifier(name) => {
993                let identifier = name.strip_suffix('+').unwrap_or(name);
994                let approved = listed(&OSI_APPROVED, identifier);
995                let verdict = Verdict {
996                    grants: approved,
997                    clean: approved || listed(&CONTENT_LICENCES, identifier),
998                };
999                if self.peek() == Some(Token::With) {
1000                    self.bump();
1001                    // SPDX requires an exception identifier here, so an
1002                    // operand this release does not recognize leaves the
1003                    // expression malformed and the licence unread.
1004                    match self.bump()? {
1005                        Token::Identifier(exception) if listed(&SPDX_EXCEPTIONS, exception) => {}
1006                        _ => return None,
1007                    }
1008                }
1009                Some(verdict)
1010            }
1011            Token::And | Token::Or | Token::With | Token::Close => None,
1012        }
1013    }
1014}
1015
1016/// Whether one SPDX expression is well formed and states an open-source
1017/// codebase.
1018///
1019/// Three conditions, and the trio is the point. The expression must parse,
1020/// because `MIT OR` names one licence and no complete offer, and accepting it
1021/// would land a workflow whose provider's terms nothing established. Every
1022/// reader of it must obtain an OSI-approved grant, which makes a disjunction
1023/// strict and a conjunction permissive. And every identifier in it must be
1024/// one this release recognizes, either OSI-approved or a Creative Commons
1025/// term over material that is not code.
1026///
1027/// The conjunction is the case this answers. A project that offers its source
1028/// under an OSI-approved licence and its prose under Creative Commons terms
1029/// declares both in one expression, and the second adds an obligation over
1030/// prose rather than withdrawing the grant over code.
1031#[must_use]
1032pub fn licence_states_an_open_source_codebase(expression: &str) -> bool {
1033    let Some(tokens) = tokenize(expression) else {
1034        return false;
1035    };
1036    let mut reader = Spdx {
1037        tokens: &tokens,
1038        at: 0,
1039    };
1040    let Some(verdict) = reader.expression() else {
1041        return false;
1042    };
1043    verdict.grants && verdict.clean && reader.at == tokens.len()
1044}
1045
1046/// Why the code scanning capability's licence condition refuses this
1047/// target, or `None` where no condition applies or the licence satisfies
1048/// it.
1049///
1050/// Only `codeql` carries a condition: its terms cover an open-source
1051/// codebase, and a run over anything else needs a paid seat, so a landing
1052/// that guessed would write a licence violation. Semgrep CE carries none,
1053/// and is the fallback the refusal names.
1054///
1055/// The licence is read where the binding declares it. For `rust` that is
1056/// the `license` field of `Cargo.toml`. A manifest that names a
1057/// `license-file` instead states no identifier this judgment can read, so
1058/// the pair refuses rather than guessing at the file's contents.
1059#[must_use]
1060pub fn code_scanning_licence_refusal(
1061    provider: Option<Provider>,
1062    driver: Option<&str>,
1063    shape: &CrateShape,
1064) -> Option<String> {
1065    if provider != Some(Provider::CodeQl) {
1066        return None;
1067    }
1068    if driver != Some("rust") {
1069        return Some(format!(
1070            "the {} binding declares no licence field this release reads, and codeql's terms cover an open-source codebase alone; land --code-scanning semgrep, which carries no licence condition",
1071            driver.unwrap_or("release-less")
1072        ));
1073    }
1074    let fallback = "land --code-scanning semgrep, which carries no licence condition";
1075    let Some(text) = shape.cargo_toml.as_deref() else {
1076        return Some(format!(
1077            "the target has no readable Cargo.toml, so the licence codeql's terms depend on cannot be read; {fallback}"
1078        ));
1079    };
1080    let Ok(table) = text.parse::<toml::Table>() else {
1081        return Some(format!(
1082            "the target's Cargo.toml does not parse, so the licence codeql's terms depend on cannot be read; {fallback}"
1083        ));
1084    };
1085    let licence = table
1086        .get("package")
1087        .and_then(toml::Value::as_table)
1088        .and_then(|package| package.get("license"))
1089        .and_then(toml::Value::as_str);
1090    match licence {
1091        None => Some(format!(
1092            "the target's Cargo.toml declares no license field, and codeql's terms cover an open-source codebase alone; declare one, or {fallback}"
1093        )),
1094        Some(expression) if !licence_states_an_open_source_codebase(expression) => Some(format!(
1095            "the target's license, {expression}, does not state a codebase this release recognizes as open source, and codeql's terms cover an open-source codebase alone; {fallback}"
1096        )),
1097        Some(_) => None,
1098    }
1099}
1100
1101/// The declared kind of a destination, or `None` for a file the sources
1102/// does not classify.
1103#[must_use]
1104pub fn kind_of(destination: &str) -> Option<Kind> {
1105    if BLOCK_DESTINATIONS.contains(&destination) {
1106        return Some(Kind::Rendered);
1107    }
1108    KINDS
1109        .iter()
1110        .find(|(name, _)| *name == destination)
1111        .map(|(_, kind)| *kind)
1112}
1113
1114/// Every destination the embedded sources can land, in declaration order.
1115///
1116/// The whole files and the three block destinations. The classification
1117/// reads it to ask whether a destination is already present at a target.
1118pub fn destinations() -> impl Iterator<Item = &'static str> {
1119    KINDS
1120        .iter()
1121        .map(|(name, _)| *name)
1122        .chain(BLOCK_DESTINATIONS)
1123}
1124
1125/// The mechanical substitution sites in `rendered` files.
1126///
1127/// Known values, substituted identically everywhere each appears. The
1128/// owner is derived from the landing's `repo` parameter and the scope
1129/// shape from [`SCOPE_SHAPE`], so the landed bytes stay a deterministic
1130/// function of the embedded sources plus parameters.
1131pub const OWNER_TOKEN: &[u8] = b"OWNER";
1132
1133/// The repository a preview stands in for where nothing answered.
1134///
1135/// It is a placeholder, never a project path: a plan that would render
1136/// it into a target is blocked, and only a preview may carry it.
1137pub const REPO_PLACEHOLDER: &str = "OWNER";
1138
1139/// The full recorded project path, including nested namespaces.
1140pub const REPO_TOKEN: &[u8] = b"RK_REPO";
1141
1142/// The one scope shape: the title checks' regular expression.
1143pub const SCOPE_SHAPE_TOKEN: &[u8] = b"RK_SCOPE_SHAPE";
1144
1145/// The recorded release style: `trunk` arms the bot's request in the
1146/// landed release workflow, `lines` leaves every request unarmed.
1147pub const STYLE_TOKEN: &[u8] = b"RK_STYLE";
1148
1149/// The one permanent branch. A landed release trigger, ref guard, and
1150/// branch guard each name it, so a target whose trunk is not `master`
1151/// needs its own answer in its own bytes.
1152pub const TRUNK_BRANCH_TOKEN: &[u8] = b"RK_TRUNK_BRANCH";
1153
1154/// The release-line branch prefix, naming the lines a release trigger
1155/// accepts beside the trunk.
1156pub const LINE_PREFIX_TOKEN: &[u8] = b"RK_LINE_PREFIX";
1157
1158/// The same prefix, escaped for a slash-delimited regular expression.
1159///
1160/// A GitLab rule names a line that way, and a raw `release/` would close
1161/// the delimiter and break the pipeline, so the two forms are two tokens.
1162/// This one substitutes first: the plain token is its own prefix.
1163pub const LINE_PREFIX_RE_TOKEN: &[u8] = b"RK_LINE_PREFIX_RE";
1164
1165/// The token a release workflow's job condition carries, so that a
1166/// rendering which adds a second trigger cannot let the new event enter a
1167/// job written for a push.
1168///
1169/// It is a token rather than a span because it substitutes in place, once
1170/// per job, and one span replaces one occurrence. It is written as a
1171/// trailing YAML comment and carries the space in front of it, so the
1172/// authored condition is an expression a workflow linter reads and every
1173/// rendering that adds no trigger strips the comment whole.
1174pub const PUSH_GUARD_TOKEN: &[u8] = b" # RK_PUSH_GUARD";
1175
1176/// The check the release gate judges.
1177pub const REQUIRED_CHECK_TOKEN: &[u8] = b"RK_REQUIRED_CHECK";
1178
1179/// The workflow whose completion wakes the release gate.
1180pub const REQUIRED_WORKFLOW_TOKEN: &[u8] = b"RK_REQUIRED_WORKFLOW";
1181
1182/// The head-branch shape the release driver gives its own request.
1183pub const BRANCH_SHAPE_TOKEN: &[u8] = b"RK_BRANCH_SHAPE";
1184
1185/// The id of the job that opens and refreshes the release request.
1186pub const REQUEST_JOB_TOKEN: &[u8] = b"RK_REQUEST_JOB";
1187
1188/// The gate's trigger block, added to a release workflow's `on` key.
1189pub const RELEASE_GATE_TRIGGER: &str = "blocks/release-gate-trigger.yaml.in";
1190
1191/// The gate's own job, added to a release workflow.
1192pub const RELEASE_GATE_JOB: &str = "blocks/release-gate-job.yaml.in";
1193
1194/// What a release driver's own release request is shaped like, and which
1195/// job maintains it, where this release drives that driver on GitHub.
1196///
1197/// The branch shape is a structural second guard and never a proof of
1198/// identity: the gate authenticates a request by its author, its base, and
1199/// its head repository, all read back from the forge.
1200#[must_use]
1201pub fn release_driver_shape(driver: Option<&str>) -> Option<(&'static str, &'static str)> {
1202    match driver {
1203        // release-plz names its own branch, and the release half already
1204        // recognizes a bump by this prefix.
1205        Some("rust") => Some(("release-plz-", "release-plz-pr")),
1206        // The bash pipeline writes the branch itself, in the request job.
1207        Some("bash") => Some(("chore/release-v", "release-request")),
1208        // release-please's own branch module builds this prefix from the
1209        // target branch, with an optional components or groups suffix, so
1210        // the trunk token stands inside the shape and resolves with it.
1211        Some("python") => Some((
1212            "release-please--branches--RK_TRUNK_BRANCH",
1213            "release-please-pr",
1214        )),
1215        _ => None,
1216    }
1217}
1218
1219/// The three replaceable spans of a release workflow under the rendering
1220/// that gates its request, each as its ordered begin and end marker.
1221///
1222/// The markers are YAML comments, because a reader may open the snippet
1223/// before any landing renders it, and each begin marker carries the
1224/// newline and the indentation in front of it, so a landing that replaces
1225/// nothing strips the pair and leaves the authored bytes exactly.
1226///
1227/// The spans are in file order: the trigger the gate wakes on, the
1228/// standing arm the gate replaces, and the gate's own job.
1229#[must_use]
1230pub fn release_gate_spans(driver: Option<&str>) -> [(&'static [u8], &'static [u8]); 3] {
1231    // The bash pipeline arms inside a shell script rather than in a step of
1232    // its own, so its arm markers are shell comments at the script's own
1233    // indentation. Every other marker is a YAML comment at column zero.
1234    let arm: (&[u8], &[u8]) = if driver == Some("bash") {
1235        (
1236            b"\n          # RK_GATE_ARM_BEGIN",
1237            b"\n          # RK_GATE_ARM_END",
1238        )
1239    } else {
1240        (b"\n# RK_GATE_ARM_BEGIN", b"\n# RK_GATE_ARM_END")
1241    };
1242    [
1243        (b"\n# RK_GATE_TRIGGER_BEGIN", b"\n# RK_GATE_TRIGGER_END"),
1244        arm,
1245        (b"\n# RK_GATE_JOB_BEGIN", b"\n# RK_GATE_JOB_END"),
1246    ]
1247}
1248
1249/// Whether this parameter set renders a release workflow that gates its
1250/// own request.
1251///
1252/// One shape does: GitHub, an automatic release, the trunk style, and
1253/// local integration, at a driver this release ships a request shape for.
1254/// Under forge integration the trunk ruleset requires the check before the
1255/// merge, so the authored standing arm is exact.
1256#[must_use]
1257fn release_gates(params: &Params) -> bool {
1258    params.forge() == Some("github")
1259        && params.integration() == crate::landing::Integration::Local
1260        && params.release_mode() == ReleaseMode::Automatic
1261        && params.style() == Some(crate::landing::Style::Trunk)
1262        && !params.required_check().is_empty()
1263        && !params.required_workflow().is_empty()
1264        && release_driver_shape(params.driver()).is_some()
1265}
1266
1267/// The replacement for each release gate span under one parameter set, or
1268/// `None` where the authored standing arm stands.
1269///
1270/// One shape renders the gate: GitHub, an automatic release, the trunk
1271/// style, and local integration, at a driver this release ships a request
1272/// shape for. Under forge integration the trunk ruleset requires the check
1273/// before the merge, so the authored arm is exact and every marker strips.
1274///
1275/// # Errors
1276///
1277/// A block this binary does not embed, a defect in the binary.
1278fn release_gate_replacements(params: &Params) -> Result<[Option<Vec<u8>>; 3], RkError> {
1279    if !release_gates(params) {
1280        return Ok([None, None, None]);
1281    }
1282    // A span's interior opens where its begin marker's newline stood, so
1283    // each replacement states its own leading newline and no trailing one.
1284    let opened = |block: &str| -> Result<Vec<u8>, RkError> {
1285        let text = embedded_block(block)?;
1286        Ok(substitute_tokens(
1287            format!("\n{}", text.trim_end_matches('\n')).as_bytes(),
1288            params,
1289        ))
1290    };
1291    Ok([
1292        Some(opened(RELEASE_GATE_TRIGGER)?),
1293        // The standing arm goes: the gate merges, and an armed request
1294        // would merge on the forge's own answer rather than on this one.
1295        Some(Vec::new()),
1296        Some(opened(RELEASE_GATE_JOB)?),
1297    ])
1298}
1299
1300/// The three replaceable spans of a landed security policy, each as its
1301/// ordered begin and end marker.
1302///
1303/// A span is not a token. Each forge's policy carries its own authored
1304/// prose inside the markers, so a landing that answers neither security
1305/// parameter strips the markers and reproduces the file the forge's
1306/// snippet states, byte for byte and in that forge's own words. A landing
1307/// that answers one replaces the interior of the spans that fact belongs
1308/// to. The markers are HTML comments because the snippet is Markdown a
1309/// reader may open before it is ever rendered.
1310pub const SECURITY_SPANS: [(&[u8], &[u8]); 3] = [
1311    (
1312        b"<!--RK_SECURITY_CONTACT_BEGIN-->",
1313        b"<!--RK_SECURITY_CONTACT_END-->",
1314    ),
1315    (
1316        b"<!--RK_SECURITY_RESPONSE_BEGIN-->",
1317        b"<!--RK_SECURITY_RESPONSE_END-->",
1318    ),
1319    (
1320        b"<!--RK_SECURITY_DEADLINE_BEGIN-->",
1321        b"<!--RK_SECURITY_DEADLINE_END-->",
1322    ),
1323];
1324
1325/// The sentence a policy with an acknowledgment window states in place of
1326/// the forge's best-effort wording.
1327fn acknowledgment(response: &str) -> String {
1328    format!("Maintainers acknowledge a report within {response}.")
1329}
1330
1331/// What a policy with an acknowledgment window says about deadlines: the
1332/// authored sentence disclaims a response deadline, which a stated window
1333/// contradicts, so only the disclosure half survives.
1334const DISCLOSURE_ONLY: &[u8] = b"This policy commits to no disclosure deadline.";
1335
1336/// The replacement for each span under one parameter set, or `None` where
1337/// the forge's authored interior stands.
1338fn security_replacements(params: &Params) -> [Option<Vec<u8>>; 3] {
1339    let contact = (!params.security_contact().is_empty())
1340        .then(|| params.security_contact().as_bytes().to_vec());
1341    let promised = params.security_response() != crate::config::RESPONSE_DEFAULT;
1342    [
1343        contact,
1344        promised.then(|| acknowledgment(params.security_response()).into_bytes()),
1345        promised.then(|| DISCLOSURE_ONLY.to_vec()),
1346    ]
1347}
1348
1349/// One marked span replaced, or the markers alone removed.
1350///
1351/// Exactly one ordered begin and end pair is a span; anything else is a
1352/// source defect a test holds, so this leaves such bytes untouched rather
1353/// than growing a runtime failure mode into every rendered file.
1354fn replace_span(baseline: &[u8], begin: &[u8], end: &[u8], value: Option<&[u8]>) -> Vec<u8> {
1355    let ordered = find(baseline, begin)
1356        .zip(find(baseline, end))
1357        .filter(|(start, stop)| stop > start);
1358    let Some((start, stop)) = ordered else {
1359        return baseline.to_vec();
1360    };
1361    let mut out = Vec::with_capacity(baseline.len());
1362    out.extend_from_slice(&baseline[..start]);
1363    out.extend_from_slice(value.unwrap_or_else(|| &baseline[start + begin.len()..stop]));
1364    out.extend_from_slice(&baseline[stop + end.len()..]);
1365    out
1366}
1367
1368/// Substitute the landing parameters into a `rendered` file's bytes.
1369///
1370/// The repository's owner, the project path's first segment, replaces
1371/// every `OWNER` occurrence; the full path replaces `RK_REPO` last. The
1372/// one scope shape replaces the scope token, and the recorded style
1373/// replaces the style token. The scope shape rests on no parameter, so it
1374/// substitutes always. An unresolved style leaves its token standing,
1375/// which only a preview renders under: an apply refuses before reaching
1376/// here.
1377///
1378/// The trunk and the line prefix substitute from the same parameters, so
1379/// a target that renames either carries the new name in every artifact
1380/// that names it rather than in the binary's behavior alone.
1381///
1382/// The security policy's marked spans resolve last, after every token, so
1383/// a contact that happens to spell a token name lands literally rather
1384/// than being read as one more substitution site.
1385#[must_use]
1386pub fn render(baseline: &[u8], params: &Params) -> Vec<u8> {
1387    let mut out = substitute_tokens(baseline, params);
1388    // The release gate's spans resolve after the tokens and before the
1389    // policy's, and each replacement carries the same token pass, so a
1390    // composed block answers the same parameters the authored file does.
1391    // A block this binary cannot read leaves the authored arm standing,
1392    // which is the rendering every other shape already takes.
1393    if let Ok(values) = release_gate_replacements(params) {
1394        for ((begin, end), value) in release_gate_spans(params.driver()).iter().zip(values) {
1395            out = replace_span(&out, begin, end, value.as_deref());
1396        }
1397    }
1398    for ((begin, end), value) in SECURITY_SPANS.iter().zip(security_replacements(params)) {
1399        out = replace_span(&out, begin, end, value.as_deref());
1400    }
1401    out
1402}
1403
1404/// Every landing token substituted, the spans left alone.
1405///
1406/// A composed block answers the same tokens an authored file does, so this
1407/// half is what both pass through.
1408#[must_use]
1409fn substitute_tokens(baseline: &[u8], params: &Params) -> Vec<u8> {
1410    let repo = params.repo();
1411    let owner = repo.split('/').next().unwrap_or(repo);
1412    let mut out = substitute(baseline, OWNER_TOKEN, owner.as_bytes());
1413    if let Some(style) = params.style() {
1414        out = substitute(&out, STYLE_TOKEN, style.as_str().as_bytes());
1415    }
1416    out = substitute(&out, SCOPE_SHAPE_TOKEN, SCOPE_SHAPE.as_bytes());
1417    // The branch shape substitutes before the trunk, because one driver's
1418    // shape names the trunk inside itself; the trunk token is its own
1419    // prefix, in the same way the escaped line prefix goes before the
1420    // plain one.
1421    let (shape, job) = release_driver_shape(params.driver()).unwrap_or_default();
1422    out = substitute(&out, BRANCH_SHAPE_TOKEN, shape.as_bytes());
1423    out = substitute(&out, REQUEST_JOB_TOKEN, job.as_bytes());
1424    out = substitute(&out, TRUNK_BRANCH_TOKEN, params.trunk().as_bytes());
1425    let escaped = params.line_prefix().replace('/', "\\/");
1426    out = substitute(&out, LINE_PREFIX_RE_TOKEN, escaped.as_bytes());
1427    out = substitute(&out, LINE_PREFIX_TOKEN, params.line_prefix().as_bytes());
1428    // JSON strings are valid YAML double-quoted scalars. Rendering the two
1429    // operator-owned names through that encoding keeps spaces, comments,
1430    // quotes, and backslashes data rather than YAML structure.
1431    let required_check = serde_json::Value::String(params.required_check().to_owned()).to_string();
1432    let required_workflow =
1433        serde_json::Value::String(params.required_workflow().to_owned()).to_string();
1434    out = substitute(&out, REQUIRED_CHECK_TOKEN, required_check.as_bytes());
1435    out = substitute(&out, REQUIRED_WORKFLOW_TOKEN, required_workflow.as_bytes());
1436    // The push guard is non-empty exactly where a second trigger is added,
1437    // and the gate is what adds one.
1438    let guard: &[u8] = if release_gates(params) {
1439        b" && github.event_name == 'push'"
1440    } else {
1441        b""
1442    };
1443    out = substitute(&out, PUSH_GUARD_TOKEN, guard);
1444    out = substitute(&out, REPO_TOKEN, repo.as_bytes());
1445    out
1446}
1447
1448/// Every `token` occurrence replaced with `value`.
1449#[must_use]
1450pub fn substitute(baseline: &[u8], token: &[u8], value: &[u8]) -> Vec<u8> {
1451    let mut out = Vec::with_capacity(baseline.len());
1452    let mut rest = baseline;
1453    while let Some(at) = find(rest, token) {
1454        out.extend_from_slice(&rest[..at]);
1455        out.extend_from_slice(value);
1456        rest = &rest[at + token.len()..];
1457    }
1458    out.extend_from_slice(rest);
1459    out
1460}
1461
1462/// First occurrence of `needle` in `haystack`.
1463fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
1464    haystack
1465        .windows(needle.len())
1466        .position(|window| window == needle)
1467}
1468
1469/// The destination the routing block splices into.
1470pub const AGENTS_DESTINATION: &str = "AGENTS.md";
1471
1472/// The block's opening marker.
1473pub const BLOCK_BEGIN: &str = "<!-- BEGIN release-kit -->";
1474
1475/// The block's closing marker.
1476pub const BLOCK_END: &str = "<!-- END release-kit -->";
1477
1478/// The destination the glossary block splices into.
1479///
1480/// The document is the target's own vocabulary, so the block shares
1481/// `AGENTS.md`'s marker pair and owns nothing outside it.
1482pub const GLOSSARY_DESTINATION: &str = "GLOSSARY.md";
1483
1484/// The destination the hook block splices into.
1485pub const HOOKS_DESTINATION: &str = ".pre-commit-config.yaml";
1486
1487/// Every block destination, in the order a landing writes them.
1488///
1489/// A block destination owns the lines between its markers and nothing
1490/// else, so every verb that asks whether a destination is block-placed
1491/// reads this one list.
1492pub const BLOCK_DESTINATIONS: [&str; 3] =
1493    [AGENTS_DESTINATION, GLOSSARY_DESTINATION, HOOKS_DESTINATION];
1494
1495/// The hook block's opening marker, a YAML comment at column zero.
1496pub const HOOKS_BEGIN: &str = "# BEGIN release-kit";
1497
1498/// The hook block's closing marker.
1499pub const HOOKS_END: &str = "# END release-kit";
1500
1501/// The top-level key the fresh hook file carries and the skills verify on
1502/// an existing one: the commit-msg and pre-push hooks run only where their
1503/// hook types are installed.
1504pub const HOOK_TYPES_LINE: &str = "default_install_hook_types: [pre-commit, commit-msg, pre-push]";
1505
1506/// The authored routing-block template.
1507pub const AGENTS_BLOCK: &str = "blocks/agents-block.md.in";
1508
1509/// The authored glossary template.
1510pub const GLOSSARY_BLOCK: &str = "blocks/glossary.md.in";
1511
1512/// The routing block's mode line, worktree form.
1513pub const AGENTS_LINE_WORKTREE: &str = "blocks/agents-line-worktree.md.in";
1514
1515/// The routing block's mode line, branches form.
1516pub const AGENTS_LINE_BRANCHES: &str = "blocks/agents-line-branches.md.in";
1517
1518/// The routing block's integration line, forge form.
1519pub const AGENTS_LINE_INTEGRATION_FORGE: &str = "blocks/agents-line-integration-forge.md.in";
1520
1521/// The routing block's integration line, local form.
1522pub const AGENTS_LINE_INTEGRATION_LOCAL: &str = "blocks/agents-line-integration-local.md.in";
1523
1524/// The routing block's integration line for one integration mode.
1525#[must_use]
1526pub const fn integration_line(integration: Integration) -> &'static str {
1527    match integration {
1528        Integration::Forge => AGENTS_LINE_INTEGRATION_FORGE,
1529        Integration::Local => AGENTS_LINE_INTEGRATION_LOCAL,
1530    }
1531}
1532
1533/// The authored hook-block template.
1534pub const PRE_COMMIT_BLOCK: &str = "blocks/pre-commit-block.yaml.in";
1535
1536/// The worktree mode's guard entry.
1537pub const PRE_COMMIT_WORKTREE_GUARD: &str = "blocks/pre-commit-worktree-guard.yaml.in";
1538
1539/// The forge integration mode's guard against a trunk commit.
1540pub const PRE_COMMIT_TRUNK_COMMIT_GUARD: &str = "blocks/pre-commit-trunk-commit-guard.yaml.in";
1541
1542/// The forge integration mode's guard against a trunk push.
1543pub const PRE_COMMIT_TRUNK_PUSH_GUARD: &str = "blocks/pre-commit-trunk-push-guard.yaml.in";
1544
1545/// The header note naming what a CI sweep skips.
1546pub const PRE_COMMIT_SWEEP_SKIP: &str = "blocks/pre-commit-sweep-skip.yaml.in";
1547
1548/// The header note for the pairing whose sweep skips nothing.
1549pub const PRE_COMMIT_SWEEP_NONE: &str = "blocks/pre-commit-sweep-none.yaml.in";
1550
1551/// The header note naming the pre-integrate contract.
1552pub const PRE_COMMIT_INTEGRATE_NOTE: &str = "blocks/pre-commit-integrate-note.yaml.in";
1553
1554/// The routing block's mode line for one checkout mode.
1555#[must_use]
1556pub const fn routing_line(mode: CheckoutMode) -> &'static str {
1557    match mode {
1558        CheckoutMode::LinkedWorktree => AGENTS_LINE_WORKTREE,
1559        CheckoutMode::MainWorktree => AGENTS_LINE_BRANCHES,
1560    }
1561}
1562
1563/// One authored block this binary embeds, as text, by its embedded path.
1564///
1565/// # Errors
1566///
1567/// [`RkError::Other`] for a block this binary does not embed or one that
1568/// is not UTF-8, both defects in the binary.
1569pub fn embedded_block(path: &str) -> Result<&'static str, RkError> {
1570    let name = path.strip_prefix("blocks/").unwrap_or(path);
1571    let file = embedded::BLOCKS
1572        .get_file(name)
1573        .ok_or_else(|| anyhow::anyhow!("{path}: this binary embeds no such block"))?;
1574    std::str::from_utf8(file.contents())
1575        .map_err(|_| anyhow::anyhow!("{path}: a block is UTF-8").into())
1576}
1577
1578/// An authored block without the one final newline the repository's
1579/// hooks enforce on every file under `blocks/`; a test in
1580/// `src/embedded.rs` holds each file to exactly one.
1581#[must_use]
1582pub fn authored(text: &str) -> &str {
1583    text.strip_suffix('\n').unwrap_or(text)
1584}
1585
1586/// The one branch grammar.
1587///
1588/// The extended regular expression the landed `rk-branch-name` hook
1589/// tests, and the same anchored language `rk worktree add` validates
1590/// before creating anything. One owner by token: `concat!` cannot
1591/// interpolate a const, so [`compose_hooks`] substitutes it for the
1592/// template's `RK_BRANCH_GRAMMAR` token.
1593pub const BRANCH_GRAMMAR: &str = r"^((build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)/[A-Za-z0-9._/-]+|([0-9]+|[A-Z][A-Z0-9]+-[0-9]+)-[A-Za-z0-9._-]+|release[-/].+)$";
1594
1595/// The one commit scope shape.
1596///
1597/// A bracket expression, lowercase, admitting the digits and `_ . / -`
1598/// beside the letters, so `area/subarea` reads as one scope. It holds the
1599/// shape of a scope and never its vocabulary: the word itself is the
1600/// author's, guided by the routing block and by the repository's own
1601/// history. One owner by token: the title checks take it as
1602/// `RK_SCOPE_SHAPE` through [`render`], and `rk message --check` reads it
1603/// directly, so the desk and the forge judge one language.
1604pub const SCOPE_SHAPE: &str = "[a-z0-9._/-]+";
1605
1606/// Whether one scope matches [`SCOPE_SHAPE`].
1607///
1608/// The predicate and the pattern are one owner, so the desk's judgment
1609/// cannot drift from the forge's: `rk message --check` calls this, the
1610/// title checks render the pattern, and a test holds the two equal over
1611/// every ASCII character.
1612#[must_use]
1613pub fn scope_is_shaped(scope: &str) -> bool {
1614    !scope.is_empty()
1615        && scope.chars().all(|c| {
1616            c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '/' | '-')
1617        })
1618}
1619
1620/// The routing block from its authored template and the mode's one
1621/// orientation line.
1622///
1623/// Markers included, without a trailing newline and with its scope token
1624/// unrendered. Everything but the substituted line, the agent-boundary
1625/// line included, is byte-identical across modes.
1626#[must_use]
1627pub fn compose_routing(template: &str, line: &str, integration_line: &str) -> String {
1628    authored(template)
1629        .replacen("RK_WORKFLOW_LINE", authored(line), 1)
1630        .replacen("RK_INTEGRATION_LINE", authored(integration_line), 1)
1631}
1632
1633/// The glossary block from its authored template: markers included and
1634/// without a trailing newline. It carries no token and no mode, so the
1635/// same bytes land in every target.
1636#[must_use]
1637pub fn compose_glossary(template: &str) -> String {
1638    authored(template).to_owned()
1639}
1640
1641/// The hook block from its authored template, for one pairing of the two
1642/// Git workflow axes.
1643///
1644/// `guard` is the checkout mode's answer: `Some` is the worktree mode,
1645/// which carries the location guard, and `None` is the branches mode,
1646/// which carries no guard entry at all rather than one that reads local
1647/// state to decide whether to enforce.
1648///
1649/// `parts` is the integration mode's answer. Under forge integration the
1650/// two trunk guards render and the block is byte-identical to what every
1651/// landed target already carries. Under local integration neither
1652/// renders, because `rk integrate` writes the trunk commit and the
1653/// operator pushes the trunk, and the header carries the pre-integrate
1654/// note in their place.
1655///
1656/// The one branch grammar substitutes from [`BRANCH_GRAMMAR`]. Markers
1657/// included, without a trailing newline and with its scope token
1658/// unrendered.
1659#[must_use]
1660pub fn compose_hooks(template: &str, guard: Option<&str>, parts: &TrunkGuards) -> String {
1661    let guard = guard.map_or_else(String::new, |entry| format!("{}\n", authored(entry)));
1662    let mut skips: Vec<&str> = Vec::new();
1663    if parts.integration == Integration::Forge {
1664        skips.push("no-commit-to-branch");
1665    }
1666    if !guard.is_empty() {
1667        skips.push("rk-worktree-location");
1668    }
1669    let sweep = if skips.is_empty() {
1670        format!("{}\n", authored(parts.sweep_none))
1671    } else {
1672        format!(
1673            "{}\n",
1674            authored(parts.sweep_skip).replacen("RK_SWEEP_SKIP", &skips.join(","), 1)
1675        )
1676    };
1677    let (commit_guard, push_guard, note) = match parts.integration {
1678        Integration::Forge => (
1679            format!("{}\n", authored(parts.trunk_commit)),
1680            format!("{}\n", authored(parts.trunk_push)),
1681            String::new(),
1682        ),
1683        Integration::Local => (
1684            String::new(),
1685            String::new(),
1686            format!("{}\n", authored(parts.integrate_note)),
1687        ),
1688    };
1689    authored(template)
1690        .replacen("RK_BRANCH_GRAMMAR", BRANCH_GRAMMAR, 1)
1691        .replacen("RK_SWEEP_NOTE", &format!("{sweep}{note}"), 1)
1692        .replacen("RK_TRUNK_COMMIT_GUARD", &commit_guard, 1)
1693        .replacen("RK_WORKTREE_GUARD", &guard, 1)
1694        .replacen("RK_TRUNK_PUSH_GUARD", &push_guard, 1)
1695}
1696
1697/// The two authored guard entries the integration mode selects between,
1698/// beside the mode itself.
1699pub struct TrunkGuards<'a> {
1700    /// Which authority moves an implementation onto the trunk.
1701    pub integration: Integration,
1702    /// The `no-commit-to-branch` entry that refuses a trunk commit.
1703    pub trunk_commit: &'a str,
1704    /// The `rk-no-push-to-trunk` entry that refuses a trunk push.
1705    pub trunk_push: &'a str,
1706    /// The header note naming what a CI sweep skips.
1707    pub sweep_skip: &'a str,
1708    /// The header note for the pairing where a sweep skips nothing.
1709    pub sweep_none: &'a str,
1710    /// The header note naming the pre-integrate contract.
1711    pub integrate_note: &'a str,
1712}
1713
1714/// The routing block for one checkout mode, from this binary's embedded
1715/// templates.
1716///
1717/// # Errors
1718///
1719/// A block this binary does not embed, a defect in the binary.
1720pub fn routing_block(mode: CheckoutMode, integration: Integration) -> Result<String, RkError> {
1721    Ok(compose_routing(
1722        embedded_block(AGENTS_BLOCK)?,
1723        embedded_block(routing_line(mode))?,
1724        embedded_block(integration_line(integration))?,
1725    ))
1726}
1727
1728/// The glossary block from this binary's embedded template.
1729///
1730/// # Errors
1731///
1732/// A block this binary does not embed, a defect in the binary.
1733pub fn glossary_block() -> Result<String, RkError> {
1734    Ok(compose_glossary(embedded_block(GLOSSARY_BLOCK)?))
1735}
1736
1737/// The hook block for one checkout mode, from this binary's embedded
1738/// templates.
1739///
1740/// # Errors
1741///
1742/// A block this binary does not embed, a defect in the binary.
1743pub fn hooks_block(mode: CheckoutMode, integration: Integration) -> Result<String, RkError> {
1744    let guard = match mode {
1745        CheckoutMode::LinkedWorktree => Some(embedded_block(PRE_COMMIT_WORKTREE_GUARD)?),
1746        CheckoutMode::MainWorktree => None,
1747    };
1748    let parts = TrunkGuards {
1749        integration,
1750        trunk_commit: embedded_block(PRE_COMMIT_TRUNK_COMMIT_GUARD)?,
1751        trunk_push: embedded_block(PRE_COMMIT_TRUNK_PUSH_GUARD)?,
1752        sweep_skip: embedded_block(PRE_COMMIT_SWEEP_SKIP)?,
1753        sweep_none: embedded_block(PRE_COMMIT_SWEEP_NONE)?,
1754        integrate_note: embedded_block(PRE_COMMIT_INTEGRATE_NOTE)?,
1755    };
1756    Ok(compose_hooks(
1757        embedded_block(PRE_COMMIT_BLOCK)?,
1758        guard,
1759        &parts,
1760    ))
1761}
1762
1763/// The unrendered block for one block destination under one checkout mode,
1764/// with the embedded source paths it was composed from.
1765fn block_template(
1766    destination: &str,
1767    mode: CheckoutMode,
1768    integration: Integration,
1769) -> Result<(String, Vec<String>), RkError> {
1770    match destination {
1771        AGENTS_DESTINATION => Ok((
1772            routing_block(mode, integration)?,
1773            vec![
1774                AGENTS_BLOCK.to_owned(),
1775                routing_line(mode).to_owned(),
1776                integration_line(integration).to_owned(),
1777            ],
1778        )),
1779        GLOSSARY_DESTINATION => Ok((glossary_block()?, vec![GLOSSARY_BLOCK.to_owned()])),
1780        HOOKS_DESTINATION => {
1781            let mut sources = vec![PRE_COMMIT_BLOCK.to_owned()];
1782            if mode == CheckoutMode::LinkedWorktree {
1783                sources.push(PRE_COMMIT_WORKTREE_GUARD.to_owned());
1784            }
1785            match integration {
1786                Integration::Forge => {
1787                    sources.push(PRE_COMMIT_TRUNK_COMMIT_GUARD.to_owned());
1788                    sources.push(PRE_COMMIT_TRUNK_PUSH_GUARD.to_owned());
1789                    sources.push(PRE_COMMIT_SWEEP_SKIP.to_owned());
1790                }
1791                Integration::Local => {
1792                    if mode == CheckoutMode::LinkedWorktree {
1793                        sources.push(PRE_COMMIT_SWEEP_SKIP.to_owned());
1794                    } else {
1795                        sources.push(PRE_COMMIT_SWEEP_NONE.to_owned());
1796                    }
1797                    sources.push(PRE_COMMIT_INTEGRATE_NOTE.to_owned());
1798                }
1799            }
1800            Ok((hooks_block(mode, integration)?, sources))
1801        }
1802        other => Err(anyhow::anyhow!("{other} is not a block destination").into()),
1803    }
1804}
1805
1806/// The markers of a block destination, or `None` for a whole-file one.
1807#[must_use]
1808pub fn block_markers(destination: &str) -> Option<(&'static str, &'static str)> {
1809    match destination {
1810        AGENTS_DESTINATION | GLOSSARY_DESTINATION => Some((BLOCK_BEGIN, BLOCK_END)),
1811        HOOKS_DESTINATION => Some((HOOKS_BEGIN, HOOKS_END)),
1812        _ => None,
1813    }
1814}
1815
1816/// The marked block inside a document, markers included, or `None` where
1817/// the text carries no complete block.
1818#[must_use]
1819pub fn extract_block<'a>(text: &'a str, begin: &str, end: &str) -> Option<&'a str> {
1820    let start = text.find(begin)?;
1821    let stop = text[start..].find(end)? + start + end.len();
1822    Some(&text[start..stop])
1823}
1824
1825/// The whole document's bytes after splicing a marked block into it.
1826///
1827/// A fresh file where none exists, the block replaced in place where one
1828/// is marked, appended after the target's own content otherwise:
1829/// release-kit owns the lines inside the markers, not the document. Both
1830/// markdown destinations take this shape, `AGENTS.md` and the glossary.
1831#[must_use]
1832pub fn splice_marked_block(existing: Option<&[u8]>, block: &str) -> Vec<u8> {
1833    let block = block.as_bytes();
1834    let Some(text) = existing else {
1835        return [block, b"\n"].concat();
1836    };
1837    // Bytes, never text: the document belongs to the target and a decode
1838    // that replaces one invalid sequence rewrites a byte outside the
1839    // markers, which is the one thing a block destination never does.
1840    if let Some(start) = find(text, BLOCK_BEGIN.as_bytes())
1841        && let Some(offset) = find(&text[start..], BLOCK_END.as_bytes())
1842    {
1843        let stop = start + offset + BLOCK_END.len();
1844        return [&text[..start], block, &text[stop..]].concat();
1845    }
1846    // Appending keeps every byte the target wrote, trailing blank lines
1847    // and an absent final newline included. The only addition is the
1848    // separator that opens the block's own line.
1849    let mut out = Vec::with_capacity(text.len() + block.len() + 3);
1850    out.extend_from_slice(text);
1851    if !text.ends_with(b"\n") {
1852        out.push(b'\n');
1853    }
1854    out.push(b'\n');
1855    out.extend_from_slice(block);
1856    out.push(b'\n');
1857    out
1858}
1859
1860/// The whole `.pre-commit-config.yaml` content after splicing the
1861/// rendered hook block.
1862///
1863/// A fresh file carries the hook-types key, the `repos:` key, and the
1864/// block; a marked file takes the block in place; an unmarked file takes
1865/// it directly under its `repos:` line, above the target's own hooks. An
1866/// unmarked file with no `repos:` line is refused by name: the block's
1867/// entries are list items and have nowhere honest to go.
1868///
1869/// # Errors
1870///
1871/// The reason the block has no place, for the caller's refusal to carry.
1872pub fn splice_hooks_block(existing: Option<&str>, block: &str) -> Result<String, String> {
1873    let Some(text) = existing else {
1874        return Ok(format!("{HOOK_TYPES_LINE}\n\nrepos:\n{block}\n"));
1875    };
1876    if let Some(defect) = hooks_marker_defect(text) {
1877        return Err(defect);
1878    }
1879    if let Some(found) = extract_block(text, HOOKS_BEGIN, HOOKS_END) {
1880        return Ok(text.replacen(found, block, 1));
1881    }
1882    let mut out = String::with_capacity(text.len() + block.len() + 1);
1883    let mut placed = false;
1884    for line in text.split_inclusive('\n') {
1885        out.push_str(line);
1886        if !placed && line.trim_end() == "repos:" {
1887            if !out.ends_with('\n') {
1888                out.push('\n');
1889            }
1890            out.push_str(block);
1891            out.push('\n');
1892            placed = true;
1893        }
1894    }
1895    if placed {
1896        Ok(out)
1897    } else {
1898        Err(format!(
1899            "{HOOKS_DESTINATION} exists with no repos: line, so the hook block has nowhere to land"
1900        ))
1901    }
1902}
1903
1904/// The one definition of an ill-formed block document, shared by every
1905/// splice and every reader that judges one: `None` for a whole-file
1906/// destination or a well-formed document.
1907///
1908/// Ownership must be unambiguous: exactly one begin marker paired with
1909/// exactly one end marker after it, or none of either. A second begin is
1910/// a second block, which for the hook file pre-commit would still run,
1911/// and a marker without its pair, or an end before its begin, is a block
1912/// whose extent nothing can state.
1913#[must_use]
1914pub fn marker_defect(destination: &str, text: &str) -> Option<String> {
1915    let (begin, end) = block_markers(destination)?;
1916    let begins = text.matches(begin).count();
1917    let ends = text.matches(end).count();
1918    if begins > 1 || ends > 1 {
1919        return Some(format!(
1920            "{destination} carries more than one release-kit marker pair; release-kit owns exactly one block"
1921        ));
1922    }
1923    match (text.find(begin), text.find(end)) {
1924        (Some(begin), Some(end)) if end > begin => None,
1925        (None, None) => None,
1926        _ => Some(format!(
1927            "{destination} carries an unmatched or misordered release-kit marker, so the block's extent is ambiguous"
1928        )),
1929    }
1930}
1931
1932/// [`marker_defect`] for the hook file, the destination whose entries
1933/// execute.
1934#[must_use]
1935pub fn hooks_marker_defect(text: &str) -> Option<String> {
1936    marker_defect(HOOKS_DESTINATION, text)
1937}
1938
1939/// The complete proposed document for one block destination: the
1940/// rendered `region` spliced into the `existing` document the evidence
1941/// carries, or the reason the document offers it no place.
1942fn propose_document(
1943    destination: &str,
1944    existing: Option<&[u8]>,
1945    region: &[u8],
1946) -> Result<Vec<u8>, String> {
1947    // The block is release-kit's own text. The document is the target's
1948    // bytes: the markdown splice works on them directly, and the marker
1949    // judgment decodes a copy only to count ASCII markers, which a
1950    // replacement character neither creates nor hides.
1951    let block = String::from_utf8_lossy(region).into_owned();
1952    if let Some(text) = existing
1953        && let Some(defect) = marker_defect(destination, &String::from_utf8_lossy(text))
1954    {
1955        return Err(defect);
1956    }
1957    if destination == HOOKS_DESTINATION {
1958        // The hook splice is line-based text, so a document that is not
1959        // UTF-8 has no honest place for the block: a lossy decode would
1960        // rewrite a byte outside the markers, which a region never does.
1961        let text = match existing {
1962            None => None,
1963            Some(bytes) => Some(std::str::from_utf8(bytes).map_err(|_| {
1964                format!(
1965                    "{destination} is not UTF-8, so the hook block has nowhere to land without rewriting the target's bytes"
1966                )
1967            })?),
1968        };
1969        return splice_hooks_block(text, &block).map(String::into_bytes);
1970    }
1971    Ok(splice_marked_block(existing, &block))
1972}
1973
1974/// Why the whole Nix capability stays out of a landing, or `None` where
1975/// the target's crate shape supports the seed.
1976///
1977/// The gate holds every structural prerequisite the seed relies on, not
1978/// only evaluation: the package expression reads `Cargo.toml` through
1979/// `importTOML` and throws without `../Cargo.lock`, and the seed flake's
1980/// smoke check runs the crate's binary, which only an implicit
1981/// `src/main.rs` or an explicit `[[bin]]` entry produces. A shape
1982/// missing any of these would land files that fail on their first
1983/// evaluation or first check, so the landing reports the smaller product
1984/// with the missing piece named instead.
1985#[must_use]
1986pub fn nix_unsupported_shape(shape: &CrateShape) -> Option<String> {
1987    let Some(text) = shape.cargo_toml.as_deref() else {
1988        return Some(
1989            "the target has no readable Cargo.toml, which the seeded package expression reads; no Nix file lands".to_owned(),
1990        );
1991    };
1992    let Ok(table) = text.parse::<toml::Table>() else {
1993        return Some(
1994            "the target's Cargo.toml does not parse, and the seeded package expression reads it; no Nix file lands".to_owned(),
1995        );
1996    };
1997    if !table.contains_key("package") {
1998        return Some(
1999            "the target's Cargo.toml has no [package] table; the seed supports a single crate, so no Nix file lands".to_owned(),
2000        );
2001    }
2002    if !shape.cargo_lock {
2003        return Some(
2004            "the target has no Cargo.lock, which the seeded package expression builds from; commit one, then opt in".to_owned(),
2005        );
2006    }
2007    let implicit_bin = shape.main_rs
2008        && table
2009            .get("package")
2010            .and_then(toml::Value::as_table)
2011            .and_then(|package| package.get("autobins"))
2012            .and_then(toml::Value::as_bool)
2013            != Some(false);
2014    let explicit_bins = table.get("bin").and_then(toml::Value::as_array);
2015    if explicit_bins.is_none() && !implicit_bin {
2016        return Some(
2017            "the target declares no binary — no effective src/main.rs and no [[bin]] entry — and the seed flake's smoke check runs one; no Nix file lands".to_owned(),
2018        );
2019    }
2020    // The seed's mainProgram is the first [[bin]] entry; one whose
2021    // required-features a default build does not enable produces no
2022    // executable, so the smoke check would fail on a green landing. A
2023    // requirement the default feature set covers builds normally and
2024    // passes.
2025    if let Some(bins) = explicit_bins {
2026        let required = bins
2027            .first()
2028            .and_then(toml::Value::as_table)
2029            .and_then(|bin| bin.get("required-features"))
2030            .and_then(toml::Value::as_array);
2031        if let Some(required) = required {
2032            let enabled = default_features(&table);
2033            let missing = required
2034                .iter()
2035                .filter_map(toml::Value::as_str)
2036                .any(|feature| !enabled.contains(feature));
2037            if missing {
2038                return Some(
2039                    "the target's first [[bin]] entry requires features a default build does not enable; no Nix file lands".to_owned(),
2040                );
2041            }
2042        }
2043    }
2044    None
2045}
2046
2047/// Whether any feature's list carries a `dep:name` edge, which is what
2048/// suppresses the optional dependency's implicit same-named feature.
2049fn dep_edge_suppresses(features: &toml::Table, name: &str) -> bool {
2050    let edge = format!("dep:{name}");
2051    features.values().any(|list| {
2052        list.as_array().is_some_and(|entries| {
2053            entries
2054                .iter()
2055                .filter_map(toml::Value::as_str)
2056                .any(|entry| entry == edge)
2057        })
2058    })
2059}
2060
2061/// Whether `name` is declared an optional dependency, in any of the
2062/// dependency tables a binary's build reads.
2063fn is_optional_dependency(table: &toml::Table, name: &str) -> bool {
2064    ["dependencies", "build-dependencies"]
2065        .iter()
2066        .any(|section| {
2067            table
2068                .get(*section)
2069                .and_then(toml::Value::as_table)
2070                .and_then(|dependencies| dependencies.get(name))
2071                .and_then(toml::Value::as_table)
2072                .and_then(|dependency| dependency.get("optional"))
2073                .and_then(toml::Value::as_bool)
2074                == Some(true)
2075        })
2076}
2077
2078/// The features a default build enables: the `default` feature resolved
2079/// through the `[features]` table's own enables, an approximation of
2080/// cargo's default resolution for the documented supported shapes, erring
2081/// toward withholding where the semantics run deeper. Dependency forms,
2082/// `dep:name` and weak `name?/feature`, are not feature names here and are
2083/// skipped; the closure is bounded by the table's size.
2084fn default_features(table: &toml::Table) -> std::collections::BTreeSet<String> {
2085    let Some(features) = table.get("features").and_then(toml::Value::as_table) else {
2086        return std::collections::BTreeSet::new();
2087    };
2088    let mut enabled = std::collections::BTreeSet::new();
2089    let mut queue = vec!["default".to_owned()];
2090    while let Some(name) = queue.pop() {
2091        if !enabled.insert(name.clone()) {
2092            continue;
2093        }
2094        if let Some(implies) = features.get(&name).and_then(toml::Value::as_array) {
2095            for implied in implies.iter().filter_map(toml::Value::as_str) {
2096                if implied.starts_with("dep:") || implied.contains("?/") {
2097                    // `dep:name` enables the dependency without a feature
2098                    // of this crate; a weak `name?/feature` edge enables
2099                    // nothing by itself.
2100                    continue;
2101                }
2102                if let Some((package, _)) = implied.split_once('/') {
2103                    // A strong `name/feature` edge activates this crate's
2104                    // same-named feature only for an optional dependency,
2105                    // and only where that feature exists: declared
2106                    // explicitly, or implicit and not suppressed by a
2107                    // `dep:` edge anywhere in the table. A non-optional
2108                    // dependency's edge enables a feature of the
2109                    // dependency and nothing of this crate.
2110                    let feature_exists =
2111                        features.contains_key(package) || !dep_edge_suppresses(features, package);
2112                    if is_optional_dependency(table, package) && feature_exists {
2113                        queue.push(package.to_owned());
2114                    }
2115                } else {
2116                    queue.push(implied.to_owned());
2117                }
2118            }
2119        }
2120    }
2121    enabled
2122}
2123
2124/// Why the flake half of the Nix capability stays out of this landing, or
2125/// `None` where the pair lands whole.
2126///
2127/// The pair is all-or-nothing: a target that already carries a
2128/// `flake.nix` or `flake.lock` of its own keeps its pair, because a seed
2129/// lock beside a foreign flake describes the wrong input graph. A pair
2130/// the record names is release-kit's own landing and is never withheld.
2131#[must_use]
2132pub fn flake_pair_withheld(
2133    flake_recorded: bool,
2134    flake_nix_present: bool,
2135    flake_lock_present: bool,
2136) -> Option<String> {
2137    if flake_recorded {
2138        return None;
2139    }
2140    let present: Vec<&str> = [
2141        ("flake.nix", flake_nix_present),
2142        ("flake.lock", flake_lock_present),
2143    ]
2144    .into_iter()
2145    .filter_map(|(name, present)| present.then_some(name))
2146    .collect();
2147    if present.is_empty() {
2148        return None;
2149    }
2150    Some(format!(
2151        "the target already carries {}; its flake pair stays its own",
2152        present.join(" and ")
2153    ))
2154}
2155
2156/// The Nix destinations an opted-in landing withholds at this target, with
2157/// the one reason, or `None` where the capability lands whole or `nix` is
2158/// off.
2159///
2160/// An unsupported crate shape names the whole capability, and a flake
2161/// pair of the target's own names the pair while the seeded package
2162/// expression still lands. Every landing verb shares this one judgment, so
2163/// a stage, an apply, an upgrade, and an adoption all withhold
2164/// identically.
2165#[must_use]
2166pub fn nix_withholding(
2167    nix: bool,
2168    evidence: &TargetEvidence,
2169) -> Option<(&'static [&'static str], String)> {
2170    if !nix {
2171        return None;
2172    }
2173    if let Some(reason) = nix_unsupported_shape(&evidence.crate_shape) {
2174        return Some((&NIX_DESTINATIONS[..], reason));
2175    }
2176    flake_pair_withheld(
2177        evidence.flake_recorded,
2178        evidence.flake_nix_present,
2179        evidence.flake_lock_present,
2180    )
2181    .map(|reason| (&NIX_WITHHOLDABLE[..], reason))
2182}
2183
2184#[cfg(test)]
2185mod tests {
2186    use super::{
2187        AGENTS_DESTINATION, BLOCK_BEGIN, BLOCK_DESTINATIONS, BLOCK_END, Candidate, Collision,
2188        CrateShape, GLOSSARY_DESTINATION, HOOK_TYPES_LINE, HOOKS_BEGIN, HOOKS_DESTINATION,
2189        HOOKS_END, Placement, Projection, ProjectionInput, TargetEvidence, extract_block,
2190    };
2191    use crate::landing::{Params, Style};
2192
2193    /// A supported single-crate shape, so nothing is withheld.
2194    fn supported_shape() -> CrateShape {
2195        CrateShape {
2196            cargo_toml: Some("[package]\nname = \"widget\"\nversion = \"0.1.0\"\n".to_owned()),
2197            cargo_lock: true,
2198            main_rs: true,
2199        }
2200    }
2201
2202    fn input(evidence: TargetEvidence) -> ProjectionInput {
2203        let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
2204        params.set_nix_for_test(true);
2205        ProjectionInput { params, evidence }
2206    }
2207
2208    fn compute(evidence: TargetEvidence) -> Projection {
2209        Projection::compute(&input(evidence)).expect("the embedded pair projects")
2210    }
2211
2212    fn candidate<'a>(projection: &'a Projection, destination: &str) -> &'a Candidate {
2213        projection
2214            .candidates
2215            .iter()
2216            .find(|candidate| candidate.destination == destination)
2217            .expect("the destination projects")
2218    }
2219
2220    /// The bytes of a document outside its one marked region.
2221    fn outside(bytes: &[u8], begin: &str, end: &str) -> (Vec<u8>, Vec<u8>) {
2222        let text = String::from_utf8_lossy(bytes);
2223        let start = text.find(begin).expect("the begin marker is present");
2224        let stop = text[start..].find(end).expect("the end marker is present") + start + end.len();
2225        (bytes[..start].to_vec(), bytes[stop..].to_vec())
2226    }
2227
2228    #[test]
2229    fn equal_projection_inputs_yield_byte_identical_projections() {
2230        let mut documents = std::collections::BTreeMap::new();
2231        documents.insert(
2232            AGENTS_DESTINATION.to_owned(),
2233            b"# Widget\n\nOwn rules.\n".to_vec(),
2234        );
2235        let evidence = TargetEvidence {
2236            documents,
2237            crate_shape: supported_shape(),
2238            ..TargetEvidence::default()
2239        };
2240        let first = input(evidence.clone());
2241        let second = input(evidence);
2242        assert_eq!(first, second, "the inputs are values and compare equal");
2243        let a = Projection::compute(&first).expect("the pair projects");
2244        let b = Projection::compute(&second).expect("the pair projects");
2245        assert_eq!(a.candidates.len(), b.candidates.len());
2246        for (x, y) in a.candidates.iter().zip(&b.candidates) {
2247            assert_eq!(x.destination, y.destination);
2248            assert_eq!(x.kind, y.kind);
2249            assert_eq!(x.placement, y.placement);
2250            assert_eq!(x.bytes, y.bytes, "{}", x.destination);
2251            assert_eq!(x.region, y.region, "{}", x.destination);
2252            assert_eq!(x.sources, y.sources, "{}", x.destination);
2253        }
2254        assert_eq!(a, b);
2255        let destinations: Vec<&str> = a
2256            .candidates
2257            .iter()
2258            .map(|candidate| candidate.destination.as_str())
2259            .collect();
2260        let mut sorted = destinations.clone();
2261        sorted.sort_unstable();
2262        assert_eq!(destinations, sorted, "candidates sort by destination");
2263        assert!(a.omissions.is_empty(), "{:?}", a.omissions);
2264        assert!(a.collisions.is_empty(), "{:?}", a.collisions);
2265    }
2266
2267    /// The Scorecard destination is classified, gated by its parameter
2268    /// alone, and rendered: the pure projection answers the capability
2269    /// with no target read and no forge call.
2270    #[test]
2271    fn the_scorecard_destination_projects_only_under_the_opt_in() {
2272        use super::{Kind, SCORECARD_DESTINATIONS, kind_of};
2273        let destination = SCORECARD_DESTINATIONS[0];
2274        assert_eq!(kind_of(destination), Some(Kind::Rendered));
2275
2276        let project = |scorecard: bool| {
2277            let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
2278            params.set_scorecard_for_test(scorecard);
2279            Projection::compute(&ProjectionInput {
2280                params,
2281                evidence: TargetEvidence::default(),
2282            })
2283            .expect("the embedded pair projects")
2284        };
2285
2286        let off = project(false);
2287        assert!(
2288            !off.candidates
2289                .iter()
2290                .any(|candidate| candidate.destination == destination),
2291            "off by default, and an absent candidate is no omission"
2292        );
2293        assert!(off.omissions.is_empty(), "{:?}", off.omissions);
2294
2295        let on = project(true);
2296        let candidate = candidate(&on, destination);
2297        assert_eq!(candidate.kind, Kind::Rendered);
2298        assert_eq!(candidate.placement, Placement::Whole);
2299        let text = String::from_utf8_lossy(&candidate.bytes);
2300        assert!(!text.contains("RK_"), "a token survived: {text}");
2301    }
2302
2303    /// The licence judgment over the expression forms a crate manifest
2304    /// uses: every reader must obtain an OSI-approved grant, every licence
2305    /// named must be one this release recognizes, and a malformed expression
2306    /// states no terms at all.
2307    #[test]
2308    fn the_licence_judgment_reads_what_every_reader_obtains() {
2309        use super::licence_states_an_open_source_codebase as approved;
2310        for expression in [
2311            "MIT",
2312            "Apache-2.0",
2313            "MIT OR Apache-2.0",
2314            "MIT AND Apache-2.0",
2315            "(MIT OR Apache-2.0) AND ISC",
2316            "Apache-2.0 WITH LLVM-exception OR MIT",
2317            "GPL-3.0+",
2318        ] {
2319            assert!(approved(expression), "{expression} is OSI-approved");
2320        }
2321        // A conjunction hands the reader every term at once, so a Creative
2322        // Commons term beside an OSI-approved licence adds an obligation over
2323        // prose and withdraws no grant over code. This is what a project that
2324        // licenses its source apart from its documentation declares.
2325        for expression in [
2326            "MIT AND CC-BY-4.0",
2327            "CC-BY-4.0 AND MIT",
2328            "MIT AND (CC-BY-4.0 OR CC-BY-SA-4.0)",
2329            "(MIT OR CC-BY-4.0) AND MIT",
2330            "MIT OR Apache-2.0 AND CC-BY-4.0",
2331            "Apache-2.0 WITH LLVM-exception AND CC-BY-SA-3.0",
2332        ] {
2333            assert!(approved(expression), "{expression} states open source");
2334        }
2335        for expression in [
2336            "",
2337            "   ",
2338            "LicenseRef-proprietary",
2339            "MIT AND LicenseRef-proprietary",
2340            "SEE LICENSE IN COPYING",
2341            "CC-BY-4.0",
2342            "DocumentRef-spdx:LicenseRef-proprietary",
2343        ] {
2344            assert!(!approved(expression), "{expression} is not recognized");
2345        }
2346        // A disjunction offers the reader a choice, so an arm that grants no
2347        // code licence is an arm the reader may take. `CC0-1.0` is refused
2348        // outright: it is absent from both lists, and its own case belongs to
2349        // a question these lists do not answer.
2350        for expression in [
2351            "MIT OR CC-BY-4.0",
2352            "CC-BY-4.0 AND CC-BY-SA-4.0",
2353            "CC-BY-4.0 AND LicenseRef-proprietary",
2354            "CC0-1.0",
2355            "MIT AND CC-BY-4.0 AND LicenseRef-proprietary",
2356        ] {
2357            assert!(!approved(expression), "{expression} grants no code terms");
2358        }
2359        // The precedence witness. SPDX binds AND tighter than OR, so this
2360        // offers the reader `CC-BY-4.0` alone. Read with the operators
2361        // flattened into one left fold it would pass, which is why the
2362        // grammar carries the two levels rather than one.
2363        assert!(
2364            !approved("CC-BY-4.0 OR CC-BY-SA-4.0 AND MIT"),
2365            "AND binds tighter than OR"
2366        );
2367    }
2368
2369    /// The same judgment against the SPDX grammar and its matching rules:
2370    /// what parses, what does not, which casing an identifier may carry,
2371    /// which casing an operator may not, and which operand `WITH` takes. An
2372    /// expression that states no complete offer is refused rather than read
2373    /// for the identifiers it happens to carry.
2374    #[test]
2375    fn the_licence_judgment_holds_the_expression_to_the_spdx_grammar() {
2376        use super::licence_states_an_open_source_codebase as approved;
2377        // A malformed expression is refused rather than read for the
2378        // identifiers it happens to carry. Each of these names at least one
2379        // approved licence and states no complete offer, and accepting any
2380        // of them would land a workflow whose terms nothing established.
2381        for expression in [
2382            "MIT OR",
2383            "OR MIT",
2384            "MIT AND",
2385            "MIT WITH",
2386            "WITH LLVM-exception",
2387            "(MIT",
2388            "MIT)",
2389            "MIT Apache-2.0",
2390            "()",
2391            "(MIT OR Apache-2.0",
2392            "MIT OR (Apache-2.0",
2393            "MIT OR ()",
2394            "AND",
2395            "(",
2396            ")",
2397            "MIT WITH AND ISC",
2398            "MIT OR OR ISC",
2399            "MIT @ Apache-2.0",
2400        ] {
2401            assert!(!approved(expression), "{expression} is malformed");
2402        }
2403        // Well-formed nesting and a nested exception still read.
2404        for expression in [
2405            "MIT AND (Apache-2.0 OR ISC)",
2406            "((MIT))",
2407            "Apache-2.0 WITH LLVM-exception AND ISC",
2408            "(Apache-2.0 WITH LLVM-exception)",
2409        ] {
2410            assert!(approved(expression), "{expression} is well formed");
2411        }
2412        // SPDX states an identifier "should be matched in a case-insensitive
2413        // manner", and cargo accepts `license = "mit"` without complaint, so a
2414        // real crate can carry any casing and none of these may read as
2415        // unrecognized.
2416        for expression in [
2417            "mit",
2418            "MiT",
2419            "apache-2.0 OR mit",
2420            "APACHE-2.0 WITH llvm-exception",
2421        ] {
2422            assert!(
2423                approved(expression),
2424                "{expression} names an approved licence"
2425            );
2426        }
2427        // The operators are the other half of the same sentence: SPDX matches
2428        // them case-sensitively, so a lowercase one is not an operator and the
2429        // expression it appears in is malformed.
2430        for expression in [
2431            "MIT or Apache-2.0",
2432            "MIT and Apache-2.0",
2433            "MIT with LLVM-exception",
2434        ] {
2435            assert!(!approved(expression), "{expression} carries no operator");
2436        }
2437        // The operand after WITH must be an exception identifier. One this
2438        // release does not recognize leaves the expression malformed, so the
2439        // licence goes unread rather than being taken from the left operand.
2440        for expression in [
2441            "MIT WITH definitely-not-an-spdx-exception",
2442            "MIT WITH MIT",
2443            "MIT WITH Apache-2.0",
2444        ] {
2445            assert!(!approved(expression), "{expression} names no exception");
2446        }
2447        // The register is carried whole, not sampled. A subset refuses a real
2448        // crate: this one names an exception older than the version a sampled
2449        // list would have kept.
2450        for expression in [
2451            "GPL-2.0-only WITH GCC-exception-2.0",
2452            "GPL-2.0-or-later WITH Classpath-exception-2.0",
2453            "Apache-2.0 WITH Swift-exception",
2454            "GPL-3.0-only WITH Autoconf-exception-generic",
2455        ] {
2456            assert!(approved(expression), "{expression} names a real exception");
2457        }
2458    }
2459
2460    /// The compatibility question has one owner, and its answer is the
2461    /// catalog's unavailable reason: a receipt reaches `rk status` through
2462    /// `from_record`, which cannot fail, so a provider whose pair ships
2463    /// nothing is named rather than read as clean. It is a report about the
2464    /// pair, not a fault in the target, so nothing refuses on it.
2465    #[test]
2466    fn a_recorded_provider_its_pair_cannot_run_is_named() {
2467        use super::{Provider, code_scanning_incompatibility};
2468
2469        assert!(code_scanning_incompatibility(None, Some("bash"), Some("gitlab")).is_none());
2470        assert!(
2471            code_scanning_incompatibility(Some(Provider::Semgrep), Some("rust"), Some("gitlab"))
2472                .is_none()
2473        );
2474        assert!(
2475            code_scanning_incompatibility(Some(Provider::CodeQl), Some("rust"), Some("github"))
2476                .is_none()
2477        );
2478
2479        let bash =
2480            code_scanning_incompatibility(Some(Provider::Semgrep), Some("bash"), Some("github"))
2481                .expect("a binding with no scanner is named");
2482        assert!(bash.contains("bash binding"), "{bash}");
2483        let gitlab =
2484            code_scanning_incompatibility(Some(Provider::CodeQl), Some("rust"), Some("gitlab"))
2485                .expect("codeql on gitlab is named");
2486        assert!(gitlab.contains("codeql"), "{gitlab}");
2487        let release_less =
2488            code_scanning_incompatibility(Some(Provider::Semgrep), None, Some("github"))
2489                .expect("no driver is named");
2490        assert!(
2491            release_less.contains("no automatic release driver"),
2492            "{release_less}"
2493        );
2494
2495        // The projection carries the same reason, so a record-only reader sees
2496        // it without going through resolution.
2497        let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
2498        params.set_code_scanning_for_test(Some(Provider::Semgrep));
2499        let clean = Projection::compute(&ProjectionInput {
2500            params: params.clone(),
2501            evidence: TargetEvidence::default(),
2502        })
2503        .expect("the pair projects");
2504        assert!(
2505            clean.record_defects.is_empty(),
2506            "{:?}",
2507            clean.record_defects
2508        );
2509    }
2510
2511    /// The code scanning capability: the provider gates its own destination,
2512    /// semgrep carries no licence condition, and codeql's condition reads the
2513    /// crate's declared licence without touching the filesystem.
2514    #[test]
2515    fn the_code_scanning_destinations_follow_the_recorded_provider() {
2516        use super::{
2517            CODE_SCANNING_DESTINATIONS, Kind, Provider, code_scanning_licence_refusal, kind_of,
2518        };
2519        for (destination, _) in CODE_SCANNING_DESTINATIONS {
2520            assert_eq!(kind_of(destination), Some(Kind::Rendered), "{destination}");
2521        }
2522
2523        let project = |provider: Option<Provider>| {
2524            let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
2525            params.set_code_scanning_for_test(provider);
2526            Projection::compute(&ProjectionInput {
2527                params,
2528                evidence: TargetEvidence {
2529                    crate_shape: CrateShape {
2530                        cargo_toml: Some(
2531                            "[package]\nname = \"widget\"\nlicense = \"MIT\"\n".to_owned(),
2532                        ),
2533                        ..CrateShape::default()
2534                    },
2535                    ..TargetEvidence::default()
2536                },
2537            })
2538            .expect("the embedded pair projects")
2539        };
2540        let landed = |projection: &Projection, destination: &str| {
2541            projection
2542                .candidates
2543                .iter()
2544                .any(|candidate| candidate.destination == destination)
2545        };
2546
2547        let off = project(None);
2548        for (destination, _) in CODE_SCANNING_DESTINATIONS {
2549            assert!(!landed(&off, destination), "{destination}");
2550        }
2551        assert!(off.licence_refusal.is_none());
2552
2553        let codeql = project(Some(Provider::CodeQl));
2554        assert!(landed(
2555            &codeql,
2556            ".github/workflows/code-scanning-codeql.yml"
2557        ));
2558        assert!(!landed(
2559            &codeql,
2560            ".github/workflows/code-scanning-semgrep.yml"
2561        ));
2562        assert!(codeql.licence_refusal.is_none(), "MIT satisfies the terms");
2563
2564        let semgrep = project(Some(Provider::Semgrep));
2565        assert!(landed(
2566            &semgrep,
2567            ".github/workflows/code-scanning-semgrep.yml"
2568        ));
2569        assert!(!landed(
2570            &semgrep,
2571            ".github/workflows/code-scanning-codeql.yml"
2572        ));
2573
2574        // Semgrep carries no condition, whatever the licence says.
2575        let proprietary = CrateShape {
2576            cargo_toml: Some("[package]\nlicense = \"LicenseRef-proprietary\"\n".to_owned()),
2577            ..CrateShape::default()
2578        };
2579        assert!(
2580            code_scanning_licence_refusal(Some(Provider::Semgrep), Some("rust"), &proprietary)
2581                .is_none()
2582        );
2583        let refusal =
2584            code_scanning_licence_refusal(Some(Provider::CodeQl), Some("rust"), &proprietary)
2585                .expect("codeql refuses a licence its terms do not cover");
2586        assert!(refusal.contains("LicenseRef-proprietary"), "{refusal}");
2587        assert!(refusal.contains("semgrep"), "{refusal}");
2588        // A binding whose licence field this release does not read refuses
2589        // rather than assuming the terms are met.
2590        let bash =
2591            code_scanning_licence_refusal(Some(Provider::CodeQl), Some("bash"), &proprietary)
2592                .expect("an unread binding refuses");
2593        assert!(bash.contains("bash binding"), "{bash}");
2594    }
2595
2596    /// The pure boundary, held by a source scan over this file's
2597    /// production code: everything above the first `#[cfg(test)]`, with
2598    /// comment lines skipped. The evidence gathering that reads a target
2599    /// lives in `src/projection/evidence.rs`, which this scan does not
2600    /// cover on purpose.
2601    #[test]
2602    fn the_projection_performs_no_filesystem_git_environment_clock_registry_or_network_read() {
2603        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/projection.rs");
2604        let text = std::fs::read_to_string(&path).expect("the source reads");
2605        let production = text.split("#[cfg(test)]").next().unwrap_or("");
2606        let needles = [
2607            "std::fs",
2608            "std::env",
2609            "std::process",
2610            "std::time",
2611            "SystemTime",
2612            "Instant",
2613            "std::net",
2614            "Command::new",
2615            "registry::",
2616            "curl",
2617            "reqwest",
2618            "blob(",
2619        ];
2620        let mut hits = Vec::new();
2621        for (index, line) in production.lines().enumerate() {
2622            if line.trim_start().starts_with("//") {
2623                continue;
2624            }
2625            for needle in needles {
2626                if line.contains(needle) {
2627                    hits.push(format!("src/projection.rs:{}: {needle}", index + 1));
2628                }
2629            }
2630        }
2631        assert!(
2632            hits.is_empty(),
2633            "the projection reads beyond its inputs: {hits:?}"
2634        );
2635    }
2636
2637    #[test]
2638    #[allow(
2639        clippy::too_many_lines,
2640        reason = "one test walks the three marked destinations and the three unmarked shapes"
2641    )]
2642    fn marked_region_projection_preserves_every_target_byte_outside_the_markers() {
2643        let agents_before = "# Widget\n\nOperator prose above.\n\n";
2644        let agents_after = "\n\n## Our rules\n\nOperator prose below.   \n";
2645        let glossary_before = "# Glossary\n\n- `spike` is a throwaway branch.\n\n";
2646        let glossary_after = "\n\n## More terms\n\n- `own` is ours.";
2647        let hooks_before = "default_install_hook_types: [pre-commit]\n\nrepos:\n";
2648        let hooks_after =
2649            "\n  - repo: https://example.com/own\n    rev: v1\n    hooks:\n      - id: own\n";
2650        let stale = |begin: &str, end: &str| format!("{begin}\nstale block\n{end}");
2651        let mut documents = std::collections::BTreeMap::new();
2652        documents.insert(
2653            AGENTS_DESTINATION.to_owned(),
2654            format!(
2655                "{agents_before}{}{agents_after}",
2656                stale(BLOCK_BEGIN, BLOCK_END)
2657            )
2658            .into_bytes(),
2659        );
2660        documents.insert(
2661            GLOSSARY_DESTINATION.to_owned(),
2662            format!(
2663                "{glossary_before}{}{glossary_after}",
2664                stale(BLOCK_BEGIN, BLOCK_END)
2665            )
2666            .into_bytes(),
2667        );
2668        documents.insert(
2669            HOOKS_DESTINATION.to_owned(),
2670            format!(
2671                "{hooks_before}{}{hooks_after}",
2672                stale(HOOKS_BEGIN, HOOKS_END)
2673            )
2674            .into_bytes(),
2675        );
2676        let projection = compute(TargetEvidence {
2677            documents: documents.clone(),
2678            crate_shape: supported_shape(),
2679            ..TargetEvidence::default()
2680        });
2681        assert!(
2682            projection.collisions.is_empty(),
2683            "{:?}",
2684            projection.collisions
2685        );
2686        for (destination, before, after) in [
2687            (AGENTS_DESTINATION, agents_before, agents_after),
2688            (GLOSSARY_DESTINATION, glossary_before, glossary_after),
2689            (HOOKS_DESTINATION, hooks_before, hooks_after),
2690        ] {
2691            let candidate = candidate(&projection, destination);
2692            let Placement::Region { begin, end } = candidate.placement else {
2693                panic!("{destination} is a region");
2694            };
2695            let region = candidate
2696                .region
2697                .as_deref()
2698                .expect("a region carries its block");
2699            let (head, tail) = outside(&candidate.bytes, begin, end);
2700            assert_eq!(
2701                head,
2702                before.as_bytes(),
2703                "{destination}: bytes before the markers"
2704            );
2705            assert_eq!(
2706                tail,
2707                after.as_bytes(),
2708                "{destination}: bytes after the markers"
2709            );
2710            let inside = &candidate.bytes[head.len()..candidate.bytes.len() - tail.len()];
2711            assert_eq!(
2712                inside, region,
2713                "{destination}: the region is the rendered block"
2714            );
2715            let (existing_head, existing_tail) = outside(&documents[destination], begin, end);
2716            assert_eq!(head, existing_head);
2717            assert_eq!(tail, existing_tail);
2718        }
2719
2720        // Unmarked documents: the hook block lands under the owning key,
2721        // the routing block appends, and an absent file yields a fresh
2722        // document.
2723        let own_hooks =
2724            "repos:\n  - repo: https://example.com/own\n    rev: v1\n    hooks:\n      - id: own\n";
2725        let own_agents = "# Widget\n\nOwn rules.";
2726        let mut documents = std::collections::BTreeMap::new();
2727        documents.insert(HOOKS_DESTINATION.to_owned(), own_hooks.as_bytes().to_vec());
2728        documents.insert(
2729            AGENTS_DESTINATION.to_owned(),
2730            own_agents.as_bytes().to_vec(),
2731        );
2732        let projection = compute(TargetEvidence {
2733            documents,
2734            crate_shape: supported_shape(),
2735            ..TargetEvidence::default()
2736        });
2737        assert!(
2738            projection.collisions.is_empty(),
2739            "{:?}",
2740            projection.collisions
2741        );
2742        let hooks = candidate(&projection, HOOKS_DESTINATION);
2743        let hooks_text = String::from_utf8_lossy(&hooks.bytes);
2744        let region = String::from_utf8_lossy(hooks.region.as_deref().expect("a region"));
2745        assert!(
2746            hooks_text.starts_with(&format!(
2747                "repos:\n{region}\n  - repo: https://example.com/own"
2748            )),
2749            "{hooks_text}"
2750        );
2751        assert!(!hooks_text.contains(HOOK_TYPES_LINE));
2752        let agents = candidate(&projection, AGENTS_DESTINATION);
2753        assert!(agents.bytes.starts_with(own_agents.as_bytes()));
2754        assert_eq!(
2755            extract_block(
2756                &String::from_utf8_lossy(&agents.bytes),
2757                BLOCK_BEGIN,
2758                BLOCK_END
2759            )
2760            .map(str::as_bytes),
2761            agents.region.as_deref()
2762        );
2763        let glossary = candidate(&projection, GLOSSARY_DESTINATION);
2764        let region = glossary.region.as_deref().expect("a region");
2765        assert_eq!(
2766            glossary.bytes,
2767            [region, b"\n"].concat(),
2768            "an absent file is fresh"
2769        );
2770    }
2771
2772    /// A hook document that is not UTF-8 offers the block no place: the
2773    /// line-based splice would have to decode it, and a lossy decode
2774    /// rewrites a byte outside the markers. A valid document still
2775    /// splices, and the markdown destinations, spliced as bytes, take an
2776    /// invalid byte outside their markers unchanged.
2777    #[test]
2778    fn a_hook_document_that_is_not_utf8_collides_instead_of_being_rewritten() {
2779        let mut documents = std::collections::BTreeMap::new();
2780        let mut invalid = b"repos:\n# own \xff above\n".to_vec();
2781        invalid.extend_from_slice(format!("{HOOKS_BEGIN}\nstale\n{HOOKS_END}\n").as_bytes());
2782        invalid.extend_from_slice(b"  - repo: local \xff below\n");
2783        documents.insert(HOOKS_DESTINATION.to_owned(), invalid);
2784        let mut agents = b"# Widget r\xe9sum\xe9\n\n".to_vec();
2785        agents.extend_from_slice(format!("{BLOCK_BEGIN}\nstale\n{BLOCK_END}\n\n").as_bytes());
2786        agents.extend_from_slice(b"r\xe9sum\xe9\n");
2787        documents.insert(AGENTS_DESTINATION.to_owned(), agents.clone());
2788        let projection = compute(TargetEvidence {
2789            documents,
2790            crate_shape: supported_shape(),
2791            ..TargetEvidence::default()
2792        });
2793        let collided: Vec<&str> = projection
2794            .collisions
2795            .iter()
2796            .map(|c| c.destination.as_str())
2797            .collect();
2798        assert_eq!(collided, [HOOKS_DESTINATION]);
2799        assert!(
2800            projection.collisions[0].reason.contains("not UTF-8"),
2801            "{}",
2802            projection.collisions[0].reason
2803        );
2804        assert!(
2805            !projection
2806                .candidates
2807                .iter()
2808                .any(|c| c.destination == HOOKS_DESTINATION),
2809            "a colliding destination projects no candidate"
2810        );
2811        let agents = candidate(&projection, AGENTS_DESTINATION);
2812        assert!(
2813            agents.bytes.starts_with(b"# Widget r\xe9sum\xe9\n\n"),
2814            "{:?}",
2815            agents.bytes
2816        );
2817        assert!(
2818            agents.bytes.ends_with(b"\n\nr\xe9sum\xe9\n"),
2819            "{:?}",
2820            agents.bytes
2821        );
2822        assert!(
2823            !agents.bytes.contains(&0xEF),
2824            "a replacement character landed"
2825        );
2826
2827        let mut documents = std::collections::BTreeMap::new();
2828        let valid =
2829            format!("repos:\n# own above\n{HOOKS_BEGIN}\nstale\n{HOOKS_END}\n  - repo: local\n");
2830        documents.insert(HOOKS_DESTINATION.to_owned(), valid.into_bytes());
2831        let projection = compute(TargetEvidence {
2832            documents,
2833            crate_shape: supported_shape(),
2834            ..TargetEvidence::default()
2835        });
2836        assert!(
2837            projection.collisions.is_empty(),
2838            "{:?}",
2839            projection.collisions
2840        );
2841        let hooks = candidate(&projection, HOOKS_DESTINATION);
2842        let text = String::from_utf8(hooks.bytes.clone()).expect("a valid document stays text");
2843        assert!(text.starts_with("repos:\n# own above\n"), "{text}");
2844        assert!(text.ends_with("\n  - repo: local\n"), "{text}");
2845        assert!(!text.contains("stale"), "the region is replaced: {text}");
2846    }
2847
2848    /// A snippet that ships a block destination as a whole file is a
2849    /// source defect named by both sides: the snippet's source path and
2850    /// the block's template paths.
2851    #[test]
2852    fn a_whole_file_colliding_with_a_marked_region_names_both_source_paths() {
2853        let files: Vec<(String, &[u8])> = vec![
2854            ("snippets/_shared/github/SECURITY.md".to_owned(), b"policy"),
2855            ("snippets/rust/github/AGENTS.md".to_owned(), b"whole"),
2856        ];
2857        let err = Projection::compute_over(&files, &input(TargetEvidence::default()))
2858            .expect_err("a whole file at a block destination refuses");
2859        let text = err.to_string();
2860        assert!(text.contains("snippets/rust/github/AGENTS.md"), "{text}");
2861        assert!(text.contains(super::AGENTS_BLOCK), "{text}");
2862        assert!(text.contains(super::AGENTS_LINE_WORKTREE), "{text}");
2863        assert!(text.contains("embedded sources are defective"), "{text}");
2864    }
2865
2866    #[test]
2867    fn duplicate_whole_file_destinations_and_overlapping_marked_regions_refuse_with_the_conflicting_source_names()
2868     {
2869        // Two capabilities shipping one destination is a source defect
2870        // named by both sides.
2871        let files: Vec<(String, &[u8])> = vec![
2872            ("snippets/_shared/github/SECURITY.md".to_owned(), b"shared"),
2873            ("snippets/rust/github/SECURITY.md".to_owned(), b"pair"),
2874            ("snippets/rust/github/release-plz.toml".to_owned(), b"seed"),
2875        ];
2876        let err = Projection::compute_over(&files, &input(TargetEvidence::default()))
2877            .expect_err("a doubled destination refuses");
2878        let text = err.to_string();
2879        assert!(
2880            text.contains("snippets/_shared/github/SECURITY.md"),
2881            "{text}"
2882        );
2883        assert!(text.contains("snippets/rust/github/SECURITY.md"), "{text}");
2884        assert!(text.contains("embedded sources are defective"), "{text}");
2885
2886        let clean: Vec<(String, &[u8])> = vec![
2887            ("snippets/_shared/github/SECURITY.md".to_owned(), b"shared"),
2888            ("snippets/rust/github/release-plz.toml".to_owned(), b"seed"),
2889        ];
2890        let projection = Projection::compute_over(&clean, &input(TargetEvidence::default()))
2891            .expect("a clean list projects");
2892        let whole: Vec<&str> = projection
2893            .candidates
2894            .iter()
2895            .filter(|c| c.placement == Placement::Whole)
2896            .map(|c| c.destination.as_str())
2897            .collect();
2898        assert_eq!(whole, ["SECURITY.md", "release-plz.toml"]);
2899        assert_eq!(
2900            projection
2901                .candidates
2902                .iter()
2903                .find(|c| c.destination == "SECURITY.md")
2904                .map(|c| c.sources.clone()),
2905            Some(vec!["snippets/_shared/github/SECURITY.md".to_owned()])
2906        );
2907
2908        let doubled = format!("{BLOCK_BEGIN}\na\n{BLOCK_END}\n{BLOCK_BEGIN}\nb\n{BLOCK_END}\n");
2909        let unmatched = format!("repos:\n{HOOKS_BEGIN}\n  - repo: local\n");
2910        let misordered = format!("# G\n{BLOCK_END}\n{BLOCK_BEGIN}\n");
2911        let mut documents = std::collections::BTreeMap::new();
2912        documents.insert(AGENTS_DESTINATION.to_owned(), doubled.into_bytes());
2913        documents.insert(HOOKS_DESTINATION.to_owned(), unmatched.into_bytes());
2914        documents.insert(GLOSSARY_DESTINATION.to_owned(), misordered.into_bytes());
2915        let projection = compute(TargetEvidence {
2916            documents,
2917            crate_shape: supported_shape(),
2918            ..TargetEvidence::default()
2919        });
2920        let mut collided: Vec<&str> = projection
2921            .collisions
2922            .iter()
2923            .map(|Collision { destination, .. }| destination.as_str())
2924            .collect();
2925        collided.sort_unstable();
2926        let mut expected = BLOCK_DESTINATIONS.to_vec();
2927        expected.sort_unstable();
2928        assert_eq!(collided, expected);
2929        for collision in &projection.collisions {
2930            assert!(
2931                collision.reason.contains(&collision.destination),
2932                "{collision:?}"
2933            );
2934            assert!(
2935                !projection
2936                    .candidates
2937                    .iter()
2938                    .any(|candidate| candidate.destination == collision.destination),
2939                "{} collided and still projects",
2940                collision.destination
2941            );
2942        }
2943        let agents = projection
2944            .collisions
2945            .iter()
2946            .find(|c| c.destination == AGENTS_DESTINATION)
2947            .expect("the doubled document collides");
2948        assert!(agents.reason.contains("more than one"), "{}", agents.reason);
2949        let hooks = projection
2950            .collisions
2951            .iter()
2952            .find(|c| c.destination == HOOKS_DESTINATION)
2953            .expect("the unmatched document collides");
2954        assert!(hooks.reason.contains("unmatched"), "{}", hooks.reason);
2955    }
2956}