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