Skip to main content

release_kit/
projection.rs

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