Skip to main content

release_kit/
landing.rs

1//! The target-side landing model: file kinds, parameter rendering, and
2//! the routing block.
3//!
4//! Every landable file has a declared kind — `rendered` files release-kit
5//! owns and may rewrite, `seeded` files the target tunes, `state` files
6//! the release automation maintains — and a `rendered` file's bytes are a
7//! deterministic function of the payload plus the landing parameters, so
8//! a later command can compare what is on disk against what would be
9//! written. The kinds are declared here, beside the payload, never
10//! inferred at runtime; a test holds the table closed over every snippet.
11
12pub mod invariants;
13pub mod manifest;
14
15use camino::Utf8Path;
16use serde::{Deserialize, Serialize};
17
18pub use manifest::{Style, Workflow};
19
20use crate::diagnostic::{Diagnostic, Reason};
21use crate::error::RkError;
22use crate::{atomic, embedded};
23
24/// Who owns a landed file's bytes after landing.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "lowercase")]
27pub enum Kind {
28    /// release-kit owns it: a newer payload re-renders it, and a target
29    /// edit is a conflict.
30    Rendered,
31    /// The target owns it: a starting point the project tunes, reported
32    /// and never rewritten.
33    Seeded,
34    /// The release automation owns it: never written after the first
35    /// landing, never compared.
36    State,
37}
38
39impl Kind {
40    /// The wire and report form.
41    #[must_use]
42    pub const fn as_str(self) -> &'static str {
43        match self {
44            Self::Rendered => "rendered",
45            Self::Seeded => "seeded",
46            Self::State => "state",
47        }
48    }
49}
50
51/// The declared classification: every landable destination and its kind.
52/// The workflow and pipeline files carry the release automation and the
53/// OIDC permission, so release-kit owns them; the tool configurations are
54/// per-project judgment; the two state files are rewritten by the release
55/// automation itself.
56const KINDS: [(&str, Kind); 16] = [
57    (".github/workflows/release-plz.yml", Kind::Rendered),
58    (".github/workflows/release-please.yml", Kind::Rendered),
59    (".github/workflows/release.yml", Kind::Rendered),
60    (".github/workflows/pr-title.yml", Kind::Rendered),
61    (".gitlab-ci.yml", Kind::Rendered),
62    ("SECURITY.md", Kind::Rendered),
63    (".gitlab/ci/mr-title.yml", Kind::Rendered),
64    ("release-plz.toml", Kind::Seeded),
65    ("dist-workspace.toml", Kind::Seeded),
66    ("release-please-config.json", Kind::Seeded),
67    ("cliff.toml", Kind::Seeded),
68    ("nix/package.nix", Kind::Seeded),
69    ("flake.nix", Kind::Seeded),
70    (".release-please-manifest.json", Kind::State),
71    ("VERSION", Kind::State),
72    ("flake.lock", Kind::State),
73];
74
75/// The destinations of the opt-in Nix capability, present in a projection
76/// only where the landing's `nix` parameter is on.
77///
78/// The parameter is recorded, so `status`, `upgrade`, and `adopt` can
79/// reconstruct whether these files are supposed to exist: an absent file
80/// under `nix = false` is not wanted, never drifted.
81///
82/// The capability lands no workflow, on either forge, and each forge's
83/// reason is its own. On GitHub a job gates the merge only inside the
84/// workflow the required check needs, and that workflow is the target's
85/// own. On GitLab the merge check is the whole pipeline, and a target's
86/// jobs live in the child pipeline the rendered parent triggers, which the
87/// target owns. The bindings serve the job for both.
88pub const NIX_DESTINATIONS: [&str; 3] = ["nix/package.nix", "flake.nix", "flake.lock"];
89
90/// The subset a target with a flake of its own keeps out: the seed pair,
91/// whose files would sit beside a flake release-kit did not author.
92///
93/// The seeded package expression is not in it — it lands either way, as
94/// the starting point the target integrates by hand.
95pub const NIX_WITHHOLDABLE: [&str; 2] = ["flake.nix", "flake.lock"];
96
97/// The declared kind of a destination, or `None` for a file the payload
98/// does not classify.
99#[must_use]
100pub fn kind_of(destination: &str) -> Option<Kind> {
101    if destination == AGENTS_DESTINATION || destination == HOOKS_DESTINATION {
102        return Some(Kind::Rendered);
103    }
104    KINDS
105        .iter()
106        .find(|(name, _)| *name == destination)
107        .map(|(_, kind)| *kind)
108}
109
110/// Every destination the payload can land — the whole files and the two
111/// block destinations — in declaration order. The classification reads
112/// it to ask whether a destination is already present at a target.
113pub fn destinations() -> impl Iterator<Item = &'static str> {
114    KINDS
115        .iter()
116        .map(|(name, _)| *name)
117        .chain([AGENTS_DESTINATION, HOOKS_DESTINATION])
118}
119
120/// The mechanical substitution sites in `rendered` files.
121///
122/// Known values, substituted identically everywhere each appears. The
123/// owner is derived from the landing's `repo` parameter and the scope
124/// shape from [`SCOPE_SHAPE`], so the landed bytes stay a deterministic
125/// function of payload plus parameters.
126pub const OWNER_TOKEN: &[u8] = b"OWNER";
127
128/// The full recorded project path, including nested namespaces.
129pub const REPO_TOKEN: &[u8] = b"RK_REPO";
130
131/// The one scope shape: the title checks' regular expression.
132pub const SCOPE_SHAPE_TOKEN: &[u8] = b"RK_SCOPE_SHAPE";
133
134/// The recorded release style: `trunk` arms the bot's request in the
135/// landed release workflow, `lines` leaves every request unarmed.
136pub const STYLE_TOKEN: &[u8] = b"RK_STYLE";
137
138/// Substitute the landing parameters into a `rendered` file's bytes.
139///
140/// The repository's owner — the project path's first segment — replaces
141/// every `OWNER` occurrence; the full path replaces `RK_REPO` last.
142/// The one scope shape replaces the scope
143/// token, and the recorded style replaces the style token. The scope
144/// shape rests on no parameter, so it substitutes always. An unresolved
145/// style leaves its token standing, which only a preview renders under:
146/// an apply refuses before reaching here.
147#[must_use]
148pub fn render(baseline: &[u8], repo: &str, style: Option<Style>) -> Vec<u8> {
149    let owner = repo.split('/').next().unwrap_or(repo);
150    let mut out = substitute(baseline, OWNER_TOKEN, owner.as_bytes());
151    if let Some(style) = style {
152        out = substitute(&out, STYLE_TOKEN, style.as_str().as_bytes());
153    }
154    out = substitute(&out, SCOPE_SHAPE_TOKEN, SCOPE_SHAPE.as_bytes());
155    substitute(&out, REPO_TOKEN, repo.as_bytes())
156}
157
158/// Every `token` occurrence replaced with `value`.
159fn substitute(baseline: &[u8], token: &[u8], value: &[u8]) -> Vec<u8> {
160    let mut out = Vec::with_capacity(baseline.len());
161    let mut rest = baseline;
162    while let Some(at) = find(rest, token) {
163        out.extend_from_slice(&rest[..at]);
164        out.extend_from_slice(value);
165        rest = &rest[at + token.len()..];
166    }
167    out.extend_from_slice(rest);
168    out
169}
170
171/// First occurrence of `needle` in `haystack`.
172fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
173    haystack
174        .windows(needle.len())
175        .position(|window| window == needle)
176}
177
178/// The destination the routing block splices into.
179pub const AGENTS_DESTINATION: &str = "AGENTS.md";
180
181/// The block's opening marker.
182pub const BLOCK_BEGIN: &str = "<!-- BEGIN release-kit -->";
183
184/// The block's closing marker.
185pub const BLOCK_END: &str = "<!-- END release-kit -->";
186
187/// The destination the hook block splices into.
188pub const HOOKS_DESTINATION: &str = ".pre-commit-config.yaml";
189
190/// The hook block's opening marker, a YAML comment at column zero.
191pub const HOOKS_BEGIN: &str = "# BEGIN release-kit";
192
193/// The hook block's closing marker.
194pub const HOOKS_END: &str = "# END release-kit";
195
196/// The top-level key the fresh hook file carries and the skills verify on
197/// an existing one: the commit-msg and pre-push hooks run only where their
198/// hook types are installed.
199pub const HOOK_TYPES_LINE: &str = "default_install_hook_types: [pre-commit, commit-msg, pre-push]";
200
201/// The authored routing-block template, `blocks/agents-block.md.in`.
202static AGENTS_BLOCK: &str = include_str!("../blocks/agents-block.md.in");
203
204/// The routing block's mode line, worktree form.
205static AGENTS_LINE_WORKTREE: &str = include_str!("../blocks/agents-line-worktree.md.in");
206
207/// The routing block's mode line, branches form.
208static AGENTS_LINE_BRANCHES: &str = include_str!("../blocks/agents-line-branches.md.in");
209
210/// The authored hook-block template, `blocks/pre-commit-block.yaml.in`.
211static PRE_COMMIT_BLOCK: &str = include_str!("../blocks/pre-commit-block.yaml.in");
212
213/// The worktree mode's guard entry, `blocks/pre-commit-worktree-guard.yaml.in`.
214static PRE_COMMIT_WORKTREE_GUARD: &str =
215    include_str!("../blocks/pre-commit-worktree-guard.yaml.in");
216
217/// An authored block without the one final newline the repository's
218/// hooks enforce on every file under `blocks/`; a test in
219/// `src/embedded.rs` holds each file to exactly one.
220fn authored(text: &str) -> &str {
221    text.strip_suffix('\n').unwrap_or(text)
222}
223
224/// The one branch grammar.
225///
226/// The extended regular expression the landed
227/// `rk-branch-name` hook tests, and the same anchored language
228/// `rk worktree add` validates before creating anything. One owner by
229/// token — `concat!` cannot interpolate a const, so [`hooks_block`]
230/// substitutes it for the template's `RK_BRANCH_GRAMMAR` token.
231pub 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[-/].+)$";
232
233/// The one commit scope shape.
234///
235/// A bracket expression, lowercase, admitting the digits and `_ . / -`
236/// beside the letters, so `area/subarea` reads as one scope. It holds the
237/// shape of a scope and never its vocabulary: the word itself is the
238/// author's, guided by the routing block and by the repository's own
239/// history. One owner by token — the title checks take it as
240/// `RK_SCOPE_SHAPE` through [`render`], and `rk message --check` reads it
241/// directly, so the desk and the forge judge one language.
242pub const SCOPE_SHAPE: &str = "[a-z0-9._/-]+";
243
244/// Whether one scope matches [`SCOPE_SHAPE`].
245///
246/// The predicate and the pattern are one owner, so the desk's judgment
247/// cannot drift from the forge's: `rk message --check` calls this, the
248/// title checks render the pattern, and a test holds the two equal over
249/// every ASCII character.
250#[must_use]
251pub fn scope_is_shaped(scope: &str) -> bool {
252    !scope.is_empty()
253        && scope.chars().all(|c| {
254            c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '/' | '-')
255        })
256}
257
258/// The routing block for one workflow mode: the whole of target-side
259/// governance, authored as `blocks/agents-block.md.in` and never grown
260/// into a method chapter.
261///
262/// Markers included, without a
263/// trailing newline and with its scope token unrendered: the template
264/// with the mode's one orientation line substituted, everything else —
265/// the agent-boundary line included — byte-identical across modes.
266#[must_use]
267pub fn routing_block(workflow: Workflow) -> String {
268    let line = match workflow {
269        Workflow::Worktree => authored(AGENTS_LINE_WORKTREE),
270        Workflow::Branches => authored(AGENTS_LINE_BRANCHES),
271    };
272    authored(AGENTS_BLOCK).replacen("RK_WORKFLOW_LINE", line, 1)
273}
274
275/// The hook block for one workflow mode, authored as
276/// `blocks/pre-commit-block.yaml.in` with the worktree mode's guard entry
277/// beside it in `blocks/pre-commit-worktree-guard.yaml.in`.
278///
279/// Markers included, without a
280/// trailing newline and with its scope token unrendered. What is landed
281/// is what runs: the worktree mode's block carries the location guard and
282/// names the sweep-skip pair, and the branches mode's block carries no
283/// guard entry at all — never an entry that reads local state to decide
284/// whether to enforce. The one branch grammar substitutes here from
285/// [`BRANCH_GRAMMAR`].
286#[must_use]
287pub fn hooks_block(workflow: Workflow) -> String {
288    let (guard, skip) = match workflow {
289        Workflow::Worktree => (
290            format!("{}\n", authored(PRE_COMMIT_WORKTREE_GUARD)),
291            "no-commit-to-branch,rk-worktree-location",
292        ),
293        Workflow::Branches => (String::new(), "no-commit-to-branch"),
294    };
295    authored(PRE_COMMIT_BLOCK)
296        .replacen("RK_BRANCH_GRAMMAR", BRANCH_GRAMMAR, 1)
297        .replacen("RK_SWEEP_SKIP", skip, 1)
298        .replacen("RK_WORKTREE_GUARD", &guard, 1)
299}
300
301/// The markers of a block destination, or `None` for a whole-file one.
302#[must_use]
303pub fn block_markers(destination: &str) -> Option<(&'static str, &'static str)> {
304    match destination {
305        AGENTS_DESTINATION => Some((BLOCK_BEGIN, BLOCK_END)),
306        HOOKS_DESTINATION => Some((HOOKS_BEGIN, HOOKS_END)),
307        _ => None,
308    }
309}
310
311/// The marked block inside a document, markers included, or `None` where
312/// the text carries no complete block.
313#[must_use]
314pub fn extract_block<'a>(text: &'a str, begin: &str, end: &str) -> Option<&'a str> {
315    let start = text.find(begin)?;
316    let stop = text[start..].find(end)? + start + end.len();
317    Some(&text[start..stop])
318}
319
320/// The whole `AGENTS.md` content after splicing the rendered block.
321///
322/// A fresh file where none exists, the block replaced in place where one
323/// is marked, appended after the target's own content otherwise —
324/// release-kit owns the lines inside the markers, not the document.
325#[must_use]
326pub fn splice_agents_block(existing: Option<&str>, block: &str) -> String {
327    existing.map_or_else(
328        || format!("{block}\n"),
329        |text| {
330            extract_block(text, BLOCK_BEGIN, BLOCK_END).map_or_else(
331                || format!("{}\n\n{block}\n", text.trim_end()),
332                |found| text.replacen(found, block, 1),
333            )
334        },
335    )
336}
337
338/// The whole `.pre-commit-config.yaml` content after splicing the
339/// rendered hook block.
340///
341/// A fresh file carries the hook-types key, the `repos:` key, and the
342/// block; a marked file takes the block in place; an unmarked file takes
343/// it directly under its `repos:` line, above the target's own hooks. An
344/// unmarked file with no `repos:` line is refused by name — the block's
345/// entries are list items and have nowhere honest to go.
346///
347/// # Errors
348///
349/// The reason the block has no place, for the caller's refusal to carry.
350pub fn splice_hooks_block(existing: Option<&str>, block: &str) -> Result<String, String> {
351    let Some(text) = existing else {
352        return Ok(format!("{HOOK_TYPES_LINE}\n\nrepos:\n{block}\n"));
353    };
354    if let Some(defect) = hooks_marker_defect(text) {
355        return Err(defect);
356    }
357    if let Some(found) = extract_block(text, HOOKS_BEGIN, HOOKS_END) {
358        return Ok(text.replacen(found, block, 1));
359    }
360    let mut out = String::with_capacity(text.len() + block.len() + 1);
361    let mut placed = false;
362    for line in text.split_inclusive('\n') {
363        out.push_str(line);
364        if !placed && line.trim_end() == "repos:" {
365            if !out.ends_with('\n') {
366                out.push('\n');
367            }
368            out.push_str(block);
369            out.push('\n');
370            placed = true;
371        }
372    }
373    if placed {
374        Ok(out)
375    } else {
376        Err(format!(
377            "{HOOKS_DESTINATION} exists with no repos: line, so the hook block has nowhere to land"
378        ))
379    }
380}
381
382/// The one definition of an ill-formed hook file, shared by the splice
383/// and every reader that judges one.
384///
385/// The hooks between the markers execute, so ownership must be
386/// unambiguous: exactly one begin marker paired with exactly one end
387/// marker after it, or none of either. A second begin is a second block
388/// pre-commit would still run, and a marker without its pair — or an end
389/// before its begin — is a block whose extent nothing can state.
390#[must_use]
391pub fn hooks_marker_defect(text: &str) -> Option<String> {
392    let begins = text.matches(HOOKS_BEGIN).count();
393    let ends = text.matches(HOOKS_END).count();
394    if begins > 1 || ends > 1 {
395        return Some(format!(
396            "{HOOKS_DESTINATION} carries more than one release-kit marker pair; release-kit owns exactly one block"
397        ));
398    }
399    match (text.find(HOOKS_BEGIN), text.find(HOOKS_END)) {
400        (Some(begin), Some(end)) if end > begin => None,
401        (None, None) => None,
402        _ => Some(format!(
403            "{HOOKS_DESTINATION} carries an unmatched or misordered release-kit marker, so the block's extent is ambiguous"
404        )),
405    }
406}
407
408/// How a projected artifact occupies its destination.
409#[derive(Debug, Clone, Copy, PartialEq, Eq)]
410pub enum Placement {
411    /// The artifact is the whole file.
412    Whole,
413    /// The artifact is the marked block inside the target's `AGENTS.md`.
414    Block,
415}
416
417/// One artifact of the payload projection: what would land at one
418/// destination, with the payload bytes it was rendered from.
419#[derive(Debug)]
420pub struct Entry {
421    /// The destination, relative to the target root.
422    pub destination: String,
423    /// The declared kind.
424    pub kind: Kind,
425    /// Whole file, or the marked block.
426    pub placement: Placement,
427    /// The payload bytes before substitution — what `baseline_sha256`
428    /// digests.
429    pub baseline: Vec<u8>,
430    /// The bytes a landing writes: substituted for `rendered` files,
431    /// identical to the baseline otherwise.
432    pub rendered: Vec<u8>,
433}
434
435/// The landable files of one `(technology, forge)` pair, as
436/// `(destination, payload bytes)`.
437///
438/// # Errors
439///
440/// Returns [`RkError::Usage`] naming the known bindings for an unknown
441/// technology, and the supported pairs for a pair with no files.
442pub fn pair_files(tech: &str, forge: &str) -> Result<Vec<(String, &'static [u8])>, RkError> {
443    // The shared zone is not a technology: `_shared/<forge>` composes into
444    // every pair and never names one.
445    if tech.starts_with('_') || embedded::SNIPPETS.get_dir(tech).is_none() {
446        let known: Vec<String> = embedded::SNIPPETS
447            .dirs()
448            .map(|dir| dir.path().to_string_lossy().into_owned())
449            .filter(|name| !name.starts_with('_'))
450            .collect();
451        return Err(RkError::Usage(format!(
452            "unknown tech '{tech}'; the bindings are: {}",
453            known.join(", ")
454        )));
455    }
456    let pair = format!("{tech}/{forge}");
457    let pair_dir = embedded::SNIPPETS.get_dir(&pair).ok_or_else(|| {
458        let known: Vec<String> = embedded::SNIPPETS
459            .dirs()
460            .filter(|dir| !dir.path().to_string_lossy().starts_with('_'))
461            .flat_map(include_dir::Dir::dirs)
462            .map(|dir| dir.path().to_string_lossy().replace('/', ", "))
463            .collect();
464        RkError::Usage(format!(
465            "the pair ({tech}, {forge}) has no landable files; the supported pairs are: {}",
466            known.join("; ")
467        ))
468    })?;
469    // Payload paths carry their zone prefix; destinations do not. The
470    // shared zone lands first, and a destination both zones ship is a
471    // payload defect refused by name, never one zone silently winning.
472    let mut files: Vec<(String, &'static [u8])> = Vec::new();
473    let shared = format!("_shared/{forge}");
474    if let Some(shared_dir) = embedded::SNIPPETS.get_dir(&shared) {
475        for (path, contents) in embedded::walk(shared_dir) {
476            let rel = path
477                .strip_prefix(&format!("{shared}/"))
478                .map_or(path.as_str(), |rel| rel)
479                .to_owned();
480            files.push((rel, contents));
481        }
482    }
483    for (path, contents) in embedded::walk(pair_dir) {
484        let rel = path
485            .strip_prefix(&format!("{pair}/"))
486            .map_or(path.as_str(), |rel| rel)
487            .to_owned();
488        if files.iter().any(|(existing, _)| *existing == rel) {
489            return Err(anyhow::anyhow!(
490                "the shared zone and the pair ({tech}, {forge}) both ship {rel}; the payload is defective"
491            )
492            .into());
493        }
494        files.push((rel, contents));
495    }
496    Ok(files)
497}
498
499/// The whole payload projection for one pair.
500///
501/// Under the `repo`, `workflow`,
502/// `style`, and `nix` parameters: every snippet with its kind and
503/// rendered bytes, plus the routing block and the hook block — each a
504/// pure function of the recorded mode — sorted by destination. The Nix
505/// destinations project only where `nix` is on; a pair that ships none of
506/// them honestly projects the smaller product.
507///
508/// # Errors
509///
510/// Returns the [`pair_files`] errors, and [`RkError::Other`] for a
511/// snippet destination the kind table does not classify, which is a
512/// defect in this binary.
513pub fn projection(
514    tech: &str,
515    forge: &str,
516    repo: &str,
517    workflow: Workflow,
518    style: Option<Style>,
519    nix: bool,
520) -> Result<Vec<Entry>, RkError> {
521    let mut entries = Vec::new();
522    for (destination, baseline) in pair_files(tech, forge)? {
523        if !nix && NIX_DESTINATIONS.contains(&destination.as_str()) {
524            continue;
525        }
526        let kind = kind_of(&destination).ok_or_else(|| {
527            anyhow::anyhow!("the payload does not classify {destination}; the kind table is stale")
528        })?;
529        let rendered = match kind {
530            Kind::Rendered => render(baseline, repo, style),
531            Kind::Seeded | Kind::State => baseline.to_vec(),
532        };
533        entries.push(Entry {
534            destination,
535            kind,
536            placement: Placement::Whole,
537            baseline: baseline.to_vec(),
538            rendered,
539        });
540    }
541    for (destination, template) in [
542        (AGENTS_DESTINATION, routing_block(workflow)),
543        (HOOKS_DESTINATION, hooks_block(workflow)),
544    ] {
545        entries.push(Entry {
546            destination: destination.to_owned(),
547            kind: Kind::Rendered,
548            placement: Placement::Block,
549            baseline: template.as_bytes().to_vec(),
550            rendered: render(template.as_bytes(), repo, style),
551        });
552    }
553    entries.sort_by(|a, b| a.destination.cmp(&b.destination));
554    Ok(entries)
555}
556
557/// Why the whole Nix capability stays out of a landing, or `None` where
558/// the target's crate shape supports the seed.
559///
560/// The gate holds every structural prerequisite the seed relies on, not
561/// only evaluation: the package expression reads `Cargo.toml` through
562/// `importTOML` and throws without `../Cargo.lock`, and the seed flake's
563/// smoke check runs the crate's binary, which only an implicit
564/// `src/main.rs` or an explicit `[[bin]]` entry produces. A shape
565/// missing any of these would land files that fail on their first
566/// evaluation or first check, so the landing reports the smaller product
567/// with the missing piece named instead.
568#[must_use]
569pub fn nix_unsupported_shape(target: &Utf8Path) -> Option<String> {
570    let Ok(text) = std::fs::read_to_string(target.join("Cargo.toml")) else {
571        return Some(
572            "the target has no readable Cargo.toml, which the seeded package expression reads; no Nix file lands".to_owned(),
573        );
574    };
575    let Ok(table) = text.parse::<toml::Table>() else {
576        return Some(
577            "the target's Cargo.toml does not parse, and the seeded package expression reads it; no Nix file lands".to_owned(),
578        );
579    };
580    if !table.contains_key("package") {
581        return Some(
582            "the target's Cargo.toml has no [package] table; the seed supports a single crate, so no Nix file lands".to_owned(),
583        );
584    }
585    if !target.join("Cargo.lock").is_file() {
586        return Some(
587            "the target has no Cargo.lock, which the seeded package expression builds from; commit one, then opt in".to_owned(),
588        );
589    }
590    let implicit_bin = target.join("src/main.rs").is_file()
591        && table
592            .get("package")
593            .and_then(toml::Value::as_table)
594            .and_then(|package| package.get("autobins"))
595            .and_then(toml::Value::as_bool)
596            != Some(false);
597    let explicit_bins = table.get("bin").and_then(toml::Value::as_array);
598    if explicit_bins.is_none() && !implicit_bin {
599        return Some(
600            "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(),
601        );
602    }
603    // The seed's mainProgram is the first [[bin]] entry; one whose
604    // required-features a default build does not enable produces no
605    // executable, so the smoke check would fail on a green landing. A
606    // requirement the default feature set covers builds normally and
607    // passes.
608    if let Some(bins) = explicit_bins {
609        let required = bins
610            .first()
611            .and_then(toml::Value::as_table)
612            .and_then(|bin| bin.get("required-features"))
613            .and_then(toml::Value::as_array);
614        if let Some(required) = required {
615            let enabled = default_features(&table);
616            let missing = required
617                .iter()
618                .filter_map(toml::Value::as_str)
619                .any(|feature| !enabled.contains(feature));
620            if missing {
621                return Some(
622                    "the target's first [[bin]] entry requires features a default build does not enable; no Nix file lands".to_owned(),
623                );
624            }
625        }
626    }
627    None
628}
629
630/// Whether any feature's list carries a `dep:name` edge, which is what
631/// suppresses the optional dependency's implicit same-named feature.
632fn dep_edge_suppresses(features: &toml::Table, name: &str) -> bool {
633    let edge = format!("dep:{name}");
634    features.values().any(|list| {
635        list.as_array().is_some_and(|entries| {
636            entries
637                .iter()
638                .filter_map(toml::Value::as_str)
639                .any(|entry| entry == edge)
640        })
641    })
642}
643
644/// Whether `name` is declared an optional dependency, in any of the
645/// dependency tables a binary's build reads.
646fn is_optional_dependency(table: &toml::Table, name: &str) -> bool {
647    ["dependencies", "build-dependencies"]
648        .iter()
649        .any(|section| {
650            table
651                .get(*section)
652                .and_then(toml::Value::as_table)
653                .and_then(|dependencies| dependencies.get(name))
654                .and_then(toml::Value::as_table)
655                .and_then(|dependency| dependency.get("optional"))
656                .and_then(toml::Value::as_bool)
657                == Some(true)
658        })
659}
660
661/// The features a default build enables: the `default` feature resolved
662/// through the `[features]` table's own enables — an approximation of
663/// cargo's default resolution for the documented supported shapes, erring
664/// toward withholding where the semantics run deeper. Dependency forms —
665/// `dep:name`, weak `name?/feature` — are not feature names here and are
666/// skipped; the closure is bounded by the table's size.
667fn default_features(table: &toml::Table) -> std::collections::BTreeSet<String> {
668    let Some(features) = table.get("features").and_then(toml::Value::as_table) else {
669        return std::collections::BTreeSet::new();
670    };
671    let mut enabled = std::collections::BTreeSet::new();
672    let mut queue = vec!["default".to_owned()];
673    while let Some(name) = queue.pop() {
674        if !enabled.insert(name.clone()) {
675            continue;
676        }
677        if let Some(implies) = features.get(&name).and_then(toml::Value::as_array) {
678            for implied in implies.iter().filter_map(toml::Value::as_str) {
679                if implied.starts_with("dep:") || implied.contains("?/") {
680                    // `dep:name` enables the dependency without a feature
681                    // of this crate; a weak `name?/feature` edge enables
682                    // nothing by itself.
683                    continue;
684                }
685                if let Some((package, _)) = implied.split_once('/') {
686                    // A strong `name/feature` edge activates this crate's
687                    // same-named feature only for an optional dependency,
688                    // and only where that feature exists: declared
689                    // explicitly, or implicit and not suppressed by a
690                    // `dep:` edge anywhere in the table. A non-optional
691                    // dependency's edge enables a feature of the
692                    // dependency and nothing of this crate.
693                    let feature_exists =
694                        features.contains_key(package) || !dep_edge_suppresses(features, package);
695                    if is_optional_dependency(table, package) && feature_exists {
696                        queue.push(package.to_owned());
697                    }
698                } else {
699                    queue.push(implied.to_owned());
700                }
701            }
702        }
703    }
704    enabled
705}
706
707/// Why the flake half of the Nix capability stays out of this landing, or
708/// `None` where the pair lands whole.
709///
710/// The pair is all-or-nothing: a target that already carries a
711/// `flake.nix` or `flake.lock` of its own keeps its pair, because a seed
712/// lock beside a foreign flake describes the wrong input graph. A pair
713/// the record names is release-kit's own landing and is never withheld.
714///
715/// # Errors
716///
717/// Any read failure other than the files being absent.
718pub fn nix_withheld(
719    target: &Utf8Path,
720    recorded: Option<&manifest::Manifest>,
721) -> std::io::Result<Option<String>> {
722    if recorded.is_some_and(|record| record.file("flake.nix").is_some()) {
723        return Ok(None);
724    }
725    let mut present = Vec::new();
726    for name in ["flake.nix", "flake.lock"] {
727        match std::fs::symlink_metadata(target.join(name).as_std_path()) {
728            Ok(_) => present.push(name),
729            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
730            Err(e) => return Err(e),
731        }
732    }
733    if present.is_empty() {
734        return Ok(None);
735    }
736    Ok(Some(format!(
737        "the target already carries {}; its flake pair stays its own",
738        present.join(" and ")
739    )))
740}
741
742/// One destination a landing withholds, with why.
743#[derive(Debug, Serialize)]
744pub struct Withheld {
745    /// The destination that stays out.
746    pub path: String,
747    /// The reason, stated once per destination so a machine reader needs
748    /// no join.
749    pub reason: String,
750}
751
752/// Drop the Nix destinations this target cannot take from a projection,
753/// naming each with its reason.
754///
755/// The one judgment every landing verb shares, so a preview, an apply, an
756/// upgrade, and an adoption all withhold identically: an unsupported
757/// crate shape withholds the whole capability, and a flake pair of the
758/// target's own withholds the pair and the workflow while the seeded
759/// package expression still lands.
760///
761/// # Errors
762///
763/// Any read failure from the pair check other than absence.
764pub fn withhold_nix(
765    target: &Utf8Path,
766    nix: bool,
767    recorded: Option<&manifest::Manifest>,
768    entries: &mut Vec<Entry>,
769) -> Result<Vec<Withheld>, RkError> {
770    if !nix {
771        return Ok(Vec::new());
772    }
773    let (set, reason): (&[&str], String) = if let Some(reason) = nix_unsupported_shape(target) {
774        (&NIX_DESTINATIONS[..], reason)
775    } else if let Some(reason) = nix_withheld(target, recorded)? {
776        (&NIX_WITHHOLDABLE[..], reason)
777    } else {
778        return Ok(Vec::new());
779    };
780    let mut withheld = Vec::new();
781    entries.retain(|entry| {
782        if set.contains(&entry.destination.as_str()) {
783            withheld.push(Withheld {
784                path: entry.destination.clone(),
785                reason: reason.clone(),
786            });
787            false
788        } else {
789            true
790        }
791    });
792    Ok(withheld)
793}
794
795/// The bytes an entry's destination currently holds: the whole file, or
796/// the marked block extracted from the target's `AGENTS.md`. `None` means
797/// the file — or the block — is absent.
798///
799/// # Errors
800///
801/// Any read failure other than the file being absent.
802pub fn read_destination(target: &Utf8Path, entry: &Entry) -> std::io::Result<Option<Vec<u8>>> {
803    read_recorded(target, &entry.destination)
804}
805
806/// The bytes a recorded destination currently holds, by the placement
807/// its name implies.
808///
809/// The marked block for `AGENTS.md` and `.pre-commit-config.yaml`, the
810/// whole file otherwise. `None` means the file — or the block — is
811/// absent.
812///
813/// # Errors
814///
815/// Any read failure other than the file being absent.
816pub fn read_recorded(target: &Utf8Path, destination: &str) -> std::io::Result<Option<Vec<u8>>> {
817    let path = target.join(destination);
818    let bytes = match std::fs::read(&path) {
819        Ok(bytes) => bytes,
820        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
821        Err(e) => return Err(e),
822    };
823    if let Some((begin, end)) = block_markers(destination) {
824        let text = String::from_utf8_lossy(&bytes);
825        Ok(extract_block(&text, begin, end).map(|block| block.as_bytes().to_vec()))
826    } else {
827        Ok(Some(bytes))
828    }
829}
830
831/// What one detection pass resolved for a target-side verb, with the
832/// override flags applied.
833#[derive(Debug)]
834pub struct Resolved {
835    /// The forge whose payload applies.
836    pub forge: String,
837    /// The project path, where a flag or the remote names one.
838    pub repo: Option<String>,
839}
840
841/// Resolve forge and repository in one pass: the flags override, the
842/// `origin` remote answers otherwise.
843///
844/// An unrecognized host refuses rather than defaulting — landing one
845/// forge's files into the other forge's project is a half-configured
846/// repository that looks done.
847///
848/// # Errors
849///
850/// Returns [`RkError::Usage`] for an unknown `--forge` value, and a
851/// refusal naming the override when no forge resolves.
852pub fn resolve(
853    target: &Utf8Path,
854    forge_flag: Option<&str>,
855    repo_flag: Option<&str>,
856) -> Result<Resolved, RkError> {
857    let forge_flag = forge_flag
858        .map(|name| {
859            crate::detect::Forge::parse(name).ok_or_else(|| {
860                RkError::Usage(format!(
861                    "unknown forge '{name}'; the forges are: github, gitlab"
862                ))
863            })
864        })
865        .transpose()?;
866    let detected = crate::detect::detect(target.as_std_path());
867    let forge = forge_flag
868        .or(detected.forge)
869        .map(|forge| forge.as_str().to_owned())
870        .ok_or_else(|| {
871            let message = detected.host.map_or_else(
872                || "no forge detected: the target has no origin remote".to_owned(),
873                |host| format!("no forge detected: the host {host} is not recognized"),
874            );
875            RkError::refusal(
876                Diagnostic::new(Reason::ForgeUndetected, message)
877                    .expected("a github.com or gitlab remote, or --forge")
878                    .action("pass --forge <github|gitlab>"),
879            )
880        })?;
881    Ok(Resolved {
882        forge,
883        repo: repo_flag.map(str::to_owned).or(detected.repo),
884    })
885}
886
887/// The refusal a verb answers when it needs the `repo` parameter and
888/// neither a flag nor the remote supplies one.
889#[must_use]
890pub fn repo_unresolved() -> RkError {
891    RkError::missing(
892        Diagnostic::new(
893            Reason::ForgeUndetected,
894            "no repository detected: the target has no origin remote",
895        )
896        .expected("an origin remote naming the project")
897        .action("pass --repo <path>"),
898    )
899}
900
901/// Land one entry: the whole file through the temp-plus-rename writer, or
902/// the block spliced into its document and the whole document rewritten
903/// the same way.
904///
905/// # Errors
906///
907/// Any write failure; the destination then holds what it held. An
908/// unspliceable hook file surfaces as an error here only as a backstop —
909/// [`hooks_splice_refusal`] is the check a verb runs before any write.
910pub fn write_destination(target: &Utf8Path, entry: &Entry) -> std::io::Result<()> {
911    let path = target.join(&entry.destination);
912    match entry.placement {
913        Placement::Whole => atomic::write(path.as_std_path(), &entry.rendered),
914        Placement::Block => {
915            let existing = match std::fs::read(&path) {
916                Ok(bytes) => Some(String::from_utf8_lossy(&bytes).into_owned()),
917                Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
918                Err(e) => return Err(e),
919            };
920            let block = String::from_utf8_lossy(&entry.rendered).into_owned();
921            let spliced = if entry.destination == HOOKS_DESTINATION {
922                splice_hooks_block(existing.as_deref(), &block).map_err(std::io::Error::other)?
923            } else {
924                splice_agents_block(existing.as_deref(), &block)
925            };
926            atomic::write(path.as_std_path(), spliced.as_bytes())
927        }
928    }
929}
930
931/// The hook file's defect, read from the target: `None` for a missing
932/// file or one the block can land in.
933///
934/// The one judgment every verb shares, covering every splice refusal —
935/// ill-formed markers, and an unmarked file offering the block no
936/// `repos:` line. Status reports it as rendered drift, upgrade collects
937/// it as a conflict in preview and apply alike so no landing dies
938/// half-written, and adopt lists it with its mismatches.
939///
940/// # Errors
941///
942/// Any read failure other than the file being absent.
943pub fn hooks_file_defect(target: &Utf8Path) -> std::io::Result<Option<String>> {
944    let path = target.join(HOOKS_DESTINATION);
945    match std::fs::read(&path) {
946        Ok(bytes) => {
947            let text = String::from_utf8_lossy(&bytes);
948            Ok(splice_hooks_block(Some(&text), authored(PRE_COMMIT_BLOCK)).err())
949        }
950        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
951        Err(e) => Err(e),
952    }
953}
954
955/// The refusal a landing verb answers before writing anything, where
956/// the target's hook file offers the block no place.
957///
958/// Checked ahead of every write so the all-or-nothing property holds and
959/// no landing dies half-written into `.pre-commit-config.yaml`.
960///
961/// # Errors
962///
963/// [`RkError::Refusal`] naming the file, and any read failure.
964pub fn hooks_splice_refusal(target: &Utf8Path) -> Result<(), RkError> {
965    hooks_file_defect(target)?.map_or(Ok(()), |reason| {
966        Err(RkError::refusal(
967            Diagnostic::new(
968                Reason::StateDrift,
969                format!("{reason}, and nothing was written"),
970            )
971            .expected("a .pre-commit-config.yaml the block can land in, or none")
972            .action(format!(
973                "resolve it in {}, then re-run",
974                target.join(HOOKS_DESTINATION)
975            ))
976            .target_state("unchanged"),
977        ))
978    })
979}
980
981#[cfg(test)]
982mod tests {
983    #![allow(clippy::expect_used)]
984
985    use super::{
986        AGENTS_DESTINATION, BLOCK_BEGIN, BLOCK_END, BRANCH_GRAMMAR, HOOK_TYPES_LINE, HOOKS_BEGIN,
987        HOOKS_DESTINATION, HOOKS_END, Kind, SCOPE_SHAPE, Style, Workflow, extract_block,
988        hooks_block, kind_of, pair_files, projection, render, routing_block, splice_agents_block,
989        splice_hooks_block,
990    };
991    use crate::embedded;
992
993    #[test]
994    fn private_reporting_path_tokens_are_reproducible() {
995        for repo in [
996            "acme/widget",
997            "acme/group/widget",
998            "acme/OWNER-RK_STYLE-RK_SCOPE_SHAPE",
999        ] {
1000            assert_eq!(
1001                super::render(
1002                    b"RK_REPO RK_REPO OWNER RK_STYLE RK_SCOPE_SHAPE",
1003                    repo,
1004                    Some(super::Style::Trunk)
1005                ),
1006                format!("{repo} {repo} acme trunk {}", super::SCOPE_SHAPE).as_bytes()
1007            );
1008        }
1009        assert_eq!(super::kind_of("SECURITY.md"), Some(super::Kind::Rendered));
1010    }
1011
1012    /// Every snippet destination has a declared kind: a new landable file
1013    /// without a classification fails here, not at a landing. The shared
1014    /// zone's files are enumerated the same way.
1015    #[test]
1016    fn the_kind_table_closes_over_every_snippet() {
1017        for tech_dir in embedded::SNIPPETS.dirs() {
1018            for pair_dir in tech_dir.dirs() {
1019                let prefix = format!("{}/", pair_dir.path().to_string_lossy());
1020                for (path, _) in embedded::walk(pair_dir) {
1021                    let destination = path.strip_prefix(&prefix).unwrap_or(&path);
1022                    assert!(
1023                        kind_of(destination).is_some(),
1024                        "{destination}: no declared kind"
1025                    );
1026                }
1027            }
1028        }
1029        assert_eq!(kind_of(AGENTS_DESTINATION), Some(Kind::Rendered));
1030        assert_eq!(kind_of(HOOKS_DESTINATION), Some(Kind::Rendered));
1031        assert_eq!(kind_of("something-else.txt"), None);
1032    }
1033
1034    /// Substitution is total and derives from the repo parameter's first
1035    /// segment, so a nested GitLab project path still yields its root
1036    /// namespace. The scope shape rests on no parameter, so it renders
1037    /// under every landing.
1038    #[test]
1039    fn rendering_substitutes_every_owner_occurrence() {
1040        let baseline = b"if: repository_owner == 'OWNER'\n# OWNER again: OWNER\n";
1041        let rendered = render(baseline, "acme/sub/widget", None);
1042        let text = String::from_utf8(rendered).expect("rendered bytes stay text");
1043        assert_eq!(text, "if: repository_owner == 'acme'\n# acme again: acme\n");
1044
1045        let baseline = b"match (RK_SCOPE_SHAPE)\n";
1046        let rendered = render(baseline, "acme/widget", None);
1047        let text = String::from_utf8(rendered).expect("rendered bytes stay text");
1048        assert_eq!(text, format!("match ({SCOPE_SHAPE})\n"));
1049    }
1050
1051    /// The one scope shape is a bracket expression an extended regular
1052    /// expression takes verbatim: lowercase, and with the `-` last, where
1053    /// it stands for itself rather than opening a range.
1054    #[test]
1055    fn the_scope_shape_drops_into_the_title_check() {
1056        assert_eq!(SCOPE_SHAPE, "[a-z0-9._/-]+");
1057        assert!(
1058            !SCOPE_SHAPE.contains('\''),
1059            "the title checks single-quote it"
1060        );
1061    }
1062
1063    /// The predicate `rk message --check` calls and the pattern the title
1064    /// checks render admit exactly the same characters. The pattern is
1065    /// expanded here from its own text, so editing one owner without the
1066    /// other fails: the desk and the forge judge one language.
1067    #[test]
1068    fn the_scope_predicate_and_the_rendered_pattern_agree() {
1069        let body = SCOPE_SHAPE
1070            .strip_prefix('[')
1071            .and_then(|rest| rest.strip_suffix("]+"))
1072            .expect("the shape is one bracket expression, repeated");
1073        let chars: Vec<char> = body.chars().collect();
1074        let mut admitted = std::collections::BTreeSet::new();
1075        let mut at = 0;
1076        while at < chars.len() {
1077            // A `-` with a neighbour on each side opens a range; last, it
1078            // stands for itself, which is why the shape ends with it.
1079            if at + 2 < chars.len() && chars[at + 1] == '-' {
1080                for c in chars[at]..=chars[at + 2] {
1081                    admitted.insert(c);
1082                }
1083                at += 3;
1084            } else {
1085                admitted.insert(chars[at]);
1086                at += 1;
1087            }
1088        }
1089        for byte in 0..=127u8 {
1090            let c = char::from(byte);
1091            assert_eq!(
1092                super::scope_is_shaped(&c.to_string()),
1093                admitted.contains(&c),
1094                "the predicate and {SCOPE_SHAPE} disagree on {c:?}"
1095            );
1096        }
1097        assert!(super::scope_is_shaped("guides/release"));
1098        assert!(!super::scope_is_shaped(""), "a scope is never empty");
1099        assert!(!super::scope_is_shaped("Specs Ugly"));
1100    }
1101
1102    /// The shared zone composes into every pair, lands first, and is
1103    /// absent from the technology listing an unknown tech names.
1104    #[test]
1105    fn the_shared_zone_composes_into_the_pair() {
1106        let files = pair_files("rust", "github").expect("the pair lists");
1107        assert!(
1108            files
1109                .iter()
1110                .any(|(dest, _)| dest == ".github/workflows/pr-title.yml"),
1111            "the shared title check lands with the pair"
1112        );
1113        let files = pair_files("rust", "gitlab").expect("the pair lists");
1114        assert!(
1115            files
1116                .iter()
1117                .any(|(dest, _)| dest == ".gitlab/ci/mr-title.yml"),
1118            "the shared title job lands with the pair"
1119        );
1120        let err = pair_files("_shared", "github").expect_err("the shared zone is no tech");
1121        let listing = err.to_string();
1122        let bindings = listing
1123            .split("the bindings are:")
1124            .nth(1)
1125            .expect("the refusal lists the bindings");
1126        assert!(!bindings.contains("_shared"), "{listing}");
1127    }
1128
1129    /// A rendered projection carries no unsubstituted token and no
1130    /// mechanical sentinel; the one judgment sentinel stays in its seeded
1131    /// file.
1132    #[test]
1133    fn a_projection_renders_owned_files_and_keeps_seeded_judgment() {
1134        let entries = projection(
1135            "rust",
1136            "github",
1137            "acme/widget",
1138            Workflow::Branches,
1139            Some(Style::Trunk),
1140            false,
1141        )
1142        .expect("the pair projects");
1143        let workflow = entries
1144            .iter()
1145            .find(|entry| entry.destination.ends_with("release-plz.yml"))
1146            .expect("the workflow projects");
1147        assert_eq!(workflow.kind, Kind::Rendered);
1148        let text = String::from_utf8_lossy(&workflow.rendered);
1149        assert!(!text.contains("OWNER"), "an owner token survived rendering");
1150        assert!(text.contains("'acme'"));
1151        assert!(!text.contains("TODO(release-kit)"));
1152        let title = entries
1153            .iter()
1154            .find(|entry| entry.destination.ends_with("pr-title.yml"))
1155            .expect("the title check projects");
1156        let text = String::from_utf8_lossy(&title.rendered);
1157        assert!(text.contains(SCOPE_SHAPE), "{text}");
1158        assert!(
1159            !text.contains("RK_SCOPE_SHAPE"),
1160            "a scope token survived: {text}"
1161        );
1162        let seeded = entries
1163            .iter()
1164            .find(|entry| entry.destination == "release-plz.toml")
1165            .expect("the seeded file projects");
1166        assert_eq!(seeded.kind, Kind::Seeded);
1167        assert_eq!(seeded.rendered, seeded.baseline);
1168        assert!(String::from_utf8_lossy(&seeded.rendered).contains("TODO(release-kit)"));
1169        for block in [AGENTS_DESTINATION, HOOKS_DESTINATION] {
1170            let entry = entries
1171                .iter()
1172                .find(|entry| entry.destination == block)
1173                .expect("both blocks are part of the projection");
1174            let text = String::from_utf8_lossy(&entry.rendered);
1175            assert!(
1176                !text.contains("RK_SCOPE_SHAPE"),
1177                "{block} kept a token: {text}"
1178            );
1179        }
1180    }
1181
1182    /// The Nix destinations project only under the opt-in: off, none of
1183    /// them appears; on, the rust pairs carry them — the gitlab pair too,
1184    /// minus the workflow, which is a forge file the gitlab payload does
1185    /// not ship — and a pair without them projects the smaller product.
1186    #[test]
1187    fn the_nix_destinations_project_only_under_the_opt_in() {
1188        use super::NIX_DESTINATIONS;
1189        let paths = |nix: bool, forge: &str| -> Vec<String> {
1190            projection(
1191                "rust",
1192                forge,
1193                "acme/widget",
1194                Workflow::Worktree,
1195                Some(Style::Trunk),
1196                nix,
1197            )
1198            .expect("the pair projects")
1199            .into_iter()
1200            .map(|entry| entry.destination)
1201            .collect()
1202        };
1203        let off = paths(false, "github");
1204        for destination in NIX_DESTINATIONS {
1205            assert!(!off.contains(&destination.to_owned()), "{destination}");
1206        }
1207        let on = paths(true, "github");
1208        for destination in ["nix/package.nix", "flake.nix", "flake.lock"] {
1209            assert!(on.contains(&destination.to_owned()), "{destination}");
1210        }
1211        // The capability lands no workflow, so both forges land the same
1212        // set: a job proving the build holds a merge only inside the
1213        // workflow the required check needs, and that one is the
1214        // target's own.
1215        let gitlab = paths(true, "gitlab");
1216        assert!(gitlab.contains(&"nix/package.nix".to_owned()));
1217        assert!(
1218            !on.iter()
1219                .chain(gitlab.iter())
1220                .any(|destination| destination.contains("nix.yml"))
1221        );
1222        let bash = projection(
1223            "bash",
1224            "github",
1225            "acme/widget",
1226            Workflow::Worktree,
1227            Some(Style::Trunk),
1228            true,
1229        )
1230        .expect("an out-of-matrix pair projects the smaller product");
1231        assert!(
1232            bash.iter()
1233                .all(|entry| !NIX_DESTINATIONS.contains(&entry.destination.as_str()))
1234        );
1235    }
1236
1237    /// The github and gitlab copies of the forge-independent Nix payload
1238    /// stay byte-identical: the loader composes exactly two layers and has
1239    /// no technology-wide zone, so the duplication is deliberate and this
1240    /// parity test is what keeps it honest.
1241    #[test]
1242    fn the_nix_seeds_are_identical_across_forge_pairs() {
1243        for name in ["nix/package.nix", "flake.nix", "flake.lock"] {
1244            let github = embedded::SNIPPETS
1245                .get_file(format!("rust/github/{name}"))
1246                .expect("the github copy ships")
1247                .contents();
1248            let gitlab = embedded::SNIPPETS
1249                .get_file(format!("rust/gitlab/{name}"))
1250                .expect("the gitlab copy ships")
1251                .contents();
1252            assert_eq!(github, gitlab, "{name} diverged between the pairs");
1253        }
1254    }
1255
1256    /// The withhold judgment: a flake pair of the target's own withholds
1257    /// the pair and the workflow while the package expression lands, a
1258    /// crate shape the seed does not support withholds everything, and a
1259    /// clean single-crate target withholds nothing.
1260    #[test]
1261    fn the_nix_withhold_judgment_covers_the_three_shapes() {
1262        use super::{NIX_DESTINATIONS, withhold_nix};
1263        let dir = tempfile::tempdir().expect("a scratch target exists");
1264        let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
1265        let entries = || {
1266            projection(
1267                "rust",
1268                "github",
1269                "acme/widget",
1270                Workflow::Worktree,
1271                Some(Style::Trunk),
1272                true,
1273            )
1274            .expect("the pair projects")
1275        };
1276
1277        // No Cargo.toml: the whole capability is withheld by name.
1278        let mut all = entries();
1279        let withheld = withhold_nix(target, true, None, &mut all).expect("the judgment runs");
1280        let paths: Vec<&str> = withheld.iter().map(|w| w.path.as_str()).collect();
1281        assert_eq!(paths, ["flake.lock", "flake.nix", "nix/package.nix"]);
1282        assert!(
1283            all.iter()
1284                .all(|entry| !NIX_DESTINATIONS.contains(&entry.destination.as_str()))
1285        );
1286
1287        // A single crate with its own flake: the seed pair is withheld,
1288        // and the package expression still lands.
1289        std::fs::write(
1290            target.join("Cargo.toml"),
1291            "[package]\nname = \"widget\"\nversion = \"0.1.0\"\n",
1292        )
1293        .expect("the crate manifest writes");
1294        std::fs::write(target.join("Cargo.lock"), "version = 4\n").expect("the lock writes");
1295        std::fs::create_dir_all(target.join("src")).expect("the src dir exists");
1296        std::fs::write(target.join("src/main.rs"), "fn main() {}\n").expect("the main writes");
1297        std::fs::write(target.join("flake.nix"), "{ }\n").expect("the flake writes");
1298        let mut all = entries();
1299        let withheld = withhold_nix(target, true, None, &mut all).expect("the judgment runs");
1300        let paths: Vec<&str> = withheld.iter().map(|w| w.path.as_str()).collect();
1301        assert_eq!(paths, ["flake.lock", "flake.nix"]);
1302        assert!(
1303            all.iter()
1304                .any(|entry| entry.destination == "nix/package.nix")
1305        );
1306
1307        // A clean single crate: nothing is withheld.
1308        std::fs::remove_file(target.join("flake.nix")).expect("the flake removes");
1309        let mut all = entries();
1310        let withheld = withhold_nix(target, true, None, &mut all).expect("the judgment runs");
1311        assert!(withheld.is_empty());
1312        assert!(all.iter().any(|entry| entry.destination == "flake.nix"));
1313
1314        // Off, the judgment does not even look.
1315        let mut all = entries();
1316        let withheld = withhold_nix(target, false, None, &mut all).expect("the judgment runs");
1317        assert!(withheld.is_empty());
1318    }
1319
1320    #[test]
1321    fn the_block_splices_into_every_agents_shape() {
1322        let owned = routing_block(Workflow::Branches);
1323        let block = owned.as_str();
1324        let fresh = splice_agents_block(None, block);
1325        assert_eq!(fresh, format!("{block}\n"));
1326        assert_eq!(extract_block(&fresh, BLOCK_BEGIN, BLOCK_END), Some(block));
1327
1328        let appended = splice_agents_block(Some("# My project\n\nOwn rules.\n"), block);
1329        assert!(appended.starts_with("# My project\n\nOwn rules.\n\n<!-- BEGIN release-kit -->"));
1330        assert_eq!(
1331            extract_block(&appended, BLOCK_BEGIN, BLOCK_END),
1332            Some(block)
1333        );
1334
1335        let stale = appended.replace("Never author a tag", "Do author a tag");
1336        let refreshed = splice_agents_block(Some(&stale), block);
1337        assert_eq!(
1338            extract_block(&refreshed, BLOCK_BEGIN, BLOCK_END),
1339            Some(block)
1340        );
1341        assert!(refreshed.starts_with("# My project"));
1342        assert_eq!(
1343            refreshed.matches("BEGIN release-kit").count(),
1344            1,
1345            "a re-splice must replace, not accumulate"
1346        );
1347    }
1348
1349    /// The hook block lands under `repos:` in every honest shape and
1350    /// refuses the one dishonest shape by name.
1351    #[test]
1352    fn the_hook_block_splices_under_repos() {
1353        let owned = hooks_block(Workflow::Branches);
1354        let block = owned.as_str();
1355        let fresh = splice_hooks_block(None, block).expect("a fresh file splices");
1356        assert!(fresh.starts_with(HOOK_TYPES_LINE));
1357        assert!(fresh.contains("\nrepos:\n# BEGIN release-kit\n"));
1358        assert_eq!(extract_block(&fresh, HOOKS_BEGIN, HOOKS_END), Some(block));
1359
1360        let own =
1361            "repos:\n  - repo: https://example.com/own\n    rev: v1\n    hooks:\n      - id: own\n";
1362        let spliced = splice_hooks_block(Some(own), block).expect("an unmarked file splices");
1363        assert!(spliced.starts_with("repos:\n# BEGIN release-kit\n"));
1364        assert!(spliced.contains("- id: own"), "the target's hooks survive");
1365        assert!(
1366            !spliced.contains(HOOK_TYPES_LINE),
1367            "an existing file's top level is the skills' duty, not the splice's"
1368        );
1369
1370        let stale = spliced.replace("--force-scope", "--no-scope");
1371        let refreshed = splice_hooks_block(Some(&stale), block).expect("a marked file re-splices");
1372        assert_eq!(
1373            extract_block(&refreshed, HOOKS_BEGIN, HOOKS_END),
1374            Some(block)
1375        );
1376        assert_eq!(refreshed.matches(HOOKS_BEGIN).count(), 1);
1377
1378        let err = splice_hooks_block(Some("minimum_pre_commit_version: '3.2.0'\n"), block)
1379            .expect_err("no repos: line refuses");
1380        assert!(err.contains("repos:"), "{err}");
1381
1382        // The hooks between the markers execute, so ownership is exactly
1383        // one well-formed block: a duplicate or an unmatched marker
1384        // refuses rather than leaving a stale block active.
1385        let doubled = format!("repos:\n{block}\n{block}\n");
1386        let err = splice_hooks_block(Some(&doubled), block).expect_err("a second block refuses");
1387        assert!(err.contains("one block"), "{err}");
1388        let unmatched = "repos:\n# BEGIN release-kit\n  - repo: local\n";
1389        let err =
1390            splice_hooks_block(Some(unmatched), block).expect_err("an unmatched marker refuses");
1391        assert!(err.contains("unmatched"), "{err}");
1392    }
1393
1394    /// Both modes of both blocks: the guard entry and the skip pair exist
1395    /// exactly in the worktree mode, one orientation line differs in the
1396    /// routing block, the rest is byte-identical, no mode token survives
1397    /// substitution, and the rendered grammar is [`BRANCH_GRAMMAR`], the
1398    /// one owner.
1399    #[test]
1400    fn the_blocks_render_per_mode_and_carry_the_one_grammar() {
1401        let worktree_hooks = hooks_block(Workflow::Worktree);
1402        let branches_hooks = hooks_block(Workflow::Branches);
1403        assert!(worktree_hooks.contains("- id: rk-worktree-location"));
1404        assert!(
1405            worktree_hooks.contains("SKIP=no-commit-to-branch,rk-worktree-location"),
1406            "{worktree_hooks}"
1407        );
1408        assert!(!branches_hooks.contains("rk-worktree-location"));
1409        assert!(branches_hooks.contains("SKIP=no-commit-to-branch in"));
1410        for block in [&worktree_hooks, &branches_hooks] {
1411            assert!(block.contains(BRANCH_GRAMMAR), "the grammar has one owner");
1412            for token in ["RK_BRANCH_GRAMMAR", "RK_SWEEP_SKIP", "RK_WORKTREE_GUARD"] {
1413                assert!(!block.contains(token), "{token} survived: {block}");
1414            }
1415        }
1416        // A hook entry renders as a YAML plain scalar, where a colon
1417        // followed by a space ends the scalar and breaks the whole file
1418        // — the defect dogfood caught in the guard's refusal messages —
1419        // so no entry value may carry one.
1420        for block in [&worktree_hooks, &branches_hooks] {
1421            for line in block.lines() {
1422                if let Some(value) = line.trim_start().strip_prefix("entry: ") {
1423                    assert!(
1424                        !value.contains(": "),
1425                        "an entry value breaks the YAML plain scalar: {line}"
1426                    );
1427                }
1428            }
1429        }
1430        let guard_line = worktree_hooks
1431            .lines()
1432            .position(|line| line.contains("id: rk-worktree-location"))
1433            .expect("the guard entry exists");
1434        let name_line = worktree_hooks
1435            .lines()
1436            .position(|line| line.contains("id: rk-branch-name"))
1437            .expect("the name hook exists");
1438        assert!(
1439            guard_line > name_line,
1440            "the guard lands directly after rk-branch-name"
1441        );
1442
1443        let worktree_routing = routing_block(Workflow::Worktree);
1444        let branches_routing = routing_block(Workflow::Branches);
1445        assert!(worktree_routing.contains("This project works in worktrees"));
1446        assert!(branches_routing.contains("Branches are worked in the main checkout"));
1447        for block in [&worktree_routing, &branches_routing] {
1448            assert!(block.contains("Create or remove a worktree"));
1449            assert!(block.contains("`rk worktree add <branch>`"));
1450            assert!(!block.contains("RK_WORKFLOW_LINE"), "{block}");
1451        }
1452        let differing: Vec<(&str, &str)> = worktree_routing
1453            .lines()
1454            .zip(branches_routing.lines())
1455            .filter(|(a, b)| a != b)
1456            .collect();
1457        assert_eq!(
1458            differing.len(),
1459            1,
1460            "exactly one routing line differs per mode: {differing:?}"
1461        );
1462    }
1463
1464    /// One definition of an ill-formed hook file, for every reader: the
1465    /// well-formed shapes pass and each ambiguous shape names a defect.
1466    #[test]
1467    fn the_hook_marker_defects_are_named() {
1468        use super::hooks_marker_defect;
1469        let owned = hooks_block(Workflow::Branches);
1470        let block = owned.as_str();
1471        assert_eq!(hooks_marker_defect(""), None);
1472        assert_eq!(hooks_marker_defect(&format!("repos:\n{block}\n")), None);
1473        for (case, text) in [
1474            (
1475                "a second begin",
1476                format!("repos:\n{block}\n# BEGIN release-kit\n"),
1477            ),
1478            (
1479                "a second end",
1480                format!("repos:\n{block}\n# END release-kit\n"),
1481            ),
1482            (
1483                "an unpaired begin",
1484                "repos:\n# BEGIN release-kit\n".to_owned(),
1485            ),
1486            ("an unpaired end", "repos:\n# END release-kit\n".to_owned()),
1487            (
1488                "an end before its begin",
1489                "repos:\n# END release-kit\n# BEGIN release-kit\n".to_owned(),
1490            ),
1491        ] {
1492            assert!(
1493                hooks_marker_defect(&text).is_some(),
1494                "{case} must be a defect"
1495            );
1496        }
1497    }
1498}