1pub mod invariants;
13pub mod manifest;
14
15use camino::Utf8Path;
16use serde::{Deserialize, Serialize};
17
18pub use manifest::{Style, 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
98pub const STYLE_TOKEN: &[u8] = b"RK_STYLE";
101
102#[must_use]
111pub fn render(baseline: &[u8], repo: &str, scopes: &[String], style: Option<Style>) -> Vec<u8> {
112 let owner = repo.split('/').next().unwrap_or(repo);
113 let mut out = substitute(baseline, OWNER_TOKEN, owner.as_bytes());
114 if let Some(style) = style {
115 out = substitute(&out, STYLE_TOKEN, style.as_str().as_bytes());
116 }
117 if !scopes.is_empty() {
118 out = substitute(&out, SCOPES_CSV_TOKEN, scopes.join(",").as_bytes());
119 let pipe: Vec<String> = scopes.iter().map(|s| s.replace('.', "\\.")).collect();
124 out = substitute(&out, SCOPES_PIPE_TOKEN, pipe.join("|").as_bytes());
125 }
126 out
127}
128
129fn substitute(baseline: &[u8], token: &[u8], value: &[u8]) -> Vec<u8> {
131 let mut out = Vec::with_capacity(baseline.len());
132 let mut rest = baseline;
133 while let Some(at) = find(rest, token) {
134 out.extend_from_slice(&rest[..at]);
135 out.extend_from_slice(value);
136 rest = &rest[at + token.len()..];
137 }
138 out.extend_from_slice(rest);
139 out
140}
141
142pub fn parse_scopes(raw: &str) -> Result<Vec<String>, RkError> {
153 let scopes: Vec<String> = raw
154 .split(',')
155 .map(str::trim)
156 .filter(|scope| !scope.is_empty())
157 .map(str::to_owned)
158 .collect();
159 if scopes.is_empty() {
160 return Err(RkError::Usage(
161 "--scopes names no scope; pass a comma-separated list, e.g. --scopes api,cli".into(),
162 ));
163 }
164 for scope in &scopes {
165 let clean = scope
166 .chars()
167 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '/' | '-'));
168 if !clean {
169 return Err(RkError::Usage(format!(
170 "the scope '{scope}' carries a character outside letters, digits, and _ . / -"
171 )));
172 }
173 }
174 Ok(scopes)
175}
176
177fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
179 haystack
180 .windows(needle.len())
181 .position(|window| window == needle)
182}
183
184pub const AGENTS_DESTINATION: &str = "AGENTS.md";
186
187pub const BLOCK_BEGIN: &str = "<!-- BEGIN release-kit -->";
189
190pub const BLOCK_END: &str = "<!-- END release-kit -->";
192
193pub const HOOKS_DESTINATION: &str = ".pre-commit-config.yaml";
195
196pub const HOOKS_BEGIN: &str = "# BEGIN release-kit";
198
199pub const HOOKS_END: &str = "# END release-kit";
201
202pub const HOOK_TYPES_LINE: &str = "default_install_hook_types: [pre-commit, commit-msg, pre-push]";
206
207static AGENTS_BLOCK: &str = include_str!("../blocks/agents-block.md.in");
209
210static AGENTS_LINE_WORKTREE: &str = include_str!("../blocks/agents-line-worktree.md.in");
212
213static AGENTS_LINE_BRANCHES: &str = include_str!("../blocks/agents-line-branches.md.in");
215
216static PRE_COMMIT_BLOCK: &str = include_str!("../blocks/pre-commit-block.yaml.in");
218
219static PRE_COMMIT_WORKTREE_GUARD: &str =
221 include_str!("../blocks/pre-commit-worktree-guard.yaml.in");
222
223fn authored(text: &str) -> &str {
227 text.strip_suffix('\n').unwrap_or(text)
228}
229
230pub 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[-/].+)$";
238
239#[must_use]
248pub fn routing_block(workflow: Workflow) -> String {
249 let line = match workflow {
250 Workflow::Worktree => authored(AGENTS_LINE_WORKTREE),
251 Workflow::Branches => authored(AGENTS_LINE_BRANCHES),
252 };
253 authored(AGENTS_BLOCK).replacen("RK_WORKFLOW_LINE", line, 1)
254}
255
256#[must_use]
268pub fn hooks_block(workflow: Workflow) -> String {
269 let (guard, skip) = match workflow {
270 Workflow::Worktree => (
271 format!("{}\n", authored(PRE_COMMIT_WORKTREE_GUARD)),
272 "no-commit-to-branch,rk-worktree-location",
273 ),
274 Workflow::Branches => (String::new(), "no-commit-to-branch"),
275 };
276 authored(PRE_COMMIT_BLOCK)
277 .replacen("RK_BRANCH_GRAMMAR", BRANCH_GRAMMAR, 1)
278 .replacen("RK_SWEEP_SKIP", skip, 1)
279 .replacen("RK_WORKTREE_GUARD", &guard, 1)
280}
281
282#[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#[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#[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
319pub 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
391pub enum Placement {
392 Whole,
394 Block,
396}
397
398#[derive(Debug)]
401pub struct Entry {
402 pub destination: String,
404 pub kind: Kind,
406 pub placement: Placement,
408 pub baseline: Vec<u8>,
411 pub rendered: Vec<u8>,
414}
415
416pub fn pair_files(tech: &str, forge: &str) -> Result<Vec<(String, &'static [u8])>, RkError> {
424 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 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
480pub fn projection(
493 tech: &str,
494 forge: &str,
495 repo: &str,
496 scopes: &[String],
497 workflow: Workflow,
498 style: Option<Style>,
499) -> Result<Vec<Entry>, RkError> {
500 let mut entries = Vec::new();
501 for (destination, baseline) in pair_files(tech, forge)? {
502 let kind = kind_of(&destination).ok_or_else(|| {
503 anyhow::anyhow!("the payload does not classify {destination}; the kind table is stale")
504 })?;
505 let rendered = match kind {
506 Kind::Rendered => render(baseline, repo, scopes, style),
507 Kind::Seeded | Kind::State => baseline.to_vec(),
508 };
509 entries.push(Entry {
510 destination,
511 kind,
512 placement: Placement::Whole,
513 baseline: baseline.to_vec(),
514 rendered,
515 });
516 }
517 for (destination, template) in [
518 (AGENTS_DESTINATION, routing_block(workflow)),
519 (HOOKS_DESTINATION, hooks_block(workflow)),
520 ] {
521 entries.push(Entry {
522 destination: destination.to_owned(),
523 kind: Kind::Rendered,
524 placement: Placement::Block,
525 baseline: template.as_bytes().to_vec(),
526 rendered: render(template.as_bytes(), repo, scopes, style),
527 });
528 }
529 entries.sort_by(|a, b| a.destination.cmp(&b.destination));
530 Ok(entries)
531}
532
533pub fn read_destination(target: &Utf8Path, entry: &Entry) -> std::io::Result<Option<Vec<u8>>> {
541 read_recorded(target, &entry.destination)
542}
543
544pub fn read_recorded(target: &Utf8Path, destination: &str) -> std::io::Result<Option<Vec<u8>>> {
555 let path = target.join(destination);
556 let bytes = match std::fs::read(&path) {
557 Ok(bytes) => bytes,
558 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
559 Err(e) => return Err(e),
560 };
561 if let Some((begin, end)) = block_markers(destination) {
562 let text = String::from_utf8_lossy(&bytes);
563 Ok(extract_block(&text, begin, end).map(|block| block.as_bytes().to_vec()))
564 } else {
565 Ok(Some(bytes))
566 }
567}
568
569#[derive(Debug)]
572pub struct Resolved {
573 pub forge: String,
575 pub repo: Option<String>,
577}
578
579pub fn resolve(
591 target: &Utf8Path,
592 forge_flag: Option<&str>,
593 repo_flag: Option<&str>,
594) -> Result<Resolved, RkError> {
595 let forge_flag = forge_flag
596 .map(|name| {
597 crate::detect::Forge::parse(name).ok_or_else(|| {
598 RkError::Usage(format!(
599 "unknown forge '{name}'; the forges are: github, gitlab"
600 ))
601 })
602 })
603 .transpose()?;
604 let detected = crate::detect::detect(target.as_std_path());
605 let forge = forge_flag
606 .or(detected.forge)
607 .map(|forge| forge.as_str().to_owned())
608 .ok_or_else(|| {
609 let message = detected.host.map_or_else(
610 || "no forge detected: the target has no origin remote".to_owned(),
611 |host| format!("no forge detected: the host {host} is not recognized"),
612 );
613 RkError::refusal(
614 Diagnostic::new(Reason::ForgeUndetected, message)
615 .expected("a github.com or gitlab remote, or --forge")
616 .action("pass --forge <github|gitlab>"),
617 )
618 })?;
619 Ok(Resolved {
620 forge,
621 repo: repo_flag.map(str::to_owned).or(detected.repo),
622 })
623}
624
625#[must_use]
628pub fn repo_unresolved() -> RkError {
629 RkError::missing(
630 Diagnostic::new(
631 Reason::ForgeUndetected,
632 "no repository detected: the target has no origin remote",
633 )
634 .expected("an origin remote naming the project")
635 .action("pass --repo <path>"),
636 )
637}
638
639pub fn write_destination(target: &Utf8Path, entry: &Entry) -> std::io::Result<()> {
649 let path = target.join(&entry.destination);
650 match entry.placement {
651 Placement::Whole => atomic::write(path.as_std_path(), &entry.rendered),
652 Placement::Block => {
653 let existing = match std::fs::read(&path) {
654 Ok(bytes) => Some(String::from_utf8_lossy(&bytes).into_owned()),
655 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
656 Err(e) => return Err(e),
657 };
658 let block = String::from_utf8_lossy(&entry.rendered).into_owned();
659 let spliced = if entry.destination == HOOKS_DESTINATION {
660 splice_hooks_block(existing.as_deref(), &block).map_err(std::io::Error::other)?
661 } else {
662 splice_agents_block(existing.as_deref(), &block)
663 };
664 atomic::write(path.as_std_path(), spliced.as_bytes())
665 }
666 }
667}
668
669pub fn hooks_file_defect(target: &Utf8Path) -> std::io::Result<Option<String>> {
682 let path = target.join(HOOKS_DESTINATION);
683 match std::fs::read(&path) {
684 Ok(bytes) => {
685 let text = String::from_utf8_lossy(&bytes);
686 Ok(splice_hooks_block(Some(&text), authored(PRE_COMMIT_BLOCK)).err())
687 }
688 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
689 Err(e) => Err(e),
690 }
691}
692
693pub fn hooks_splice_refusal(target: &Utf8Path) -> Result<(), RkError> {
703 hooks_file_defect(target)?.map_or(Ok(()), |reason| {
704 Err(RkError::refusal(
705 Diagnostic::new(
706 Reason::StateDrift,
707 format!("{reason}, and nothing was written"),
708 )
709 .expected("a .pre-commit-config.yaml the block can land in, or none")
710 .action(format!(
711 "resolve it in {}, then re-run",
712 target.join(HOOKS_DESTINATION)
713 ))
714 .target_state("unchanged"),
715 ))
716 })
717}
718
719#[cfg(test)]
720mod tests {
721 #![allow(clippy::expect_used)]
722
723 use super::{
724 AGENTS_DESTINATION, BLOCK_BEGIN, BLOCK_END, BRANCH_GRAMMAR, HOOK_TYPES_LINE, HOOKS_BEGIN,
725 HOOKS_DESTINATION, HOOKS_END, Kind, Style, Workflow, extract_block, hooks_block, kind_of,
726 pair_files, parse_scopes, projection, render, routing_block, splice_agents_block,
727 splice_hooks_block,
728 };
729 use crate::embedded;
730
731 fn scopes(list: &[&str]) -> Vec<String> {
732 list.iter().map(|s| (*s).to_owned()).collect()
733 }
734
735 #[test]
739 fn the_kind_table_closes_over_every_snippet() {
740 for tech_dir in embedded::SNIPPETS.dirs() {
741 for pair_dir in tech_dir.dirs() {
742 let prefix = format!("{}/", pair_dir.path().to_string_lossy());
743 for (path, _) in embedded::walk(pair_dir) {
744 let destination = path.strip_prefix(&prefix).unwrap_or(&path);
745 assert!(
746 kind_of(destination).is_some(),
747 "{destination}: no declared kind"
748 );
749 }
750 }
751 }
752 assert_eq!(kind_of(AGENTS_DESTINATION), Some(Kind::Rendered));
753 assert_eq!(kind_of(HOOKS_DESTINATION), Some(Kind::Rendered));
754 assert_eq!(kind_of("something-else.txt"), None);
755 }
756
757 #[test]
761 fn rendering_substitutes_every_owner_occurrence() {
762 let baseline = b"if: repository_owner == 'OWNER'\n# OWNER again: OWNER\n";
763 let rendered = render(baseline, "acme/sub/widget", &[], None);
764 let text = String::from_utf8(rendered).expect("rendered bytes stay text");
765 assert_eq!(text, "if: repository_owner == 'acme'\n# acme again: acme\n");
766
767 let baseline = b"scopes 'RK_SCOPES_CSV' match (RK_SCOPES_PIPE)\n";
768 let rendered = render(baseline, "acme/widget", &scopes(&["api", "cli"]), None);
769 let text = String::from_utf8(rendered).expect("rendered bytes stay text");
770 assert_eq!(text, "scopes 'api,cli' match (api|cli)\n");
771
772 let rendered = render(baseline, "acme/widget", &scopes(&["api.v1"]), None);
775 let text = String::from_utf8(rendered).expect("rendered bytes stay text");
776 assert_eq!(text, "scopes 'api.v1' match (api\\.v1)\n");
777 }
778
779 #[test]
782 fn scope_parsing_refuses_the_unusable() {
783 assert_eq!(
784 parse_scopes("api, cli,guides/release").expect("a clean list parses"),
785 scopes(&["api", "cli", "guides/release"])
786 );
787 assert!(parse_scopes("").is_err());
788 assert!(parse_scopes(" , ").is_err());
789 assert!(parse_scopes("api|cli").is_err());
790 assert!(parse_scopes("a b").is_err());
791 }
792
793 #[test]
796 fn the_shared_zone_composes_into_the_pair() {
797 let files = pair_files("rust", "github").expect("the pair lists");
798 assert!(
799 files
800 .iter()
801 .any(|(dest, _)| dest == ".github/workflows/pr-title.yml"),
802 "the shared title check lands with the pair"
803 );
804 let files = pair_files("rust", "gitlab").expect("the pair lists");
805 assert!(
806 files
807 .iter()
808 .any(|(dest, _)| dest == ".gitlab/ci/mr-title.yml"),
809 "the shared title job lands with the pair"
810 );
811 let err = pair_files("_shared", "github").expect_err("the shared zone is no tech");
812 let listing = err.to_string();
813 let bindings = listing
814 .split("the bindings are:")
815 .nth(1)
816 .expect("the refusal lists the bindings");
817 assert!(!bindings.contains("_shared"), "{listing}");
818 }
819
820 #[test]
824 fn a_projection_renders_owned_files_and_keeps_seeded_judgment() {
825 let entries = projection(
826 "rust",
827 "github",
828 "acme/widget",
829 &scopes(&["api", "cli"]),
830 Workflow::Branches,
831 Some(Style::Trunk),
832 )
833 .expect("the pair projects");
834 let workflow = entries
835 .iter()
836 .find(|entry| entry.destination.ends_with("release-plz.yml"))
837 .expect("the workflow projects");
838 assert_eq!(workflow.kind, Kind::Rendered);
839 let text = String::from_utf8_lossy(&workflow.rendered);
840 assert!(!text.contains("OWNER"), "an owner token survived rendering");
841 assert!(text.contains("'acme'"));
842 assert!(!text.contains("TODO(release-kit)"));
843 let title = entries
844 .iter()
845 .find(|entry| entry.destination.ends_with("pr-title.yml"))
846 .expect("the title check projects");
847 let text = String::from_utf8_lossy(&title.rendered);
848 assert!(text.contains("api|cli"), "{text}");
849 assert!(
850 !text.contains("RK_SCOPES"),
851 "a scope token survived: {text}"
852 );
853 let seeded = entries
854 .iter()
855 .find(|entry| entry.destination == "release-plz.toml")
856 .expect("the seeded file projects");
857 assert_eq!(seeded.kind, Kind::Seeded);
858 assert_eq!(seeded.rendered, seeded.baseline);
859 assert!(String::from_utf8_lossy(&seeded.rendered).contains("TODO(release-kit)"));
860 for block in [AGENTS_DESTINATION, HOOKS_DESTINATION] {
861 let entry = entries
862 .iter()
863 .find(|entry| entry.destination == block)
864 .expect("both blocks are part of the projection");
865 let text = String::from_utf8_lossy(&entry.rendered);
866 assert!(!text.contains("RK_SCOPES"), "{block} kept a token: {text}");
867 assert!(text.contains("api,cli"), "{block} lost the scopes: {text}");
868 }
869 }
870
871 #[test]
872 fn the_block_splices_into_every_agents_shape() {
873 let owned = routing_block(Workflow::Branches);
874 let block = owned.as_str();
875 let fresh = splice_agents_block(None, block);
876 assert_eq!(fresh, format!("{block}\n"));
877 assert_eq!(extract_block(&fresh, BLOCK_BEGIN, BLOCK_END), Some(block));
878
879 let appended = splice_agents_block(Some("# My project\n\nOwn rules.\n"), block);
880 assert!(appended.starts_with("# My project\n\nOwn rules.\n\n<!-- BEGIN release-kit -->"));
881 assert_eq!(
882 extract_block(&appended, BLOCK_BEGIN, BLOCK_END),
883 Some(block)
884 );
885
886 let stale = appended.replace("Never author a tag", "Do author a tag");
887 let refreshed = splice_agents_block(Some(&stale), block);
888 assert_eq!(
889 extract_block(&refreshed, BLOCK_BEGIN, BLOCK_END),
890 Some(block)
891 );
892 assert!(refreshed.starts_with("# My project"));
893 assert_eq!(
894 refreshed.matches("BEGIN release-kit").count(),
895 1,
896 "a re-splice must replace, not accumulate"
897 );
898 }
899
900 #[test]
903 fn the_hook_block_splices_under_repos() {
904 let owned = hooks_block(Workflow::Branches);
905 let block = owned.as_str();
906 let fresh = splice_hooks_block(None, block).expect("a fresh file splices");
907 assert!(fresh.starts_with(HOOK_TYPES_LINE));
908 assert!(fresh.contains("\nrepos:\n# BEGIN release-kit\n"));
909 assert_eq!(extract_block(&fresh, HOOKS_BEGIN, HOOKS_END), Some(block));
910
911 let own =
912 "repos:\n - repo: https://example.com/own\n rev: v1\n hooks:\n - id: own\n";
913 let spliced = splice_hooks_block(Some(own), block).expect("an unmarked file splices");
914 assert!(spliced.starts_with("repos:\n# BEGIN release-kit\n"));
915 assert!(spliced.contains("- id: own"), "the target's hooks survive");
916 assert!(
917 !spliced.contains(HOOK_TYPES_LINE),
918 "an existing file's top level is the skills' duty, not the splice's"
919 );
920
921 let stale = spliced.replace("--force-scope", "--no-scope");
922 let refreshed = splice_hooks_block(Some(&stale), block).expect("a marked file re-splices");
923 assert_eq!(
924 extract_block(&refreshed, HOOKS_BEGIN, HOOKS_END),
925 Some(block)
926 );
927 assert_eq!(refreshed.matches(HOOKS_BEGIN).count(), 1);
928
929 let err = splice_hooks_block(Some("minimum_pre_commit_version: '3.2.0'\n"), block)
930 .expect_err("no repos: line refuses");
931 assert!(err.contains("repos:"), "{err}");
932
933 let doubled = format!("repos:\n{block}\n{block}\n");
937 let err = splice_hooks_block(Some(&doubled), block).expect_err("a second block refuses");
938 assert!(err.contains("one block"), "{err}");
939 let unmatched = "repos:\n# BEGIN release-kit\n - repo: local\n";
940 let err =
941 splice_hooks_block(Some(unmatched), block).expect_err("an unmatched marker refuses");
942 assert!(err.contains("unmatched"), "{err}");
943 }
944
945 #[test]
951 fn the_blocks_render_per_mode_and_carry_the_one_grammar() {
952 let worktree_hooks = hooks_block(Workflow::Worktree);
953 let branches_hooks = hooks_block(Workflow::Branches);
954 assert!(worktree_hooks.contains("- id: rk-worktree-location"));
955 assert!(
956 worktree_hooks.contains("SKIP=no-commit-to-branch,rk-worktree-location"),
957 "{worktree_hooks}"
958 );
959 assert!(!branches_hooks.contains("rk-worktree-location"));
960 assert!(branches_hooks.contains("SKIP=no-commit-to-branch in"));
961 for block in [&worktree_hooks, &branches_hooks] {
962 assert!(block.contains(BRANCH_GRAMMAR), "the grammar has one owner");
963 for token in ["RK_BRANCH_GRAMMAR", "RK_SWEEP_SKIP", "RK_WORKTREE_GUARD"] {
964 assert!(!block.contains(token), "{token} survived: {block}");
965 }
966 }
967 for block in [&worktree_hooks, &branches_hooks] {
972 for line in block.lines() {
973 if let Some(value) = line.trim_start().strip_prefix("entry: ") {
974 assert!(
975 !value.contains(": "),
976 "an entry value breaks the YAML plain scalar: {line}"
977 );
978 }
979 }
980 }
981 let guard_line = worktree_hooks
982 .lines()
983 .position(|line| line.contains("id: rk-worktree-location"))
984 .expect("the guard entry exists");
985 let name_line = worktree_hooks
986 .lines()
987 .position(|line| line.contains("id: rk-branch-name"))
988 .expect("the name hook exists");
989 assert!(
990 guard_line > name_line,
991 "the guard lands directly after rk-branch-name"
992 );
993
994 let worktree_routing = routing_block(Workflow::Worktree);
995 let branches_routing = routing_block(Workflow::Branches);
996 assert!(worktree_routing.contains("This project works in worktrees"));
997 assert!(branches_routing.contains("Branches are worked in the main checkout"));
998 for block in [&worktree_routing, &branches_routing] {
999 assert!(block.contains("creating or removing a worktree"));
1000 assert!(block.contains("`rk worktree add <branch>`"));
1001 assert!(!block.contains("RK_WORKFLOW_LINE"), "{block}");
1002 }
1003 let differing: Vec<(&str, &str)> = worktree_routing
1004 .lines()
1005 .zip(branches_routing.lines())
1006 .filter(|(a, b)| a != b)
1007 .collect();
1008 assert_eq!(
1009 differing.len(),
1010 1,
1011 "exactly one routing line differs per mode: {differing:?}"
1012 );
1013 }
1014
1015 #[test]
1018 fn the_hook_marker_defects_are_named() {
1019 use super::hooks_marker_defect;
1020 let owned = hooks_block(Workflow::Branches);
1021 let block = owned.as_str();
1022 assert_eq!(hooks_marker_defect(""), None);
1023 assert_eq!(hooks_marker_defect(&format!("repos:\n{block}\n")), None);
1024 for (case, text) in [
1025 (
1026 "a second begin",
1027 format!("repos:\n{block}\n# BEGIN release-kit\n"),
1028 ),
1029 (
1030 "a second end",
1031 format!("repos:\n{block}\n# END release-kit\n"),
1032 ),
1033 (
1034 "an unpaired begin",
1035 "repos:\n# BEGIN release-kit\n".to_owned(),
1036 ),
1037 ("an unpaired end", "repos:\n# END release-kit\n".to_owned()),
1038 (
1039 "an end before its begin",
1040 "repos:\n# END release-kit\n# BEGIN release-kit\n".to_owned(),
1041 ),
1042 ] {
1043 assert!(
1044 hooks_marker_defect(&text).is_some(),
1045 "{case} must be a defect"
1046 );
1047 }
1048 }
1049}