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
198static AGENTS_BLOCK: &str = include_str!("../blocks/agents-block.md.in");
200
201static AGENTS_LINE_WORKTREE: &str = include_str!("../blocks/agents-line-worktree.md.in");
203
204static AGENTS_LINE_BRANCHES: &str = include_str!("../blocks/agents-line-branches.md.in");
206
207static PRE_COMMIT_BLOCK: &str = include_str!("../blocks/pre-commit-block.yaml.in");
209
210static PRE_COMMIT_WORKTREE_GUARD: &str =
212 include_str!("../blocks/pre-commit-worktree-guard.yaml.in");
213
214fn authored(text: &str) -> &str {
218 text.strip_suffix('\n').unwrap_or(text)
219}
220
221pub 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[-/].+)$";
229
230#[must_use]
239pub fn routing_block(workflow: Workflow) -> String {
240 let line = match workflow {
241 Workflow::Worktree => authored(AGENTS_LINE_WORKTREE),
242 Workflow::Branches => authored(AGENTS_LINE_BRANCHES),
243 };
244 authored(AGENTS_BLOCK).replacen("RK_WORKFLOW_LINE", line, 1)
245}
246
247#[must_use]
259pub fn hooks_block(workflow: Workflow) -> String {
260 let (guard, skip) = match workflow {
261 Workflow::Worktree => (
262 format!("{}\n", authored(PRE_COMMIT_WORKTREE_GUARD)),
263 "no-commit-to-branch,rk-worktree-location",
264 ),
265 Workflow::Branches => (String::new(), "no-commit-to-branch"),
266 };
267 authored(PRE_COMMIT_BLOCK)
268 .replacen("RK_BRANCH_GRAMMAR", BRANCH_GRAMMAR, 1)
269 .replacen("RK_SWEEP_SKIP", skip, 1)
270 .replacen("RK_WORKTREE_GUARD", &guard, 1)
271}
272
273#[must_use]
275pub fn block_markers(destination: &str) -> Option<(&'static str, &'static str)> {
276 match destination {
277 AGENTS_DESTINATION => Some((BLOCK_BEGIN, BLOCK_END)),
278 HOOKS_DESTINATION => Some((HOOKS_BEGIN, HOOKS_END)),
279 _ => None,
280 }
281}
282
283#[must_use]
286pub fn extract_block<'a>(text: &'a str, begin: &str, end: &str) -> Option<&'a str> {
287 let start = text.find(begin)?;
288 let stop = text[start..].find(end)? + start + end.len();
289 Some(&text[start..stop])
290}
291
292#[must_use]
298pub fn splice_agents_block(existing: Option<&str>, block: &str) -> String {
299 existing.map_or_else(
300 || format!("{block}\n"),
301 |text| {
302 extract_block(text, BLOCK_BEGIN, BLOCK_END).map_or_else(
303 || format!("{}\n\n{block}\n", text.trim_end()),
304 |found| text.replacen(found, block, 1),
305 )
306 },
307 )
308}
309
310pub fn splice_hooks_block(existing: Option<&str>, block: &str) -> Result<String, String> {
323 let Some(text) = existing else {
324 return Ok(format!("{HOOK_TYPES_LINE}\n\nrepos:\n{block}\n"));
325 };
326 if let Some(defect) = hooks_marker_defect(text) {
327 return Err(defect);
328 }
329 if let Some(found) = extract_block(text, HOOKS_BEGIN, HOOKS_END) {
330 return Ok(text.replacen(found, block, 1));
331 }
332 let mut out = String::with_capacity(text.len() + block.len() + 1);
333 let mut placed = false;
334 for line in text.split_inclusive('\n') {
335 out.push_str(line);
336 if !placed && line.trim_end() == "repos:" {
337 if !out.ends_with('\n') {
338 out.push('\n');
339 }
340 out.push_str(block);
341 out.push('\n');
342 placed = true;
343 }
344 }
345 if placed {
346 Ok(out)
347 } else {
348 Err(format!(
349 "{HOOKS_DESTINATION} exists with no repos: line, so the hook block has nowhere to land"
350 ))
351 }
352}
353
354#[must_use]
363pub fn hooks_marker_defect(text: &str) -> Option<String> {
364 let begins = text.matches(HOOKS_BEGIN).count();
365 let ends = text.matches(HOOKS_END).count();
366 if begins > 1 || ends > 1 {
367 return Some(format!(
368 "{HOOKS_DESTINATION} carries more than one release-kit marker pair; release-kit owns exactly one block"
369 ));
370 }
371 match (text.find(HOOKS_BEGIN), text.find(HOOKS_END)) {
372 (Some(begin), Some(end)) if end > begin => None,
373 (None, None) => None,
374 _ => Some(format!(
375 "{HOOKS_DESTINATION} carries an unmatched or misordered release-kit marker, so the block's extent is ambiguous"
376 )),
377 }
378}
379
380#[derive(Debug, Clone, Copy, PartialEq, Eq)]
382pub enum Placement {
383 Whole,
385 Block,
387}
388
389#[derive(Debug)]
392pub struct Entry {
393 pub destination: String,
395 pub kind: Kind,
397 pub placement: Placement,
399 pub baseline: Vec<u8>,
402 pub rendered: Vec<u8>,
405}
406
407pub fn pair_files(tech: &str, forge: &str) -> Result<Vec<(String, &'static [u8])>, RkError> {
415 if tech.starts_with('_') || embedded::SNIPPETS.get_dir(tech).is_none() {
418 let known: Vec<String> = embedded::SNIPPETS
419 .dirs()
420 .map(|dir| dir.path().to_string_lossy().into_owned())
421 .filter(|name| !name.starts_with('_'))
422 .collect();
423 return Err(RkError::Usage(format!(
424 "unknown tech '{tech}'; the bindings are: {}",
425 known.join(", ")
426 )));
427 }
428 let pair = format!("{tech}/{forge}");
429 let pair_dir = embedded::SNIPPETS.get_dir(&pair).ok_or_else(|| {
430 let known: Vec<String> = embedded::SNIPPETS
431 .dirs()
432 .filter(|dir| !dir.path().to_string_lossy().starts_with('_'))
433 .flat_map(include_dir::Dir::dirs)
434 .map(|dir| dir.path().to_string_lossy().replace('/', ", "))
435 .collect();
436 RkError::Usage(format!(
437 "the pair ({tech}, {forge}) has no landable files; the supported pairs are: {}",
438 known.join("; ")
439 ))
440 })?;
441 let mut files: Vec<(String, &'static [u8])> = Vec::new();
445 let shared = format!("_shared/{forge}");
446 if let Some(shared_dir) = embedded::SNIPPETS.get_dir(&shared) {
447 for (path, contents) in embedded::walk(shared_dir) {
448 let rel = path
449 .strip_prefix(&format!("{shared}/"))
450 .map_or(path.as_str(), |rel| rel)
451 .to_owned();
452 files.push((rel, contents));
453 }
454 }
455 for (path, contents) in embedded::walk(pair_dir) {
456 let rel = path
457 .strip_prefix(&format!("{pair}/"))
458 .map_or(path.as_str(), |rel| rel)
459 .to_owned();
460 if files.iter().any(|(existing, _)| *existing == rel) {
461 return Err(anyhow::anyhow!(
462 "the shared zone and the pair ({tech}, {forge}) both ship {rel}; the payload is defective"
463 )
464 .into());
465 }
466 files.push((rel, contents));
467 }
468 Ok(files)
469}
470
471pub fn projection(
484 tech: &str,
485 forge: &str,
486 repo: &str,
487 scopes: &[String],
488 workflow: Workflow,
489) -> Result<Vec<Entry>, RkError> {
490 let mut entries = Vec::new();
491 for (destination, baseline) in pair_files(tech, forge)? {
492 let kind = kind_of(&destination).ok_or_else(|| {
493 anyhow::anyhow!("the payload does not classify {destination}; the kind table is stale")
494 })?;
495 let rendered = match kind {
496 Kind::Rendered => render(baseline, repo, scopes),
497 Kind::Seeded | Kind::State => baseline.to_vec(),
498 };
499 entries.push(Entry {
500 destination,
501 kind,
502 placement: Placement::Whole,
503 baseline: baseline.to_vec(),
504 rendered,
505 });
506 }
507 for (destination, template) in [
508 (AGENTS_DESTINATION, routing_block(workflow)),
509 (HOOKS_DESTINATION, hooks_block(workflow)),
510 ] {
511 entries.push(Entry {
512 destination: destination.to_owned(),
513 kind: Kind::Rendered,
514 placement: Placement::Block,
515 baseline: template.as_bytes().to_vec(),
516 rendered: render(template.as_bytes(), repo, scopes),
517 });
518 }
519 entries.sort_by(|a, b| a.destination.cmp(&b.destination));
520 Ok(entries)
521}
522
523pub fn read_destination(target: &Utf8Path, entry: &Entry) -> std::io::Result<Option<Vec<u8>>> {
531 read_recorded(target, &entry.destination)
532}
533
534pub fn read_recorded(target: &Utf8Path, destination: &str) -> std::io::Result<Option<Vec<u8>>> {
545 let path = target.join(destination);
546 let bytes = match std::fs::read(&path) {
547 Ok(bytes) => bytes,
548 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
549 Err(e) => return Err(e),
550 };
551 if let Some((begin, end)) = block_markers(destination) {
552 let text = String::from_utf8_lossy(&bytes);
553 Ok(extract_block(&text, begin, end).map(|block| block.as_bytes().to_vec()))
554 } else {
555 Ok(Some(bytes))
556 }
557}
558
559#[derive(Debug)]
562pub struct Resolved {
563 pub forge: String,
565 pub repo: Option<String>,
567}
568
569pub fn resolve(
581 target: &Utf8Path,
582 forge_flag: Option<&str>,
583 repo_flag: Option<&str>,
584) -> Result<Resolved, RkError> {
585 let forge_flag = forge_flag
586 .map(|name| {
587 crate::detect::Forge::parse(name).ok_or_else(|| {
588 RkError::Usage(format!(
589 "unknown forge '{name}'; the forges are: github, gitlab"
590 ))
591 })
592 })
593 .transpose()?;
594 let detected = crate::detect::detect(target.as_std_path());
595 let forge = forge_flag
596 .or(detected.forge)
597 .map(|forge| forge.as_str().to_owned())
598 .ok_or_else(|| {
599 let message = detected.host.map_or_else(
600 || "no forge detected: the target has no origin remote".to_owned(),
601 |host| format!("no forge detected: the host {host} is not recognized"),
602 );
603 RkError::refusal(
604 Diagnostic::new(Reason::ForgeUndetected, message)
605 .expected("a github.com or gitlab remote, or --forge")
606 .action("pass --forge <github|gitlab>"),
607 )
608 })?;
609 Ok(Resolved {
610 forge,
611 repo: repo_flag.map(str::to_owned).or(detected.repo),
612 })
613}
614
615#[must_use]
618pub fn repo_unresolved() -> RkError {
619 RkError::missing(
620 Diagnostic::new(
621 Reason::ForgeUndetected,
622 "no repository detected: the target has no origin remote",
623 )
624 .expected("an origin remote naming the project")
625 .action("pass --repo <path>"),
626 )
627}
628
629pub fn write_destination(target: &Utf8Path, entry: &Entry) -> std::io::Result<()> {
639 let path = target.join(&entry.destination);
640 match entry.placement {
641 Placement::Whole => atomic::write(path.as_std_path(), &entry.rendered),
642 Placement::Block => {
643 let existing = match std::fs::read(&path) {
644 Ok(bytes) => Some(String::from_utf8_lossy(&bytes).into_owned()),
645 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
646 Err(e) => return Err(e),
647 };
648 let block = String::from_utf8_lossy(&entry.rendered).into_owned();
649 let spliced = if entry.destination == HOOKS_DESTINATION {
650 splice_hooks_block(existing.as_deref(), &block).map_err(std::io::Error::other)?
651 } else {
652 splice_agents_block(existing.as_deref(), &block)
653 };
654 atomic::write(path.as_std_path(), spliced.as_bytes())
655 }
656 }
657}
658
659pub fn hooks_file_defect(target: &Utf8Path) -> std::io::Result<Option<String>> {
672 let path = target.join(HOOKS_DESTINATION);
673 match std::fs::read(&path) {
674 Ok(bytes) => {
675 let text = String::from_utf8_lossy(&bytes);
676 Ok(splice_hooks_block(Some(&text), authored(PRE_COMMIT_BLOCK)).err())
677 }
678 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
679 Err(e) => Err(e),
680 }
681}
682
683pub fn hooks_splice_refusal(target: &Utf8Path) -> Result<(), RkError> {
693 hooks_file_defect(target)?.map_or(Ok(()), |reason| {
694 Err(RkError::refusal(
695 Diagnostic::new(
696 Reason::StateDrift,
697 format!("{reason}, and nothing was written"),
698 )
699 .expected("a .pre-commit-config.yaml the block can land in, or none")
700 .action(format!(
701 "resolve it in {}, then re-run",
702 target.join(HOOKS_DESTINATION)
703 ))
704 .target_state("unchanged"),
705 ))
706 })
707}
708
709#[cfg(test)]
710mod tests {
711 #![allow(clippy::expect_used)]
712
713 use super::{
714 AGENTS_DESTINATION, BLOCK_BEGIN, BLOCK_END, BRANCH_GRAMMAR, HOOK_TYPES_LINE, HOOKS_BEGIN,
715 HOOKS_DESTINATION, HOOKS_END, Kind, Workflow, extract_block, hooks_block, kind_of,
716 pair_files, parse_scopes, projection, render, routing_block, splice_agents_block,
717 splice_hooks_block,
718 };
719 use crate::embedded;
720
721 fn scopes(list: &[&str]) -> Vec<String> {
722 list.iter().map(|s| (*s).to_owned()).collect()
723 }
724
725 #[test]
729 fn the_kind_table_closes_over_every_snippet() {
730 for tech_dir in embedded::SNIPPETS.dirs() {
731 for pair_dir in tech_dir.dirs() {
732 let prefix = format!("{}/", pair_dir.path().to_string_lossy());
733 for (path, _) in embedded::walk(pair_dir) {
734 let destination = path.strip_prefix(&prefix).unwrap_or(&path);
735 assert!(
736 kind_of(destination).is_some(),
737 "{destination}: no declared kind"
738 );
739 }
740 }
741 }
742 assert_eq!(kind_of(AGENTS_DESTINATION), Some(Kind::Rendered));
743 assert_eq!(kind_of(HOOKS_DESTINATION), Some(Kind::Rendered));
744 assert_eq!(kind_of("something-else.txt"), None);
745 }
746
747 #[test]
751 fn rendering_substitutes_every_owner_occurrence() {
752 let baseline = b"if: repository_owner == 'OWNER'\n# OWNER again: OWNER\n";
753 let rendered = render(baseline, "acme/sub/widget", &[]);
754 let text = String::from_utf8(rendered).expect("rendered bytes stay text");
755 assert_eq!(text, "if: repository_owner == 'acme'\n# acme again: acme\n");
756
757 let baseline = b"scopes 'RK_SCOPES_CSV' match (RK_SCOPES_PIPE)\n";
758 let rendered = render(baseline, "acme/widget", &scopes(&["api", "cli"]));
759 let text = String::from_utf8(rendered).expect("rendered bytes stay text");
760 assert_eq!(text, "scopes 'api,cli' match (api|cli)\n");
761
762 let rendered = render(baseline, "acme/widget", &scopes(&["api.v1"]));
765 let text = String::from_utf8(rendered).expect("rendered bytes stay text");
766 assert_eq!(text, "scopes 'api.v1' match (api\\.v1)\n");
767 }
768
769 #[test]
772 fn scope_parsing_refuses_the_unusable() {
773 assert_eq!(
774 parse_scopes("api, cli,guides/release").expect("a clean list parses"),
775 scopes(&["api", "cli", "guides/release"])
776 );
777 assert!(parse_scopes("").is_err());
778 assert!(parse_scopes(" , ").is_err());
779 assert!(parse_scopes("api|cli").is_err());
780 assert!(parse_scopes("a b").is_err());
781 }
782
783 #[test]
786 fn the_shared_zone_composes_into_the_pair() {
787 let files = pair_files("rust", "github").expect("the pair lists");
788 assert!(
789 files
790 .iter()
791 .any(|(dest, _)| dest == ".github/workflows/pr-title.yml"),
792 "the shared title check lands with the pair"
793 );
794 let files = pair_files("rust", "gitlab").expect("the pair lists");
795 assert!(
796 files
797 .iter()
798 .any(|(dest, _)| dest == ".gitlab/ci/mr-title.yml"),
799 "the shared title job lands with the pair"
800 );
801 let err = pair_files("_shared", "github").expect_err("the shared zone is no tech");
802 let listing = err.to_string();
803 let bindings = listing
804 .split("the bindings are:")
805 .nth(1)
806 .expect("the refusal lists the bindings");
807 assert!(!bindings.contains("_shared"), "{listing}");
808 }
809
810 #[test]
814 fn a_projection_renders_owned_files_and_keeps_seeded_judgment() {
815 let entries = projection(
816 "rust",
817 "github",
818 "acme/widget",
819 &scopes(&["api", "cli"]),
820 Workflow::Branches,
821 )
822 .expect("the pair projects");
823 let workflow = entries
824 .iter()
825 .find(|entry| entry.destination.ends_with("release-plz.yml"))
826 .expect("the workflow projects");
827 assert_eq!(workflow.kind, Kind::Rendered);
828 let text = String::from_utf8_lossy(&workflow.rendered);
829 assert!(!text.contains("OWNER"), "an owner token survived rendering");
830 assert!(text.contains("'acme'"));
831 assert!(!text.contains("TODO(release-kit)"));
832 let title = entries
833 .iter()
834 .find(|entry| entry.destination.ends_with("pr-title.yml"))
835 .expect("the title check projects");
836 let text = String::from_utf8_lossy(&title.rendered);
837 assert!(text.contains("api|cli"), "{text}");
838 assert!(
839 !text.contains("RK_SCOPES"),
840 "a scope token survived: {text}"
841 );
842 let seeded = entries
843 .iter()
844 .find(|entry| entry.destination == "release-plz.toml")
845 .expect("the seeded file projects");
846 assert_eq!(seeded.kind, Kind::Seeded);
847 assert_eq!(seeded.rendered, seeded.baseline);
848 assert!(String::from_utf8_lossy(&seeded.rendered).contains("TODO(release-kit)"));
849 for block in [AGENTS_DESTINATION, HOOKS_DESTINATION] {
850 let entry = entries
851 .iter()
852 .find(|entry| entry.destination == block)
853 .expect("both blocks are part of the projection");
854 let text = String::from_utf8_lossy(&entry.rendered);
855 assert!(!text.contains("RK_SCOPES"), "{block} kept a token: {text}");
856 assert!(text.contains("api,cli"), "{block} lost the scopes: {text}");
857 }
858 }
859
860 #[test]
861 fn the_block_splices_into_every_agents_shape() {
862 let owned = routing_block(Workflow::Branches);
863 let block = owned.as_str();
864 let fresh = splice_agents_block(None, block);
865 assert_eq!(fresh, format!("{block}\n"));
866 assert_eq!(extract_block(&fresh, BLOCK_BEGIN, BLOCK_END), Some(block));
867
868 let appended = splice_agents_block(Some("# My project\n\nOwn rules.\n"), block);
869 assert!(appended.starts_with("# My project\n\nOwn rules.\n\n<!-- BEGIN release-kit -->"));
870 assert_eq!(
871 extract_block(&appended, BLOCK_BEGIN, BLOCK_END),
872 Some(block)
873 );
874
875 let stale = appended.replace("Never author a tag", "Do author a tag");
876 let refreshed = splice_agents_block(Some(&stale), block);
877 assert_eq!(
878 extract_block(&refreshed, BLOCK_BEGIN, BLOCK_END),
879 Some(block)
880 );
881 assert!(refreshed.starts_with("# My project"));
882 assert_eq!(
883 refreshed.matches("BEGIN release-kit").count(),
884 1,
885 "a re-splice must replace, not accumulate"
886 );
887 }
888
889 #[test]
892 fn the_hook_block_splices_under_repos() {
893 let owned = hooks_block(Workflow::Branches);
894 let block = owned.as_str();
895 let fresh = splice_hooks_block(None, block).expect("a fresh file splices");
896 assert!(fresh.starts_with(HOOK_TYPES_LINE));
897 assert!(fresh.contains("\nrepos:\n# BEGIN release-kit\n"));
898 assert_eq!(extract_block(&fresh, HOOKS_BEGIN, HOOKS_END), Some(block));
899
900 let own =
901 "repos:\n - repo: https://example.com/own\n rev: v1\n hooks:\n - id: own\n";
902 let spliced = splice_hooks_block(Some(own), block).expect("an unmarked file splices");
903 assert!(spliced.starts_with("repos:\n# BEGIN release-kit\n"));
904 assert!(spliced.contains("- id: own"), "the target's hooks survive");
905 assert!(
906 !spliced.contains(HOOK_TYPES_LINE),
907 "an existing file's top level is the skills' duty, not the splice's"
908 );
909
910 let stale = spliced.replace("--force-scope", "--no-scope");
911 let refreshed = splice_hooks_block(Some(&stale), block).expect("a marked file re-splices");
912 assert_eq!(
913 extract_block(&refreshed, HOOKS_BEGIN, HOOKS_END),
914 Some(block)
915 );
916 assert_eq!(refreshed.matches(HOOKS_BEGIN).count(), 1);
917
918 let err = splice_hooks_block(Some("minimum_pre_commit_version: '3.2.0'\n"), block)
919 .expect_err("no repos: line refuses");
920 assert!(err.contains("repos:"), "{err}");
921
922 let doubled = format!("repos:\n{block}\n{block}\n");
926 let err = splice_hooks_block(Some(&doubled), block).expect_err("a second block refuses");
927 assert!(err.contains("one block"), "{err}");
928 let unmatched = "repos:\n# BEGIN release-kit\n - repo: local\n";
929 let err =
930 splice_hooks_block(Some(unmatched), block).expect_err("an unmatched marker refuses");
931 assert!(err.contains("unmatched"), "{err}");
932 }
933
934 #[test]
940 fn the_blocks_render_per_mode_and_carry_the_one_grammar() {
941 let worktree_hooks = hooks_block(Workflow::Worktree);
942 let branches_hooks = hooks_block(Workflow::Branches);
943 assert!(worktree_hooks.contains("- id: rk-worktree-location"));
944 assert!(
945 worktree_hooks.contains("SKIP=no-commit-to-branch,rk-worktree-location"),
946 "{worktree_hooks}"
947 );
948 assert!(!branches_hooks.contains("rk-worktree-location"));
949 assert!(branches_hooks.contains("SKIP=no-commit-to-branch in"));
950 for block in [&worktree_hooks, &branches_hooks] {
951 assert!(block.contains(BRANCH_GRAMMAR), "the grammar has one owner");
952 for token in ["RK_BRANCH_GRAMMAR", "RK_SWEEP_SKIP", "RK_WORKTREE_GUARD"] {
953 assert!(!block.contains(token), "{token} survived: {block}");
954 }
955 }
956 for block in [&worktree_hooks, &branches_hooks] {
961 for line in block.lines() {
962 if let Some(value) = line.trim_start().strip_prefix("entry: ") {
963 assert!(
964 !value.contains(": "),
965 "an entry value breaks the YAML plain scalar: {line}"
966 );
967 }
968 }
969 }
970 let guard_line = worktree_hooks
971 .lines()
972 .position(|line| line.contains("id: rk-worktree-location"))
973 .expect("the guard entry exists");
974 let name_line = worktree_hooks
975 .lines()
976 .position(|line| line.contains("id: rk-branch-name"))
977 .expect("the name hook exists");
978 assert!(
979 guard_line > name_line,
980 "the guard lands directly after rk-branch-name"
981 );
982
983 let worktree_routing = routing_block(Workflow::Worktree);
984 let branches_routing = routing_block(Workflow::Branches);
985 assert!(worktree_routing.contains("This project works in worktrees"));
986 assert!(branches_routing.contains("Branches are worked in the main checkout"));
987 for block in [&worktree_routing, &branches_routing] {
988 assert!(block.contains("creating or removing a worktree"));
989 assert!(block.contains("`rk worktree add <branch>`"));
990 assert!(!block.contains("RK_WORKFLOW_LINE"), "{block}");
991 }
992 let differing: Vec<(&str, &str)> = worktree_routing
993 .lines()
994 .zip(branches_routing.lines())
995 .filter(|(a, b)| a != b)
996 .collect();
997 assert_eq!(
998 differing.len(),
999 1,
1000 "exactly one routing line differs per mode: {differing:?}"
1001 );
1002 }
1003
1004 #[test]
1007 fn the_hook_marker_defects_are_named() {
1008 use super::hooks_marker_defect;
1009 let owned = hooks_block(Workflow::Branches);
1010 let block = owned.as_str();
1011 assert_eq!(hooks_marker_defect(""), None);
1012 assert_eq!(hooks_marker_defect(&format!("repos:\n{block}\n")), None);
1013 for (case, text) in [
1014 (
1015 "a second begin",
1016 format!("repos:\n{block}\n# BEGIN release-kit\n"),
1017 ),
1018 (
1019 "a second end",
1020 format!("repos:\n{block}\n# END release-kit\n"),
1021 ),
1022 (
1023 "an unpaired begin",
1024 "repos:\n# BEGIN release-kit\n".to_owned(),
1025 ),
1026 ("an unpaired end", "repos:\n# END release-kit\n".to_owned()),
1027 (
1028 "an end before its begin",
1029 "repos:\n# END release-kit\n# BEGIN release-kit\n".to_owned(),
1030 ),
1031 ] {
1032 assert!(
1033 hooks_marker_defect(&text).is_some(),
1034 "{case} must be a defect"
1035 );
1036 }
1037 }
1038}