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