1pub mod manifest;
13
14use camino::Utf8Path;
15use serde::{Deserialize, Serialize};
16
17use crate::diagnostic::{Diagnostic, Reason};
18use crate::error::RkError;
19use crate::{atomic, embedded};
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "lowercase")]
24pub enum Kind {
25 Rendered,
28 Seeded,
31 State,
34}
35
36impl Kind {
37 #[must_use]
39 pub const fn as_str(self) -> &'static str {
40 match self {
41 Self::Rendered => "rendered",
42 Self::Seeded => "seeded",
43 Self::State => "state",
44 }
45 }
46}
47
48const KINDS: [(&str, Kind); 12] = [
54 (".github/workflows/release-plz.yml", Kind::Rendered),
55 (".github/workflows/release-please.yml", Kind::Rendered),
56 (".github/workflows/release.yml", Kind::Rendered),
57 (".github/workflows/pr-title.yml", Kind::Rendered),
58 (".gitlab-ci.yml", Kind::Rendered),
59 (".gitlab/ci/mr-title.yml", Kind::Rendered),
60 ("release-plz.toml", Kind::Seeded),
61 ("dist-workspace.toml", Kind::Seeded),
62 ("release-please-config.json", Kind::Seeded),
63 ("cliff.toml", Kind::Seeded),
64 (".release-please-manifest.json", Kind::State),
65 ("VERSION", Kind::State),
66];
67
68#[must_use]
71pub fn kind_of(destination: &str) -> Option<Kind> {
72 if destination == AGENTS_DESTINATION || destination == HOOKS_DESTINATION {
73 return Some(Kind::Rendered);
74 }
75 KINDS
76 .iter()
77 .find(|(name, _)| *name == destination)
78 .map(|(_, kind)| *kind)
79}
80
81pub const OWNER_TOKEN: &[u8] = b"OWNER";
88
89pub const SCOPES_CSV_TOKEN: &[u8] = b"RK_SCOPES_CSV";
91
92pub const SCOPES_PIPE_TOKEN: &[u8] = b"RK_SCOPES_PIPE";
94
95#[must_use]
102pub fn render(baseline: &[u8], repo: &str, scopes: &[String]) -> Vec<u8> {
103 let owner = repo.split('/').next().unwrap_or(repo);
104 let mut out = substitute(baseline, OWNER_TOKEN, owner.as_bytes());
105 if !scopes.is_empty() {
106 out = substitute(&out, SCOPES_CSV_TOKEN, scopes.join(",").as_bytes());
107 let pipe: Vec<String> = scopes.iter().map(|s| s.replace('.', "\\.")).collect();
112 out = substitute(&out, SCOPES_PIPE_TOKEN, pipe.join("|").as_bytes());
113 }
114 out
115}
116
117fn substitute(baseline: &[u8], token: &[u8], value: &[u8]) -> Vec<u8> {
119 let mut out = Vec::with_capacity(baseline.len());
120 let mut rest = baseline;
121 while let Some(at) = find(rest, token) {
122 out.extend_from_slice(&rest[..at]);
123 out.extend_from_slice(value);
124 rest = &rest[at + token.len()..];
125 }
126 out.extend_from_slice(rest);
127 out
128}
129
130pub fn parse_scopes(raw: &str) -> Result<Vec<String>, RkError> {
141 let scopes: Vec<String> = raw
142 .split(',')
143 .map(str::trim)
144 .filter(|scope| !scope.is_empty())
145 .map(str::to_owned)
146 .collect();
147 if scopes.is_empty() {
148 return Err(RkError::Usage(
149 "--scopes names no scope; pass a comma-separated list, e.g. --scopes api,cli".into(),
150 ));
151 }
152 for scope in &scopes {
153 let clean = scope
154 .chars()
155 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '/' | '-'));
156 if !clean {
157 return Err(RkError::Usage(format!(
158 "the scope '{scope}' carries a character outside letters, digits, and _ . / -"
159 )));
160 }
161 }
162 Ok(scopes)
163}
164
165fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
167 haystack
168 .windows(needle.len())
169 .position(|window| window == needle)
170}
171
172pub const AGENTS_DESTINATION: &str = "AGENTS.md";
174
175pub const BLOCK_BEGIN: &str = "<!-- BEGIN release-kit -->";
177
178pub const BLOCK_END: &str = "<!-- END release-kit -->";
180
181pub const HOOKS_DESTINATION: &str = ".pre-commit-config.yaml";
183
184pub const HOOKS_BEGIN: &str = "# BEGIN release-kit";
186
187pub const HOOKS_END: &str = "# END release-kit";
189
190pub const HOOK_TYPES_LINE: &str = "default_install_hook_types: [pre-commit, commit-msg, pre-push]";
194
195const ROUTING_BLOCK: &str = "<!-- BEGIN release-kit -->
202
203## Releases
204
205- This repository runs the release-kit convention; `rk method invariants` states what must stay true.
206- Change nothing while on `master`: work starts on a short-lived branch — `<type>/<slug>` mirroring the squash title's type, or the forge-minted `<issue-id>-<slug>` — and reaches the trunk only through its pull request. When asked to implement or change code while the checkout sits on `master`, branch first.
207- Land work through squash-merged pull requests. The request's title becomes the trunk's commit message, so it MUST be a scoped Conventional Commit; the body carries the context.
208- Every commit follows the same scoped convention; the landed commit-msg hook enforces it, and the scopes this project accepts are `RK_SCOPES_CSV`.
209- Never author a tag, and never hand-edit a generated artifact workflow.
210- Run `rk status` before changing anything under `.github/workflows/` or `.gitlab-ci.yml`, or any file `.release-kit/manifest.json` names.
211- The full method is `rk method --list`; the recovery paths are `rk method recovery`.
212
213<!-- END release-kit -->";
214
215const HOOKS_BLOCK: &str = r#"# BEGIN release-kit
223# The release convention's hooks. Install every stage they run at:
224# pre-commit install --hook-type pre-commit --hook-type commit-msg --hook-type pre-push
225# A CI sweep commits nothing, so a job running pre-commit against a trunk
226# checkout sets SKIP=no-commit-to-branch in its environment.
227 - repo: https://github.com/compilerla/conventional-pre-commit
228 rev: v4.4.0
229 hooks:
230 - id: conventional-pre-commit
231 stages: [commit-msg]
232 args: [--strict, --force-scope, --scopes, 'RK_SCOPES_CSV']
233 - repo: https://github.com/pre-commit/pre-commit-hooks
234 rev: v6.0.0
235 hooks:
236 - id: no-commit-to-branch
237 args: [--branch, master]
238 - repo: local
239 hooks:
240 - id: rk-branch-name
241 name: rk branch name
242 language: system
243 always_run: true
244 pass_filenames: false
245 entry: sh -c 'branch=$(git symbolic-ref --quiet --short HEAD) || exit 0; [ "$branch" = master ] && exit 0; printf %s "$branch" | grep -Eq "^((build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)/[A-Za-z0-9._/-]+|([0-9]+|[A-Z][A-Z0-9]+-[0-9]+)-[A-Za-z0-9._-]+|release[-/].+)$" && exit 0; echo "branch $branch is neither <type>/<slug> nor <issue-id>-<slug>; gh issue develop <issue> --checkout or its glab counterpart mints the linked form" >&2; exit 1'
246 - id: rk-no-push-to-trunk
247 name: rk no push to trunk
248 stages: [pre-push]
249 language: system
250 always_run: true
251 pass_filenames: false
252 entry: sh -c '[ "$PRE_COMMIT_REMOTE_BRANCH" != refs/heads/master ] || { echo "the trunk takes no direct push; it is written through squash-merged pull requests alone" >&2; exit 1; }'
253 - id: rk-no-hand-authored-tag
254 name: rk no hand-authored tag
255 stages: [pre-push]
256 language: system
257 always_run: true
258 pass_filenames: false
259 entry: sh -c 'case "$PRE_COMMIT_REMOTE_BRANCH" in refs/tags/v*) echo "never author a tag; the release automation mints every v* tag" >&2; exit 1;; esac'
260 - id: rk-status-check
261 name: rk status check
262 language: system
263 pass_filenames: false
264 entry: rk status --check --target .
265 files: '^(\.github/workflows/|\.gitlab-ci\.yml$|\.gitlab/ci/|AGENTS\.md$|\.release-kit/|\.pre-commit-config\.yaml$|release-plz\.toml$|dist-workspace\.toml$|release-please-config\.json$|cliff\.toml$|\.release-please-manifest\.json$|VERSION$)'
266# END release-kit"#;
267
268#[must_use]
271pub const fn routing_block() -> &'static str {
272 ROUTING_BLOCK
273}
274
275#[must_use]
278pub const fn hooks_block() -> &'static str {
279 HOOKS_BLOCK
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(
490 tech: &str,
491 forge: &str,
492 repo: &str,
493 scopes: &[String],
494) -> Result<Vec<Entry>, RkError> {
495 let mut entries = Vec::new();
496 for (destination, baseline) in pair_files(tech, forge)? {
497 let kind = kind_of(&destination).ok_or_else(|| {
498 anyhow::anyhow!("the payload does not classify {destination}; the kind table is stale")
499 })?;
500 let rendered = match kind {
501 Kind::Rendered => render(baseline, repo, scopes),
502 Kind::Seeded | Kind::State => baseline.to_vec(),
503 };
504 entries.push(Entry {
505 destination,
506 kind,
507 placement: Placement::Whole,
508 baseline: baseline.to_vec(),
509 rendered,
510 });
511 }
512 for (destination, template) in [
513 (AGENTS_DESTINATION, ROUTING_BLOCK),
514 (HOOKS_DESTINATION, HOOKS_BLOCK),
515 ] {
516 entries.push(Entry {
517 destination: destination.to_owned(),
518 kind: Kind::Rendered,
519 placement: Placement::Block,
520 baseline: template.as_bytes().to_vec(),
521 rendered: render(template.as_bytes(), repo, scopes),
522 });
523 }
524 entries.sort_by(|a, b| a.destination.cmp(&b.destination));
525 Ok(entries)
526}
527
528pub fn read_destination(target: &Utf8Path, entry: &Entry) -> std::io::Result<Option<Vec<u8>>> {
536 read_recorded(target, &entry.destination)
537}
538
539pub fn read_recorded(target: &Utf8Path, destination: &str) -> std::io::Result<Option<Vec<u8>>> {
550 let path = target.join(destination);
551 let bytes = match std::fs::read(&path) {
552 Ok(bytes) => bytes,
553 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
554 Err(e) => return Err(e),
555 };
556 if let Some((begin, end)) = block_markers(destination) {
557 let text = String::from_utf8_lossy(&bytes);
558 Ok(extract_block(&text, begin, end).map(|block| block.as_bytes().to_vec()))
559 } else {
560 Ok(Some(bytes))
561 }
562}
563
564#[derive(Debug)]
567pub struct Resolved {
568 pub forge: String,
570 pub repo: Option<String>,
572}
573
574pub fn resolve(
586 target: &Utf8Path,
587 forge_flag: Option<&str>,
588 repo_flag: Option<&str>,
589) -> Result<Resolved, RkError> {
590 let forge_flag = forge_flag
591 .map(|name| {
592 crate::detect::Forge::parse(name).ok_or_else(|| {
593 RkError::Usage(format!(
594 "unknown forge '{name}'; the forges are: github, gitlab"
595 ))
596 })
597 })
598 .transpose()?;
599 let detected = crate::detect::detect(target.as_std_path());
600 let forge = forge_flag
601 .or(detected.forge)
602 .map(|forge| forge.as_str().to_owned())
603 .ok_or_else(|| {
604 let message = detected.host.map_or_else(
605 || "no forge detected: the target has no origin remote".to_owned(),
606 |host| format!("no forge detected: the host {host} is not recognized"),
607 );
608 RkError::refusal(
609 Diagnostic::new(Reason::ForgeUndetected, message)
610 .expected("a github.com or gitlab remote, or --forge")
611 .action("pass --forge <github|gitlab>"),
612 )
613 })?;
614 Ok(Resolved {
615 forge,
616 repo: repo_flag.map(str::to_owned).or(detected.repo),
617 })
618}
619
620#[must_use]
623pub fn repo_unresolved() -> RkError {
624 RkError::missing(
625 Diagnostic::new(
626 Reason::ForgeUndetected,
627 "no repository detected: the target has no origin remote",
628 )
629 .expected("an origin remote naming the project")
630 .action("pass --repo <path>"),
631 )
632}
633
634pub fn write_destination(target: &Utf8Path, entry: &Entry) -> std::io::Result<()> {
644 let path = target.join(&entry.destination);
645 match entry.placement {
646 Placement::Whole => atomic::write(path.as_std_path(), &entry.rendered),
647 Placement::Block => {
648 let existing = match std::fs::read(&path) {
649 Ok(bytes) => Some(String::from_utf8_lossy(&bytes).into_owned()),
650 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
651 Err(e) => return Err(e),
652 };
653 let block = String::from_utf8_lossy(&entry.rendered).into_owned();
654 let spliced = if entry.destination == HOOKS_DESTINATION {
655 splice_hooks_block(existing.as_deref(), &block).map_err(std::io::Error::other)?
656 } else {
657 splice_agents_block(existing.as_deref(), &block)
658 };
659 atomic::write(path.as_std_path(), spliced.as_bytes())
660 }
661 }
662}
663
664pub fn hooks_file_defect(target: &Utf8Path) -> std::io::Result<Option<String>> {
677 let path = target.join(HOOKS_DESTINATION);
678 match std::fs::read(&path) {
679 Ok(bytes) => {
680 let text = String::from_utf8_lossy(&bytes);
681 Ok(splice_hooks_block(Some(&text), HOOKS_BLOCK).err())
682 }
683 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
684 Err(e) => Err(e),
685 }
686}
687
688pub fn hooks_splice_refusal(target: &Utf8Path) -> Result<(), RkError> {
698 hooks_file_defect(target)?.map_or(Ok(()), |reason| {
699 Err(RkError::refusal(
700 Diagnostic::new(
701 Reason::StateDrift,
702 format!("{reason}, and nothing was written"),
703 )
704 .expected("a .pre-commit-config.yaml the block can land in, or none")
705 .action(format!(
706 "resolve it in {}, then re-run",
707 target.join(HOOKS_DESTINATION)
708 ))
709 .target_state("unchanged"),
710 ))
711 })
712}
713
714#[cfg(test)]
715mod tests {
716 #![allow(clippy::expect_used)]
717
718 use super::{
719 AGENTS_DESTINATION, BLOCK_BEGIN, BLOCK_END, HOOK_TYPES_LINE, HOOKS_BEGIN,
720 HOOKS_DESTINATION, HOOKS_END, Kind, extract_block, hooks_block, kind_of, pair_files,
721 parse_scopes, projection, render, routing_block, splice_agents_block, splice_hooks_block,
722 };
723 use crate::embedded;
724
725 fn scopes(list: &[&str]) -> Vec<String> {
726 list.iter().map(|s| (*s).to_owned()).collect()
727 }
728
729 #[test]
733 fn the_kind_table_closes_over_every_snippet() {
734 for tech_dir in embedded::SNIPPETS.dirs() {
735 for pair_dir in tech_dir.dirs() {
736 let prefix = format!("{}/", pair_dir.path().to_string_lossy());
737 for (path, _) in embedded::walk(pair_dir) {
738 let destination = path.strip_prefix(&prefix).unwrap_or(&path);
739 assert!(
740 kind_of(destination).is_some(),
741 "{destination}: no declared kind"
742 );
743 }
744 }
745 }
746 assert_eq!(kind_of(AGENTS_DESTINATION), Some(Kind::Rendered));
747 assert_eq!(kind_of(HOOKS_DESTINATION), Some(Kind::Rendered));
748 assert_eq!(kind_of("something-else.txt"), None);
749 }
750
751 #[test]
755 fn rendering_substitutes_every_owner_occurrence() {
756 let baseline = b"if: repository_owner == 'OWNER'\n# OWNER again: OWNER\n";
757 let rendered = render(baseline, "acme/sub/widget", &[]);
758 let text = String::from_utf8(rendered).expect("rendered bytes stay text");
759 assert_eq!(text, "if: repository_owner == 'acme'\n# acme again: acme\n");
760
761 let baseline = b"scopes 'RK_SCOPES_CSV' match (RK_SCOPES_PIPE)\n";
762 let rendered = render(baseline, "acme/widget", &scopes(&["api", "cli"]));
763 let text = String::from_utf8(rendered).expect("rendered bytes stay text");
764 assert_eq!(text, "scopes 'api,cli' match (api|cli)\n");
765
766 let rendered = render(baseline, "acme/widget", &scopes(&["api.v1"]));
769 let text = String::from_utf8(rendered).expect("rendered bytes stay text");
770 assert_eq!(text, "scopes 'api.v1' match (api\\.v1)\n");
771 }
772
773 #[test]
776 fn scope_parsing_refuses_the_unusable() {
777 assert_eq!(
778 parse_scopes("api, cli,guides/release").expect("a clean list parses"),
779 scopes(&["api", "cli", "guides/release"])
780 );
781 assert!(parse_scopes("").is_err());
782 assert!(parse_scopes(" , ").is_err());
783 assert!(parse_scopes("api|cli").is_err());
784 assert!(parse_scopes("a b").is_err());
785 }
786
787 #[test]
790 fn the_shared_zone_composes_into_the_pair() {
791 let files = pair_files("rust", "github").expect("the pair lists");
792 assert!(
793 files
794 .iter()
795 .any(|(dest, _)| dest == ".github/workflows/pr-title.yml"),
796 "the shared title check lands with the pair"
797 );
798 let files = pair_files("rust", "gitlab").expect("the pair lists");
799 assert!(
800 files
801 .iter()
802 .any(|(dest, _)| dest == ".gitlab/ci/mr-title.yml"),
803 "the shared title job lands with the pair"
804 );
805 let err = pair_files("_shared", "github").expect_err("the shared zone is no tech");
806 let listing = err.to_string();
807 let bindings = listing
808 .split("the bindings are:")
809 .nth(1)
810 .expect("the refusal lists the bindings");
811 assert!(!bindings.contains("_shared"), "{listing}");
812 }
813
814 #[test]
818 fn a_projection_renders_owned_files_and_keeps_seeded_judgment() {
819 let entries = projection("rust", "github", "acme/widget", &scopes(&["api", "cli"]))
820 .expect("the pair projects");
821 let workflow = entries
822 .iter()
823 .find(|entry| entry.destination.ends_with("release-plz.yml"))
824 .expect("the workflow projects");
825 assert_eq!(workflow.kind, Kind::Rendered);
826 let text = String::from_utf8_lossy(&workflow.rendered);
827 assert!(!text.contains("OWNER"), "an owner token survived rendering");
828 assert!(text.contains("'acme'"));
829 assert!(!text.contains("TODO(release-kit)"));
830 let title = entries
831 .iter()
832 .find(|entry| entry.destination.ends_with("pr-title.yml"))
833 .expect("the title check projects");
834 let text = String::from_utf8_lossy(&title.rendered);
835 assert!(text.contains("api|cli"), "{text}");
836 assert!(
837 !text.contains("RK_SCOPES"),
838 "a scope token survived: {text}"
839 );
840 let seeded = entries
841 .iter()
842 .find(|entry| entry.destination == "release-plz.toml")
843 .expect("the seeded file projects");
844 assert_eq!(seeded.kind, Kind::Seeded);
845 assert_eq!(seeded.rendered, seeded.baseline);
846 assert!(String::from_utf8_lossy(&seeded.rendered).contains("TODO(release-kit)"));
847 for block in [AGENTS_DESTINATION, HOOKS_DESTINATION] {
848 let entry = entries
849 .iter()
850 .find(|entry| entry.destination == block)
851 .expect("both blocks are part of the projection");
852 let text = String::from_utf8_lossy(&entry.rendered);
853 assert!(!text.contains("RK_SCOPES"), "{block} kept a token: {text}");
854 assert!(text.contains("api,cli"), "{block} lost the scopes: {text}");
855 }
856 }
857
858 #[test]
859 fn the_block_splices_into_every_agents_shape() {
860 let block = routing_block();
861 let fresh = splice_agents_block(None, block);
862 assert_eq!(fresh, format!("{block}\n"));
863 assert_eq!(extract_block(&fresh, BLOCK_BEGIN, BLOCK_END), Some(block));
864
865 let appended = splice_agents_block(Some("# My project\n\nOwn rules.\n"), block);
866 assert!(appended.starts_with("# My project\n\nOwn rules.\n\n<!-- BEGIN release-kit -->"));
867 assert_eq!(
868 extract_block(&appended, BLOCK_BEGIN, BLOCK_END),
869 Some(block)
870 );
871
872 let stale = appended.replace("Never author a tag", "Do author a tag");
873 let refreshed = splice_agents_block(Some(&stale), block);
874 assert_eq!(
875 extract_block(&refreshed, BLOCK_BEGIN, BLOCK_END),
876 Some(block)
877 );
878 assert!(refreshed.starts_with("# My project"));
879 assert_eq!(
880 refreshed.matches("BEGIN release-kit").count(),
881 1,
882 "a re-splice must replace, not accumulate"
883 );
884 }
885
886 #[test]
889 fn the_hook_block_splices_under_repos() {
890 let block = hooks_block();
891 let fresh = splice_hooks_block(None, block).expect("a fresh file splices");
892 assert!(fresh.starts_with(HOOK_TYPES_LINE));
893 assert!(fresh.contains("\nrepos:\n# BEGIN release-kit\n"));
894 assert_eq!(extract_block(&fresh, HOOKS_BEGIN, HOOKS_END), Some(block));
895
896 let own =
897 "repos:\n - repo: https://example.com/own\n rev: v1\n hooks:\n - id: own\n";
898 let spliced = splice_hooks_block(Some(own), block).expect("an unmarked file splices");
899 assert!(spliced.starts_with("repos:\n# BEGIN release-kit\n"));
900 assert!(spliced.contains("- id: own"), "the target's hooks survive");
901 assert!(
902 !spliced.contains(HOOK_TYPES_LINE),
903 "an existing file's top level is the skills' duty, not the splice's"
904 );
905
906 let stale = spliced.replace("--force-scope", "--no-scope");
907 let refreshed = splice_hooks_block(Some(&stale), block).expect("a marked file re-splices");
908 assert_eq!(
909 extract_block(&refreshed, HOOKS_BEGIN, HOOKS_END),
910 Some(block)
911 );
912 assert_eq!(refreshed.matches(HOOKS_BEGIN).count(), 1);
913
914 let err = splice_hooks_block(Some("minimum_pre_commit_version: '3.2.0'\n"), block)
915 .expect_err("no repos: line refuses");
916 assert!(err.contains("repos:"), "{err}");
917
918 let doubled = format!("repos:\n{block}\n{block}\n");
922 let err = splice_hooks_block(Some(&doubled), block).expect_err("a second block refuses");
923 assert!(err.contains("one block"), "{err}");
924 let unmatched = "repos:\n# BEGIN release-kit\n - repo: local\n";
925 let err =
926 splice_hooks_block(Some(unmatched), block).expect_err("an unmatched marker refuses");
927 assert!(err.contains("unmatched"), "{err}");
928 }
929
930 #[test]
933 fn the_hook_marker_defects_are_named() {
934 use super::hooks_marker_defect;
935 let block = hooks_block();
936 assert_eq!(hooks_marker_defect(""), None);
937 assert_eq!(hooks_marker_defect(&format!("repos:\n{block}\n")), None);
938 for (case, text) in [
939 (
940 "a second begin",
941 format!("repos:\n{block}\n# BEGIN release-kit\n"),
942 ),
943 (
944 "a second end",
945 format!("repos:\n{block}\n# END release-kit\n"),
946 ),
947 (
948 "an unpaired begin",
949 "repos:\n# BEGIN release-kit\n".to_owned(),
950 ),
951 ("an unpaired end", "repos:\n# END release-kit\n".to_owned()),
952 (
953 "an end before its begin",
954 "repos:\n# END release-kit\n# BEGIN release-kit\n".to_owned(),
955 ),
956 ] {
957 assert!(
958 hooks_marker_defect(&text).is_some(),
959 "{case} must be a defect"
960 );
961 }
962 }
963}