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