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