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