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