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