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); 16] = [
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 ("SECURITY.md", Kind::Rendered),
63 (".gitlab/ci/mr-title.yml", Kind::Rendered),
64 ("release-plz.toml", Kind::Seeded),
65 ("dist-workspace.toml", Kind::Seeded),
66 ("release-please-config.json", Kind::Seeded),
67 ("cliff.toml", Kind::Seeded),
68 ("nix/package.nix", Kind::Seeded),
69 ("flake.nix", Kind::Seeded),
70 (".release-please-manifest.json", Kind::State),
71 ("VERSION", Kind::State),
72 ("flake.lock", Kind::State),
73];
74
75pub const NIX_DESTINATIONS: [&str; 3] = ["nix/package.nix", "flake.nix", "flake.lock"];
89
90pub const NIX_WITHHOLDABLE: [&str; 2] = ["flake.nix", "flake.lock"];
96
97#[must_use]
100pub fn kind_of(destination: &str) -> Option<Kind> {
101 if destination == AGENTS_DESTINATION || destination == HOOKS_DESTINATION {
102 return Some(Kind::Rendered);
103 }
104 KINDS
105 .iter()
106 .find(|(name, _)| *name == destination)
107 .map(|(_, kind)| *kind)
108}
109
110pub fn destinations() -> impl Iterator<Item = &'static str> {
114 KINDS
115 .iter()
116 .map(|(name, _)| *name)
117 .chain([AGENTS_DESTINATION, HOOKS_DESTINATION])
118}
119
120pub const OWNER_TOKEN: &[u8] = b"OWNER";
127
128pub const REPO_TOKEN: &[u8] = b"RK_REPO";
130
131pub const SCOPE_SHAPE_TOKEN: &[u8] = b"RK_SCOPE_SHAPE";
133
134pub const STYLE_TOKEN: &[u8] = b"RK_STYLE";
137
138#[must_use]
148pub fn render(baseline: &[u8], repo: &str, style: Option<Style>) -> Vec<u8> {
149 let owner = repo.split('/').next().unwrap_or(repo);
150 let mut out = substitute(baseline, OWNER_TOKEN, owner.as_bytes());
151 if let Some(style) = style {
152 out = substitute(&out, STYLE_TOKEN, style.as_str().as_bytes());
153 }
154 out = substitute(&out, SCOPE_SHAPE_TOKEN, SCOPE_SHAPE.as_bytes());
155 substitute(&out, REPO_TOKEN, repo.as_bytes())
156}
157
158fn substitute(baseline: &[u8], token: &[u8], value: &[u8]) -> Vec<u8> {
160 let mut out = Vec::with_capacity(baseline.len());
161 let mut rest = baseline;
162 while let Some(at) = find(rest, token) {
163 out.extend_from_slice(&rest[..at]);
164 out.extend_from_slice(value);
165 rest = &rest[at + token.len()..];
166 }
167 out.extend_from_slice(rest);
168 out
169}
170
171fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
173 haystack
174 .windows(needle.len())
175 .position(|window| window == needle)
176}
177
178pub const AGENTS_DESTINATION: &str = "AGENTS.md";
180
181pub const BLOCK_BEGIN: &str = "<!-- BEGIN release-kit -->";
183
184pub const BLOCK_END: &str = "<!-- END release-kit -->";
186
187pub const HOOKS_DESTINATION: &str = ".pre-commit-config.yaml";
189
190pub const HOOKS_BEGIN: &str = "# BEGIN release-kit";
192
193pub const HOOKS_END: &str = "# END release-kit";
195
196pub const HOOK_TYPES_LINE: &str = "default_install_hook_types: [pre-commit, commit-msg, pre-push]";
200
201static AGENTS_BLOCK: &str = include_str!("../blocks/agents-block.md.in");
203
204static AGENTS_LINE_WORKTREE: &str = include_str!("../blocks/agents-line-worktree.md.in");
206
207static AGENTS_LINE_BRANCHES: &str = include_str!("../blocks/agents-line-branches.md.in");
209
210static PRE_COMMIT_BLOCK: &str = include_str!("../blocks/pre-commit-block.yaml.in");
212
213static PRE_COMMIT_WORKTREE_GUARD: &str =
215 include_str!("../blocks/pre-commit-worktree-guard.yaml.in");
216
217fn authored(text: &str) -> &str {
221 text.strip_suffix('\n').unwrap_or(text)
222}
223
224pub 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[-/].+)$";
232
233pub const SCOPE_SHAPE: &str = "[a-z0-9._/-]+";
243
244#[must_use]
251pub fn scope_is_shaped(scope: &str) -> bool {
252 !scope.is_empty()
253 && scope.chars().all(|c| {
254 c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '/' | '-')
255 })
256}
257
258#[must_use]
267pub fn routing_block(workflow: Workflow) -> String {
268 let line = match workflow {
269 Workflow::Worktree => authored(AGENTS_LINE_WORKTREE),
270 Workflow::Branches => authored(AGENTS_LINE_BRANCHES),
271 };
272 authored(AGENTS_BLOCK).replacen("RK_WORKFLOW_LINE", line, 1)
273}
274
275#[must_use]
287pub fn hooks_block(workflow: Workflow) -> String {
288 let (guard, skip) = match workflow {
289 Workflow::Worktree => (
290 format!("{}\n", authored(PRE_COMMIT_WORKTREE_GUARD)),
291 "no-commit-to-branch,rk-worktree-location",
292 ),
293 Workflow::Branches => (String::new(), "no-commit-to-branch"),
294 };
295 authored(PRE_COMMIT_BLOCK)
296 .replacen("RK_BRANCH_GRAMMAR", BRANCH_GRAMMAR, 1)
297 .replacen("RK_SWEEP_SKIP", skip, 1)
298 .replacen("RK_WORKTREE_GUARD", &guard, 1)
299}
300
301#[must_use]
303pub fn block_markers(destination: &str) -> Option<(&'static str, &'static str)> {
304 match destination {
305 AGENTS_DESTINATION => Some((BLOCK_BEGIN, BLOCK_END)),
306 HOOKS_DESTINATION => Some((HOOKS_BEGIN, HOOKS_END)),
307 _ => None,
308 }
309}
310
311#[must_use]
314pub fn extract_block<'a>(text: &'a str, begin: &str, end: &str) -> Option<&'a str> {
315 let start = text.find(begin)?;
316 let stop = text[start..].find(end)? + start + end.len();
317 Some(&text[start..stop])
318}
319
320#[must_use]
326pub fn splice_agents_block(existing: Option<&str>, block: &str) -> String {
327 existing.map_or_else(
328 || format!("{block}\n"),
329 |text| {
330 extract_block(text, BLOCK_BEGIN, BLOCK_END).map_or_else(
331 || format!("{}\n\n{block}\n", text.trim_end()),
332 |found| text.replacen(found, block, 1),
333 )
334 },
335 )
336}
337
338pub fn splice_hooks_block(existing: Option<&str>, block: &str) -> Result<String, String> {
351 let Some(text) = existing else {
352 return Ok(format!("{HOOK_TYPES_LINE}\n\nrepos:\n{block}\n"));
353 };
354 if let Some(defect) = hooks_marker_defect(text) {
355 return Err(defect);
356 }
357 if let Some(found) = extract_block(text, HOOKS_BEGIN, HOOKS_END) {
358 return Ok(text.replacen(found, block, 1));
359 }
360 let mut out = String::with_capacity(text.len() + block.len() + 1);
361 let mut placed = false;
362 for line in text.split_inclusive('\n') {
363 out.push_str(line);
364 if !placed && line.trim_end() == "repos:" {
365 if !out.ends_with('\n') {
366 out.push('\n');
367 }
368 out.push_str(block);
369 out.push('\n');
370 placed = true;
371 }
372 }
373 if placed {
374 Ok(out)
375 } else {
376 Err(format!(
377 "{HOOKS_DESTINATION} exists with no repos: line, so the hook block has nowhere to land"
378 ))
379 }
380}
381
382#[must_use]
391pub fn hooks_marker_defect(text: &str) -> Option<String> {
392 let begins = text.matches(HOOKS_BEGIN).count();
393 let ends = text.matches(HOOKS_END).count();
394 if begins > 1 || ends > 1 {
395 return Some(format!(
396 "{HOOKS_DESTINATION} carries more than one release-kit marker pair; release-kit owns exactly one block"
397 ));
398 }
399 match (text.find(HOOKS_BEGIN), text.find(HOOKS_END)) {
400 (Some(begin), Some(end)) if end > begin => None,
401 (None, None) => None,
402 _ => Some(format!(
403 "{HOOKS_DESTINATION} carries an unmatched or misordered release-kit marker, so the block's extent is ambiguous"
404 )),
405 }
406}
407
408#[derive(Debug, Clone, Copy, PartialEq, Eq)]
410pub enum Placement {
411 Whole,
413 Block,
415}
416
417#[derive(Debug)]
420pub struct Entry {
421 pub destination: String,
423 pub kind: Kind,
425 pub placement: Placement,
427 pub baseline: Vec<u8>,
430 pub rendered: Vec<u8>,
433}
434
435pub fn pair_files(tech: &str, forge: &str) -> Result<Vec<(String, &'static [u8])>, RkError> {
443 if tech.starts_with('_') || embedded::SNIPPETS.get_dir(tech).is_none() {
446 let known: Vec<String> = embedded::SNIPPETS
447 .dirs()
448 .map(|dir| dir.path().to_string_lossy().into_owned())
449 .filter(|name| !name.starts_with('_'))
450 .collect();
451 return Err(RkError::Usage(format!(
452 "unknown tech '{tech}'; the bindings are: {}",
453 known.join(", ")
454 )));
455 }
456 let pair = format!("{tech}/{forge}");
457 let pair_dir = embedded::SNIPPETS.get_dir(&pair).ok_or_else(|| {
458 let known: Vec<String> = embedded::SNIPPETS
459 .dirs()
460 .filter(|dir| !dir.path().to_string_lossy().starts_with('_'))
461 .flat_map(include_dir::Dir::dirs)
462 .map(|dir| dir.path().to_string_lossy().replace('/', ", "))
463 .collect();
464 RkError::Usage(format!(
465 "the pair ({tech}, {forge}) has no landable files; the supported pairs are: {}",
466 known.join("; ")
467 ))
468 })?;
469 let mut files: Vec<(String, &'static [u8])> = Vec::new();
473 let shared = format!("_shared/{forge}");
474 if let Some(shared_dir) = embedded::SNIPPETS.get_dir(&shared) {
475 for (path, contents) in embedded::walk(shared_dir) {
476 let rel = path
477 .strip_prefix(&format!("{shared}/"))
478 .map_or(path.as_str(), |rel| rel)
479 .to_owned();
480 files.push((rel, contents));
481 }
482 }
483 for (path, contents) in embedded::walk(pair_dir) {
484 let rel = path
485 .strip_prefix(&format!("{pair}/"))
486 .map_or(path.as_str(), |rel| rel)
487 .to_owned();
488 if files.iter().any(|(existing, _)| *existing == rel) {
489 return Err(anyhow::anyhow!(
490 "the shared zone and the pair ({tech}, {forge}) both ship {rel}; the payload is defective"
491 )
492 .into());
493 }
494 files.push((rel, contents));
495 }
496 Ok(files)
497}
498
499pub fn projection(
514 tech: &str,
515 forge: &str,
516 repo: &str,
517 workflow: Workflow,
518 style: Option<Style>,
519 nix: bool,
520) -> Result<Vec<Entry>, RkError> {
521 let mut entries = Vec::new();
522 for (destination, baseline) in pair_files(tech, forge)? {
523 if !nix && NIX_DESTINATIONS.contains(&destination.as_str()) {
524 continue;
525 }
526 let kind = kind_of(&destination).ok_or_else(|| {
527 anyhow::anyhow!("the payload does not classify {destination}; the kind table is stale")
528 })?;
529 let rendered = match kind {
530 Kind::Rendered => render(baseline, repo, style),
531 Kind::Seeded | Kind::State => baseline.to_vec(),
532 };
533 entries.push(Entry {
534 destination,
535 kind,
536 placement: Placement::Whole,
537 baseline: baseline.to_vec(),
538 rendered,
539 });
540 }
541 for (destination, template) in [
542 (AGENTS_DESTINATION, routing_block(workflow)),
543 (HOOKS_DESTINATION, hooks_block(workflow)),
544 ] {
545 entries.push(Entry {
546 destination: destination.to_owned(),
547 kind: Kind::Rendered,
548 placement: Placement::Block,
549 baseline: template.as_bytes().to_vec(),
550 rendered: render(template.as_bytes(), repo, style),
551 });
552 }
553 entries.sort_by(|a, b| a.destination.cmp(&b.destination));
554 Ok(entries)
555}
556
557#[must_use]
569pub fn nix_unsupported_shape(target: &Utf8Path) -> Option<String> {
570 let Ok(text) = std::fs::read_to_string(target.join("Cargo.toml")) else {
571 return Some(
572 "the target has no readable Cargo.toml, which the seeded package expression reads; no Nix file lands".to_owned(),
573 );
574 };
575 let Ok(table) = text.parse::<toml::Table>() else {
576 return Some(
577 "the target's Cargo.toml does not parse, and the seeded package expression reads it; no Nix file lands".to_owned(),
578 );
579 };
580 if !table.contains_key("package") {
581 return Some(
582 "the target's Cargo.toml has no [package] table; the seed supports a single crate, so no Nix file lands".to_owned(),
583 );
584 }
585 if !target.join("Cargo.lock").is_file() {
586 return Some(
587 "the target has no Cargo.lock, which the seeded package expression builds from; commit one, then opt in".to_owned(),
588 );
589 }
590 let implicit_bin = target.join("src/main.rs").is_file()
591 && table
592 .get("package")
593 .and_then(toml::Value::as_table)
594 .and_then(|package| package.get("autobins"))
595 .and_then(toml::Value::as_bool)
596 != Some(false);
597 let explicit_bins = table.get("bin").and_then(toml::Value::as_array);
598 if explicit_bins.is_none() && !implicit_bin {
599 return Some(
600 "the target declares no binary — no effective src/main.rs and no [[bin]] entry — and the seed flake's smoke check runs one; no Nix file lands".to_owned(),
601 );
602 }
603 if let Some(bins) = explicit_bins {
609 let required = bins
610 .first()
611 .and_then(toml::Value::as_table)
612 .and_then(|bin| bin.get("required-features"))
613 .and_then(toml::Value::as_array);
614 if let Some(required) = required {
615 let enabled = default_features(&table);
616 let missing = required
617 .iter()
618 .filter_map(toml::Value::as_str)
619 .any(|feature| !enabled.contains(feature));
620 if missing {
621 return Some(
622 "the target's first [[bin]] entry requires features a default build does not enable; no Nix file lands".to_owned(),
623 );
624 }
625 }
626 }
627 None
628}
629
630fn dep_edge_suppresses(features: &toml::Table, name: &str) -> bool {
633 let edge = format!("dep:{name}");
634 features.values().any(|list| {
635 list.as_array().is_some_and(|entries| {
636 entries
637 .iter()
638 .filter_map(toml::Value::as_str)
639 .any(|entry| entry == edge)
640 })
641 })
642}
643
644fn is_optional_dependency(table: &toml::Table, name: &str) -> bool {
647 ["dependencies", "build-dependencies"]
648 .iter()
649 .any(|section| {
650 table
651 .get(*section)
652 .and_then(toml::Value::as_table)
653 .and_then(|dependencies| dependencies.get(name))
654 .and_then(toml::Value::as_table)
655 .and_then(|dependency| dependency.get("optional"))
656 .and_then(toml::Value::as_bool)
657 == Some(true)
658 })
659}
660
661fn default_features(table: &toml::Table) -> std::collections::BTreeSet<String> {
668 let Some(features) = table.get("features").and_then(toml::Value::as_table) else {
669 return std::collections::BTreeSet::new();
670 };
671 let mut enabled = std::collections::BTreeSet::new();
672 let mut queue = vec!["default".to_owned()];
673 while let Some(name) = queue.pop() {
674 if !enabled.insert(name.clone()) {
675 continue;
676 }
677 if let Some(implies) = features.get(&name).and_then(toml::Value::as_array) {
678 for implied in implies.iter().filter_map(toml::Value::as_str) {
679 if implied.starts_with("dep:") || implied.contains("?/") {
680 continue;
684 }
685 if let Some((package, _)) = implied.split_once('/') {
686 let feature_exists =
694 features.contains_key(package) || !dep_edge_suppresses(features, package);
695 if is_optional_dependency(table, package) && feature_exists {
696 queue.push(package.to_owned());
697 }
698 } else {
699 queue.push(implied.to_owned());
700 }
701 }
702 }
703 }
704 enabled
705}
706
707pub fn nix_withheld(
719 target: &Utf8Path,
720 recorded: Option<&manifest::Manifest>,
721) -> std::io::Result<Option<String>> {
722 if recorded.is_some_and(|record| record.file("flake.nix").is_some()) {
723 return Ok(None);
724 }
725 let mut present = Vec::new();
726 for name in ["flake.nix", "flake.lock"] {
727 match std::fs::symlink_metadata(target.join(name).as_std_path()) {
728 Ok(_) => present.push(name),
729 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
730 Err(e) => return Err(e),
731 }
732 }
733 if present.is_empty() {
734 return Ok(None);
735 }
736 Ok(Some(format!(
737 "the target already carries {}; its flake pair stays its own",
738 present.join(" and ")
739 )))
740}
741
742#[derive(Debug, Serialize)]
744pub struct Withheld {
745 pub path: String,
747 pub reason: String,
750}
751
752pub fn withhold_nix(
765 target: &Utf8Path,
766 nix: bool,
767 recorded: Option<&manifest::Manifest>,
768 entries: &mut Vec<Entry>,
769) -> Result<Vec<Withheld>, RkError> {
770 if !nix {
771 return Ok(Vec::new());
772 }
773 let (set, reason): (&[&str], String) = if let Some(reason) = nix_unsupported_shape(target) {
774 (&NIX_DESTINATIONS[..], reason)
775 } else if let Some(reason) = nix_withheld(target, recorded)? {
776 (&NIX_WITHHOLDABLE[..], reason)
777 } else {
778 return Ok(Vec::new());
779 };
780 let mut withheld = Vec::new();
781 entries.retain(|entry| {
782 if set.contains(&entry.destination.as_str()) {
783 withheld.push(Withheld {
784 path: entry.destination.clone(),
785 reason: reason.clone(),
786 });
787 false
788 } else {
789 true
790 }
791 });
792 Ok(withheld)
793}
794
795pub fn read_destination(target: &Utf8Path, entry: &Entry) -> std::io::Result<Option<Vec<u8>>> {
803 read_recorded(target, &entry.destination)
804}
805
806pub fn read_recorded(target: &Utf8Path, destination: &str) -> std::io::Result<Option<Vec<u8>>> {
817 let path = target.join(destination);
818 let bytes = match std::fs::read(&path) {
819 Ok(bytes) => bytes,
820 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
821 Err(e) => return Err(e),
822 };
823 if let Some((begin, end)) = block_markers(destination) {
824 let text = String::from_utf8_lossy(&bytes);
825 Ok(extract_block(&text, begin, end).map(|block| block.as_bytes().to_vec()))
826 } else {
827 Ok(Some(bytes))
828 }
829}
830
831#[derive(Debug)]
834pub struct Resolved {
835 pub forge: String,
837 pub repo: Option<String>,
839}
840
841pub fn resolve(
853 target: &Utf8Path,
854 forge_flag: Option<&str>,
855 repo_flag: Option<&str>,
856) -> Result<Resolved, RkError> {
857 let forge_flag = forge_flag
858 .map(|name| {
859 crate::detect::Forge::parse(name).ok_or_else(|| {
860 RkError::Usage(format!(
861 "unknown forge '{name}'; the forges are: github, gitlab"
862 ))
863 })
864 })
865 .transpose()?;
866 let detected = crate::detect::detect(target.as_std_path());
867 let forge = forge_flag
868 .or(detected.forge)
869 .map(|forge| forge.as_str().to_owned())
870 .ok_or_else(|| {
871 let message = detected.host.map_or_else(
872 || "no forge detected: the target has no origin remote".to_owned(),
873 |host| format!("no forge detected: the host {host} is not recognized"),
874 );
875 RkError::refusal(
876 Diagnostic::new(Reason::ForgeUndetected, message)
877 .expected("a github.com or gitlab remote, or --forge")
878 .action("pass --forge <github|gitlab>"),
879 )
880 })?;
881 Ok(Resolved {
882 forge,
883 repo: repo_flag.map(str::to_owned).or(detected.repo),
884 })
885}
886
887#[must_use]
890pub fn repo_unresolved() -> RkError {
891 RkError::missing(
892 Diagnostic::new(
893 Reason::ForgeUndetected,
894 "no repository detected: the target has no origin remote",
895 )
896 .expected("an origin remote naming the project")
897 .action("pass --repo <path>"),
898 )
899}
900
901pub fn write_destination(target: &Utf8Path, entry: &Entry) -> std::io::Result<()> {
911 let path = target.join(&entry.destination);
912 match entry.placement {
913 Placement::Whole => atomic::write(path.as_std_path(), &entry.rendered),
914 Placement::Block => {
915 let existing = match std::fs::read(&path) {
916 Ok(bytes) => Some(String::from_utf8_lossy(&bytes).into_owned()),
917 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
918 Err(e) => return Err(e),
919 };
920 let block = String::from_utf8_lossy(&entry.rendered).into_owned();
921 let spliced = if entry.destination == HOOKS_DESTINATION {
922 splice_hooks_block(existing.as_deref(), &block).map_err(std::io::Error::other)?
923 } else {
924 splice_agents_block(existing.as_deref(), &block)
925 };
926 atomic::write(path.as_std_path(), spliced.as_bytes())
927 }
928 }
929}
930
931pub fn hooks_file_defect(target: &Utf8Path) -> std::io::Result<Option<String>> {
944 let path = target.join(HOOKS_DESTINATION);
945 match std::fs::read(&path) {
946 Ok(bytes) => {
947 let text = String::from_utf8_lossy(&bytes);
948 Ok(splice_hooks_block(Some(&text), authored(PRE_COMMIT_BLOCK)).err())
949 }
950 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
951 Err(e) => Err(e),
952 }
953}
954
955pub fn hooks_splice_refusal(target: &Utf8Path) -> Result<(), RkError> {
965 hooks_file_defect(target)?.map_or(Ok(()), |reason| {
966 Err(RkError::refusal(
967 Diagnostic::new(
968 Reason::StateDrift,
969 format!("{reason}, and nothing was written"),
970 )
971 .expected("a .pre-commit-config.yaml the block can land in, or none")
972 .action(format!(
973 "resolve it in {}, then re-run",
974 target.join(HOOKS_DESTINATION)
975 ))
976 .target_state("unchanged"),
977 ))
978 })
979}
980
981#[cfg(test)]
982mod tests {
983 #![allow(clippy::expect_used)]
984
985 use super::{
986 AGENTS_DESTINATION, BLOCK_BEGIN, BLOCK_END, BRANCH_GRAMMAR, HOOK_TYPES_LINE, HOOKS_BEGIN,
987 HOOKS_DESTINATION, HOOKS_END, Kind, SCOPE_SHAPE, Style, Workflow, extract_block,
988 hooks_block, kind_of, pair_files, projection, render, routing_block, splice_agents_block,
989 splice_hooks_block,
990 };
991 use crate::embedded;
992
993 #[test]
994 fn private_reporting_path_tokens_are_reproducible() {
995 for repo in [
996 "acme/widget",
997 "acme/group/widget",
998 "acme/OWNER-RK_STYLE-RK_SCOPE_SHAPE",
999 ] {
1000 assert_eq!(
1001 super::render(
1002 b"RK_REPO RK_REPO OWNER RK_STYLE RK_SCOPE_SHAPE",
1003 repo,
1004 Some(super::Style::Trunk)
1005 ),
1006 format!("{repo} {repo} acme trunk {}", super::SCOPE_SHAPE).as_bytes()
1007 );
1008 }
1009 assert_eq!(super::kind_of("SECURITY.md"), Some(super::Kind::Rendered));
1010 }
1011
1012 #[test]
1016 fn the_kind_table_closes_over_every_snippet() {
1017 for tech_dir in embedded::SNIPPETS.dirs() {
1018 for pair_dir in tech_dir.dirs() {
1019 let prefix = format!("{}/", pair_dir.path().to_string_lossy());
1020 for (path, _) in embedded::walk(pair_dir) {
1021 let destination = path.strip_prefix(&prefix).unwrap_or(&path);
1022 assert!(
1023 kind_of(destination).is_some(),
1024 "{destination}: no declared kind"
1025 );
1026 }
1027 }
1028 }
1029 assert_eq!(kind_of(AGENTS_DESTINATION), Some(Kind::Rendered));
1030 assert_eq!(kind_of(HOOKS_DESTINATION), Some(Kind::Rendered));
1031 assert_eq!(kind_of("something-else.txt"), None);
1032 }
1033
1034 #[test]
1039 fn rendering_substitutes_every_owner_occurrence() {
1040 let baseline = b"if: repository_owner == 'OWNER'\n# OWNER again: OWNER\n";
1041 let rendered = render(baseline, "acme/sub/widget", None);
1042 let text = String::from_utf8(rendered).expect("rendered bytes stay text");
1043 assert_eq!(text, "if: repository_owner == 'acme'\n# acme again: acme\n");
1044
1045 let baseline = b"match (RK_SCOPE_SHAPE)\n";
1046 let rendered = render(baseline, "acme/widget", None);
1047 let text = String::from_utf8(rendered).expect("rendered bytes stay text");
1048 assert_eq!(text, format!("match ({SCOPE_SHAPE})\n"));
1049 }
1050
1051 #[test]
1055 fn the_scope_shape_drops_into_the_title_check() {
1056 assert_eq!(SCOPE_SHAPE, "[a-z0-9._/-]+");
1057 assert!(
1058 !SCOPE_SHAPE.contains('\''),
1059 "the title checks single-quote it"
1060 );
1061 }
1062
1063 #[test]
1068 fn the_scope_predicate_and_the_rendered_pattern_agree() {
1069 let body = SCOPE_SHAPE
1070 .strip_prefix('[')
1071 .and_then(|rest| rest.strip_suffix("]+"))
1072 .expect("the shape is one bracket expression, repeated");
1073 let chars: Vec<char> = body.chars().collect();
1074 let mut admitted = std::collections::BTreeSet::new();
1075 let mut at = 0;
1076 while at < chars.len() {
1077 if at + 2 < chars.len() && chars[at + 1] == '-' {
1080 for c in chars[at]..=chars[at + 2] {
1081 admitted.insert(c);
1082 }
1083 at += 3;
1084 } else {
1085 admitted.insert(chars[at]);
1086 at += 1;
1087 }
1088 }
1089 for byte in 0..=127u8 {
1090 let c = char::from(byte);
1091 assert_eq!(
1092 super::scope_is_shaped(&c.to_string()),
1093 admitted.contains(&c),
1094 "the predicate and {SCOPE_SHAPE} disagree on {c:?}"
1095 );
1096 }
1097 assert!(super::scope_is_shaped("guides/release"));
1098 assert!(!super::scope_is_shaped(""), "a scope is never empty");
1099 assert!(!super::scope_is_shaped("Specs Ugly"));
1100 }
1101
1102 #[test]
1105 fn the_shared_zone_composes_into_the_pair() {
1106 let files = pair_files("rust", "github").expect("the pair lists");
1107 assert!(
1108 files
1109 .iter()
1110 .any(|(dest, _)| dest == ".github/workflows/pr-title.yml"),
1111 "the shared title check lands with the pair"
1112 );
1113 let files = pair_files("rust", "gitlab").expect("the pair lists");
1114 assert!(
1115 files
1116 .iter()
1117 .any(|(dest, _)| dest == ".gitlab/ci/mr-title.yml"),
1118 "the shared title job lands with the pair"
1119 );
1120 let err = pair_files("_shared", "github").expect_err("the shared zone is no tech");
1121 let listing = err.to_string();
1122 let bindings = listing
1123 .split("the bindings are:")
1124 .nth(1)
1125 .expect("the refusal lists the bindings");
1126 assert!(!bindings.contains("_shared"), "{listing}");
1127 }
1128
1129 #[test]
1133 fn a_projection_renders_owned_files_and_keeps_seeded_judgment() {
1134 let entries = projection(
1135 "rust",
1136 "github",
1137 "acme/widget",
1138 Workflow::Branches,
1139 Some(Style::Trunk),
1140 false,
1141 )
1142 .expect("the pair projects");
1143 let workflow = entries
1144 .iter()
1145 .find(|entry| entry.destination.ends_with("release-plz.yml"))
1146 .expect("the workflow projects");
1147 assert_eq!(workflow.kind, Kind::Rendered);
1148 let text = String::from_utf8_lossy(&workflow.rendered);
1149 assert!(!text.contains("OWNER"), "an owner token survived rendering");
1150 assert!(text.contains("'acme'"));
1151 assert!(!text.contains("TODO(release-kit)"));
1152 let title = entries
1153 .iter()
1154 .find(|entry| entry.destination.ends_with("pr-title.yml"))
1155 .expect("the title check projects");
1156 let text = String::from_utf8_lossy(&title.rendered);
1157 assert!(text.contains(SCOPE_SHAPE), "{text}");
1158 assert!(
1159 !text.contains("RK_SCOPE_SHAPE"),
1160 "a scope token survived: {text}"
1161 );
1162 let seeded = entries
1163 .iter()
1164 .find(|entry| entry.destination == "release-plz.toml")
1165 .expect("the seeded file projects");
1166 assert_eq!(seeded.kind, Kind::Seeded);
1167 assert_eq!(seeded.rendered, seeded.baseline);
1168 assert!(String::from_utf8_lossy(&seeded.rendered).contains("TODO(release-kit)"));
1169 for block in [AGENTS_DESTINATION, HOOKS_DESTINATION] {
1170 let entry = entries
1171 .iter()
1172 .find(|entry| entry.destination == block)
1173 .expect("both blocks are part of the projection");
1174 let text = String::from_utf8_lossy(&entry.rendered);
1175 assert!(
1176 !text.contains("RK_SCOPE_SHAPE"),
1177 "{block} kept a token: {text}"
1178 );
1179 }
1180 }
1181
1182 #[test]
1187 fn the_nix_destinations_project_only_under_the_opt_in() {
1188 use super::NIX_DESTINATIONS;
1189 let paths = |nix: bool, forge: &str| -> Vec<String> {
1190 projection(
1191 "rust",
1192 forge,
1193 "acme/widget",
1194 Workflow::Worktree,
1195 Some(Style::Trunk),
1196 nix,
1197 )
1198 .expect("the pair projects")
1199 .into_iter()
1200 .map(|entry| entry.destination)
1201 .collect()
1202 };
1203 let off = paths(false, "github");
1204 for destination in NIX_DESTINATIONS {
1205 assert!(!off.contains(&destination.to_owned()), "{destination}");
1206 }
1207 let on = paths(true, "github");
1208 for destination in ["nix/package.nix", "flake.nix", "flake.lock"] {
1209 assert!(on.contains(&destination.to_owned()), "{destination}");
1210 }
1211 let gitlab = paths(true, "gitlab");
1216 assert!(gitlab.contains(&"nix/package.nix".to_owned()));
1217 assert!(
1218 !on.iter()
1219 .chain(gitlab.iter())
1220 .any(|destination| destination.contains("nix.yml"))
1221 );
1222 let bash = projection(
1223 "bash",
1224 "github",
1225 "acme/widget",
1226 Workflow::Worktree,
1227 Some(Style::Trunk),
1228 true,
1229 )
1230 .expect("an out-of-matrix pair projects the smaller product");
1231 assert!(
1232 bash.iter()
1233 .all(|entry| !NIX_DESTINATIONS.contains(&entry.destination.as_str()))
1234 );
1235 }
1236
1237 #[test]
1242 fn the_nix_seeds_are_identical_across_forge_pairs() {
1243 for name in ["nix/package.nix", "flake.nix", "flake.lock"] {
1244 let github = embedded::SNIPPETS
1245 .get_file(format!("rust/github/{name}"))
1246 .expect("the github copy ships")
1247 .contents();
1248 let gitlab = embedded::SNIPPETS
1249 .get_file(format!("rust/gitlab/{name}"))
1250 .expect("the gitlab copy ships")
1251 .contents();
1252 assert_eq!(github, gitlab, "{name} diverged between the pairs");
1253 }
1254 }
1255
1256 #[test]
1261 fn the_nix_withhold_judgment_covers_the_three_shapes() {
1262 use super::{NIX_DESTINATIONS, withhold_nix};
1263 let dir = tempfile::tempdir().expect("a scratch target exists");
1264 let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
1265 let entries = || {
1266 projection(
1267 "rust",
1268 "github",
1269 "acme/widget",
1270 Workflow::Worktree,
1271 Some(Style::Trunk),
1272 true,
1273 )
1274 .expect("the pair projects")
1275 };
1276
1277 let mut all = entries();
1279 let withheld = withhold_nix(target, true, None, &mut all).expect("the judgment runs");
1280 let paths: Vec<&str> = withheld.iter().map(|w| w.path.as_str()).collect();
1281 assert_eq!(paths, ["flake.lock", "flake.nix", "nix/package.nix"]);
1282 assert!(
1283 all.iter()
1284 .all(|entry| !NIX_DESTINATIONS.contains(&entry.destination.as_str()))
1285 );
1286
1287 std::fs::write(
1290 target.join("Cargo.toml"),
1291 "[package]\nname = \"widget\"\nversion = \"0.1.0\"\n",
1292 )
1293 .expect("the crate manifest writes");
1294 std::fs::write(target.join("Cargo.lock"), "version = 4\n").expect("the lock writes");
1295 std::fs::create_dir_all(target.join("src")).expect("the src dir exists");
1296 std::fs::write(target.join("src/main.rs"), "fn main() {}\n").expect("the main writes");
1297 std::fs::write(target.join("flake.nix"), "{ }\n").expect("the flake writes");
1298 let mut all = entries();
1299 let withheld = withhold_nix(target, true, None, &mut all).expect("the judgment runs");
1300 let paths: Vec<&str> = withheld.iter().map(|w| w.path.as_str()).collect();
1301 assert_eq!(paths, ["flake.lock", "flake.nix"]);
1302 assert!(
1303 all.iter()
1304 .any(|entry| entry.destination == "nix/package.nix")
1305 );
1306
1307 std::fs::remove_file(target.join("flake.nix")).expect("the flake removes");
1309 let mut all = entries();
1310 let withheld = withhold_nix(target, true, None, &mut all).expect("the judgment runs");
1311 assert!(withheld.is_empty());
1312 assert!(all.iter().any(|entry| entry.destination == "flake.nix"));
1313
1314 let mut all = entries();
1316 let withheld = withhold_nix(target, false, None, &mut all).expect("the judgment runs");
1317 assert!(withheld.is_empty());
1318 }
1319
1320 #[test]
1321 fn the_block_splices_into_every_agents_shape() {
1322 let owned = routing_block(Workflow::Branches);
1323 let block = owned.as_str();
1324 let fresh = splice_agents_block(None, block);
1325 assert_eq!(fresh, format!("{block}\n"));
1326 assert_eq!(extract_block(&fresh, BLOCK_BEGIN, BLOCK_END), Some(block));
1327
1328 let appended = splice_agents_block(Some("# My project\n\nOwn rules.\n"), block);
1329 assert!(appended.starts_with("# My project\n\nOwn rules.\n\n<!-- BEGIN release-kit -->"));
1330 assert_eq!(
1331 extract_block(&appended, BLOCK_BEGIN, BLOCK_END),
1332 Some(block)
1333 );
1334
1335 let stale = appended.replace("Never author a tag", "Do author a tag");
1336 let refreshed = splice_agents_block(Some(&stale), block);
1337 assert_eq!(
1338 extract_block(&refreshed, BLOCK_BEGIN, BLOCK_END),
1339 Some(block)
1340 );
1341 assert!(refreshed.starts_with("# My project"));
1342 assert_eq!(
1343 refreshed.matches("BEGIN release-kit").count(),
1344 1,
1345 "a re-splice must replace, not accumulate"
1346 );
1347 }
1348
1349 #[test]
1352 fn the_hook_block_splices_under_repos() {
1353 let owned = hooks_block(Workflow::Branches);
1354 let block = owned.as_str();
1355 let fresh = splice_hooks_block(None, block).expect("a fresh file splices");
1356 assert!(fresh.starts_with(HOOK_TYPES_LINE));
1357 assert!(fresh.contains("\nrepos:\n# BEGIN release-kit\n"));
1358 assert_eq!(extract_block(&fresh, HOOKS_BEGIN, HOOKS_END), Some(block));
1359
1360 let own =
1361 "repos:\n - repo: https://example.com/own\n rev: v1\n hooks:\n - id: own\n";
1362 let spliced = splice_hooks_block(Some(own), block).expect("an unmarked file splices");
1363 assert!(spliced.starts_with("repos:\n# BEGIN release-kit\n"));
1364 assert!(spliced.contains("- id: own"), "the target's hooks survive");
1365 assert!(
1366 !spliced.contains(HOOK_TYPES_LINE),
1367 "an existing file's top level is the skills' duty, not the splice's"
1368 );
1369
1370 let stale = spliced.replace("--force-scope", "--no-scope");
1371 let refreshed = splice_hooks_block(Some(&stale), block).expect("a marked file re-splices");
1372 assert_eq!(
1373 extract_block(&refreshed, HOOKS_BEGIN, HOOKS_END),
1374 Some(block)
1375 );
1376 assert_eq!(refreshed.matches(HOOKS_BEGIN).count(), 1);
1377
1378 let err = splice_hooks_block(Some("minimum_pre_commit_version: '3.2.0'\n"), block)
1379 .expect_err("no repos: line refuses");
1380 assert!(err.contains("repos:"), "{err}");
1381
1382 let doubled = format!("repos:\n{block}\n{block}\n");
1386 let err = splice_hooks_block(Some(&doubled), block).expect_err("a second block refuses");
1387 assert!(err.contains("one block"), "{err}");
1388 let unmatched = "repos:\n# BEGIN release-kit\n - repo: local\n";
1389 let err =
1390 splice_hooks_block(Some(unmatched), block).expect_err("an unmatched marker refuses");
1391 assert!(err.contains("unmatched"), "{err}");
1392 }
1393
1394 #[test]
1400 fn the_blocks_render_per_mode_and_carry_the_one_grammar() {
1401 let worktree_hooks = hooks_block(Workflow::Worktree);
1402 let branches_hooks = hooks_block(Workflow::Branches);
1403 assert!(worktree_hooks.contains("- id: rk-worktree-location"));
1404 assert!(
1405 worktree_hooks.contains("SKIP=no-commit-to-branch,rk-worktree-location"),
1406 "{worktree_hooks}"
1407 );
1408 assert!(!branches_hooks.contains("rk-worktree-location"));
1409 assert!(branches_hooks.contains("SKIP=no-commit-to-branch in"));
1410 for block in [&worktree_hooks, &branches_hooks] {
1411 assert!(block.contains(BRANCH_GRAMMAR), "the grammar has one owner");
1412 for token in ["RK_BRANCH_GRAMMAR", "RK_SWEEP_SKIP", "RK_WORKTREE_GUARD"] {
1413 assert!(!block.contains(token), "{token} survived: {block}");
1414 }
1415 }
1416 for block in [&worktree_hooks, &branches_hooks] {
1421 for line in block.lines() {
1422 if let Some(value) = line.trim_start().strip_prefix("entry: ") {
1423 assert!(
1424 !value.contains(": "),
1425 "an entry value breaks the YAML plain scalar: {line}"
1426 );
1427 }
1428 }
1429 }
1430 let guard_line = worktree_hooks
1431 .lines()
1432 .position(|line| line.contains("id: rk-worktree-location"))
1433 .expect("the guard entry exists");
1434 let name_line = worktree_hooks
1435 .lines()
1436 .position(|line| line.contains("id: rk-branch-name"))
1437 .expect("the name hook exists");
1438 assert!(
1439 guard_line > name_line,
1440 "the guard lands directly after rk-branch-name"
1441 );
1442
1443 let worktree_routing = routing_block(Workflow::Worktree);
1444 let branches_routing = routing_block(Workflow::Branches);
1445 assert!(worktree_routing.contains("This project works in worktrees"));
1446 assert!(branches_routing.contains("Branches are worked in the main checkout"));
1447 for block in [&worktree_routing, &branches_routing] {
1448 assert!(block.contains("Create or remove a worktree"));
1449 assert!(block.contains("`rk worktree add <branch>`"));
1450 assert!(!block.contains("RK_WORKFLOW_LINE"), "{block}");
1451 }
1452 let differing: Vec<(&str, &str)> = worktree_routing
1453 .lines()
1454 .zip(branches_routing.lines())
1455 .filter(|(a, b)| a != b)
1456 .collect();
1457 assert_eq!(
1458 differing.len(),
1459 1,
1460 "exactly one routing line differs per mode: {differing:?}"
1461 );
1462 }
1463
1464 #[test]
1467 fn the_hook_marker_defects_are_named() {
1468 use super::hooks_marker_defect;
1469 let owned = hooks_block(Workflow::Branches);
1470 let block = owned.as_str();
1471 assert_eq!(hooks_marker_defect(""), None);
1472 assert_eq!(hooks_marker_defect(&format!("repos:\n{block}\n")), None);
1473 for (case, text) in [
1474 (
1475 "a second begin",
1476 format!("repos:\n{block}\n# BEGIN release-kit\n"),
1477 ),
1478 (
1479 "a second end",
1480 format!("repos:\n{block}\n# END release-kit\n"),
1481 ),
1482 (
1483 "an unpaired begin",
1484 "repos:\n# BEGIN release-kit\n".to_owned(),
1485 ),
1486 ("an unpaired end", "repos:\n# END release-kit\n".to_owned()),
1487 (
1488 "an end before its begin",
1489 "repos:\n# END release-kit\n# BEGIN release-kit\n".to_owned(),
1490 ),
1491 ] {
1492 assert!(
1493 hooks_marker_defect(&text).is_some(),
1494 "{case} must be a defect"
1495 );
1496 }
1497 }
1498}