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