Skip to main content

release_kit/
projection.rs

1//! The one pure target-specific projection over this binary's embedded
2//! sources.
3//!
4//! [`Projection::compute`] takes a [`ProjectionInput`], values alone, and
5//! answers the complete candidate artifact tree this installed binary
6//! would land in one target: every destination with its ownership
7//! [`Kind`], its [`Placement`], the complete proposed bytes, the rendered
8//! region where the destination is a marked region, and the embedded
9//! source paths it was rendered from. Staging and production landing
10//! share it byte for byte, so what an agent studies in a stage is what a
11//! landing writes.
12//!
13//! This module reads snippets and blocks through `src/embedded.rs`
14//! directly and reads nothing else: no target file, no environment, no
15//! Git state, no clock, no registry, no network. Whatever a projection
16//! needs from the target arrives as [`TargetEvidence`], gathered before
17//! construction by [`evidence::gather`], which lives in its own file so a
18//! source scan can hold this one to the pure boundary.
19//!
20//! Every pure piece of the landing model has one implementation here: the
21//! kind table, the token substitution, the block templating, the splice
22//! and marker judgments, the pair selection, and the Nix crate-shape
23//! judgment. `src/landing.rs` re-exports them under their old names.
24//!
25//! The project-profile work extends [`ProjectionInput`] and the capability
26//! catalog this module selects from. It creates no second projection: one
27//! input type, one compute function, one candidate shape.
28
29pub mod evidence;
30
31use std::collections::BTreeMap;
32
33use serde::{Deserialize, Serialize};
34
35use crate::embedded;
36use crate::error::RkError;
37use crate::landing::{Params, Workflow};
38
39/// The complete input to one projection: the resolved landing parameters
40/// and the typed evidence read from the target beforehand.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct ProjectionInput {
43    /// The resolved landing parameters.
44    pub params: Params,
45    /// What the target already holds, as values.
46    pub evidence: TargetEvidence,
47}
48
49/// What a projection needs to know about the target, gathered before the
50/// projection runs and carried as values.
51#[derive(Debug, Clone, PartialEq, Eq, Default)]
52pub struct TargetEvidence {
53    /// The complete existing document at each block destination that
54    /// exists on disk, keyed by destination. An absent key is an absent
55    /// file.
56    pub documents: BTreeMap<String, Vec<u8>>,
57    /// The crate facts the Nix seed relies on.
58    pub crate_shape: CrateShape,
59    /// Whether `flake.nix` is present at the target, a link included.
60    pub flake_nix_present: bool,
61    /// Whether `flake.lock` is present at the target, a link included.
62    pub flake_lock_present: bool,
63    /// Whether the receipt already records `flake.nix`: a pair release-kit
64    /// landed is its own and is never withheld.
65    pub flake_recorded: bool,
66}
67
68impl TargetEvidence {
69    /// The existing document at one block destination.
70    #[must_use]
71    pub fn document(&self, destination: &str) -> Option<&[u8]> {
72        self.documents.get(destination).map(Vec::as_slice)
73    }
74}
75
76/// The structural facts of the target's crate that the seeded Nix
77/// package expression and the seed flake's smoke check rely on.
78#[derive(Debug, Clone, PartialEq, Eq, Default)]
79pub struct CrateShape {
80    /// The text of the target's `Cargo.toml`, or `None` where none reads.
81    pub cargo_toml: Option<String>,
82    /// Whether `Cargo.lock` is a file at the target.
83    pub cargo_lock: bool,
84    /// Whether `src/main.rs` is a file at the target.
85    pub main_rs: bool,
86}
87
88/// The complete candidate artifact tree for one target.
89///
90/// A value, never a record: it carries no operation, no decision, and no
91/// apply state, and it is not serializable. A consumer renders it again
92/// from scratch rather than reading a saved copy.
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct Projection {
95    /// Every candidate, sorted by destination.
96    pub candidates: Vec<Candidate>,
97    /// The destinations the target's own state withholds, each with its
98    /// one reason. A destination the pair does not ship is absent, never
99    /// omitted.
100    pub omissions: Vec<Omission>,
101    /// The block destinations whose existing document offers the block no
102    /// place, a target-side defect staging explains and landing refuses.
103    pub collisions: Vec<Collision>,
104}
105
106/// One proposed destination.
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct Candidate {
109    /// The destination, relative to the target root.
110    pub destination: String,
111    /// Who owns the bytes after landing.
112    pub kind: Kind,
113    /// The whole file, or the one marked region.
114    pub placement: Placement,
115    /// The complete proposed destination bytes. For a region this is the
116    /// complete spliced document, computed from the existing document in
117    /// the evidence, so a staged view equals what production writes.
118    pub bytes: Vec<u8>,
119    /// The rendered block alone for a region destination: what the
120    /// receipt digests. `None` for a whole file.
121    pub region: Option<Vec<u8>>,
122    /// The embedded source paths the candidate was rendered from, each
123    /// carrying its distribution root as the first segment.
124    pub sources: Vec<String>,
125}
126
127/// How a candidate occupies its destination.
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub enum Placement {
130    /// The candidate is the whole file.
131    Whole,
132    /// The candidate is the one marked region between these markers; the
133    /// bytes outside them belong to the target.
134    Region {
135        /// The opening marker.
136        begin: &'static str,
137        /// The closing marker.
138        end: &'static str,
139    },
140}
141
142/// One destination withheld from this target, with why.
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub struct Omission {
145    /// The destination that stays out.
146    pub destination: String,
147    /// The reason, stated once per destination.
148    pub reason: String,
149}
150
151/// One block destination the target's document cannot take.
152#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct Collision {
154    /// The destination whose document offers the block no place.
155    pub destination: String,
156    /// The reason, for staging to explain and landing to refuse with.
157    pub reason: String,
158}
159
160impl Projection {
161    /// The complete candidate tree for `input`, from this binary's
162    /// embedded sources alone.
163    ///
164    /// # Errors
165    ///
166    /// A source defect in this binary: an unknown technology or an
167    /// unsupported pair as [`RkError::Usage`], and as [`RkError::Other`] a
168    /// destination two sources ship, a snippet the kind table does not
169    /// classify, or a block this binary does not embed.
170    pub fn compute(input: &ProjectionInput) -> Result<Self, RkError> {
171        Self::compute_over(&embedded_snippets(), input)
172    }
173
174    /// [`Self::compute`] over an explicit snippet list, whose paths carry
175    /// the `snippets/` root; the embedded tree in production, an injected
176    /// one under test.
177    fn compute_over(files: &[(String, &[u8])], input: &ProjectionInput) -> Result<Self, RkError> {
178        let params = &input.params;
179        let evidence = &input.evidence;
180        let mut candidates = Vec::new();
181        for selected in select_pair(files, params.tech(), params.forge())? {
182            if !params.nix() && NIX_DESTINATIONS.contains(&selected.destination.as_str()) {
183                continue;
184            }
185            let kind = kind_of(&selected.destination).ok_or_else(|| {
186                anyhow::anyhow!(
187                    "the embedded sources do not classify {}; the kind table is stale",
188                    selected.destination
189                )
190            })?;
191            let bytes = match kind {
192                Kind::Rendered => render(selected.bytes, params),
193                Kind::Seeded | Kind::State => selected.bytes.to_vec(),
194            };
195            candidates.push(Candidate {
196                destination: selected.destination,
197                kind,
198                placement: Placement::Whole,
199                bytes,
200                region: None,
201                sources: vec![selected.source.to_owned()],
202            });
203        }
204        let mut collisions = Vec::new();
205        for destination in BLOCK_DESTINATIONS {
206            let (template, sources) = block_template(destination, params.workflow())?;
207            if let Some(whole) = candidates
208                .iter()
209                .find(|candidate| candidate.destination == destination)
210            {
211                return Err(anyhow::anyhow!(
212                    "{destination} is both a whole file from {} and a marked region from {}; the embedded sources are defective",
213                    whole.sources.join(", "),
214                    sources.join(", ")
215                )
216                .into());
217            }
218            let region = render(template.as_bytes(), params);
219            let (begin, end) = block_markers(destination).ok_or_else(|| {
220                anyhow::anyhow!("{destination} is a block destination with no markers")
221            })?;
222            match propose_document(destination, evidence.document(destination), &region) {
223                Ok(bytes) => candidates.push(Candidate {
224                    destination: destination.to_owned(),
225                    kind: Kind::Rendered,
226                    placement: Placement::Region { begin, end },
227                    bytes,
228                    region: Some(region),
229                    sources,
230                }),
231                Err(reason) => collisions.push(Collision {
232                    destination: destination.to_owned(),
233                    reason,
234                }),
235            }
236        }
237        let mut omissions = Vec::new();
238        if let Some((set, reason)) = nix_withholding(params.nix(), evidence) {
239            candidates.retain(|candidate| {
240                if set.contains(&candidate.destination.as_str()) {
241                    omissions.push(Omission {
242                        destination: candidate.destination.clone(),
243                        reason: reason.clone(),
244                    });
245                    false
246                } else {
247                    true
248                }
249            });
250        }
251        candidates.sort_by(|a, b| a.destination.cmp(&b.destination));
252        omissions.sort_by(|a, b| a.destination.cmp(&b.destination));
253        Ok(Self {
254            candidates,
255            omissions,
256            collisions,
257        })
258    }
259}
260
261/// Every snippet this binary embeds, as `(path, bytes)` with the path
262/// carrying the `snippets/` root, sorted by path.
263fn embedded_snippets() -> Vec<(String, &'static [u8])> {
264    embedded::walk(&embedded::SNIPPETS)
265        .into_iter()
266        .map(|(path, bytes)| (format!("snippets/{path}"), bytes))
267        .collect()
268}
269
270/// Whether the embedded snippets ship the `(tech, forge)` pair, with the
271/// same refusals [`select_pair`] answers.
272///
273/// # Errors
274///
275/// Returns [`RkError::Usage`] naming the known bindings for an unknown
276/// technology and the supported pairs for a pair with no files.
277pub fn check_pair(tech: &str, forge: &str) -> Result<(), RkError> {
278    select_pair(&embedded_snippets(), tech, forge).map(|_| ())
279}
280
281/// Every `(technology, forge)` pair the embedded snippets ship, in path
282/// order.
283#[must_use]
284pub fn supported_pairs() -> Vec<(String, String)> {
285    let mut pairs = Vec::new();
286    for (path, _) in embedded_snippets() {
287        let Some(rest) = path.strip_prefix("snippets/") else {
288            continue;
289        };
290        let mut segments = rest.split('/');
291        let (Some(tech), Some(forge), Some(_)) =
292            (segments.next(), segments.next(), segments.next())
293        else {
294            continue;
295        };
296        if tech.starts_with('_') {
297            continue;
298        }
299        let pair = (tech.to_owned(), forge.to_owned());
300        if !pairs.contains(&pair) {
301            pairs.push(pair);
302        }
303    }
304    pairs
305}
306
307/// One file selected for a pair: where it lands, which source it is, and
308/// what the source carries.
309#[derive(Debug)]
310pub struct Selected<'a, T> {
311    /// The destination, relative to the target root.
312    pub destination: String,
313    /// The source path, carrying its distribution root.
314    pub source: &'a str,
315    /// What the source carries.
316    pub bytes: &'a T,
317}
318
319/// The files one `(technology, forge)` pair lands, selected from `files`,
320/// whose paths carry the `snippets/` root.
321///
322/// The shared zone `snippets/_shared/<forge>` composes into every pair
323/// and lands first. It is not a technology and never names one.
324///
325/// # Errors
326///
327/// Returns [`RkError::Usage`] naming the known bindings for an unknown
328/// technology and the supported pairs for a pair with no files, and
329/// [`RkError::Other`] naming both source paths for a destination two
330/// sources ship, which is a source defect and never one source silently
331/// winning.
332pub fn select_pair<'a, T>(
333    files: &'a [(String, T)],
334    tech: &str,
335    forge: &str,
336) -> Result<Vec<Selected<'a, T>>, RkError> {
337    let mut techs: Vec<&str> = Vec::new();
338    for (path, _) in files {
339        if let Some(rest) = path.strip_prefix("snippets/")
340            && let Some((dir, _)) = rest.split_once('/')
341            && !dir.starts_with('_')
342            && !techs.contains(&dir)
343        {
344            techs.push(dir);
345        }
346    }
347    if tech.starts_with('_') || !techs.contains(&tech) {
348        return Err(RkError::Usage(format!(
349            "unknown tech '{tech}'; the bindings are: {}",
350            techs.join(", ")
351        )));
352    }
353    let pair = format!("snippets/{tech}/{forge}/");
354    if !files.iter().any(|(path, _)| path.starts_with(&pair)) {
355        let mut known: Vec<String> = Vec::new();
356        for tech in &techs {
357            let prefix = format!("snippets/{tech}/");
358            for (path, _) in files {
359                if let Some(rest) = path.strip_prefix(&prefix)
360                    && let Some((forge, _)) = rest.split_once('/')
361                {
362                    let entry = format!("{tech}, {forge}");
363                    if !known.contains(&entry) {
364                        known.push(entry);
365                    }
366                }
367            }
368        }
369        return Err(RkError::Usage(format!(
370            "the pair ({tech}, {forge}) has no landable files; the supported pairs are: {}",
371            known.join("; ")
372        )));
373    }
374    let shared = format!("snippets/_shared/{forge}/");
375    let mut out: Vec<Selected<'a, T>> = Vec::new();
376    for zone in [&shared, &pair] {
377        for (path, bytes) in files {
378            let Some(rel) = path.strip_prefix(zone.as_str()) else {
379                continue;
380            };
381            if let Some(existing) = out.iter().find(|selected| selected.destination == rel) {
382                return Err(anyhow::anyhow!(
383                    "the shared zone and the pair ({tech}, {forge}) both ship {rel}: {} and {path}; the embedded sources are defective",
384                    existing.source
385                )
386                .into());
387            }
388            out.push(Selected {
389                destination: rel.to_owned(),
390                source: path,
391                bytes,
392            });
393        }
394    }
395    Ok(out)
396}
397
398/// Who owns a landed file's bytes after landing.
399#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
400#[serde(rename_all = "lowercase")]
401pub enum Kind {
402    /// release-kit owns it: a newer binary re-renders it, and a target
403    /// edit is a conflict.
404    Rendered,
405    /// The target owns it: a starting point the project tunes, reported
406    /// and never rewritten.
407    Seeded,
408    /// The release automation owns it: never written after the first
409    /// landing, never compared.
410    State,
411}
412
413impl Kind {
414    /// The wire and report form.
415    #[must_use]
416    pub const fn as_str(self) -> &'static str {
417        match self {
418            Self::Rendered => "rendered",
419            Self::Seeded => "seeded",
420            Self::State => "state",
421        }
422    }
423}
424
425/// The declared classification: every landable destination and its kind.
426/// The workflow and pipeline files carry the release automation and the
427/// OIDC permission, so release-kit owns them; the tool configurations are
428/// per-project judgment; the two state files are rewritten by the release
429/// automation itself.
430const KINDS: [(&str, Kind); 16] = [
431    (".github/workflows/release-plz.yml", Kind::Rendered),
432    (".github/workflows/release-please.yml", Kind::Rendered),
433    (".github/workflows/release.yml", Kind::Rendered),
434    (".github/workflows/pr-title.yml", Kind::Rendered),
435    (".gitlab-ci.yml", Kind::Rendered),
436    ("SECURITY.md", Kind::Rendered),
437    (".gitlab/ci/mr-title.yml", Kind::Rendered),
438    ("release-plz.toml", Kind::Seeded),
439    ("dist-workspace.toml", Kind::Seeded),
440    ("release-please-config.json", Kind::Seeded),
441    ("cliff.toml", Kind::Seeded),
442    ("nix/package.nix", Kind::Seeded),
443    ("flake.nix", Kind::Seeded),
444    (".release-please-manifest.json", Kind::State),
445    ("VERSION", Kind::State),
446    ("flake.lock", Kind::State),
447];
448
449/// The destinations of the opt-in Nix capability, present in a projection
450/// only where the landing's `nix` parameter is on.
451///
452/// The parameter is recorded, so `status`, `upgrade`, and `adopt` can
453/// reconstruct whether these files are supposed to exist: an absent file
454/// under `nix = false` is not wanted, never drifted.
455///
456/// The capability lands no workflow, on either forge, and each forge's
457/// reason is its own. On GitHub a job gates the merge only inside the
458/// workflow the required check needs, and that workflow is the target's
459/// own. On GitLab the merge check is the whole pipeline, and a target's
460/// jobs live in the child pipeline the rendered parent triggers, which the
461/// target owns. The bindings serve the job for both.
462pub const NIX_DESTINATIONS: [&str; 3] = ["nix/package.nix", "flake.nix", "flake.lock"];
463
464/// The subset a target with a flake of its own keeps out: the seed pair,
465/// whose files would sit beside a flake release-kit did not author.
466///
467/// The seeded package expression is not in it: it lands either way, as
468/// the starting point the target integrates by hand.
469pub const NIX_WITHHOLDABLE: [&str; 2] = ["flake.nix", "flake.lock"];
470
471/// The declared kind of a destination, or `None` for a file the sources
472/// does not classify.
473#[must_use]
474pub fn kind_of(destination: &str) -> Option<Kind> {
475    if BLOCK_DESTINATIONS.contains(&destination) {
476        return Some(Kind::Rendered);
477    }
478    KINDS
479        .iter()
480        .find(|(name, _)| *name == destination)
481        .map(|(_, kind)| *kind)
482}
483
484/// Every destination the embedded sources can land, in declaration order.
485///
486/// The whole files and the three block destinations. The classification
487/// reads it to ask whether a destination is already present at a target.
488pub fn destinations() -> impl Iterator<Item = &'static str> {
489    KINDS
490        .iter()
491        .map(|(name, _)| *name)
492        .chain(BLOCK_DESTINATIONS)
493}
494
495/// The mechanical substitution sites in `rendered` files.
496///
497/// Known values, substituted identically everywhere each appears. The
498/// owner is derived from the landing's `repo` parameter and the scope
499/// shape from [`SCOPE_SHAPE`], so the landed bytes stay a deterministic
500/// function of the embedded sources plus parameters.
501pub const OWNER_TOKEN: &[u8] = b"OWNER";
502
503/// The repository a preview stands in for where nothing answered.
504///
505/// It is a placeholder, never a project path: a plan that would render
506/// it into a target is blocked, and only a preview may carry it.
507pub const REPO_PLACEHOLDER: &str = "OWNER";
508
509/// The full recorded project path, including nested namespaces.
510pub const REPO_TOKEN: &[u8] = b"RK_REPO";
511
512/// The one scope shape: the title checks' regular expression.
513pub const SCOPE_SHAPE_TOKEN: &[u8] = b"RK_SCOPE_SHAPE";
514
515/// The recorded release style: `trunk` arms the bot's request in the
516/// landed release workflow, `lines` leaves every request unarmed.
517pub const STYLE_TOKEN: &[u8] = b"RK_STYLE";
518
519/// The one permanent branch. A landed release trigger, ref guard, and
520/// branch guard each name it, so a target whose trunk is not `master`
521/// needs its own answer in its own bytes.
522pub const TRUNK_BRANCH_TOKEN: &[u8] = b"RK_TRUNK_BRANCH";
523
524/// The release-line branch prefix, naming the lines a release trigger
525/// accepts beside the trunk.
526pub const LINE_PREFIX_TOKEN: &[u8] = b"RK_LINE_PREFIX";
527
528/// The same prefix, escaped for a slash-delimited regular expression.
529///
530/// A GitLab rule names a line that way, and a raw `release/` would close
531/// the delimiter and break the pipeline, so the two forms are two tokens.
532/// This one substitutes first: the plain token is its own prefix.
533pub const LINE_PREFIX_RE_TOKEN: &[u8] = b"RK_LINE_PREFIX_RE";
534
535/// The three replaceable spans of a landed security policy, each as its
536/// ordered begin and end marker.
537///
538/// A span is not a token. Each forge's policy carries its own authored
539/// prose inside the markers, so a landing that answers neither security
540/// parameter strips the markers and reproduces the file the forge's
541/// snippet states, byte for byte and in that forge's own words. A landing
542/// that answers one replaces the interior of the spans that fact belongs
543/// to. The markers are HTML comments because the snippet is Markdown a
544/// reader may open before it is ever rendered.
545pub const SECURITY_SPANS: [(&[u8], &[u8]); 3] = [
546    (
547        b"<!--RK_SECURITY_CONTACT_BEGIN-->",
548        b"<!--RK_SECURITY_CONTACT_END-->",
549    ),
550    (
551        b"<!--RK_SECURITY_RESPONSE_BEGIN-->",
552        b"<!--RK_SECURITY_RESPONSE_END-->",
553    ),
554    (
555        b"<!--RK_SECURITY_DEADLINE_BEGIN-->",
556        b"<!--RK_SECURITY_DEADLINE_END-->",
557    ),
558];
559
560/// The sentence a policy with an acknowledgment window states in place of
561/// the forge's best-effort wording.
562fn acknowledgment(response: &str) -> String {
563    format!("Maintainers acknowledge a report within {response}.")
564}
565
566/// What a policy with an acknowledgment window says about deadlines: the
567/// authored sentence disclaims a response deadline, which a stated window
568/// contradicts, so only the disclosure half survives.
569const DISCLOSURE_ONLY: &[u8] = b"This policy commits to no disclosure deadline.";
570
571/// The replacement for each span under one parameter set, or `None` where
572/// the forge's authored interior stands.
573fn security_replacements(params: &Params) -> [Option<Vec<u8>>; 3] {
574    let contact = (!params.security_contact().is_empty())
575        .then(|| params.security_contact().as_bytes().to_vec());
576    let promised = params.security_response() != crate::config::RESPONSE_DEFAULT;
577    [
578        contact,
579        promised.then(|| acknowledgment(params.security_response()).into_bytes()),
580        promised.then(|| DISCLOSURE_ONLY.to_vec()),
581    ]
582}
583
584/// One marked span replaced, or the markers alone removed.
585///
586/// Exactly one ordered begin and end pair is a span; anything else is a
587/// source defect a test holds, so this leaves such bytes untouched rather
588/// than growing a runtime failure mode into every rendered file.
589fn replace_span(baseline: &[u8], begin: &[u8], end: &[u8], value: Option<&[u8]>) -> Vec<u8> {
590    let ordered = find(baseline, begin)
591        .zip(find(baseline, end))
592        .filter(|(start, stop)| stop > start);
593    let Some((start, stop)) = ordered else {
594        return baseline.to_vec();
595    };
596    let mut out = Vec::with_capacity(baseline.len());
597    out.extend_from_slice(&baseline[..start]);
598    out.extend_from_slice(value.unwrap_or_else(|| &baseline[start + begin.len()..stop]));
599    out.extend_from_slice(&baseline[stop + end.len()..]);
600    out
601}
602
603/// Substitute the landing parameters into a `rendered` file's bytes.
604///
605/// The repository's owner, the project path's first segment, replaces
606/// every `OWNER` occurrence; the full path replaces `RK_REPO` last. The
607/// one scope shape replaces the scope token, and the recorded style
608/// replaces the style token. The scope shape rests on no parameter, so it
609/// substitutes always. An unresolved style leaves its token standing,
610/// which only a preview renders under: an apply refuses before reaching
611/// here.
612///
613/// The trunk and the line prefix substitute from the same parameters, so
614/// a target that renames either carries the new name in every artifact
615/// that names it rather than in the binary's behavior alone.
616///
617/// The security policy's marked spans resolve last, after every token, so
618/// a contact that happens to spell a token name lands literally rather
619/// than being read as one more substitution site.
620#[must_use]
621pub fn render(baseline: &[u8], params: &Params) -> Vec<u8> {
622    let repo = params.repo();
623    let owner = repo.split('/').next().unwrap_or(repo);
624    let mut out = substitute(baseline, OWNER_TOKEN, owner.as_bytes());
625    if let Some(style) = params.style() {
626        out = substitute(&out, STYLE_TOKEN, style.as_str().as_bytes());
627    }
628    out = substitute(&out, SCOPE_SHAPE_TOKEN, SCOPE_SHAPE.as_bytes());
629    out = substitute(&out, TRUNK_BRANCH_TOKEN, params.trunk().as_bytes());
630    let escaped = params.line_prefix().replace('/', "\\/");
631    out = substitute(&out, LINE_PREFIX_RE_TOKEN, escaped.as_bytes());
632    out = substitute(&out, LINE_PREFIX_TOKEN, params.line_prefix().as_bytes());
633    out = substitute(&out, REPO_TOKEN, repo.as_bytes());
634    for ((begin, end), value) in SECURITY_SPANS.iter().zip(security_replacements(params)) {
635        out = replace_span(&out, begin, end, value.as_deref());
636    }
637    out
638}
639
640/// Every `token` occurrence replaced with `value`.
641#[must_use]
642pub fn substitute(baseline: &[u8], token: &[u8], value: &[u8]) -> Vec<u8> {
643    let mut out = Vec::with_capacity(baseline.len());
644    let mut rest = baseline;
645    while let Some(at) = find(rest, token) {
646        out.extend_from_slice(&rest[..at]);
647        out.extend_from_slice(value);
648        rest = &rest[at + token.len()..];
649    }
650    out.extend_from_slice(rest);
651    out
652}
653
654/// First occurrence of `needle` in `haystack`.
655fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
656    haystack
657        .windows(needle.len())
658        .position(|window| window == needle)
659}
660
661/// The destination the routing block splices into.
662pub const AGENTS_DESTINATION: &str = "AGENTS.md";
663
664/// The block's opening marker.
665pub const BLOCK_BEGIN: &str = "<!-- BEGIN release-kit -->";
666
667/// The block's closing marker.
668pub const BLOCK_END: &str = "<!-- END release-kit -->";
669
670/// The destination the glossary block splices into.
671///
672/// The document is the target's own vocabulary, so the block shares
673/// `AGENTS.md`'s marker pair and owns nothing outside it.
674pub const GLOSSARY_DESTINATION: &str = "GLOSSARY.md";
675
676/// The destination the hook block splices into.
677pub const HOOKS_DESTINATION: &str = ".pre-commit-config.yaml";
678
679/// Every block destination, in the order a landing writes them.
680///
681/// A block destination owns the lines between its markers and nothing
682/// else, so every verb that asks whether a destination is block-placed
683/// reads this one list.
684pub const BLOCK_DESTINATIONS: [&str; 3] =
685    [AGENTS_DESTINATION, GLOSSARY_DESTINATION, HOOKS_DESTINATION];
686
687/// The hook block's opening marker, a YAML comment at column zero.
688pub const HOOKS_BEGIN: &str = "# BEGIN release-kit";
689
690/// The hook block's closing marker.
691pub const HOOKS_END: &str = "# END release-kit";
692
693/// The top-level key the fresh hook file carries and the skills verify on
694/// an existing one: the commit-msg and pre-push hooks run only where their
695/// hook types are installed.
696pub const HOOK_TYPES_LINE: &str = "default_install_hook_types: [pre-commit, commit-msg, pre-push]";
697
698/// The authored routing-block template.
699pub const AGENTS_BLOCK: &str = "blocks/agents-block.md.in";
700
701/// The authored glossary template.
702pub const GLOSSARY_BLOCK: &str = "blocks/glossary.md.in";
703
704/// The routing block's mode line, worktree form.
705pub const AGENTS_LINE_WORKTREE: &str = "blocks/agents-line-worktree.md.in";
706
707/// The routing block's mode line, branches form.
708pub const AGENTS_LINE_BRANCHES: &str = "blocks/agents-line-branches.md.in";
709
710/// The authored hook-block template.
711pub const PRE_COMMIT_BLOCK: &str = "blocks/pre-commit-block.yaml.in";
712
713/// The worktree mode's guard entry.
714pub const PRE_COMMIT_WORKTREE_GUARD: &str = "blocks/pre-commit-worktree-guard.yaml.in";
715
716/// The routing block's mode line for one workflow.
717#[must_use]
718pub const fn routing_line(workflow: Workflow) -> &'static str {
719    match workflow {
720        Workflow::Worktree => AGENTS_LINE_WORKTREE,
721        Workflow::Branches => AGENTS_LINE_BRANCHES,
722    }
723}
724
725/// One authored block this binary embeds, as text, by its embedded path.
726///
727/// # Errors
728///
729/// [`RkError::Other`] for a block this binary does not embed or one that
730/// is not UTF-8, both defects in the binary.
731pub fn embedded_block(path: &str) -> Result<&'static str, RkError> {
732    let name = path.strip_prefix("blocks/").unwrap_or(path);
733    let file = embedded::BLOCKS
734        .get_file(name)
735        .ok_or_else(|| anyhow::anyhow!("{path}: this binary embeds no such block"))?;
736    std::str::from_utf8(file.contents())
737        .map_err(|_| anyhow::anyhow!("{path}: a block is UTF-8").into())
738}
739
740/// An authored block without the one final newline the repository's
741/// hooks enforce on every file under `blocks/`; a test in
742/// `src/embedded.rs` holds each file to exactly one.
743#[must_use]
744pub fn authored(text: &str) -> &str {
745    text.strip_suffix('\n').unwrap_or(text)
746}
747
748/// The one branch grammar.
749///
750/// The extended regular expression the landed `rk-branch-name` hook
751/// tests, and the same anchored language `rk worktree add` validates
752/// before creating anything. One owner by token: `concat!` cannot
753/// interpolate a const, so [`compose_hooks`] substitutes it for the
754/// template's `RK_BRANCH_GRAMMAR` token.
755pub 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[-/].+)$";
756
757/// The one commit scope shape.
758///
759/// A bracket expression, lowercase, admitting the digits and `_ . / -`
760/// beside the letters, so `area/subarea` reads as one scope. It holds the
761/// shape of a scope and never its vocabulary: the word itself is the
762/// author's, guided by the routing block and by the repository's own
763/// history. One owner by token: the title checks take it as
764/// `RK_SCOPE_SHAPE` through [`render`], and `rk message --check` reads it
765/// directly, so the desk and the forge judge one language.
766pub const SCOPE_SHAPE: &str = "[a-z0-9._/-]+";
767
768/// Whether one scope matches [`SCOPE_SHAPE`].
769///
770/// The predicate and the pattern are one owner, so the desk's judgment
771/// cannot drift from the forge's: `rk message --check` calls this, the
772/// title checks render the pattern, and a test holds the two equal over
773/// every ASCII character.
774#[must_use]
775pub fn scope_is_shaped(scope: &str) -> bool {
776    !scope.is_empty()
777        && scope.chars().all(|c| {
778            c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '/' | '-')
779        })
780}
781
782/// The routing block from its authored template and the mode's one
783/// orientation line.
784///
785/// Markers included, without a trailing newline and with its scope token
786/// unrendered. Everything but the substituted line, the agent-boundary
787/// line included, is byte-identical across modes.
788#[must_use]
789pub fn compose_routing(template: &str, line: &str) -> String {
790    authored(template).replacen("RK_WORKFLOW_LINE", authored(line), 1)
791}
792
793/// The glossary block from its authored template: markers included and
794/// without a trailing newline. It carries no token and no mode, so the
795/// same bytes land in every target.
796#[must_use]
797pub fn compose_glossary(template: &str) -> String {
798    authored(template).to_owned()
799}
800
801/// The hook block from its authored template, with the worktree mode's
802/// guard entry where `guard` carries one.
803///
804/// `Some` is the worktree mode: the block carries the location guard and
805/// names the sweep-skip pair. `None` is the branches mode: no guard entry
806/// at all, never an entry that reads local state to decide whether to
807/// enforce. The one branch grammar substitutes from [`BRANCH_GRAMMAR`].
808/// Markers included, without a trailing newline and with its scope token
809/// unrendered.
810#[must_use]
811pub fn compose_hooks(template: &str, guard: Option<&str>) -> String {
812    let (guard, skip) = guard.map_or_else(
813        || (String::new(), "no-commit-to-branch"),
814        |entry| {
815            (
816                format!("{}\n", authored(entry)),
817                "no-commit-to-branch,rk-worktree-location",
818            )
819        },
820    );
821    authored(template)
822        .replacen("RK_BRANCH_GRAMMAR", BRANCH_GRAMMAR, 1)
823        .replacen("RK_SWEEP_SKIP", skip, 1)
824        .replacen("RK_WORKTREE_GUARD", &guard, 1)
825}
826
827/// The routing block for one workflow mode, from this binary's embedded
828/// templates.
829///
830/// # Errors
831///
832/// A block this binary does not embed, a defect in the binary.
833pub fn routing_block(workflow: Workflow) -> Result<String, RkError> {
834    Ok(compose_routing(
835        embedded_block(AGENTS_BLOCK)?,
836        embedded_block(routing_line(workflow))?,
837    ))
838}
839
840/// The glossary block from this binary's embedded template.
841///
842/// # Errors
843///
844/// A block this binary does not embed, a defect in the binary.
845pub fn glossary_block() -> Result<String, RkError> {
846    Ok(compose_glossary(embedded_block(GLOSSARY_BLOCK)?))
847}
848
849/// The hook block for one workflow mode, from this binary's embedded
850/// templates.
851///
852/// # Errors
853///
854/// A block this binary does not embed, a defect in the binary.
855pub fn hooks_block(workflow: Workflow) -> Result<String, RkError> {
856    let guard = match workflow {
857        Workflow::Worktree => Some(embedded_block(PRE_COMMIT_WORKTREE_GUARD)?),
858        Workflow::Branches => None,
859    };
860    Ok(compose_hooks(embedded_block(PRE_COMMIT_BLOCK)?, guard))
861}
862
863/// The unrendered block for one block destination under one workflow,
864/// with the embedded source paths it was composed from.
865fn block_template(destination: &str, workflow: Workflow) -> Result<(String, Vec<String>), RkError> {
866    match destination {
867        AGENTS_DESTINATION => Ok((
868            routing_block(workflow)?,
869            vec![AGENTS_BLOCK.to_owned(), routing_line(workflow).to_owned()],
870        )),
871        GLOSSARY_DESTINATION => Ok((glossary_block()?, vec![GLOSSARY_BLOCK.to_owned()])),
872        HOOKS_DESTINATION => {
873            let mut sources = vec![PRE_COMMIT_BLOCK.to_owned()];
874            if workflow == Workflow::Worktree {
875                sources.push(PRE_COMMIT_WORKTREE_GUARD.to_owned());
876            }
877            Ok((hooks_block(workflow)?, sources))
878        }
879        other => Err(anyhow::anyhow!("{other} is not a block destination").into()),
880    }
881}
882
883/// The markers of a block destination, or `None` for a whole-file one.
884#[must_use]
885pub fn block_markers(destination: &str) -> Option<(&'static str, &'static str)> {
886    match destination {
887        AGENTS_DESTINATION | GLOSSARY_DESTINATION => Some((BLOCK_BEGIN, BLOCK_END)),
888        HOOKS_DESTINATION => Some((HOOKS_BEGIN, HOOKS_END)),
889        _ => None,
890    }
891}
892
893/// The marked block inside a document, markers included, or `None` where
894/// the text carries no complete block.
895#[must_use]
896pub fn extract_block<'a>(text: &'a str, begin: &str, end: &str) -> Option<&'a str> {
897    let start = text.find(begin)?;
898    let stop = text[start..].find(end)? + start + end.len();
899    Some(&text[start..stop])
900}
901
902/// The whole document's bytes after splicing a marked block into it.
903///
904/// A fresh file where none exists, the block replaced in place where one
905/// is marked, appended after the target's own content otherwise:
906/// release-kit owns the lines inside the markers, not the document. Both
907/// markdown destinations take this shape, `AGENTS.md` and the glossary.
908#[must_use]
909pub fn splice_marked_block(existing: Option<&[u8]>, block: &str) -> Vec<u8> {
910    let block = block.as_bytes();
911    let Some(text) = existing else {
912        return [block, b"\n"].concat();
913    };
914    // Bytes, never text: the document belongs to the target and a decode
915    // that replaces one invalid sequence rewrites a byte outside the
916    // markers, which is the one thing a block destination never does.
917    if let Some(start) = find(text, BLOCK_BEGIN.as_bytes())
918        && let Some(offset) = find(&text[start..], BLOCK_END.as_bytes())
919    {
920        let stop = start + offset + BLOCK_END.len();
921        return [&text[..start], block, &text[stop..]].concat();
922    }
923    // Appending keeps every byte the target wrote, trailing blank lines
924    // and an absent final newline included. The only addition is the
925    // separator that opens the block's own line.
926    let mut out = Vec::with_capacity(text.len() + block.len() + 3);
927    out.extend_from_slice(text);
928    if !text.ends_with(b"\n") {
929        out.push(b'\n');
930    }
931    out.push(b'\n');
932    out.extend_from_slice(block);
933    out.push(b'\n');
934    out
935}
936
937/// The whole `.pre-commit-config.yaml` content after splicing the
938/// rendered hook block.
939///
940/// A fresh file carries the hook-types key, the `repos:` key, and the
941/// block; a marked file takes the block in place; an unmarked file takes
942/// it directly under its `repos:` line, above the target's own hooks. An
943/// unmarked file with no `repos:` line is refused by name: the block's
944/// entries are list items and have nowhere honest to go.
945///
946/// # Errors
947///
948/// The reason the block has no place, for the caller's refusal to carry.
949pub fn splice_hooks_block(existing: Option<&str>, block: &str) -> Result<String, String> {
950    let Some(text) = existing else {
951        return Ok(format!("{HOOK_TYPES_LINE}\n\nrepos:\n{block}\n"));
952    };
953    if let Some(defect) = hooks_marker_defect(text) {
954        return Err(defect);
955    }
956    if let Some(found) = extract_block(text, HOOKS_BEGIN, HOOKS_END) {
957        return Ok(text.replacen(found, block, 1));
958    }
959    let mut out = String::with_capacity(text.len() + block.len() + 1);
960    let mut placed = false;
961    for line in text.split_inclusive('\n') {
962        out.push_str(line);
963        if !placed && line.trim_end() == "repos:" {
964            if !out.ends_with('\n') {
965                out.push('\n');
966            }
967            out.push_str(block);
968            out.push('\n');
969            placed = true;
970        }
971    }
972    if placed {
973        Ok(out)
974    } else {
975        Err(format!(
976            "{HOOKS_DESTINATION} exists with no repos: line, so the hook block has nowhere to land"
977        ))
978    }
979}
980
981/// The one definition of an ill-formed block document, shared by every
982/// splice and every reader that judges one: `None` for a whole-file
983/// destination or a well-formed document.
984///
985/// Ownership must be unambiguous: exactly one begin marker paired with
986/// exactly one end marker after it, or none of either. A second begin is
987/// a second block, which for the hook file pre-commit would still run,
988/// and a marker without its pair, or an end before its begin, is a block
989/// whose extent nothing can state.
990#[must_use]
991pub fn marker_defect(destination: &str, text: &str) -> Option<String> {
992    let (begin, end) = block_markers(destination)?;
993    let begins = text.matches(begin).count();
994    let ends = text.matches(end).count();
995    if begins > 1 || ends > 1 {
996        return Some(format!(
997            "{destination} carries more than one release-kit marker pair; release-kit owns exactly one block"
998        ));
999    }
1000    match (text.find(begin), text.find(end)) {
1001        (Some(begin), Some(end)) if end > begin => None,
1002        (None, None) => None,
1003        _ => Some(format!(
1004            "{destination} carries an unmatched or misordered release-kit marker, so the block's extent is ambiguous"
1005        )),
1006    }
1007}
1008
1009/// [`marker_defect`] for the hook file, the destination whose entries
1010/// execute.
1011#[must_use]
1012pub fn hooks_marker_defect(text: &str) -> Option<String> {
1013    marker_defect(HOOKS_DESTINATION, text)
1014}
1015
1016/// The complete proposed document for one block destination: the
1017/// rendered `region` spliced into the `existing` document the evidence
1018/// carries, or the reason the document offers it no place.
1019fn propose_document(
1020    destination: &str,
1021    existing: Option<&[u8]>,
1022    region: &[u8],
1023) -> Result<Vec<u8>, String> {
1024    // The block is release-kit's own text. The document is the target's
1025    // bytes: the markdown splice works on them directly, and the marker
1026    // judgment decodes a copy only to count ASCII markers, which a
1027    // replacement character neither creates nor hides.
1028    let block = String::from_utf8_lossy(region).into_owned();
1029    if let Some(text) = existing
1030        && let Some(defect) = marker_defect(destination, &String::from_utf8_lossy(text))
1031    {
1032        return Err(defect);
1033    }
1034    if destination == HOOKS_DESTINATION {
1035        // The hook splice is line-based text, so a document that is not
1036        // UTF-8 has no honest place for the block: a lossy decode would
1037        // rewrite a byte outside the markers, which a region never does.
1038        let text = match existing {
1039            None => None,
1040            Some(bytes) => Some(std::str::from_utf8(bytes).map_err(|_| {
1041                format!(
1042                    "{destination} is not UTF-8, so the hook block has nowhere to land without rewriting the target's bytes"
1043                )
1044            })?),
1045        };
1046        return splice_hooks_block(text, &block).map(String::into_bytes);
1047    }
1048    Ok(splice_marked_block(existing, &block))
1049}
1050
1051/// Why the whole Nix capability stays out of a landing, or `None` where
1052/// the target's crate shape supports the seed.
1053///
1054/// The gate holds every structural prerequisite the seed relies on, not
1055/// only evaluation: the package expression reads `Cargo.toml` through
1056/// `importTOML` and throws without `../Cargo.lock`, and the seed flake's
1057/// smoke check runs the crate's binary, which only an implicit
1058/// `src/main.rs` or an explicit `[[bin]]` entry produces. A shape
1059/// missing any of these would land files that fail on their first
1060/// evaluation or first check, so the landing reports the smaller product
1061/// with the missing piece named instead.
1062#[must_use]
1063pub fn nix_unsupported_shape(shape: &CrateShape) -> Option<String> {
1064    let Some(text) = shape.cargo_toml.as_deref() else {
1065        return Some(
1066            "the target has no readable Cargo.toml, which the seeded package expression reads; no Nix file lands".to_owned(),
1067        );
1068    };
1069    let Ok(table) = text.parse::<toml::Table>() else {
1070        return Some(
1071            "the target's Cargo.toml does not parse, and the seeded package expression reads it; no Nix file lands".to_owned(),
1072        );
1073    };
1074    if !table.contains_key("package") {
1075        return Some(
1076            "the target's Cargo.toml has no [package] table; the seed supports a single crate, so no Nix file lands".to_owned(),
1077        );
1078    }
1079    if !shape.cargo_lock {
1080        return Some(
1081            "the target has no Cargo.lock, which the seeded package expression builds from; commit one, then opt in".to_owned(),
1082        );
1083    }
1084    let implicit_bin = shape.main_rs
1085        && table
1086            .get("package")
1087            .and_then(toml::Value::as_table)
1088            .and_then(|package| package.get("autobins"))
1089            .and_then(toml::Value::as_bool)
1090            != Some(false);
1091    let explicit_bins = table.get("bin").and_then(toml::Value::as_array);
1092    if explicit_bins.is_none() && !implicit_bin {
1093        return Some(
1094            "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(),
1095        );
1096    }
1097    // The seed's mainProgram is the first [[bin]] entry; one whose
1098    // required-features a default build does not enable produces no
1099    // executable, so the smoke check would fail on a green landing. A
1100    // requirement the default feature set covers builds normally and
1101    // passes.
1102    if let Some(bins) = explicit_bins {
1103        let required = bins
1104            .first()
1105            .and_then(toml::Value::as_table)
1106            .and_then(|bin| bin.get("required-features"))
1107            .and_then(toml::Value::as_array);
1108        if let Some(required) = required {
1109            let enabled = default_features(&table);
1110            let missing = required
1111                .iter()
1112                .filter_map(toml::Value::as_str)
1113                .any(|feature| !enabled.contains(feature));
1114            if missing {
1115                return Some(
1116                    "the target's first [[bin]] entry requires features a default build does not enable; no Nix file lands".to_owned(),
1117                );
1118            }
1119        }
1120    }
1121    None
1122}
1123
1124/// Whether any feature's list carries a `dep:name` edge, which is what
1125/// suppresses the optional dependency's implicit same-named feature.
1126fn dep_edge_suppresses(features: &toml::Table, name: &str) -> bool {
1127    let edge = format!("dep:{name}");
1128    features.values().any(|list| {
1129        list.as_array().is_some_and(|entries| {
1130            entries
1131                .iter()
1132                .filter_map(toml::Value::as_str)
1133                .any(|entry| entry == edge)
1134        })
1135    })
1136}
1137
1138/// Whether `name` is declared an optional dependency, in any of the
1139/// dependency tables a binary's build reads.
1140fn is_optional_dependency(table: &toml::Table, name: &str) -> bool {
1141    ["dependencies", "build-dependencies"]
1142        .iter()
1143        .any(|section| {
1144            table
1145                .get(*section)
1146                .and_then(toml::Value::as_table)
1147                .and_then(|dependencies| dependencies.get(name))
1148                .and_then(toml::Value::as_table)
1149                .and_then(|dependency| dependency.get("optional"))
1150                .and_then(toml::Value::as_bool)
1151                == Some(true)
1152        })
1153}
1154
1155/// The features a default build enables: the `default` feature resolved
1156/// through the `[features]` table's own enables, an approximation of
1157/// cargo's default resolution for the documented supported shapes, erring
1158/// toward withholding where the semantics run deeper. Dependency forms,
1159/// `dep:name` and weak `name?/feature`, are not feature names here and are
1160/// skipped; the closure is bounded by the table's size.
1161fn default_features(table: &toml::Table) -> std::collections::BTreeSet<String> {
1162    let Some(features) = table.get("features").and_then(toml::Value::as_table) else {
1163        return std::collections::BTreeSet::new();
1164    };
1165    let mut enabled = std::collections::BTreeSet::new();
1166    let mut queue = vec!["default".to_owned()];
1167    while let Some(name) = queue.pop() {
1168        if !enabled.insert(name.clone()) {
1169            continue;
1170        }
1171        if let Some(implies) = features.get(&name).and_then(toml::Value::as_array) {
1172            for implied in implies.iter().filter_map(toml::Value::as_str) {
1173                if implied.starts_with("dep:") || implied.contains("?/") {
1174                    // `dep:name` enables the dependency without a feature
1175                    // of this crate; a weak `name?/feature` edge enables
1176                    // nothing by itself.
1177                    continue;
1178                }
1179                if let Some((package, _)) = implied.split_once('/') {
1180                    // A strong `name/feature` edge activates this crate's
1181                    // same-named feature only for an optional dependency,
1182                    // and only where that feature exists: declared
1183                    // explicitly, or implicit and not suppressed by a
1184                    // `dep:` edge anywhere in the table. A non-optional
1185                    // dependency's edge enables a feature of the
1186                    // dependency and nothing of this crate.
1187                    let feature_exists =
1188                        features.contains_key(package) || !dep_edge_suppresses(features, package);
1189                    if is_optional_dependency(table, package) && feature_exists {
1190                        queue.push(package.to_owned());
1191                    }
1192                } else {
1193                    queue.push(implied.to_owned());
1194                }
1195            }
1196        }
1197    }
1198    enabled
1199}
1200
1201/// Why the flake half of the Nix capability stays out of this landing, or
1202/// `None` where the pair lands whole.
1203///
1204/// The pair is all-or-nothing: a target that already carries a
1205/// `flake.nix` or `flake.lock` of its own keeps its pair, because a seed
1206/// lock beside a foreign flake describes the wrong input graph. A pair
1207/// the record names is release-kit's own landing and is never withheld.
1208#[must_use]
1209pub fn flake_pair_withheld(
1210    flake_recorded: bool,
1211    flake_nix_present: bool,
1212    flake_lock_present: bool,
1213) -> Option<String> {
1214    if flake_recorded {
1215        return None;
1216    }
1217    let present: Vec<&str> = [
1218        ("flake.nix", flake_nix_present),
1219        ("flake.lock", flake_lock_present),
1220    ]
1221    .into_iter()
1222    .filter_map(|(name, present)| present.then_some(name))
1223    .collect();
1224    if present.is_empty() {
1225        return None;
1226    }
1227    Some(format!(
1228        "the target already carries {}; its flake pair stays its own",
1229        present.join(" and ")
1230    ))
1231}
1232
1233/// The Nix destinations an opted-in landing withholds at this target, with
1234/// the one reason, or `None` where the capability lands whole or `nix` is
1235/// off.
1236///
1237/// An unsupported crate shape names the whole capability, and a flake
1238/// pair of the target's own names the pair while the seeded package
1239/// expression still lands. Every landing verb shares this one judgment, so
1240/// a stage, an apply, an upgrade, and an adoption all withhold
1241/// identically.
1242#[must_use]
1243pub fn nix_withholding(
1244    nix: bool,
1245    evidence: &TargetEvidence,
1246) -> Option<(&'static [&'static str], String)> {
1247    if !nix {
1248        return None;
1249    }
1250    if let Some(reason) = nix_unsupported_shape(&evidence.crate_shape) {
1251        return Some((&NIX_DESTINATIONS[..], reason));
1252    }
1253    flake_pair_withheld(
1254        evidence.flake_recorded,
1255        evidence.flake_nix_present,
1256        evidence.flake_lock_present,
1257    )
1258    .map(|reason| (&NIX_WITHHOLDABLE[..], reason))
1259}
1260
1261#[cfg(test)]
1262mod tests {
1263    use super::{
1264        AGENTS_DESTINATION, BLOCK_BEGIN, BLOCK_DESTINATIONS, BLOCK_END, Candidate, Collision,
1265        CrateShape, GLOSSARY_DESTINATION, HOOK_TYPES_LINE, HOOKS_BEGIN, HOOKS_DESTINATION,
1266        HOOKS_END, Placement, Projection, ProjectionInput, TargetEvidence, extract_block,
1267        select_pair,
1268    };
1269    use crate::landing::{Params, Style};
1270
1271    /// A supported single-crate shape, so nothing is withheld.
1272    fn supported_shape() -> CrateShape {
1273        CrateShape {
1274            cargo_toml: Some("[package]\nname = \"widget\"\nversion = \"0.1.0\"\n".to_owned()),
1275            cargo_lock: true,
1276            main_rs: true,
1277        }
1278    }
1279
1280    fn input(evidence: TargetEvidence) -> ProjectionInput {
1281        let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
1282        params.set_nix_for_test(true);
1283        ProjectionInput { params, evidence }
1284    }
1285
1286    fn compute(evidence: TargetEvidence) -> Projection {
1287        Projection::compute(&input(evidence)).expect("the embedded pair projects")
1288    }
1289
1290    fn candidate<'a>(projection: &'a Projection, destination: &str) -> &'a Candidate {
1291        projection
1292            .candidates
1293            .iter()
1294            .find(|candidate| candidate.destination == destination)
1295            .expect("the destination projects")
1296    }
1297
1298    /// The bytes of a document outside its one marked region.
1299    fn outside(bytes: &[u8], begin: &str, end: &str) -> (Vec<u8>, Vec<u8>) {
1300        let text = String::from_utf8_lossy(bytes);
1301        let start = text.find(begin).expect("the begin marker is present");
1302        let stop = text[start..].find(end).expect("the end marker is present") + start + end.len();
1303        (bytes[..start].to_vec(), bytes[stop..].to_vec())
1304    }
1305
1306    #[test]
1307    fn equal_projection_inputs_yield_byte_identical_projections() {
1308        let mut documents = std::collections::BTreeMap::new();
1309        documents.insert(
1310            AGENTS_DESTINATION.to_owned(),
1311            b"# Widget\n\nOwn rules.\n".to_vec(),
1312        );
1313        let evidence = TargetEvidence {
1314            documents,
1315            crate_shape: supported_shape(),
1316            ..TargetEvidence::default()
1317        };
1318        let first = input(evidence.clone());
1319        let second = input(evidence);
1320        assert_eq!(first, second, "the inputs are values and compare equal");
1321        let a = Projection::compute(&first).expect("the pair projects");
1322        let b = Projection::compute(&second).expect("the pair projects");
1323        assert_eq!(a.candidates.len(), b.candidates.len());
1324        for (x, y) in a.candidates.iter().zip(&b.candidates) {
1325            assert_eq!(x.destination, y.destination);
1326            assert_eq!(x.kind, y.kind);
1327            assert_eq!(x.placement, y.placement);
1328            assert_eq!(x.bytes, y.bytes, "{}", x.destination);
1329            assert_eq!(x.region, y.region, "{}", x.destination);
1330            assert_eq!(x.sources, y.sources, "{}", x.destination);
1331        }
1332        assert_eq!(a, b);
1333        let destinations: Vec<&str> = a
1334            .candidates
1335            .iter()
1336            .map(|candidate| candidate.destination.as_str())
1337            .collect();
1338        let mut sorted = destinations.clone();
1339        sorted.sort_unstable();
1340        assert_eq!(destinations, sorted, "candidates sort by destination");
1341        assert!(a.omissions.is_empty(), "{:?}", a.omissions);
1342        assert!(a.collisions.is_empty(), "{:?}", a.collisions);
1343    }
1344
1345    /// The pure boundary, held by a source scan over this file's
1346    /// production code: everything above the first `#[cfg(test)]`, with
1347    /// comment lines skipped. The evidence gathering that reads a target
1348    /// lives in `src/projection/evidence.rs`, which this scan does not
1349    /// cover on purpose.
1350    #[test]
1351    fn the_projection_performs_no_filesystem_git_environment_clock_registry_or_network_read() {
1352        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/projection.rs");
1353        let text = std::fs::read_to_string(&path).expect("the source reads");
1354        let production = text.split("#[cfg(test)]").next().unwrap_or("");
1355        let needles = [
1356            "std::fs",
1357            "std::env",
1358            "std::process",
1359            "std::time",
1360            "SystemTime",
1361            "Instant",
1362            "std::net",
1363            "Command::new",
1364            "registry::",
1365            "curl",
1366            "reqwest",
1367            "blob(",
1368        ];
1369        let mut hits = Vec::new();
1370        for (index, line) in production.lines().enumerate() {
1371            if line.trim_start().starts_with("//") {
1372                continue;
1373            }
1374            for needle in needles {
1375                if line.contains(needle) {
1376                    hits.push(format!("src/projection.rs:{}: {needle}", index + 1));
1377                }
1378            }
1379        }
1380        assert!(
1381            hits.is_empty(),
1382            "the projection reads beyond its inputs: {hits:?}"
1383        );
1384    }
1385
1386    #[test]
1387    #[allow(
1388        clippy::too_many_lines,
1389        reason = "one test walks the three marked destinations and the three unmarked shapes"
1390    )]
1391    fn marked_region_projection_preserves_every_target_byte_outside_the_markers() {
1392        let agents_before = "# Widget\n\nOperator prose above.\n\n";
1393        let agents_after = "\n\n## Our rules\n\nOperator prose below.   \n";
1394        let glossary_before = "# Glossary\n\n- `spike` is a throwaway branch.\n\n";
1395        let glossary_after = "\n\n## More terms\n\n- `own` is ours.";
1396        let hooks_before = "default_install_hook_types: [pre-commit]\n\nrepos:\n";
1397        let hooks_after =
1398            "\n  - repo: https://example.com/own\n    rev: v1\n    hooks:\n      - id: own\n";
1399        let stale = |begin: &str, end: &str| format!("{begin}\nstale block\n{end}");
1400        let mut documents = std::collections::BTreeMap::new();
1401        documents.insert(
1402            AGENTS_DESTINATION.to_owned(),
1403            format!(
1404                "{agents_before}{}{agents_after}",
1405                stale(BLOCK_BEGIN, BLOCK_END)
1406            )
1407            .into_bytes(),
1408        );
1409        documents.insert(
1410            GLOSSARY_DESTINATION.to_owned(),
1411            format!(
1412                "{glossary_before}{}{glossary_after}",
1413                stale(BLOCK_BEGIN, BLOCK_END)
1414            )
1415            .into_bytes(),
1416        );
1417        documents.insert(
1418            HOOKS_DESTINATION.to_owned(),
1419            format!(
1420                "{hooks_before}{}{hooks_after}",
1421                stale(HOOKS_BEGIN, HOOKS_END)
1422            )
1423            .into_bytes(),
1424        );
1425        let projection = compute(TargetEvidence {
1426            documents: documents.clone(),
1427            crate_shape: supported_shape(),
1428            ..TargetEvidence::default()
1429        });
1430        assert!(
1431            projection.collisions.is_empty(),
1432            "{:?}",
1433            projection.collisions
1434        );
1435        for (destination, before, after) in [
1436            (AGENTS_DESTINATION, agents_before, agents_after),
1437            (GLOSSARY_DESTINATION, glossary_before, glossary_after),
1438            (HOOKS_DESTINATION, hooks_before, hooks_after),
1439        ] {
1440            let candidate = candidate(&projection, destination);
1441            let Placement::Region { begin, end } = candidate.placement else {
1442                panic!("{destination} is a region");
1443            };
1444            let region = candidate
1445                .region
1446                .as_deref()
1447                .expect("a region carries its block");
1448            let (head, tail) = outside(&candidate.bytes, begin, end);
1449            assert_eq!(
1450                head,
1451                before.as_bytes(),
1452                "{destination}: bytes before the markers"
1453            );
1454            assert_eq!(
1455                tail,
1456                after.as_bytes(),
1457                "{destination}: bytes after the markers"
1458            );
1459            let inside = &candidate.bytes[head.len()..candidate.bytes.len() - tail.len()];
1460            assert_eq!(
1461                inside, region,
1462                "{destination}: the region is the rendered block"
1463            );
1464            let (existing_head, existing_tail) = outside(&documents[destination], begin, end);
1465            assert_eq!(head, existing_head);
1466            assert_eq!(tail, existing_tail);
1467        }
1468
1469        // Unmarked documents: the hook block lands under the owning key,
1470        // the routing block appends, and an absent file yields a fresh
1471        // document.
1472        let own_hooks =
1473            "repos:\n  - repo: https://example.com/own\n    rev: v1\n    hooks:\n      - id: own\n";
1474        let own_agents = "# Widget\n\nOwn rules.";
1475        let mut documents = std::collections::BTreeMap::new();
1476        documents.insert(HOOKS_DESTINATION.to_owned(), own_hooks.as_bytes().to_vec());
1477        documents.insert(
1478            AGENTS_DESTINATION.to_owned(),
1479            own_agents.as_bytes().to_vec(),
1480        );
1481        let projection = compute(TargetEvidence {
1482            documents,
1483            crate_shape: supported_shape(),
1484            ..TargetEvidence::default()
1485        });
1486        assert!(
1487            projection.collisions.is_empty(),
1488            "{:?}",
1489            projection.collisions
1490        );
1491        let hooks = candidate(&projection, HOOKS_DESTINATION);
1492        let hooks_text = String::from_utf8_lossy(&hooks.bytes);
1493        let region = String::from_utf8_lossy(hooks.region.as_deref().expect("a region"));
1494        assert!(
1495            hooks_text.starts_with(&format!(
1496                "repos:\n{region}\n  - repo: https://example.com/own"
1497            )),
1498            "{hooks_text}"
1499        );
1500        assert!(!hooks_text.contains(HOOK_TYPES_LINE));
1501        let agents = candidate(&projection, AGENTS_DESTINATION);
1502        assert!(agents.bytes.starts_with(own_agents.as_bytes()));
1503        assert_eq!(
1504            extract_block(
1505                &String::from_utf8_lossy(&agents.bytes),
1506                BLOCK_BEGIN,
1507                BLOCK_END
1508            )
1509            .map(str::as_bytes),
1510            agents.region.as_deref()
1511        );
1512        let glossary = candidate(&projection, GLOSSARY_DESTINATION);
1513        let region = glossary.region.as_deref().expect("a region");
1514        assert_eq!(
1515            glossary.bytes,
1516            [region, b"\n"].concat(),
1517            "an absent file is fresh"
1518        );
1519    }
1520
1521    /// A hook document that is not UTF-8 offers the block no place: the
1522    /// line-based splice would have to decode it, and a lossy decode
1523    /// rewrites a byte outside the markers. A valid document still
1524    /// splices, and the markdown destinations, spliced as bytes, take an
1525    /// invalid byte outside their markers unchanged.
1526    #[test]
1527    fn a_hook_document_that_is_not_utf8_collides_instead_of_being_rewritten() {
1528        let mut documents = std::collections::BTreeMap::new();
1529        let mut invalid = b"repos:\n# own \xff above\n".to_vec();
1530        invalid.extend_from_slice(format!("{HOOKS_BEGIN}\nstale\n{HOOKS_END}\n").as_bytes());
1531        invalid.extend_from_slice(b"  - repo: local \xff below\n");
1532        documents.insert(HOOKS_DESTINATION.to_owned(), invalid);
1533        let mut agents = b"# Widget r\xe9sum\xe9\n\n".to_vec();
1534        agents.extend_from_slice(format!("{BLOCK_BEGIN}\nstale\n{BLOCK_END}\n\n").as_bytes());
1535        agents.extend_from_slice(b"r\xe9sum\xe9\n");
1536        documents.insert(AGENTS_DESTINATION.to_owned(), agents.clone());
1537        let projection = compute(TargetEvidence {
1538            documents,
1539            crate_shape: supported_shape(),
1540            ..TargetEvidence::default()
1541        });
1542        let collided: Vec<&str> = projection
1543            .collisions
1544            .iter()
1545            .map(|c| c.destination.as_str())
1546            .collect();
1547        assert_eq!(collided, [HOOKS_DESTINATION]);
1548        assert!(
1549            projection.collisions[0].reason.contains("not UTF-8"),
1550            "{}",
1551            projection.collisions[0].reason
1552        );
1553        assert!(
1554            !projection
1555                .candidates
1556                .iter()
1557                .any(|c| c.destination == HOOKS_DESTINATION),
1558            "a colliding destination projects no candidate"
1559        );
1560        let agents = candidate(&projection, AGENTS_DESTINATION);
1561        assert!(
1562            agents.bytes.starts_with(b"# Widget r\xe9sum\xe9\n\n"),
1563            "{:?}",
1564            agents.bytes
1565        );
1566        assert!(
1567            agents.bytes.ends_with(b"\n\nr\xe9sum\xe9\n"),
1568            "{:?}",
1569            agents.bytes
1570        );
1571        assert!(
1572            !agents.bytes.contains(&0xEF),
1573            "a replacement character landed"
1574        );
1575
1576        let mut documents = std::collections::BTreeMap::new();
1577        let valid =
1578            format!("repos:\n# own above\n{HOOKS_BEGIN}\nstale\n{HOOKS_END}\n  - repo: local\n");
1579        documents.insert(HOOKS_DESTINATION.to_owned(), valid.into_bytes());
1580        let projection = compute(TargetEvidence {
1581            documents,
1582            crate_shape: supported_shape(),
1583            ..TargetEvidence::default()
1584        });
1585        assert!(
1586            projection.collisions.is_empty(),
1587            "{:?}",
1588            projection.collisions
1589        );
1590        let hooks = candidate(&projection, HOOKS_DESTINATION);
1591        let text = String::from_utf8(hooks.bytes.clone()).expect("a valid document stays text");
1592        assert!(text.starts_with("repos:\n# own above\n"), "{text}");
1593        assert!(text.ends_with("\n  - repo: local\n"), "{text}");
1594        assert!(!text.contains("stale"), "the region is replaced: {text}");
1595    }
1596
1597    /// A snippet that ships a block destination as a whole file is a
1598    /// source defect named by both sides: the snippet's source path and
1599    /// the block's template paths.
1600    #[test]
1601    fn a_whole_file_colliding_with_a_marked_region_names_both_source_paths() {
1602        let files: Vec<(String, &[u8])> = vec![
1603            ("snippets/_shared/github/SECURITY.md".to_owned(), b"policy"),
1604            ("snippets/rust/github/AGENTS.md".to_owned(), b"whole"),
1605        ];
1606        let err = Projection::compute_over(&files, &input(TargetEvidence::default()))
1607            .expect_err("a whole file at a block destination refuses");
1608        let text = err.to_string();
1609        assert!(text.contains("snippets/rust/github/AGENTS.md"), "{text}");
1610        assert!(text.contains(super::AGENTS_BLOCK), "{text}");
1611        assert!(text.contains(super::AGENTS_LINE_WORKTREE), "{text}");
1612        assert!(text.contains("embedded sources are defective"), "{text}");
1613    }
1614
1615    #[test]
1616    fn duplicate_whole_file_destinations_and_overlapping_marked_regions_refuse_with_the_conflicting_source_names()
1617     {
1618        let files: Vec<(String, &[u8])> = vec![
1619            ("snippets/_shared/github/SECURITY.md".to_owned(), b"shared"),
1620            ("snippets/rust/github/SECURITY.md".to_owned(), b"pair"),
1621            ("snippets/rust/github/release-plz.toml".to_owned(), b"seed"),
1622        ];
1623        let err = select_pair(&files, "rust", "github").expect_err("a doubled destination refuses");
1624        let text = err.to_string();
1625        assert!(
1626            text.contains("snippets/_shared/github/SECURITY.md"),
1627            "{text}"
1628        );
1629        assert!(text.contains("snippets/rust/github/SECURITY.md"), "{text}");
1630        assert!(text.contains("embedded sources are defective"), "{text}");
1631
1632        let clean: Vec<(String, &[u8])> = vec![
1633            ("snippets/_shared/github/SECURITY.md".to_owned(), b"shared"),
1634            ("snippets/rust/github/release-plz.toml".to_owned(), b"seed"),
1635        ];
1636        let selected = select_pair(&clean, "rust", "github").expect("a clean list selects");
1637        let destinations: Vec<&str> = selected.iter().map(|s| s.destination.as_str()).collect();
1638        assert_eq!(destinations, ["SECURITY.md", "release-plz.toml"]);
1639        assert_eq!(selected[0].source, "snippets/_shared/github/SECURITY.md");
1640        let err = select_pair(&clean, "_shared", "github").expect_err("the shared zone is no tech");
1641        assert!(!err.to_string().contains("bindings are: _shared"), "{err}");
1642        let err = select_pair(&clean, "rust", "gitlab").expect_err("an unshipped pair refuses");
1643        assert!(err.to_string().contains("rust, github"), "{err}");
1644
1645        let doubled = format!("{BLOCK_BEGIN}\na\n{BLOCK_END}\n{BLOCK_BEGIN}\nb\n{BLOCK_END}\n");
1646        let unmatched = format!("repos:\n{HOOKS_BEGIN}\n  - repo: local\n");
1647        let misordered = format!("# G\n{BLOCK_END}\n{BLOCK_BEGIN}\n");
1648        let mut documents = std::collections::BTreeMap::new();
1649        documents.insert(AGENTS_DESTINATION.to_owned(), doubled.into_bytes());
1650        documents.insert(HOOKS_DESTINATION.to_owned(), unmatched.into_bytes());
1651        documents.insert(GLOSSARY_DESTINATION.to_owned(), misordered.into_bytes());
1652        let projection = compute(TargetEvidence {
1653            documents,
1654            crate_shape: supported_shape(),
1655            ..TargetEvidence::default()
1656        });
1657        let mut collided: Vec<&str> = projection
1658            .collisions
1659            .iter()
1660            .map(|Collision { destination, .. }| destination.as_str())
1661            .collect();
1662        collided.sort_unstable();
1663        let mut expected = BLOCK_DESTINATIONS.to_vec();
1664        expected.sort_unstable();
1665        assert_eq!(collided, expected);
1666        for collision in &projection.collisions {
1667            assert!(
1668                collision.reason.contains(&collision.destination),
1669                "{collision:?}"
1670            );
1671            assert!(
1672                !projection
1673                    .candidates
1674                    .iter()
1675                    .any(|candidate| candidate.destination == collision.destination),
1676                "{} collided and still projects",
1677                collision.destination
1678            );
1679        }
1680        let agents = projection
1681            .collisions
1682            .iter()
1683            .find(|c| c.destination == AGENTS_DESTINATION)
1684            .expect("the doubled document collides");
1685        assert!(agents.reason.contains("more than one"), "{}", agents.reason);
1686        let hooks = projection
1687            .collisions
1688            .iter()
1689            .find(|c| c.destination == HOOKS_DESTINATION)
1690            .expect("the unmatched document collides");
1691        assert!(hooks.reason.contains("unmatched"), "{}", hooks.reason);
1692    }
1693}