Skip to main content

release_kit/
landing.rs

1//! The target-side landing model: file kinds, parameter rendering, and
2//! the routing block.
3//!
4//! Every landable file has a declared kind — `rendered` files release-kit
5//! owns and may rewrite, `seeded` files the target tunes, `state` files
6//! the release automation maintains — and a `rendered` file's bytes are a
7//! deterministic function of the payload plus the landing parameters, so
8//! a later command can compare what is on disk against what would be
9//! written. The kinds are declared here, beside the payload, never
10//! inferred at runtime; a test holds the table closed over every snippet.
11
12pub mod invariants;
13pub mod manifest;
14
15use camino::Utf8Path;
16use serde::{Deserialize, Serialize};
17
18pub use manifest::{Style, Workflow};
19
20use crate::diagnostic::{Diagnostic, Reason};
21use crate::error::RkError;
22use crate::{atomic, embedded};
23
24/// Who owns a landed file's bytes after landing.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "lowercase")]
27pub enum Kind {
28    /// release-kit owns it: a newer payload re-renders it, and a target
29    /// edit is a conflict.
30    Rendered,
31    /// The target owns it: a starting point the project tunes, reported
32    /// and never rewritten.
33    Seeded,
34    /// The release automation owns it: never written after the first
35    /// landing, never compared.
36    State,
37}
38
39impl Kind {
40    /// The wire and report form.
41    #[must_use]
42    pub const fn as_str(self) -> &'static str {
43        match self {
44            Self::Rendered => "rendered",
45            Self::Seeded => "seeded",
46            Self::State => "state",
47        }
48    }
49}
50
51/// The declared classification: every landable destination and its kind.
52/// The workflow and pipeline files carry the release automation and the
53/// OIDC permission, so release-kit owns them; the tool configurations are
54/// per-project judgment; the two state files are rewritten by the release
55/// automation itself.
56const KINDS: [(&str, Kind); 12] = [
57    (".github/workflows/release-plz.yml", Kind::Rendered),
58    (".github/workflows/release-please.yml", Kind::Rendered),
59    (".github/workflows/release.yml", Kind::Rendered),
60    (".github/workflows/pr-title.yml", Kind::Rendered),
61    (".gitlab-ci.yml", Kind::Rendered),
62    (".gitlab/ci/mr-title.yml", Kind::Rendered),
63    ("release-plz.toml", Kind::Seeded),
64    ("dist-workspace.toml", Kind::Seeded),
65    ("release-please-config.json", Kind::Seeded),
66    ("cliff.toml", Kind::Seeded),
67    (".release-please-manifest.json", Kind::State),
68    ("VERSION", Kind::State),
69];
70
71/// The declared kind of a destination, or `None` for a file the payload
72/// does not classify.
73#[must_use]
74pub fn kind_of(destination: &str) -> Option<Kind> {
75    if destination == AGENTS_DESTINATION || destination == HOOKS_DESTINATION {
76        return Some(Kind::Rendered);
77    }
78    KINDS
79        .iter()
80        .find(|(name, _)| *name == destination)
81        .map(|(_, kind)| *kind)
82}
83
84/// The mechanical substitution sites in `rendered` files.
85///
86/// Known values, substituted identically everywhere each appears. The
87/// owner is derived from the landing's `repo` parameter and the two scope
88/// forms from its `scopes` list, so the landed bytes stay a deterministic
89/// function of payload plus parameters.
90pub const OWNER_TOKEN: &[u8] = b"OWNER";
91
92/// The scope list, comma-joined: hook arguments and prose.
93pub const SCOPES_CSV_TOKEN: &[u8] = b"RK_SCOPES_CSV";
94
95/// The scope list, pipe-joined: the title checks' regular expression.
96pub const SCOPES_PIPE_TOKEN: &[u8] = b"RK_SCOPES_PIPE";
97
98/// The recorded release style: `trunk` arms the bot's request in the
99/// landed release workflow, `lines` leaves every request unarmed.
100pub const STYLE_TOKEN: &[u8] = b"RK_STYLE";
101
102/// Substitute the landing parameters into a `rendered` file's bytes.
103///
104/// The repository's owner — the project path's first segment — replaces
105/// every `OWNER` occurrence, the scope list replaces the two scope
106/// tokens, and the recorded style replaces the style token. An empty
107/// scope list — or an unresolved style — leaves its tokens standing,
108/// which only a preview renders under; an apply refuses before reaching
109/// here.
110#[must_use]
111pub fn render(baseline: &[u8], repo: &str, scopes: &[String], style: Option<Style>) -> Vec<u8> {
112    let owner = repo.split('/').next().unwrap_or(repo);
113    let mut out = substitute(baseline, OWNER_TOKEN, owner.as_bytes());
114    if let Some(style) = style {
115        out = substitute(&out, STYLE_TOKEN, style.as_str().as_bytes());
116    }
117    if !scopes.is_empty() {
118        out = substitute(&out, SCOPES_CSV_TOKEN, scopes.join(",").as_bytes());
119        // The pipe form drops into an extended regular expression, where a
120        // dot matches any character; among the characters `parse_scopes`
121        // admits, the dot is the only special one, so `api.v1` escapes to
122        // match itself alone.
123        let pipe: Vec<String> = scopes.iter().map(|s| s.replace('.', "\\.")).collect();
124        out = substitute(&out, SCOPES_PIPE_TOKEN, pipe.join("|").as_bytes());
125    }
126    out
127}
128
129/// Every `token` occurrence replaced with `value`.
130fn substitute(baseline: &[u8], token: &[u8], value: &[u8]) -> Vec<u8> {
131    let mut out = Vec::with_capacity(baseline.len());
132    let mut rest = baseline;
133    while let Some(at) = find(rest, token) {
134        out.extend_from_slice(&rest[..at]);
135        out.extend_from_slice(value);
136        rest = &rest[at + token.len()..];
137    }
138    out.extend_from_slice(rest);
139    out
140}
141
142/// The `--scopes` argument parsed into the recorded list.
143///
144/// Comma-separated, each scope non-empty and made of letters, digits, and
145/// `_ . / -` — a set safe for the title checks' regular expression once
146/// the renderer escapes the dot, the one special character among them.
147///
148/// # Errors
149///
150/// Returns [`RkError::Usage`] naming the offending scope, or the empty
151/// list.
152pub fn parse_scopes(raw: &str) -> Result<Vec<String>, RkError> {
153    let scopes: Vec<String> = raw
154        .split(',')
155        .map(str::trim)
156        .filter(|scope| !scope.is_empty())
157        .map(str::to_owned)
158        .collect();
159    if scopes.is_empty() {
160        return Err(RkError::Usage(
161            "--scopes names no scope; pass a comma-separated list, e.g. --scopes api,cli".into(),
162        ));
163    }
164    for scope in &scopes {
165        let clean = scope
166            .chars()
167            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '/' | '-'));
168        if !clean {
169            return Err(RkError::Usage(format!(
170                "the scope '{scope}' carries a character outside letters, digits, and _ . / -"
171            )));
172        }
173    }
174    Ok(scopes)
175}
176
177/// First occurrence of `needle` in `haystack`.
178fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
179    haystack
180        .windows(needle.len())
181        .position(|window| window == needle)
182}
183
184/// The destination the routing block splices into.
185pub const AGENTS_DESTINATION: &str = "AGENTS.md";
186
187/// The block's opening marker.
188pub const BLOCK_BEGIN: &str = "<!-- BEGIN release-kit -->";
189
190/// The block's closing marker.
191pub const BLOCK_END: &str = "<!-- END release-kit -->";
192
193/// The destination the hook block splices into.
194pub const HOOKS_DESTINATION: &str = ".pre-commit-config.yaml";
195
196/// The hook block's opening marker, a YAML comment at column zero.
197pub const HOOKS_BEGIN: &str = "# BEGIN release-kit";
198
199/// The hook block's closing marker.
200pub const HOOKS_END: &str = "# END release-kit";
201
202/// The top-level key the fresh hook file carries and the skills verify on
203/// an existing one: the commit-msg and pre-push hooks run only where their
204/// hook types are installed.
205pub const HOOK_TYPES_LINE: &str = "default_install_hook_types: [pre-commit, commit-msg, pre-push]";
206
207/// The authored routing-block template, `blocks/agents-block.md.in`.
208static AGENTS_BLOCK: &str = include_str!("../blocks/agents-block.md.in");
209
210/// The routing block's mode line, worktree form.
211static AGENTS_LINE_WORKTREE: &str = include_str!("../blocks/agents-line-worktree.md.in");
212
213/// The routing block's mode line, branches form.
214static AGENTS_LINE_BRANCHES: &str = include_str!("../blocks/agents-line-branches.md.in");
215
216/// The authored hook-block template, `blocks/pre-commit-block.yaml.in`.
217static PRE_COMMIT_BLOCK: &str = include_str!("../blocks/pre-commit-block.yaml.in");
218
219/// The worktree mode's guard entry, `blocks/pre-commit-worktree-guard.yaml.in`.
220static PRE_COMMIT_WORKTREE_GUARD: &str =
221    include_str!("../blocks/pre-commit-worktree-guard.yaml.in");
222
223/// An authored block without the one final newline the repository's
224/// hooks enforce on every file under `blocks/`; a test in
225/// `src/embedded.rs` holds each file to exactly one.
226fn authored(text: &str) -> &str {
227    text.strip_suffix('\n').unwrap_or(text)
228}
229
230/// The one branch grammar.
231///
232/// The extended regular expression the landed
233/// `rk-branch-name` hook tests, and the same anchored language
234/// `rk worktree add` validates before creating anything. One owner by
235/// token — `concat!` cannot interpolate a const, so [`hooks_block`]
236/// substitutes it for the template's `RK_BRANCH_GRAMMAR` token.
237pub 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[-/].+)$";
238
239/// The routing block for one workflow mode: the whole of target-side
240/// governance, authored as `blocks/agents-block.md.in` and never grown
241/// into a method chapter.
242///
243/// Markers included, without a
244/// trailing newline and with its scope token unrendered: the template
245/// with the mode's one orientation line substituted, everything else —
246/// the agent-boundary line included — byte-identical across modes.
247#[must_use]
248pub fn routing_block(workflow: Workflow) -> String {
249    let line = match workflow {
250        Workflow::Worktree => authored(AGENTS_LINE_WORKTREE),
251        Workflow::Branches => authored(AGENTS_LINE_BRANCHES),
252    };
253    authored(AGENTS_BLOCK).replacen("RK_WORKFLOW_LINE", line, 1)
254}
255
256/// The hook block for one workflow mode, authored as
257/// `blocks/pre-commit-block.yaml.in` with the worktree mode's guard entry
258/// beside it in `blocks/pre-commit-worktree-guard.yaml.in`.
259///
260/// Markers included, without a
261/// trailing newline and with its scope token unrendered. What is landed
262/// is what runs: the worktree mode's block carries the location guard and
263/// names the sweep-skip pair, and the branches mode's block carries no
264/// guard entry at all — never an entry that reads local state to decide
265/// whether to enforce. The one branch grammar substitutes here from
266/// [`BRANCH_GRAMMAR`].
267#[must_use]
268pub fn hooks_block(workflow: Workflow) -> String {
269    let (guard, skip) = match workflow {
270        Workflow::Worktree => (
271            format!("{}\n", authored(PRE_COMMIT_WORKTREE_GUARD)),
272            "no-commit-to-branch,rk-worktree-location",
273        ),
274        Workflow::Branches => (String::new(), "no-commit-to-branch"),
275    };
276    authored(PRE_COMMIT_BLOCK)
277        .replacen("RK_BRANCH_GRAMMAR", BRANCH_GRAMMAR, 1)
278        .replacen("RK_SWEEP_SKIP", skip, 1)
279        .replacen("RK_WORKTREE_GUARD", &guard, 1)
280}
281
282/// The markers of a block destination, or `None` for a whole-file one.
283#[must_use]
284pub fn block_markers(destination: &str) -> Option<(&'static str, &'static str)> {
285    match destination {
286        AGENTS_DESTINATION => Some((BLOCK_BEGIN, BLOCK_END)),
287        HOOKS_DESTINATION => Some((HOOKS_BEGIN, HOOKS_END)),
288        _ => None,
289    }
290}
291
292/// The marked block inside a document, markers included, or `None` where
293/// the text carries no complete block.
294#[must_use]
295pub fn extract_block<'a>(text: &'a str, begin: &str, end: &str) -> Option<&'a str> {
296    let start = text.find(begin)?;
297    let stop = text[start..].find(end)? + start + end.len();
298    Some(&text[start..stop])
299}
300
301/// The whole `AGENTS.md` content after splicing the rendered block.
302///
303/// A fresh file where none exists, the block replaced in place where one
304/// is marked, appended after the target's own content otherwise —
305/// release-kit owns the lines inside the markers, not the document.
306#[must_use]
307pub fn splice_agents_block(existing: Option<&str>, block: &str) -> String {
308    existing.map_or_else(
309        || format!("{block}\n"),
310        |text| {
311            extract_block(text, BLOCK_BEGIN, BLOCK_END).map_or_else(
312                || format!("{}\n\n{block}\n", text.trim_end()),
313                |found| text.replacen(found, block, 1),
314            )
315        },
316    )
317}
318
319/// The whole `.pre-commit-config.yaml` content after splicing the
320/// rendered hook block.
321///
322/// A fresh file carries the hook-types key, the `repos:` key, and the
323/// block; a marked file takes the block in place; an unmarked file takes
324/// it directly under its `repos:` line, above the target's own hooks. An
325/// unmarked file with no `repos:` line is refused by name — the block's
326/// entries are list items and have nowhere honest to go.
327///
328/// # Errors
329///
330/// The reason the block has no place, for the caller's refusal to carry.
331pub fn splice_hooks_block(existing: Option<&str>, block: &str) -> Result<String, String> {
332    let Some(text) = existing else {
333        return Ok(format!("{HOOK_TYPES_LINE}\n\nrepos:\n{block}\n"));
334    };
335    if let Some(defect) = hooks_marker_defect(text) {
336        return Err(defect);
337    }
338    if let Some(found) = extract_block(text, HOOKS_BEGIN, HOOKS_END) {
339        return Ok(text.replacen(found, block, 1));
340    }
341    let mut out = String::with_capacity(text.len() + block.len() + 1);
342    let mut placed = false;
343    for line in text.split_inclusive('\n') {
344        out.push_str(line);
345        if !placed && line.trim_end() == "repos:" {
346            if !out.ends_with('\n') {
347                out.push('\n');
348            }
349            out.push_str(block);
350            out.push('\n');
351            placed = true;
352        }
353    }
354    if placed {
355        Ok(out)
356    } else {
357        Err(format!(
358            "{HOOKS_DESTINATION} exists with no repos: line, so the hook block has nowhere to land"
359        ))
360    }
361}
362
363/// The one definition of an ill-formed hook file, shared by the splice
364/// and every reader that judges one.
365///
366/// The hooks between the markers execute, so ownership must be
367/// unambiguous: exactly one begin marker paired with exactly one end
368/// marker after it, or none of either. A second begin is a second block
369/// pre-commit would still run, and a marker without its pair — or an end
370/// before its begin — is a block whose extent nothing can state.
371#[must_use]
372pub fn hooks_marker_defect(text: &str) -> Option<String> {
373    let begins = text.matches(HOOKS_BEGIN).count();
374    let ends = text.matches(HOOKS_END).count();
375    if begins > 1 || ends > 1 {
376        return Some(format!(
377            "{HOOKS_DESTINATION} carries more than one release-kit marker pair; release-kit owns exactly one block"
378        ));
379    }
380    match (text.find(HOOKS_BEGIN), text.find(HOOKS_END)) {
381        (Some(begin), Some(end)) if end > begin => None,
382        (None, None) => None,
383        _ => Some(format!(
384            "{HOOKS_DESTINATION} carries an unmatched or misordered release-kit marker, so the block's extent is ambiguous"
385        )),
386    }
387}
388
389/// How a projected artifact occupies its destination.
390#[derive(Debug, Clone, Copy, PartialEq, Eq)]
391pub enum Placement {
392    /// The artifact is the whole file.
393    Whole,
394    /// The artifact is the marked block inside the target's `AGENTS.md`.
395    Block,
396}
397
398/// One artifact of the payload projection: what would land at one
399/// destination, with the payload bytes it was rendered from.
400#[derive(Debug)]
401pub struct Entry {
402    /// The destination, relative to the target root.
403    pub destination: String,
404    /// The declared kind.
405    pub kind: Kind,
406    /// Whole file, or the marked block.
407    pub placement: Placement,
408    /// The payload bytes before substitution — what `baseline_sha256`
409    /// digests.
410    pub baseline: Vec<u8>,
411    /// The bytes a landing writes: substituted for `rendered` files,
412    /// identical to the baseline otherwise.
413    pub rendered: Vec<u8>,
414}
415
416/// The landable files of one `(technology, forge)` pair, as
417/// `(destination, payload bytes)`.
418///
419/// # Errors
420///
421/// Returns [`RkError::Usage`] naming the known bindings for an unknown
422/// technology, and the supported pairs for a pair with no files.
423pub fn pair_files(tech: &str, forge: &str) -> Result<Vec<(String, &'static [u8])>, RkError> {
424    // The shared zone is not a technology: `_shared/<forge>` composes into
425    // every pair and never names one.
426    if tech.starts_with('_') || embedded::SNIPPETS.get_dir(tech).is_none() {
427        let known: Vec<String> = embedded::SNIPPETS
428            .dirs()
429            .map(|dir| dir.path().to_string_lossy().into_owned())
430            .filter(|name| !name.starts_with('_'))
431            .collect();
432        return Err(RkError::Usage(format!(
433            "unknown tech '{tech}'; the bindings are: {}",
434            known.join(", ")
435        )));
436    }
437    let pair = format!("{tech}/{forge}");
438    let pair_dir = embedded::SNIPPETS.get_dir(&pair).ok_or_else(|| {
439        let known: Vec<String> = embedded::SNIPPETS
440            .dirs()
441            .filter(|dir| !dir.path().to_string_lossy().starts_with('_'))
442            .flat_map(include_dir::Dir::dirs)
443            .map(|dir| dir.path().to_string_lossy().replace('/', ", "))
444            .collect();
445        RkError::Usage(format!(
446            "the pair ({tech}, {forge}) has no landable files; the supported pairs are: {}",
447            known.join("; ")
448        ))
449    })?;
450    // Payload paths carry their zone prefix; destinations do not. The
451    // shared zone lands first, and a destination both zones ship is a
452    // payload defect refused by name, never one zone silently winning.
453    let mut files: Vec<(String, &'static [u8])> = Vec::new();
454    let shared = format!("_shared/{forge}");
455    if let Some(shared_dir) = embedded::SNIPPETS.get_dir(&shared) {
456        for (path, contents) in embedded::walk(shared_dir) {
457            let rel = path
458                .strip_prefix(&format!("{shared}/"))
459                .map_or(path.as_str(), |rel| rel)
460                .to_owned();
461            files.push((rel, contents));
462        }
463    }
464    for (path, contents) in embedded::walk(pair_dir) {
465        let rel = path
466            .strip_prefix(&format!("{pair}/"))
467            .map_or(path.as_str(), |rel| rel)
468            .to_owned();
469        if files.iter().any(|(existing, _)| *existing == rel) {
470            return Err(anyhow::anyhow!(
471                "the shared zone and the pair ({tech}, {forge}) both ship {rel}; the payload is defective"
472            )
473            .into());
474        }
475        files.push((rel, contents));
476    }
477    Ok(files)
478}
479
480/// The whole payload projection for one pair.
481///
482/// Under the `repo`, `scopes`, `workflow`,
483/// and `style` parameters: every snippet with its kind and rendered
484/// bytes, plus the routing block and the hook block — each a pure
485/// function of the recorded mode — sorted by destination.
486///
487/// # Errors
488///
489/// Returns the [`pair_files`] errors, and [`RkError::Other`] for a
490/// snippet destination the kind table does not classify, which is a
491/// defect in this binary.
492pub fn projection(
493    tech: &str,
494    forge: &str,
495    repo: &str,
496    scopes: &[String],
497    workflow: Workflow,
498    style: Option<Style>,
499) -> Result<Vec<Entry>, RkError> {
500    let mut entries = Vec::new();
501    for (destination, baseline) in pair_files(tech, forge)? {
502        let kind = kind_of(&destination).ok_or_else(|| {
503            anyhow::anyhow!("the payload does not classify {destination}; the kind table is stale")
504        })?;
505        let rendered = match kind {
506            Kind::Rendered => render(baseline, repo, scopes, style),
507            Kind::Seeded | Kind::State => baseline.to_vec(),
508        };
509        entries.push(Entry {
510            destination,
511            kind,
512            placement: Placement::Whole,
513            baseline: baseline.to_vec(),
514            rendered,
515        });
516    }
517    for (destination, template) in [
518        (AGENTS_DESTINATION, routing_block(workflow)),
519        (HOOKS_DESTINATION, hooks_block(workflow)),
520    ] {
521        entries.push(Entry {
522            destination: destination.to_owned(),
523            kind: Kind::Rendered,
524            placement: Placement::Block,
525            baseline: template.as_bytes().to_vec(),
526            rendered: render(template.as_bytes(), repo, scopes, style),
527        });
528    }
529    entries.sort_by(|a, b| a.destination.cmp(&b.destination));
530    Ok(entries)
531}
532
533/// The bytes an entry's destination currently holds: the whole file, or
534/// the marked block extracted from the target's `AGENTS.md`. `None` means
535/// the file — or the block — is absent.
536///
537/// # Errors
538///
539/// Any read failure other than the file being absent.
540pub fn read_destination(target: &Utf8Path, entry: &Entry) -> std::io::Result<Option<Vec<u8>>> {
541    read_recorded(target, &entry.destination)
542}
543
544/// The bytes a recorded destination currently holds, by the placement
545/// its name implies.
546///
547/// The marked block for `AGENTS.md` and `.pre-commit-config.yaml`, the
548/// whole file otherwise. `None` means the file — or the block — is
549/// absent.
550///
551/// # Errors
552///
553/// Any read failure other than the file being absent.
554pub fn read_recorded(target: &Utf8Path, destination: &str) -> std::io::Result<Option<Vec<u8>>> {
555    let path = target.join(destination);
556    let bytes = match std::fs::read(&path) {
557        Ok(bytes) => bytes,
558        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
559        Err(e) => return Err(e),
560    };
561    if let Some((begin, end)) = block_markers(destination) {
562        let text = String::from_utf8_lossy(&bytes);
563        Ok(extract_block(&text, begin, end).map(|block| block.as_bytes().to_vec()))
564    } else {
565        Ok(Some(bytes))
566    }
567}
568
569/// What one detection pass resolved for a target-side verb, with the
570/// override flags applied.
571#[derive(Debug)]
572pub struct Resolved {
573    /// The forge whose payload applies.
574    pub forge: String,
575    /// The project path, where a flag or the remote names one.
576    pub repo: Option<String>,
577}
578
579/// Resolve forge and repository in one pass: the flags override, the
580/// `origin` remote answers otherwise.
581///
582/// An unrecognized host refuses rather than defaulting — landing one
583/// forge's files into the other forge's project is a half-configured
584/// repository that looks done.
585///
586/// # Errors
587///
588/// Returns [`RkError::Usage`] for an unknown `--forge` value, and a
589/// refusal naming the override when no forge resolves.
590pub fn resolve(
591    target: &Utf8Path,
592    forge_flag: Option<&str>,
593    repo_flag: Option<&str>,
594) -> Result<Resolved, RkError> {
595    let forge_flag = forge_flag
596        .map(|name| {
597            crate::detect::Forge::parse(name).ok_or_else(|| {
598                RkError::Usage(format!(
599                    "unknown forge '{name}'; the forges are: github, gitlab"
600                ))
601            })
602        })
603        .transpose()?;
604    let detected = crate::detect::detect(target.as_std_path());
605    let forge = forge_flag
606        .or(detected.forge)
607        .map(|forge| forge.as_str().to_owned())
608        .ok_or_else(|| {
609            let message = detected.host.map_or_else(
610                || "no forge detected: the target has no origin remote".to_owned(),
611                |host| format!("no forge detected: the host {host} is not recognized"),
612            );
613            RkError::refusal(
614                Diagnostic::new(Reason::ForgeUndetected, message)
615                    .expected("a github.com or gitlab remote, or --forge")
616                    .action("pass --forge <github|gitlab>"),
617            )
618        })?;
619    Ok(Resolved {
620        forge,
621        repo: repo_flag.map(str::to_owned).or(detected.repo),
622    })
623}
624
625/// The refusal a verb answers when it needs the `repo` parameter and
626/// neither a flag nor the remote supplies one.
627#[must_use]
628pub fn repo_unresolved() -> RkError {
629    RkError::missing(
630        Diagnostic::new(
631            Reason::ForgeUndetected,
632            "no repository detected: the target has no origin remote",
633        )
634        .expected("an origin remote naming the project")
635        .action("pass --repo <path>"),
636    )
637}
638
639/// Land one entry: the whole file through the temp-plus-rename writer, or
640/// the block spliced into its document and the whole document rewritten
641/// the same way.
642///
643/// # Errors
644///
645/// Any write failure; the destination then holds what it held. An
646/// unspliceable hook file surfaces as an error here only as a backstop —
647/// [`hooks_splice_refusal`] is the check a verb runs before any write.
648pub fn write_destination(target: &Utf8Path, entry: &Entry) -> std::io::Result<()> {
649    let path = target.join(&entry.destination);
650    match entry.placement {
651        Placement::Whole => atomic::write(path.as_std_path(), &entry.rendered),
652        Placement::Block => {
653            let existing = match std::fs::read(&path) {
654                Ok(bytes) => Some(String::from_utf8_lossy(&bytes).into_owned()),
655                Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
656                Err(e) => return Err(e),
657            };
658            let block = String::from_utf8_lossy(&entry.rendered).into_owned();
659            let spliced = if entry.destination == HOOKS_DESTINATION {
660                splice_hooks_block(existing.as_deref(), &block).map_err(std::io::Error::other)?
661            } else {
662                splice_agents_block(existing.as_deref(), &block)
663            };
664            atomic::write(path.as_std_path(), spliced.as_bytes())
665        }
666    }
667}
668
669/// The hook file's defect, read from the target: `None` for a missing
670/// file or one the block can land in.
671///
672/// The one judgment every verb shares, covering every splice refusal —
673/// ill-formed markers, and an unmarked file offering the block no
674/// `repos:` line. Status reports it as rendered drift, upgrade collects
675/// it as a conflict in preview and apply alike so no landing dies
676/// half-written, and adopt lists it with its mismatches.
677///
678/// # Errors
679///
680/// Any read failure other than the file being absent.
681pub fn hooks_file_defect(target: &Utf8Path) -> std::io::Result<Option<String>> {
682    let path = target.join(HOOKS_DESTINATION);
683    match std::fs::read(&path) {
684        Ok(bytes) => {
685            let text = String::from_utf8_lossy(&bytes);
686            Ok(splice_hooks_block(Some(&text), authored(PRE_COMMIT_BLOCK)).err())
687        }
688        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
689        Err(e) => Err(e),
690    }
691}
692
693/// The refusal a landing verb answers before writing anything, where
694/// the target's hook file offers the block no place.
695///
696/// Checked ahead of every write so the all-or-nothing property holds and
697/// no landing dies half-written into `.pre-commit-config.yaml`.
698///
699/// # Errors
700///
701/// [`RkError::Refusal`] naming the file, and any read failure.
702pub fn hooks_splice_refusal(target: &Utf8Path) -> Result<(), RkError> {
703    hooks_file_defect(target)?.map_or(Ok(()), |reason| {
704        Err(RkError::refusal(
705            Diagnostic::new(
706                Reason::StateDrift,
707                format!("{reason}, and nothing was written"),
708            )
709            .expected("a .pre-commit-config.yaml the block can land in, or none")
710            .action(format!(
711                "resolve it in {}, then re-run",
712                target.join(HOOKS_DESTINATION)
713            ))
714            .target_state("unchanged"),
715        ))
716    })
717}
718
719#[cfg(test)]
720mod tests {
721    #![allow(clippy::expect_used)]
722
723    use super::{
724        AGENTS_DESTINATION, BLOCK_BEGIN, BLOCK_END, BRANCH_GRAMMAR, HOOK_TYPES_LINE, HOOKS_BEGIN,
725        HOOKS_DESTINATION, HOOKS_END, Kind, Style, Workflow, extract_block, hooks_block, kind_of,
726        pair_files, parse_scopes, projection, render, routing_block, splice_agents_block,
727        splice_hooks_block,
728    };
729    use crate::embedded;
730
731    fn scopes(list: &[&str]) -> Vec<String> {
732        list.iter().map(|s| (*s).to_owned()).collect()
733    }
734
735    /// Every snippet destination has a declared kind: a new landable file
736    /// without a classification fails here, not at a landing. The shared
737    /// zone's files are enumerated the same way.
738    #[test]
739    fn the_kind_table_closes_over_every_snippet() {
740        for tech_dir in embedded::SNIPPETS.dirs() {
741            for pair_dir in tech_dir.dirs() {
742                let prefix = format!("{}/", pair_dir.path().to_string_lossy());
743                for (path, _) in embedded::walk(pair_dir) {
744                    let destination = path.strip_prefix(&prefix).unwrap_or(&path);
745                    assert!(
746                        kind_of(destination).is_some(),
747                        "{destination}: no declared kind"
748                    );
749                }
750            }
751        }
752        assert_eq!(kind_of(AGENTS_DESTINATION), Some(Kind::Rendered));
753        assert_eq!(kind_of(HOOKS_DESTINATION), Some(Kind::Rendered));
754        assert_eq!(kind_of("something-else.txt"), None);
755    }
756
757    /// Substitution is total and derives from the repo parameter's first
758    /// segment, so a nested GitLab project path still yields its root
759    /// namespace; the scope list renders in both joined forms.
760    #[test]
761    fn rendering_substitutes_every_owner_occurrence() {
762        let baseline = b"if: repository_owner == 'OWNER'\n# OWNER again: OWNER\n";
763        let rendered = render(baseline, "acme/sub/widget", &[], None);
764        let text = String::from_utf8(rendered).expect("rendered bytes stay text");
765        assert_eq!(text, "if: repository_owner == 'acme'\n# acme again: acme\n");
766
767        let baseline = b"scopes 'RK_SCOPES_CSV' match (RK_SCOPES_PIPE)\n";
768        let rendered = render(baseline, "acme/widget", &scopes(&["api", "cli"]), None);
769        let text = String::from_utf8(rendered).expect("rendered bytes stay text");
770        assert_eq!(text, "scopes 'api,cli' match (api|cli)\n");
771
772        // A dot is the one admitted character that is special in the
773        // regular expression: it escapes, so `api.v1` matches only itself.
774        let rendered = render(baseline, "acme/widget", &scopes(&["api.v1"]), None);
775        let text = String::from_utf8(rendered).expect("rendered bytes stay text");
776        assert_eq!(text, "scopes 'api.v1' match (api\\.v1)\n");
777    }
778
779    /// The scope argument parses to the recorded list, refusing the empty
780    /// list and any scope that would not drop into the title regex.
781    #[test]
782    fn scope_parsing_refuses_the_unusable() {
783        assert_eq!(
784            parse_scopes("api, cli,guides/release").expect("a clean list parses"),
785            scopes(&["api", "cli", "guides/release"])
786        );
787        assert!(parse_scopes("").is_err());
788        assert!(parse_scopes(" , ").is_err());
789        assert!(parse_scopes("api|cli").is_err());
790        assert!(parse_scopes("a b").is_err());
791    }
792
793    /// The shared zone composes into every pair, lands first, and is
794    /// absent from the technology listing an unknown tech names.
795    #[test]
796    fn the_shared_zone_composes_into_the_pair() {
797        let files = pair_files("rust", "github").expect("the pair lists");
798        assert!(
799            files
800                .iter()
801                .any(|(dest, _)| dest == ".github/workflows/pr-title.yml"),
802            "the shared title check lands with the pair"
803        );
804        let files = pair_files("rust", "gitlab").expect("the pair lists");
805        assert!(
806            files
807                .iter()
808                .any(|(dest, _)| dest == ".gitlab/ci/mr-title.yml"),
809            "the shared title job lands with the pair"
810        );
811        let err = pair_files("_shared", "github").expect_err("the shared zone is no tech");
812        let listing = err.to_string();
813        let bindings = listing
814            .split("the bindings are:")
815            .nth(1)
816            .expect("the refusal lists the bindings");
817        assert!(!bindings.contains("_shared"), "{listing}");
818    }
819
820    /// A rendered projection carries no unsubstituted token and no
821    /// mechanical sentinel; the one judgment sentinel stays in its seeded
822    /// file.
823    #[test]
824    fn a_projection_renders_owned_files_and_keeps_seeded_judgment() {
825        let entries = projection(
826            "rust",
827            "github",
828            "acme/widget",
829            &scopes(&["api", "cli"]),
830            Workflow::Branches,
831            Some(Style::Trunk),
832        )
833        .expect("the pair projects");
834        let workflow = entries
835            .iter()
836            .find(|entry| entry.destination.ends_with("release-plz.yml"))
837            .expect("the workflow projects");
838        assert_eq!(workflow.kind, Kind::Rendered);
839        let text = String::from_utf8_lossy(&workflow.rendered);
840        assert!(!text.contains("OWNER"), "an owner token survived rendering");
841        assert!(text.contains("'acme'"));
842        assert!(!text.contains("TODO(release-kit)"));
843        let title = entries
844            .iter()
845            .find(|entry| entry.destination.ends_with("pr-title.yml"))
846            .expect("the title check projects");
847        let text = String::from_utf8_lossy(&title.rendered);
848        assert!(text.contains("api|cli"), "{text}");
849        assert!(
850            !text.contains("RK_SCOPES"),
851            "a scope token survived: {text}"
852        );
853        let seeded = entries
854            .iter()
855            .find(|entry| entry.destination == "release-plz.toml")
856            .expect("the seeded file projects");
857        assert_eq!(seeded.kind, Kind::Seeded);
858        assert_eq!(seeded.rendered, seeded.baseline);
859        assert!(String::from_utf8_lossy(&seeded.rendered).contains("TODO(release-kit)"));
860        for block in [AGENTS_DESTINATION, HOOKS_DESTINATION] {
861            let entry = entries
862                .iter()
863                .find(|entry| entry.destination == block)
864                .expect("both blocks are part of the projection");
865            let text = String::from_utf8_lossy(&entry.rendered);
866            assert!(!text.contains("RK_SCOPES"), "{block} kept a token: {text}");
867            assert!(text.contains("api,cli"), "{block} lost the scopes: {text}");
868        }
869    }
870
871    #[test]
872    fn the_block_splices_into_every_agents_shape() {
873        let owned = routing_block(Workflow::Branches);
874        let block = owned.as_str();
875        let fresh = splice_agents_block(None, block);
876        assert_eq!(fresh, format!("{block}\n"));
877        assert_eq!(extract_block(&fresh, BLOCK_BEGIN, BLOCK_END), Some(block));
878
879        let appended = splice_agents_block(Some("# My project\n\nOwn rules.\n"), block);
880        assert!(appended.starts_with("# My project\n\nOwn rules.\n\n<!-- BEGIN release-kit -->"));
881        assert_eq!(
882            extract_block(&appended, BLOCK_BEGIN, BLOCK_END),
883            Some(block)
884        );
885
886        let stale = appended.replace("Never author a tag", "Do author a tag");
887        let refreshed = splice_agents_block(Some(&stale), block);
888        assert_eq!(
889            extract_block(&refreshed, BLOCK_BEGIN, BLOCK_END),
890            Some(block)
891        );
892        assert!(refreshed.starts_with("# My project"));
893        assert_eq!(
894            refreshed.matches("BEGIN release-kit").count(),
895            1,
896            "a re-splice must replace, not accumulate"
897        );
898    }
899
900    /// The hook block lands under `repos:` in every honest shape and
901    /// refuses the one dishonest shape by name.
902    #[test]
903    fn the_hook_block_splices_under_repos() {
904        let owned = hooks_block(Workflow::Branches);
905        let block = owned.as_str();
906        let fresh = splice_hooks_block(None, block).expect("a fresh file splices");
907        assert!(fresh.starts_with(HOOK_TYPES_LINE));
908        assert!(fresh.contains("\nrepos:\n# BEGIN release-kit\n"));
909        assert_eq!(extract_block(&fresh, HOOKS_BEGIN, HOOKS_END), Some(block));
910
911        let own =
912            "repos:\n  - repo: https://example.com/own\n    rev: v1\n    hooks:\n      - id: own\n";
913        let spliced = splice_hooks_block(Some(own), block).expect("an unmarked file splices");
914        assert!(spliced.starts_with("repos:\n# BEGIN release-kit\n"));
915        assert!(spliced.contains("- id: own"), "the target's hooks survive");
916        assert!(
917            !spliced.contains(HOOK_TYPES_LINE),
918            "an existing file's top level is the skills' duty, not the splice's"
919        );
920
921        let stale = spliced.replace("--force-scope", "--no-scope");
922        let refreshed = splice_hooks_block(Some(&stale), block).expect("a marked file re-splices");
923        assert_eq!(
924            extract_block(&refreshed, HOOKS_BEGIN, HOOKS_END),
925            Some(block)
926        );
927        assert_eq!(refreshed.matches(HOOKS_BEGIN).count(), 1);
928
929        let err = splice_hooks_block(Some("minimum_pre_commit_version: '3.2.0'\n"), block)
930            .expect_err("no repos: line refuses");
931        assert!(err.contains("repos:"), "{err}");
932
933        // The hooks between the markers execute, so ownership is exactly
934        // one well-formed block: a duplicate or an unmatched marker
935        // refuses rather than leaving a stale block active.
936        let doubled = format!("repos:\n{block}\n{block}\n");
937        let err = splice_hooks_block(Some(&doubled), block).expect_err("a second block refuses");
938        assert!(err.contains("one block"), "{err}");
939        let unmatched = "repos:\n# BEGIN release-kit\n  - repo: local\n";
940        let err =
941            splice_hooks_block(Some(unmatched), block).expect_err("an unmatched marker refuses");
942        assert!(err.contains("unmatched"), "{err}");
943    }
944
945    /// Both modes of both blocks: the guard entry and the skip pair exist
946    /// exactly in the worktree mode, one orientation line differs in the
947    /// routing block, the rest is byte-identical, no mode token survives
948    /// substitution, and the rendered grammar is [`BRANCH_GRAMMAR`], the
949    /// one owner.
950    #[test]
951    fn the_blocks_render_per_mode_and_carry_the_one_grammar() {
952        let worktree_hooks = hooks_block(Workflow::Worktree);
953        let branches_hooks = hooks_block(Workflow::Branches);
954        assert!(worktree_hooks.contains("- id: rk-worktree-location"));
955        assert!(
956            worktree_hooks.contains("SKIP=no-commit-to-branch,rk-worktree-location"),
957            "{worktree_hooks}"
958        );
959        assert!(!branches_hooks.contains("rk-worktree-location"));
960        assert!(branches_hooks.contains("SKIP=no-commit-to-branch in"));
961        for block in [&worktree_hooks, &branches_hooks] {
962            assert!(block.contains(BRANCH_GRAMMAR), "the grammar has one owner");
963            for token in ["RK_BRANCH_GRAMMAR", "RK_SWEEP_SKIP", "RK_WORKTREE_GUARD"] {
964                assert!(!block.contains(token), "{token} survived: {block}");
965            }
966        }
967        // A hook entry renders as a YAML plain scalar, where a colon
968        // followed by a space ends the scalar and breaks the whole file
969        // — the defect dogfood caught in the guard's refusal messages —
970        // so no entry value may carry one.
971        for block in [&worktree_hooks, &branches_hooks] {
972            for line in block.lines() {
973                if let Some(value) = line.trim_start().strip_prefix("entry: ") {
974                    assert!(
975                        !value.contains(": "),
976                        "an entry value breaks the YAML plain scalar: {line}"
977                    );
978                }
979            }
980        }
981        let guard_line = worktree_hooks
982            .lines()
983            .position(|line| line.contains("id: rk-worktree-location"))
984            .expect("the guard entry exists");
985        let name_line = worktree_hooks
986            .lines()
987            .position(|line| line.contains("id: rk-branch-name"))
988            .expect("the name hook exists");
989        assert!(
990            guard_line > name_line,
991            "the guard lands directly after rk-branch-name"
992        );
993
994        let worktree_routing = routing_block(Workflow::Worktree);
995        let branches_routing = routing_block(Workflow::Branches);
996        assert!(worktree_routing.contains("This project works in worktrees"));
997        assert!(branches_routing.contains("Branches are worked in the main checkout"));
998        for block in [&worktree_routing, &branches_routing] {
999            assert!(block.contains("creating or removing a worktree"));
1000            assert!(block.contains("`rk worktree add <branch>`"));
1001            assert!(!block.contains("RK_WORKFLOW_LINE"), "{block}");
1002        }
1003        let differing: Vec<(&str, &str)> = worktree_routing
1004            .lines()
1005            .zip(branches_routing.lines())
1006            .filter(|(a, b)| a != b)
1007            .collect();
1008        assert_eq!(
1009            differing.len(),
1010            1,
1011            "exactly one routing line differs per mode: {differing:?}"
1012        );
1013    }
1014
1015    /// One definition of an ill-formed hook file, for every reader: the
1016    /// well-formed shapes pass and each ambiguous shape names a defect.
1017    #[test]
1018    fn the_hook_marker_defects_are_named() {
1019        use super::hooks_marker_defect;
1020        let owned = hooks_block(Workflow::Branches);
1021        let block = owned.as_str();
1022        assert_eq!(hooks_marker_defect(""), None);
1023        assert_eq!(hooks_marker_defect(&format!("repos:\n{block}\n")), None);
1024        for (case, text) in [
1025            (
1026                "a second begin",
1027                format!("repos:\n{block}\n# BEGIN release-kit\n"),
1028            ),
1029            (
1030                "a second end",
1031                format!("repos:\n{block}\n# END release-kit\n"),
1032            ),
1033            (
1034                "an unpaired begin",
1035                "repos:\n# BEGIN release-kit\n".to_owned(),
1036            ),
1037            ("an unpaired end", "repos:\n# END release-kit\n".to_owned()),
1038            (
1039                "an end before its begin",
1040                "repos:\n# END release-kit\n# BEGIN release-kit\n".to_owned(),
1041            ),
1042        ] {
1043            assert!(
1044                hooks_marker_defect(&text).is_some(),
1045                "{case} must be a defect"
1046            );
1047        }
1048    }
1049}