Skip to main content

release_kit/
projection.rs

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