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