1pub mod invariants;
13pub mod manifest;
14
15use camino::Utf8Path;
16use serde::{Deserialize, Serialize};
17
18pub use manifest::Workflow;
19
20use crate::diagnostic::{Diagnostic, Reason};
21use crate::error::RkError;
22use crate::{atomic, embedded};
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "lowercase")]
27pub enum Kind {
28 Rendered,
31 Seeded,
34 State,
37}
38
39impl Kind {
40 #[must_use]
42 pub const fn as_str(self) -> &'static str {
43 match self {
44 Self::Rendered => "rendered",
45 Self::Seeded => "seeded",
46 Self::State => "state",
47 }
48 }
49}
50
51const KINDS: [(&str, Kind); 12] = [
57 (".github/workflows/release-plz.yml", Kind::Rendered),
58 (".github/workflows/release-please.yml", Kind::Rendered),
59 (".github/workflows/release.yml", Kind::Rendered),
60 (".github/workflows/pr-title.yml", Kind::Rendered),
61 (".gitlab-ci.yml", Kind::Rendered),
62 (".gitlab/ci/mr-title.yml", Kind::Rendered),
63 ("release-plz.toml", Kind::Seeded),
64 ("dist-workspace.toml", Kind::Seeded),
65 ("release-please-config.json", Kind::Seeded),
66 ("cliff.toml", Kind::Seeded),
67 (".release-please-manifest.json", Kind::State),
68 ("VERSION", Kind::State),
69];
70
71#[must_use]
74pub fn kind_of(destination: &str) -> Option<Kind> {
75 if destination == AGENTS_DESTINATION || destination == HOOKS_DESTINATION {
76 return Some(Kind::Rendered);
77 }
78 KINDS
79 .iter()
80 .find(|(name, _)| *name == destination)
81 .map(|(_, kind)| *kind)
82}
83
84pub const OWNER_TOKEN: &[u8] = b"OWNER";
91
92pub const SCOPES_CSV_TOKEN: &[u8] = b"RK_SCOPES_CSV";
94
95pub const SCOPES_PIPE_TOKEN: &[u8] = b"RK_SCOPES_PIPE";
97
98#[must_use]
105pub fn render(baseline: &[u8], repo: &str, scopes: &[String]) -> Vec<u8> {
106 let owner = repo.split('/').next().unwrap_or(repo);
107 let mut out = substitute(baseline, OWNER_TOKEN, owner.as_bytes());
108 if !scopes.is_empty() {
109 out = substitute(&out, SCOPES_CSV_TOKEN, scopes.join(",").as_bytes());
110 let pipe: Vec<String> = scopes.iter().map(|s| s.replace('.', "\\.")).collect();
115 out = substitute(&out, SCOPES_PIPE_TOKEN, pipe.join("|").as_bytes());
116 }
117 out
118}
119
120fn substitute(baseline: &[u8], token: &[u8], value: &[u8]) -> Vec<u8> {
122 let mut out = Vec::with_capacity(baseline.len());
123 let mut rest = baseline;
124 while let Some(at) = find(rest, token) {
125 out.extend_from_slice(&rest[..at]);
126 out.extend_from_slice(value);
127 rest = &rest[at + token.len()..];
128 }
129 out.extend_from_slice(rest);
130 out
131}
132
133pub fn parse_scopes(raw: &str) -> Result<Vec<String>, RkError> {
144 let scopes: Vec<String> = raw
145 .split(',')
146 .map(str::trim)
147 .filter(|scope| !scope.is_empty())
148 .map(str::to_owned)
149 .collect();
150 if scopes.is_empty() {
151 return Err(RkError::Usage(
152 "--scopes names no scope; pass a comma-separated list, e.g. --scopes api,cli".into(),
153 ));
154 }
155 for scope in &scopes {
156 let clean = scope
157 .chars()
158 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '/' | '-'));
159 if !clean {
160 return Err(RkError::Usage(format!(
161 "the scope '{scope}' carries a character outside letters, digits, and _ . / -"
162 )));
163 }
164 }
165 Ok(scopes)
166}
167
168fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
170 haystack
171 .windows(needle.len())
172 .position(|window| window == needle)
173}
174
175pub const AGENTS_DESTINATION: &str = "AGENTS.md";
177
178pub const BLOCK_BEGIN: &str = "<!-- BEGIN release-kit -->";
180
181pub const BLOCK_END: &str = "<!-- END release-kit -->";
183
184pub const HOOKS_DESTINATION: &str = ".pre-commit-config.yaml";
186
187pub const HOOKS_BEGIN: &str = "# BEGIN release-kit";
189
190pub const HOOKS_END: &str = "# END release-kit";
192
193pub const HOOK_TYPES_LINE: &str = "default_install_hook_types: [pre-commit, commit-msg, pre-push]";
197
198const ROUTING_BLOCK: &str = "<!-- BEGIN release-kit -->
207
208## Releases
209
210- This repository runs the release-kit convention; `rk method invariants` states what must stay true.
211- An agent here guides and never drives: it reads this convention, tells the operator which step comes next, and takes no git or forge action — creating, switching or deleting a branch, creating or removing a worktree, committing, pushing, tagging, opening or updating or merging a pull request — unless the operator's request named that action. A request to change code authorizes the file changes alone.
212- Work reaches the trunk only through a squash-merged pull request from a short-lived branch — `<type>/<slug>` mirroring the squash title's type, or the forge-minted `<issue-id>-<slug>`. Nothing is committed on `master`.
213RK_WORKFLOW_LINE
214- The request's title becomes the trunk's commit message, so it MUST be a scoped Conventional Commit; the body carries the context.
215- Every commit follows the same scoped convention; the landed commit-msg hook enforces it, and the scopes this project accepts are `RK_SCOPES_CSV`.
216- Never author a tag, and never hand-edit a generated artifact workflow.
217- Run `rk status` before changing anything under `.github/workflows/` or `.gitlab-ci.yml`, or any file `.release-kit/manifest.json` names.
218- The full method is `rk method --list`; the recovery paths are `rk method recovery`.
219
220<!-- END release-kit -->";
221
222const ROUTING_WORKTREE_LINE: &str = "- This project works in worktrees: every code-changing branch lives in its linked worktree (`rk worktree add <branch>` creates or adopts it beside the checkout), the main checkout commits nothing, and `rk worktree prune` retires a merged worktree. One branch, one writer.";
224
225const ROUTING_BRANCHES_LINE: &str = "- Branches are worked in the main checkout or in linked worktrees (`rk worktree add <branch>`); parallel work takes worktrees, one branch one writer, and `rk worktree prune` retires a merged worktree.";
227
228pub const BRANCH_GRAMMAR: &str = r"^((build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)/[A-Za-z0-9._/-]+|([0-9]+|[A-Z][A-Z0-9]+-[0-9]+)-[A-Za-z0-9._-]+|release[-/].+)$";
236
237const HOOKS_BLOCK: &str = r#"# BEGIN release-kit
246# The release convention's hooks. Install every stage they run at:
247# pre-commit install --hook-type pre-commit --hook-type commit-msg --hook-type pre-push
248# A CI sweep commits nothing, so a job running pre-commit against a trunk
249# checkout sets SKIP=RK_SWEEP_SKIP in its environment.
250 - repo: https://github.com/compilerla/conventional-pre-commit
251 rev: v4.4.0
252 hooks:
253 - id: conventional-pre-commit
254 stages: [commit-msg]
255 args: [--strict, --force-scope, --scopes, 'RK_SCOPES_CSV']
256 - repo: https://github.com/pre-commit/pre-commit-hooks
257 rev: v6.0.0
258 hooks:
259 - id: no-commit-to-branch
260 args: [--branch, master]
261 - repo: local
262 hooks:
263 - id: rk-branch-name
264 name: rk branch name
265 language: system
266 always_run: true
267 pass_filenames: false
268 entry: sh -c 'branch=$(git symbolic-ref --quiet --short HEAD) || exit 0; [ "$branch" = master ] && exit 0; printf %s "$branch" | grep -Eq "RK_BRANCH_GRAMMAR" && exit 0; echo "branch $branch is neither <type>/<slug> nor <issue-id>-<slug>; gh issue develop <issue> --checkout or its glab counterpart mints the linked form" >&2; exit 1'
269RK_WORKTREE_GUARD - id: rk-no-push-to-trunk
270 name: rk no push to trunk
271 stages: [pre-push]
272 language: system
273 always_run: true
274 pass_filenames: false
275 entry: sh -c '[ "$PRE_COMMIT_REMOTE_BRANCH" != refs/heads/master ] || { echo "the trunk takes no direct push; it is written through squash-merged pull requests alone" >&2; exit 1; }'
276 - id: rk-no-hand-authored-tag
277 name: rk no hand-authored tag
278 stages: [pre-push]
279 language: system
280 always_run: true
281 pass_filenames: false
282 entry: sh -c 'case "$PRE_COMMIT_REMOTE_BRANCH" in refs/tags/v*) echo "never author a tag; the release automation mints every v* tag" >&2; exit 1;; esac'
283 - id: rk-status-check
284 name: rk status check
285 language: system
286 pass_filenames: false
287 entry: rk status --check --target .
288 files: '^(\.github/workflows/|\.gitlab-ci\.yml$|\.gitlab/ci/|AGENTS\.md$|\.release-kit/|\.pre-commit-config\.yaml$|release-plz\.toml$|dist-workspace\.toml$|release-please-config\.json$|cliff\.toml$|\.release-please-manifest\.json$|VERSION$)'
289# END release-kit"#;
290
291const WORKTREE_GUARD_ENTRY: &str = r#" - id: rk-worktree-location
299 name: rk worktree location
300 language: system
301 always_run: true
302 pass_filenames: false
303 entry: sh -c '[ "$(git rev-parse --git-dir)" = "$(git rev-parse --git-common-dir)" ] || exit 0; branch=$(git symbolic-ref --quiet --short HEAD) || { echo "this project works in worktrees: the main checkout takes no commit, detached included; rk worktree add <branch> seats work in its own worktree" >&2; exit 1; }; [ "$branch" = master ] && exit 0; echo "this project works in worktrees: the main checkout takes no branch commit, as the forge trunk takes no direct push; rk worktree add $branch gives this branch its own worktree" >&2; exit 1'"#;
304
305#[must_use]
312pub fn routing_block(workflow: Workflow) -> String {
313 let line = match workflow {
314 Workflow::Worktree => ROUTING_WORKTREE_LINE,
315 Workflow::Branches => ROUTING_BRANCHES_LINE,
316 };
317 ROUTING_BLOCK.replacen("RK_WORKFLOW_LINE", line, 1)
318}
319
320#[must_use]
330pub fn hooks_block(workflow: Workflow) -> String {
331 let (guard, skip) = match workflow {
332 Workflow::Worktree => (
333 format!("{WORKTREE_GUARD_ENTRY}\n"),
334 "no-commit-to-branch,rk-worktree-location",
335 ),
336 Workflow::Branches => (String::new(), "no-commit-to-branch"),
337 };
338 HOOKS_BLOCK
339 .replacen("RK_BRANCH_GRAMMAR", BRANCH_GRAMMAR, 1)
340 .replacen("RK_SWEEP_SKIP", skip, 1)
341 .replacen("RK_WORKTREE_GUARD", &guard, 1)
342}
343
344#[must_use]
346pub fn block_markers(destination: &str) -> Option<(&'static str, &'static str)> {
347 match destination {
348 AGENTS_DESTINATION => Some((BLOCK_BEGIN, BLOCK_END)),
349 HOOKS_DESTINATION => Some((HOOKS_BEGIN, HOOKS_END)),
350 _ => None,
351 }
352}
353
354#[must_use]
357pub fn extract_block<'a>(text: &'a str, begin: &str, end: &str) -> Option<&'a str> {
358 let start = text.find(begin)?;
359 let stop = text[start..].find(end)? + start + end.len();
360 Some(&text[start..stop])
361}
362
363#[must_use]
369pub fn splice_agents_block(existing: Option<&str>, block: &str) -> String {
370 existing.map_or_else(
371 || format!("{block}\n"),
372 |text| {
373 extract_block(text, BLOCK_BEGIN, BLOCK_END).map_or_else(
374 || format!("{}\n\n{block}\n", text.trim_end()),
375 |found| text.replacen(found, block, 1),
376 )
377 },
378 )
379}
380
381pub fn splice_hooks_block(existing: Option<&str>, block: &str) -> Result<String, String> {
394 let Some(text) = existing else {
395 return Ok(format!("{HOOK_TYPES_LINE}\n\nrepos:\n{block}\n"));
396 };
397 if let Some(defect) = hooks_marker_defect(text) {
398 return Err(defect);
399 }
400 if let Some(found) = extract_block(text, HOOKS_BEGIN, HOOKS_END) {
401 return Ok(text.replacen(found, block, 1));
402 }
403 let mut out = String::with_capacity(text.len() + block.len() + 1);
404 let mut placed = false;
405 for line in text.split_inclusive('\n') {
406 out.push_str(line);
407 if !placed && line.trim_end() == "repos:" {
408 if !out.ends_with('\n') {
409 out.push('\n');
410 }
411 out.push_str(block);
412 out.push('\n');
413 placed = true;
414 }
415 }
416 if placed {
417 Ok(out)
418 } else {
419 Err(format!(
420 "{HOOKS_DESTINATION} exists with no repos: line, so the hook block has nowhere to land"
421 ))
422 }
423}
424
425#[must_use]
434pub fn hooks_marker_defect(text: &str) -> Option<String> {
435 let begins = text.matches(HOOKS_BEGIN).count();
436 let ends = text.matches(HOOKS_END).count();
437 if begins > 1 || ends > 1 {
438 return Some(format!(
439 "{HOOKS_DESTINATION} carries more than one release-kit marker pair; release-kit owns exactly one block"
440 ));
441 }
442 match (text.find(HOOKS_BEGIN), text.find(HOOKS_END)) {
443 (Some(begin), Some(end)) if end > begin => None,
444 (None, None) => None,
445 _ => Some(format!(
446 "{HOOKS_DESTINATION} carries an unmatched or misordered release-kit marker, so the block's extent is ambiguous"
447 )),
448 }
449}
450
451#[derive(Debug, Clone, Copy, PartialEq, Eq)]
453pub enum Placement {
454 Whole,
456 Block,
458}
459
460#[derive(Debug)]
463pub struct Entry {
464 pub destination: String,
466 pub kind: Kind,
468 pub placement: Placement,
470 pub baseline: Vec<u8>,
473 pub rendered: Vec<u8>,
476}
477
478pub fn pair_files(tech: &str, forge: &str) -> Result<Vec<(String, &'static [u8])>, RkError> {
486 if tech.starts_with('_') || embedded::SNIPPETS.get_dir(tech).is_none() {
489 let known: Vec<String> = embedded::SNIPPETS
490 .dirs()
491 .map(|dir| dir.path().to_string_lossy().into_owned())
492 .filter(|name| !name.starts_with('_'))
493 .collect();
494 return Err(RkError::Usage(format!(
495 "unknown tech '{tech}'; the bindings are: {}",
496 known.join(", ")
497 )));
498 }
499 let pair = format!("{tech}/{forge}");
500 let pair_dir = embedded::SNIPPETS.get_dir(&pair).ok_or_else(|| {
501 let known: Vec<String> = embedded::SNIPPETS
502 .dirs()
503 .filter(|dir| !dir.path().to_string_lossy().starts_with('_'))
504 .flat_map(include_dir::Dir::dirs)
505 .map(|dir| dir.path().to_string_lossy().replace('/', ", "))
506 .collect();
507 RkError::Usage(format!(
508 "the pair ({tech}, {forge}) has no landable files; the supported pairs are: {}",
509 known.join("; ")
510 ))
511 })?;
512 let mut files: Vec<(String, &'static [u8])> = Vec::new();
516 let shared = format!("_shared/{forge}");
517 if let Some(shared_dir) = embedded::SNIPPETS.get_dir(&shared) {
518 for (path, contents) in embedded::walk(shared_dir) {
519 let rel = path
520 .strip_prefix(&format!("{shared}/"))
521 .map_or(path.as_str(), |rel| rel)
522 .to_owned();
523 files.push((rel, contents));
524 }
525 }
526 for (path, contents) in embedded::walk(pair_dir) {
527 let rel = path
528 .strip_prefix(&format!("{pair}/"))
529 .map_or(path.as_str(), |rel| rel)
530 .to_owned();
531 if files.iter().any(|(existing, _)| *existing == rel) {
532 return Err(anyhow::anyhow!(
533 "the shared zone and the pair ({tech}, {forge}) both ship {rel}; the payload is defective"
534 )
535 .into());
536 }
537 files.push((rel, contents));
538 }
539 Ok(files)
540}
541
542pub fn projection(
555 tech: &str,
556 forge: &str,
557 repo: &str,
558 scopes: &[String],
559 workflow: Workflow,
560) -> Result<Vec<Entry>, RkError> {
561 let mut entries = Vec::new();
562 for (destination, baseline) in pair_files(tech, forge)? {
563 let kind = kind_of(&destination).ok_or_else(|| {
564 anyhow::anyhow!("the payload does not classify {destination}; the kind table is stale")
565 })?;
566 let rendered = match kind {
567 Kind::Rendered => render(baseline, repo, scopes),
568 Kind::Seeded | Kind::State => baseline.to_vec(),
569 };
570 entries.push(Entry {
571 destination,
572 kind,
573 placement: Placement::Whole,
574 baseline: baseline.to_vec(),
575 rendered,
576 });
577 }
578 for (destination, template) in [
579 (AGENTS_DESTINATION, routing_block(workflow)),
580 (HOOKS_DESTINATION, hooks_block(workflow)),
581 ] {
582 entries.push(Entry {
583 destination: destination.to_owned(),
584 kind: Kind::Rendered,
585 placement: Placement::Block,
586 baseline: template.as_bytes().to_vec(),
587 rendered: render(template.as_bytes(), repo, scopes),
588 });
589 }
590 entries.sort_by(|a, b| a.destination.cmp(&b.destination));
591 Ok(entries)
592}
593
594pub fn read_destination(target: &Utf8Path, entry: &Entry) -> std::io::Result<Option<Vec<u8>>> {
602 read_recorded(target, &entry.destination)
603}
604
605pub fn read_recorded(target: &Utf8Path, destination: &str) -> std::io::Result<Option<Vec<u8>>> {
616 let path = target.join(destination);
617 let bytes = match std::fs::read(&path) {
618 Ok(bytes) => bytes,
619 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
620 Err(e) => return Err(e),
621 };
622 if let Some((begin, end)) = block_markers(destination) {
623 let text = String::from_utf8_lossy(&bytes);
624 Ok(extract_block(&text, begin, end).map(|block| block.as_bytes().to_vec()))
625 } else {
626 Ok(Some(bytes))
627 }
628}
629
630#[derive(Debug)]
633pub struct Resolved {
634 pub forge: String,
636 pub repo: Option<String>,
638}
639
640pub fn resolve(
652 target: &Utf8Path,
653 forge_flag: Option<&str>,
654 repo_flag: Option<&str>,
655) -> Result<Resolved, RkError> {
656 let forge_flag = forge_flag
657 .map(|name| {
658 crate::detect::Forge::parse(name).ok_or_else(|| {
659 RkError::Usage(format!(
660 "unknown forge '{name}'; the forges are: github, gitlab"
661 ))
662 })
663 })
664 .transpose()?;
665 let detected = crate::detect::detect(target.as_std_path());
666 let forge = forge_flag
667 .or(detected.forge)
668 .map(|forge| forge.as_str().to_owned())
669 .ok_or_else(|| {
670 let message = detected.host.map_or_else(
671 || "no forge detected: the target has no origin remote".to_owned(),
672 |host| format!("no forge detected: the host {host} is not recognized"),
673 );
674 RkError::refusal(
675 Diagnostic::new(Reason::ForgeUndetected, message)
676 .expected("a github.com or gitlab remote, or --forge")
677 .action("pass --forge <github|gitlab>"),
678 )
679 })?;
680 Ok(Resolved {
681 forge,
682 repo: repo_flag.map(str::to_owned).or(detected.repo),
683 })
684}
685
686#[must_use]
689pub fn repo_unresolved() -> RkError {
690 RkError::missing(
691 Diagnostic::new(
692 Reason::ForgeUndetected,
693 "no repository detected: the target has no origin remote",
694 )
695 .expected("an origin remote naming the project")
696 .action("pass --repo <path>"),
697 )
698}
699
700pub fn write_destination(target: &Utf8Path, entry: &Entry) -> std::io::Result<()> {
710 let path = target.join(&entry.destination);
711 match entry.placement {
712 Placement::Whole => atomic::write(path.as_std_path(), &entry.rendered),
713 Placement::Block => {
714 let existing = match std::fs::read(&path) {
715 Ok(bytes) => Some(String::from_utf8_lossy(&bytes).into_owned()),
716 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
717 Err(e) => return Err(e),
718 };
719 let block = String::from_utf8_lossy(&entry.rendered).into_owned();
720 let spliced = if entry.destination == HOOKS_DESTINATION {
721 splice_hooks_block(existing.as_deref(), &block).map_err(std::io::Error::other)?
722 } else {
723 splice_agents_block(existing.as_deref(), &block)
724 };
725 atomic::write(path.as_std_path(), spliced.as_bytes())
726 }
727 }
728}
729
730pub fn hooks_file_defect(target: &Utf8Path) -> std::io::Result<Option<String>> {
743 let path = target.join(HOOKS_DESTINATION);
744 match std::fs::read(&path) {
745 Ok(bytes) => {
746 let text = String::from_utf8_lossy(&bytes);
747 Ok(splice_hooks_block(Some(&text), HOOKS_BLOCK).err())
748 }
749 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
750 Err(e) => Err(e),
751 }
752}
753
754pub fn hooks_splice_refusal(target: &Utf8Path) -> Result<(), RkError> {
764 hooks_file_defect(target)?.map_or(Ok(()), |reason| {
765 Err(RkError::refusal(
766 Diagnostic::new(
767 Reason::StateDrift,
768 format!("{reason}, and nothing was written"),
769 )
770 .expected("a .pre-commit-config.yaml the block can land in, or none")
771 .action(format!(
772 "resolve it in {}, then re-run",
773 target.join(HOOKS_DESTINATION)
774 ))
775 .target_state("unchanged"),
776 ))
777 })
778}
779
780#[cfg(test)]
781mod tests {
782 #![allow(clippy::expect_used)]
783
784 use super::{
785 AGENTS_DESTINATION, BLOCK_BEGIN, BLOCK_END, BRANCH_GRAMMAR, HOOK_TYPES_LINE, HOOKS_BEGIN,
786 HOOKS_DESTINATION, HOOKS_END, Kind, Workflow, extract_block, hooks_block, kind_of,
787 pair_files, parse_scopes, projection, render, routing_block, splice_agents_block,
788 splice_hooks_block,
789 };
790 use crate::embedded;
791
792 fn scopes(list: &[&str]) -> Vec<String> {
793 list.iter().map(|s| (*s).to_owned()).collect()
794 }
795
796 #[test]
800 fn the_kind_table_closes_over_every_snippet() {
801 for tech_dir in embedded::SNIPPETS.dirs() {
802 for pair_dir in tech_dir.dirs() {
803 let prefix = format!("{}/", pair_dir.path().to_string_lossy());
804 for (path, _) in embedded::walk(pair_dir) {
805 let destination = path.strip_prefix(&prefix).unwrap_or(&path);
806 assert!(
807 kind_of(destination).is_some(),
808 "{destination}: no declared kind"
809 );
810 }
811 }
812 }
813 assert_eq!(kind_of(AGENTS_DESTINATION), Some(Kind::Rendered));
814 assert_eq!(kind_of(HOOKS_DESTINATION), Some(Kind::Rendered));
815 assert_eq!(kind_of("something-else.txt"), None);
816 }
817
818 #[test]
822 fn rendering_substitutes_every_owner_occurrence() {
823 let baseline = b"if: repository_owner == 'OWNER'\n# OWNER again: OWNER\n";
824 let rendered = render(baseline, "acme/sub/widget", &[]);
825 let text = String::from_utf8(rendered).expect("rendered bytes stay text");
826 assert_eq!(text, "if: repository_owner == 'acme'\n# acme again: acme\n");
827
828 let baseline = b"scopes 'RK_SCOPES_CSV' match (RK_SCOPES_PIPE)\n";
829 let rendered = render(baseline, "acme/widget", &scopes(&["api", "cli"]));
830 let text = String::from_utf8(rendered).expect("rendered bytes stay text");
831 assert_eq!(text, "scopes 'api,cli' match (api|cli)\n");
832
833 let rendered = render(baseline, "acme/widget", &scopes(&["api.v1"]));
836 let text = String::from_utf8(rendered).expect("rendered bytes stay text");
837 assert_eq!(text, "scopes 'api.v1' match (api\\.v1)\n");
838 }
839
840 #[test]
843 fn scope_parsing_refuses_the_unusable() {
844 assert_eq!(
845 parse_scopes("api, cli,guides/release").expect("a clean list parses"),
846 scopes(&["api", "cli", "guides/release"])
847 );
848 assert!(parse_scopes("").is_err());
849 assert!(parse_scopes(" , ").is_err());
850 assert!(parse_scopes("api|cli").is_err());
851 assert!(parse_scopes("a b").is_err());
852 }
853
854 #[test]
857 fn the_shared_zone_composes_into_the_pair() {
858 let files = pair_files("rust", "github").expect("the pair lists");
859 assert!(
860 files
861 .iter()
862 .any(|(dest, _)| dest == ".github/workflows/pr-title.yml"),
863 "the shared title check lands with the pair"
864 );
865 let files = pair_files("rust", "gitlab").expect("the pair lists");
866 assert!(
867 files
868 .iter()
869 .any(|(dest, _)| dest == ".gitlab/ci/mr-title.yml"),
870 "the shared title job lands with the pair"
871 );
872 let err = pair_files("_shared", "github").expect_err("the shared zone is no tech");
873 let listing = err.to_string();
874 let bindings = listing
875 .split("the bindings are:")
876 .nth(1)
877 .expect("the refusal lists the bindings");
878 assert!(!bindings.contains("_shared"), "{listing}");
879 }
880
881 #[test]
885 fn a_projection_renders_owned_files_and_keeps_seeded_judgment() {
886 let entries = projection(
887 "rust",
888 "github",
889 "acme/widget",
890 &scopes(&["api", "cli"]),
891 Workflow::Branches,
892 )
893 .expect("the pair projects");
894 let workflow = entries
895 .iter()
896 .find(|entry| entry.destination.ends_with("release-plz.yml"))
897 .expect("the workflow projects");
898 assert_eq!(workflow.kind, Kind::Rendered);
899 let text = String::from_utf8_lossy(&workflow.rendered);
900 assert!(!text.contains("OWNER"), "an owner token survived rendering");
901 assert!(text.contains("'acme'"));
902 assert!(!text.contains("TODO(release-kit)"));
903 let title = entries
904 .iter()
905 .find(|entry| entry.destination.ends_with("pr-title.yml"))
906 .expect("the title check projects");
907 let text = String::from_utf8_lossy(&title.rendered);
908 assert!(text.contains("api|cli"), "{text}");
909 assert!(
910 !text.contains("RK_SCOPES"),
911 "a scope token survived: {text}"
912 );
913 let seeded = entries
914 .iter()
915 .find(|entry| entry.destination == "release-plz.toml")
916 .expect("the seeded file projects");
917 assert_eq!(seeded.kind, Kind::Seeded);
918 assert_eq!(seeded.rendered, seeded.baseline);
919 assert!(String::from_utf8_lossy(&seeded.rendered).contains("TODO(release-kit)"));
920 for block in [AGENTS_DESTINATION, HOOKS_DESTINATION] {
921 let entry = entries
922 .iter()
923 .find(|entry| entry.destination == block)
924 .expect("both blocks are part of the projection");
925 let text = String::from_utf8_lossy(&entry.rendered);
926 assert!(!text.contains("RK_SCOPES"), "{block} kept a token: {text}");
927 assert!(text.contains("api,cli"), "{block} lost the scopes: {text}");
928 }
929 }
930
931 #[test]
932 fn the_block_splices_into_every_agents_shape() {
933 let owned = routing_block(Workflow::Branches);
934 let block = owned.as_str();
935 let fresh = splice_agents_block(None, block);
936 assert_eq!(fresh, format!("{block}\n"));
937 assert_eq!(extract_block(&fresh, BLOCK_BEGIN, BLOCK_END), Some(block));
938
939 let appended = splice_agents_block(Some("# My project\n\nOwn rules.\n"), block);
940 assert!(appended.starts_with("# My project\n\nOwn rules.\n\n<!-- BEGIN release-kit -->"));
941 assert_eq!(
942 extract_block(&appended, BLOCK_BEGIN, BLOCK_END),
943 Some(block)
944 );
945
946 let stale = appended.replace("Never author a tag", "Do author a tag");
947 let refreshed = splice_agents_block(Some(&stale), block);
948 assert_eq!(
949 extract_block(&refreshed, BLOCK_BEGIN, BLOCK_END),
950 Some(block)
951 );
952 assert!(refreshed.starts_with("# My project"));
953 assert_eq!(
954 refreshed.matches("BEGIN release-kit").count(),
955 1,
956 "a re-splice must replace, not accumulate"
957 );
958 }
959
960 #[test]
963 fn the_hook_block_splices_under_repos() {
964 let owned = hooks_block(Workflow::Branches);
965 let block = owned.as_str();
966 let fresh = splice_hooks_block(None, block).expect("a fresh file splices");
967 assert!(fresh.starts_with(HOOK_TYPES_LINE));
968 assert!(fresh.contains("\nrepos:\n# BEGIN release-kit\n"));
969 assert_eq!(extract_block(&fresh, HOOKS_BEGIN, HOOKS_END), Some(block));
970
971 let own =
972 "repos:\n - repo: https://example.com/own\n rev: v1\n hooks:\n - id: own\n";
973 let spliced = splice_hooks_block(Some(own), block).expect("an unmarked file splices");
974 assert!(spliced.starts_with("repos:\n# BEGIN release-kit\n"));
975 assert!(spliced.contains("- id: own"), "the target's hooks survive");
976 assert!(
977 !spliced.contains(HOOK_TYPES_LINE),
978 "an existing file's top level is the skills' duty, not the splice's"
979 );
980
981 let stale = spliced.replace("--force-scope", "--no-scope");
982 let refreshed = splice_hooks_block(Some(&stale), block).expect("a marked file re-splices");
983 assert_eq!(
984 extract_block(&refreshed, HOOKS_BEGIN, HOOKS_END),
985 Some(block)
986 );
987 assert_eq!(refreshed.matches(HOOKS_BEGIN).count(), 1);
988
989 let err = splice_hooks_block(Some("minimum_pre_commit_version: '3.2.0'\n"), block)
990 .expect_err("no repos: line refuses");
991 assert!(err.contains("repos:"), "{err}");
992
993 let doubled = format!("repos:\n{block}\n{block}\n");
997 let err = splice_hooks_block(Some(&doubled), block).expect_err("a second block refuses");
998 assert!(err.contains("one block"), "{err}");
999 let unmatched = "repos:\n# BEGIN release-kit\n - repo: local\n";
1000 let err =
1001 splice_hooks_block(Some(unmatched), block).expect_err("an unmatched marker refuses");
1002 assert!(err.contains("unmatched"), "{err}");
1003 }
1004
1005 #[test]
1011 fn the_blocks_render_per_mode_and_carry_the_one_grammar() {
1012 let worktree_hooks = hooks_block(Workflow::Worktree);
1013 let branches_hooks = hooks_block(Workflow::Branches);
1014 assert!(worktree_hooks.contains("- id: rk-worktree-location"));
1015 assert!(
1016 worktree_hooks.contains("SKIP=no-commit-to-branch,rk-worktree-location"),
1017 "{worktree_hooks}"
1018 );
1019 assert!(!branches_hooks.contains("rk-worktree-location"));
1020 assert!(branches_hooks.contains("SKIP=no-commit-to-branch in"));
1021 for block in [&worktree_hooks, &branches_hooks] {
1022 assert!(block.contains(BRANCH_GRAMMAR), "the grammar has one owner");
1023 for token in ["RK_BRANCH_GRAMMAR", "RK_SWEEP_SKIP", "RK_WORKTREE_GUARD"] {
1024 assert!(!block.contains(token), "{token} survived: {block}");
1025 }
1026 }
1027 let guard_line = worktree_hooks
1028 .lines()
1029 .position(|line| line.contains("id: rk-worktree-location"))
1030 .expect("the guard entry exists");
1031 let name_line = worktree_hooks
1032 .lines()
1033 .position(|line| line.contains("id: rk-branch-name"))
1034 .expect("the name hook exists");
1035 assert!(
1036 guard_line > name_line,
1037 "the guard lands directly after rk-branch-name"
1038 );
1039
1040 let worktree_routing = routing_block(Workflow::Worktree);
1041 let branches_routing = routing_block(Workflow::Branches);
1042 assert!(worktree_routing.contains("This project works in worktrees"));
1043 assert!(branches_routing.contains("Branches are worked in the main checkout"));
1044 for block in [&worktree_routing, &branches_routing] {
1045 assert!(block.contains("creating or removing a worktree"));
1046 assert!(block.contains("`rk worktree add <branch>`"));
1047 assert!(!block.contains("RK_WORKFLOW_LINE"), "{block}");
1048 }
1049 let differing: Vec<(&str, &str)> = worktree_routing
1050 .lines()
1051 .zip(branches_routing.lines())
1052 .filter(|(a, b)| a != b)
1053 .collect();
1054 assert_eq!(
1055 differing.len(),
1056 1,
1057 "exactly one routing line differs per mode: {differing:?}"
1058 );
1059 }
1060
1061 #[test]
1064 fn the_hook_marker_defects_are_named() {
1065 use super::hooks_marker_defect;
1066 let owned = hooks_block(Workflow::Branches);
1067 let block = owned.as_str();
1068 assert_eq!(hooks_marker_defect(""), None);
1069 assert_eq!(hooks_marker_defect(&format!("repos:\n{block}\n")), None);
1070 for (case, text) in [
1071 (
1072 "a second begin",
1073 format!("repos:\n{block}\n# BEGIN release-kit\n"),
1074 ),
1075 (
1076 "a second end",
1077 format!("repos:\n{block}\n# END release-kit\n"),
1078 ),
1079 (
1080 "an unpaired begin",
1081 "repos:\n# BEGIN release-kit\n".to_owned(),
1082 ),
1083 ("an unpaired end", "repos:\n# END release-kit\n".to_owned()),
1084 (
1085 "an end before its begin",
1086 "repos:\n# END release-kit\n# BEGIN release-kit\n".to_owned(),
1087 ),
1088 ] {
1089 assert!(
1090 hooks_marker_defect(&text).is_some(),
1091 "{case} must be a defect"
1092 );
1093 }
1094 }
1095}