Skip to main content

release_kit/
projection.rs

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