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