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