Skip to main content

release_kit/
projection.rs

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