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