1pub mod invariants;
13pub mod manifest;
14
15use camino::Utf8Path;
16use serde::{Deserialize, Serialize};
17
18use crate::diagnostic::{Diagnostic, Reason};
19use crate::error::RkError;
20use crate::{atomic, embedded};
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "lowercase")]
25pub enum Kind {
26 Rendered,
29 Seeded,
32 State,
35}
36
37impl Kind {
38 #[must_use]
40 pub const fn as_str(self) -> &'static str {
41 match self {
42 Self::Rendered => "rendered",
43 Self::Seeded => "seeded",
44 Self::State => "state",
45 }
46 }
47}
48
49const KINDS: [(&str, Kind); 12] = [
55 (".github/workflows/release-plz.yml", Kind::Rendered),
56 (".github/workflows/release-please.yml", Kind::Rendered),
57 (".github/workflows/release.yml", Kind::Rendered),
58 (".github/workflows/pr-title.yml", Kind::Rendered),
59 (".gitlab-ci.yml", Kind::Rendered),
60 (".gitlab/ci/mr-title.yml", Kind::Rendered),
61 ("release-plz.toml", Kind::Seeded),
62 ("dist-workspace.toml", Kind::Seeded),
63 ("release-please-config.json", Kind::Seeded),
64 ("cliff.toml", Kind::Seeded),
65 (".release-please-manifest.json", Kind::State),
66 ("VERSION", Kind::State),
67];
68
69#[must_use]
72pub fn kind_of(destination: &str) -> Option<Kind> {
73 if destination == AGENTS_DESTINATION || destination == HOOKS_DESTINATION {
74 return Some(Kind::Rendered);
75 }
76 KINDS
77 .iter()
78 .find(|(name, _)| *name == destination)
79 .map(|(_, kind)| *kind)
80}
81
82pub const OWNER_TOKEN: &[u8] = b"OWNER";
89
90pub const SCOPES_CSV_TOKEN: &[u8] = b"RK_SCOPES_CSV";
92
93pub const SCOPES_PIPE_TOKEN: &[u8] = b"RK_SCOPES_PIPE";
95
96#[must_use]
103pub fn render(baseline: &[u8], repo: &str, scopes: &[String]) -> Vec<u8> {
104 let owner = repo.split('/').next().unwrap_or(repo);
105 let mut out = substitute(baseline, OWNER_TOKEN, owner.as_bytes());
106 if !scopes.is_empty() {
107 out = substitute(&out, SCOPES_CSV_TOKEN, scopes.join(",").as_bytes());
108 let pipe: Vec<String> = scopes.iter().map(|s| s.replace('.', "\\.")).collect();
113 out = substitute(&out, SCOPES_PIPE_TOKEN, pipe.join("|").as_bytes());
114 }
115 out
116}
117
118fn substitute(baseline: &[u8], token: &[u8], value: &[u8]) -> Vec<u8> {
120 let mut out = Vec::with_capacity(baseline.len());
121 let mut rest = baseline;
122 while let Some(at) = find(rest, token) {
123 out.extend_from_slice(&rest[..at]);
124 out.extend_from_slice(value);
125 rest = &rest[at + token.len()..];
126 }
127 out.extend_from_slice(rest);
128 out
129}
130
131pub fn parse_scopes(raw: &str) -> Result<Vec<String>, RkError> {
142 let scopes: Vec<String> = raw
143 .split(',')
144 .map(str::trim)
145 .filter(|scope| !scope.is_empty())
146 .map(str::to_owned)
147 .collect();
148 if scopes.is_empty() {
149 return Err(RkError::Usage(
150 "--scopes names no scope; pass a comma-separated list, e.g. --scopes api,cli".into(),
151 ));
152 }
153 for scope in &scopes {
154 let clean = scope
155 .chars()
156 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '/' | '-'));
157 if !clean {
158 return Err(RkError::Usage(format!(
159 "the scope '{scope}' carries a character outside letters, digits, and _ . / -"
160 )));
161 }
162 }
163 Ok(scopes)
164}
165
166fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
168 haystack
169 .windows(needle.len())
170 .position(|window| window == needle)
171}
172
173pub const AGENTS_DESTINATION: &str = "AGENTS.md";
175
176pub const BLOCK_BEGIN: &str = "<!-- BEGIN release-kit -->";
178
179pub const BLOCK_END: &str = "<!-- END release-kit -->";
181
182pub const HOOKS_DESTINATION: &str = ".pre-commit-config.yaml";
184
185pub const HOOKS_BEGIN: &str = "# BEGIN release-kit";
187
188pub const HOOKS_END: &str = "# END release-kit";
190
191pub const HOOK_TYPES_LINE: &str = "default_install_hook_types: [pre-commit, commit-msg, pre-push]";
195
196const ROUTING_BLOCK: &str = "<!-- BEGIN release-kit -->
203
204## Releases
205
206- This repository runs the release-kit convention; `rk method invariants` states what must stay true.
207- 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, 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.
208- 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`.
209- The request's title becomes the trunk's commit message, so it MUST be a scoped Conventional Commit; the body carries the context.
210- Every commit follows the same scoped convention; the landed commit-msg hook enforces it, and the scopes this project accepts are `RK_SCOPES_CSV`.
211- Never author a tag, and never hand-edit a generated artifact workflow.
212- Run `rk status` before changing anything under `.github/workflows/` or `.gitlab-ci.yml`, or any file `.release-kit/manifest.json` names.
213- The full method is `rk method --list`; the recovery paths are `rk method recovery`.
214
215<!-- END release-kit -->";
216
217const HOOKS_BLOCK: &str = r#"# BEGIN release-kit
225# The release convention's hooks. Install every stage they run at:
226# pre-commit install --hook-type pre-commit --hook-type commit-msg --hook-type pre-push
227# A CI sweep commits nothing, so a job running pre-commit against a trunk
228# checkout sets SKIP=no-commit-to-branch in its environment.
229 - repo: https://github.com/compilerla/conventional-pre-commit
230 rev: v4.4.0
231 hooks:
232 - id: conventional-pre-commit
233 stages: [commit-msg]
234 args: [--strict, --force-scope, --scopes, 'RK_SCOPES_CSV']
235 - repo: https://github.com/pre-commit/pre-commit-hooks
236 rev: v6.0.0
237 hooks:
238 - id: no-commit-to-branch
239 args: [--branch, master]
240 - repo: local
241 hooks:
242 - id: rk-branch-name
243 name: rk branch name
244 language: system
245 always_run: true
246 pass_filenames: false
247 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'
248 - id: rk-no-push-to-trunk
249 name: rk no push to trunk
250 stages: [pre-push]
251 language: system
252 always_run: true
253 pass_filenames: false
254 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; }'
255 - id: rk-no-hand-authored-tag
256 name: rk no hand-authored tag
257 stages: [pre-push]
258 language: system
259 always_run: true
260 pass_filenames: false
261 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'
262 - id: rk-status-check
263 name: rk status check
264 language: system
265 pass_filenames: false
266 entry: rk status --check --target .
267 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$)'
268# END release-kit"#;
269
270#[must_use]
273pub const fn routing_block() -> &'static str {
274 ROUTING_BLOCK
275}
276
277#[must_use]
280pub const fn hooks_block() -> &'static str {
281 HOOKS_BLOCK
282}
283
284#[must_use]
286pub fn block_markers(destination: &str) -> Option<(&'static str, &'static str)> {
287 match destination {
288 AGENTS_DESTINATION => Some((BLOCK_BEGIN, BLOCK_END)),
289 HOOKS_DESTINATION => Some((HOOKS_BEGIN, HOOKS_END)),
290 _ => None,
291 }
292}
293
294#[must_use]
297pub fn extract_block<'a>(text: &'a str, begin: &str, end: &str) -> Option<&'a str> {
298 let start = text.find(begin)?;
299 let stop = text[start..].find(end)? + start + end.len();
300 Some(&text[start..stop])
301}
302
303#[must_use]
309pub fn splice_agents_block(existing: Option<&str>, block: &str) -> String {
310 existing.map_or_else(
311 || format!("{block}\n"),
312 |text| {
313 extract_block(text, BLOCK_BEGIN, BLOCK_END).map_or_else(
314 || format!("{}\n\n{block}\n", text.trim_end()),
315 |found| text.replacen(found, block, 1),
316 )
317 },
318 )
319}
320
321pub fn splice_hooks_block(existing: Option<&str>, block: &str) -> Result<String, String> {
334 let Some(text) = existing else {
335 return Ok(format!("{HOOK_TYPES_LINE}\n\nrepos:\n{block}\n"));
336 };
337 if let Some(defect) = hooks_marker_defect(text) {
338 return Err(defect);
339 }
340 if let Some(found) = extract_block(text, HOOKS_BEGIN, HOOKS_END) {
341 return Ok(text.replacen(found, block, 1));
342 }
343 let mut out = String::with_capacity(text.len() + block.len() + 1);
344 let mut placed = false;
345 for line in text.split_inclusive('\n') {
346 out.push_str(line);
347 if !placed && line.trim_end() == "repos:" {
348 if !out.ends_with('\n') {
349 out.push('\n');
350 }
351 out.push_str(block);
352 out.push('\n');
353 placed = true;
354 }
355 }
356 if placed {
357 Ok(out)
358 } else {
359 Err(format!(
360 "{HOOKS_DESTINATION} exists with no repos: line, so the hook block has nowhere to land"
361 ))
362 }
363}
364
365#[must_use]
374pub fn hooks_marker_defect(text: &str) -> Option<String> {
375 let begins = text.matches(HOOKS_BEGIN).count();
376 let ends = text.matches(HOOKS_END).count();
377 if begins > 1 || ends > 1 {
378 return Some(format!(
379 "{HOOKS_DESTINATION} carries more than one release-kit marker pair; release-kit owns exactly one block"
380 ));
381 }
382 match (text.find(HOOKS_BEGIN), text.find(HOOKS_END)) {
383 (Some(begin), Some(end)) if end > begin => None,
384 (None, None) => None,
385 _ => Some(format!(
386 "{HOOKS_DESTINATION} carries an unmatched or misordered release-kit marker, so the block's extent is ambiguous"
387 )),
388 }
389}
390
391#[derive(Debug, Clone, Copy, PartialEq, Eq)]
393pub enum Placement {
394 Whole,
396 Block,
398}
399
400#[derive(Debug)]
403pub struct Entry {
404 pub destination: String,
406 pub kind: Kind,
408 pub placement: Placement,
410 pub baseline: Vec<u8>,
413 pub rendered: Vec<u8>,
416}
417
418pub fn pair_files(tech: &str, forge: &str) -> Result<Vec<(String, &'static [u8])>, RkError> {
426 if tech.starts_with('_') || embedded::SNIPPETS.get_dir(tech).is_none() {
429 let known: Vec<String> = embedded::SNIPPETS
430 .dirs()
431 .map(|dir| dir.path().to_string_lossy().into_owned())
432 .filter(|name| !name.starts_with('_'))
433 .collect();
434 return Err(RkError::Usage(format!(
435 "unknown tech '{tech}'; the bindings are: {}",
436 known.join(", ")
437 )));
438 }
439 let pair = format!("{tech}/{forge}");
440 let pair_dir = embedded::SNIPPETS.get_dir(&pair).ok_or_else(|| {
441 let known: Vec<String> = embedded::SNIPPETS
442 .dirs()
443 .filter(|dir| !dir.path().to_string_lossy().starts_with('_'))
444 .flat_map(include_dir::Dir::dirs)
445 .map(|dir| dir.path().to_string_lossy().replace('/', ", "))
446 .collect();
447 RkError::Usage(format!(
448 "the pair ({tech}, {forge}) has no landable files; the supported pairs are: {}",
449 known.join("; ")
450 ))
451 })?;
452 let mut files: Vec<(String, &'static [u8])> = Vec::new();
456 let shared = format!("_shared/{forge}");
457 if let Some(shared_dir) = embedded::SNIPPETS.get_dir(&shared) {
458 for (path, contents) in embedded::walk(shared_dir) {
459 let rel = path
460 .strip_prefix(&format!("{shared}/"))
461 .map_or(path.as_str(), |rel| rel)
462 .to_owned();
463 files.push((rel, contents));
464 }
465 }
466 for (path, contents) in embedded::walk(pair_dir) {
467 let rel = path
468 .strip_prefix(&format!("{pair}/"))
469 .map_or(path.as_str(), |rel| rel)
470 .to_owned();
471 if files.iter().any(|(existing, _)| *existing == rel) {
472 return Err(anyhow::anyhow!(
473 "the shared zone and the pair ({tech}, {forge}) both ship {rel}; the payload is defective"
474 )
475 .into());
476 }
477 files.push((rel, contents));
478 }
479 Ok(files)
480}
481
482pub fn projection(
492 tech: &str,
493 forge: &str,
494 repo: &str,
495 scopes: &[String],
496) -> Result<Vec<Entry>, RkError> {
497 let mut entries = Vec::new();
498 for (destination, baseline) in pair_files(tech, forge)? {
499 let kind = kind_of(&destination).ok_or_else(|| {
500 anyhow::anyhow!("the payload does not classify {destination}; the kind table is stale")
501 })?;
502 let rendered = match kind {
503 Kind::Rendered => render(baseline, repo, scopes),
504 Kind::Seeded | Kind::State => baseline.to_vec(),
505 };
506 entries.push(Entry {
507 destination,
508 kind,
509 placement: Placement::Whole,
510 baseline: baseline.to_vec(),
511 rendered,
512 });
513 }
514 for (destination, template) in [
515 (AGENTS_DESTINATION, ROUTING_BLOCK),
516 (HOOKS_DESTINATION, HOOKS_BLOCK),
517 ] {
518 entries.push(Entry {
519 destination: destination.to_owned(),
520 kind: Kind::Rendered,
521 placement: Placement::Block,
522 baseline: template.as_bytes().to_vec(),
523 rendered: render(template.as_bytes(), repo, scopes),
524 });
525 }
526 entries.sort_by(|a, b| a.destination.cmp(&b.destination));
527 Ok(entries)
528}
529
530pub fn read_destination(target: &Utf8Path, entry: &Entry) -> std::io::Result<Option<Vec<u8>>> {
538 read_recorded(target, &entry.destination)
539}
540
541pub fn read_recorded(target: &Utf8Path, destination: &str) -> std::io::Result<Option<Vec<u8>>> {
552 let path = target.join(destination);
553 let bytes = match std::fs::read(&path) {
554 Ok(bytes) => bytes,
555 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
556 Err(e) => return Err(e),
557 };
558 if let Some((begin, end)) = block_markers(destination) {
559 let text = String::from_utf8_lossy(&bytes);
560 Ok(extract_block(&text, begin, end).map(|block| block.as_bytes().to_vec()))
561 } else {
562 Ok(Some(bytes))
563 }
564}
565
566#[derive(Debug)]
569pub struct Resolved {
570 pub forge: String,
572 pub repo: Option<String>,
574}
575
576pub fn resolve(
588 target: &Utf8Path,
589 forge_flag: Option<&str>,
590 repo_flag: Option<&str>,
591) -> Result<Resolved, RkError> {
592 let forge_flag = forge_flag
593 .map(|name| {
594 crate::detect::Forge::parse(name).ok_or_else(|| {
595 RkError::Usage(format!(
596 "unknown forge '{name}'; the forges are: github, gitlab"
597 ))
598 })
599 })
600 .transpose()?;
601 let detected = crate::detect::detect(target.as_std_path());
602 let forge = forge_flag
603 .or(detected.forge)
604 .map(|forge| forge.as_str().to_owned())
605 .ok_or_else(|| {
606 let message = detected.host.map_or_else(
607 || "no forge detected: the target has no origin remote".to_owned(),
608 |host| format!("no forge detected: the host {host} is not recognized"),
609 );
610 RkError::refusal(
611 Diagnostic::new(Reason::ForgeUndetected, message)
612 .expected("a github.com or gitlab remote, or --forge")
613 .action("pass --forge <github|gitlab>"),
614 )
615 })?;
616 Ok(Resolved {
617 forge,
618 repo: repo_flag.map(str::to_owned).or(detected.repo),
619 })
620}
621
622#[must_use]
625pub fn repo_unresolved() -> RkError {
626 RkError::missing(
627 Diagnostic::new(
628 Reason::ForgeUndetected,
629 "no repository detected: the target has no origin remote",
630 )
631 .expected("an origin remote naming the project")
632 .action("pass --repo <path>"),
633 )
634}
635
636pub fn write_destination(target: &Utf8Path, entry: &Entry) -> std::io::Result<()> {
646 let path = target.join(&entry.destination);
647 match entry.placement {
648 Placement::Whole => atomic::write(path.as_std_path(), &entry.rendered),
649 Placement::Block => {
650 let existing = match std::fs::read(&path) {
651 Ok(bytes) => Some(String::from_utf8_lossy(&bytes).into_owned()),
652 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
653 Err(e) => return Err(e),
654 };
655 let block = String::from_utf8_lossy(&entry.rendered).into_owned();
656 let spliced = if entry.destination == HOOKS_DESTINATION {
657 splice_hooks_block(existing.as_deref(), &block).map_err(std::io::Error::other)?
658 } else {
659 splice_agents_block(existing.as_deref(), &block)
660 };
661 atomic::write(path.as_std_path(), spliced.as_bytes())
662 }
663 }
664}
665
666pub fn hooks_file_defect(target: &Utf8Path) -> std::io::Result<Option<String>> {
679 let path = target.join(HOOKS_DESTINATION);
680 match std::fs::read(&path) {
681 Ok(bytes) => {
682 let text = String::from_utf8_lossy(&bytes);
683 Ok(splice_hooks_block(Some(&text), HOOKS_BLOCK).err())
684 }
685 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
686 Err(e) => Err(e),
687 }
688}
689
690pub fn hooks_splice_refusal(target: &Utf8Path) -> Result<(), RkError> {
700 hooks_file_defect(target)?.map_or(Ok(()), |reason| {
701 Err(RkError::refusal(
702 Diagnostic::new(
703 Reason::StateDrift,
704 format!("{reason}, and nothing was written"),
705 )
706 .expected("a .pre-commit-config.yaml the block can land in, or none")
707 .action(format!(
708 "resolve it in {}, then re-run",
709 target.join(HOOKS_DESTINATION)
710 ))
711 .target_state("unchanged"),
712 ))
713 })
714}
715
716#[cfg(test)]
717mod tests {
718 #![allow(clippy::expect_used)]
719
720 use super::{
721 AGENTS_DESTINATION, BLOCK_BEGIN, BLOCK_END, HOOK_TYPES_LINE, HOOKS_BEGIN,
722 HOOKS_DESTINATION, HOOKS_END, Kind, extract_block, hooks_block, kind_of, pair_files,
723 parse_scopes, projection, render, routing_block, splice_agents_block, splice_hooks_block,
724 };
725 use crate::embedded;
726
727 fn scopes(list: &[&str]) -> Vec<String> {
728 list.iter().map(|s| (*s).to_owned()).collect()
729 }
730
731 #[test]
735 fn the_kind_table_closes_over_every_snippet() {
736 for tech_dir in embedded::SNIPPETS.dirs() {
737 for pair_dir in tech_dir.dirs() {
738 let prefix = format!("{}/", pair_dir.path().to_string_lossy());
739 for (path, _) in embedded::walk(pair_dir) {
740 let destination = path.strip_prefix(&prefix).unwrap_or(&path);
741 assert!(
742 kind_of(destination).is_some(),
743 "{destination}: no declared kind"
744 );
745 }
746 }
747 }
748 assert_eq!(kind_of(AGENTS_DESTINATION), Some(Kind::Rendered));
749 assert_eq!(kind_of(HOOKS_DESTINATION), Some(Kind::Rendered));
750 assert_eq!(kind_of("something-else.txt"), None);
751 }
752
753 #[test]
757 fn rendering_substitutes_every_owner_occurrence() {
758 let baseline = b"if: repository_owner == 'OWNER'\n# OWNER again: OWNER\n";
759 let rendered = render(baseline, "acme/sub/widget", &[]);
760 let text = String::from_utf8(rendered).expect("rendered bytes stay text");
761 assert_eq!(text, "if: repository_owner == 'acme'\n# acme again: acme\n");
762
763 let baseline = b"scopes 'RK_SCOPES_CSV' match (RK_SCOPES_PIPE)\n";
764 let rendered = render(baseline, "acme/widget", &scopes(&["api", "cli"]));
765 let text = String::from_utf8(rendered).expect("rendered bytes stay text");
766 assert_eq!(text, "scopes 'api,cli' match (api|cli)\n");
767
768 let rendered = render(baseline, "acme/widget", &scopes(&["api.v1"]));
771 let text = String::from_utf8(rendered).expect("rendered bytes stay text");
772 assert_eq!(text, "scopes 'api.v1' match (api\\.v1)\n");
773 }
774
775 #[test]
778 fn scope_parsing_refuses_the_unusable() {
779 assert_eq!(
780 parse_scopes("api, cli,guides/release").expect("a clean list parses"),
781 scopes(&["api", "cli", "guides/release"])
782 );
783 assert!(parse_scopes("").is_err());
784 assert!(parse_scopes(" , ").is_err());
785 assert!(parse_scopes("api|cli").is_err());
786 assert!(parse_scopes("a b").is_err());
787 }
788
789 #[test]
792 fn the_shared_zone_composes_into_the_pair() {
793 let files = pair_files("rust", "github").expect("the pair lists");
794 assert!(
795 files
796 .iter()
797 .any(|(dest, _)| dest == ".github/workflows/pr-title.yml"),
798 "the shared title check lands with the pair"
799 );
800 let files = pair_files("rust", "gitlab").expect("the pair lists");
801 assert!(
802 files
803 .iter()
804 .any(|(dest, _)| dest == ".gitlab/ci/mr-title.yml"),
805 "the shared title job lands with the pair"
806 );
807 let err = pair_files("_shared", "github").expect_err("the shared zone is no tech");
808 let listing = err.to_string();
809 let bindings = listing
810 .split("the bindings are:")
811 .nth(1)
812 .expect("the refusal lists the bindings");
813 assert!(!bindings.contains("_shared"), "{listing}");
814 }
815
816 #[test]
820 fn a_projection_renders_owned_files_and_keeps_seeded_judgment() {
821 let entries = projection("rust", "github", "acme/widget", &scopes(&["api", "cli"]))
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 block = routing_block();
863 let fresh = splice_agents_block(None, block);
864 assert_eq!(fresh, format!("{block}\n"));
865 assert_eq!(extract_block(&fresh, BLOCK_BEGIN, BLOCK_END), Some(block));
866
867 let appended = splice_agents_block(Some("# My project\n\nOwn rules.\n"), block);
868 assert!(appended.starts_with("# My project\n\nOwn rules.\n\n<!-- BEGIN release-kit -->"));
869 assert_eq!(
870 extract_block(&appended, BLOCK_BEGIN, BLOCK_END),
871 Some(block)
872 );
873
874 let stale = appended.replace("Never author a tag", "Do author a tag");
875 let refreshed = splice_agents_block(Some(&stale), block);
876 assert_eq!(
877 extract_block(&refreshed, BLOCK_BEGIN, BLOCK_END),
878 Some(block)
879 );
880 assert!(refreshed.starts_with("# My project"));
881 assert_eq!(
882 refreshed.matches("BEGIN release-kit").count(),
883 1,
884 "a re-splice must replace, not accumulate"
885 );
886 }
887
888 #[test]
891 fn the_hook_block_splices_under_repos() {
892 let block = hooks_block();
893 let fresh = splice_hooks_block(None, block).expect("a fresh file splices");
894 assert!(fresh.starts_with(HOOK_TYPES_LINE));
895 assert!(fresh.contains("\nrepos:\n# BEGIN release-kit\n"));
896 assert_eq!(extract_block(&fresh, HOOKS_BEGIN, HOOKS_END), Some(block));
897
898 let own =
899 "repos:\n - repo: https://example.com/own\n rev: v1\n hooks:\n - id: own\n";
900 let spliced = splice_hooks_block(Some(own), block).expect("an unmarked file splices");
901 assert!(spliced.starts_with("repos:\n# BEGIN release-kit\n"));
902 assert!(spliced.contains("- id: own"), "the target's hooks survive");
903 assert!(
904 !spliced.contains(HOOK_TYPES_LINE),
905 "an existing file's top level is the skills' duty, not the splice's"
906 );
907
908 let stale = spliced.replace("--force-scope", "--no-scope");
909 let refreshed = splice_hooks_block(Some(&stale), block).expect("a marked file re-splices");
910 assert_eq!(
911 extract_block(&refreshed, HOOKS_BEGIN, HOOKS_END),
912 Some(block)
913 );
914 assert_eq!(refreshed.matches(HOOKS_BEGIN).count(), 1);
915
916 let err = splice_hooks_block(Some("minimum_pre_commit_version: '3.2.0'\n"), block)
917 .expect_err("no repos: line refuses");
918 assert!(err.contains("repos:"), "{err}");
919
920 let doubled = format!("repos:\n{block}\n{block}\n");
924 let err = splice_hooks_block(Some(&doubled), block).expect_err("a second block refuses");
925 assert!(err.contains("one block"), "{err}");
926 let unmatched = "repos:\n# BEGIN release-kit\n - repo: local\n";
927 let err =
928 splice_hooks_block(Some(unmatched), block).expect_err("an unmatched marker refuses");
929 assert!(err.contains("unmatched"), "{err}");
930 }
931
932 #[test]
935 fn the_hook_marker_defects_are_named() {
936 use super::hooks_marker_defect;
937 let block = hooks_block();
938 assert_eq!(hooks_marker_defect(""), None);
939 assert_eq!(hooks_marker_defect(&format!("repos:\n{block}\n")), None);
940 for (case, text) in [
941 (
942 "a second begin",
943 format!("repos:\n{block}\n# BEGIN release-kit\n"),
944 ),
945 (
946 "a second end",
947 format!("repos:\n{block}\n# END release-kit\n"),
948 ),
949 (
950 "an unpaired begin",
951 "repos:\n# BEGIN release-kit\n".to_owned(),
952 ),
953 ("an unpaired end", "repos:\n# END release-kit\n".to_owned()),
954 (
955 "an end before its begin",
956 "repos:\n# END release-kit\n# BEGIN release-kit\n".to_owned(),
957 ),
958 ] {
959 assert!(
960 hooks_marker_defect(&text).is_some(),
961 "{case} must be a defect"
962 );
963 }
964 }
965}