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