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