Skip to main content

release_kit/
projection.rs

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