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); 15] = [
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 ("nix/package.nix", Kind::Seeded),
68 ("flake.nix", Kind::Seeded),
69 (".release-please-manifest.json", Kind::State),
70 ("VERSION", Kind::State),
71 ("flake.lock", Kind::State),
72];
73
74pub const NIX_DESTINATIONS: [&str; 3] = ["nix/package.nix", "flake.nix", "flake.lock"];
85
86pub const NIX_WITHHOLDABLE: [&str; 2] = ["flake.nix", "flake.lock"];
92
93#[must_use]
96pub fn kind_of(destination: &str) -> Option<Kind> {
97 if destination == AGENTS_DESTINATION || destination == HOOKS_DESTINATION {
98 return Some(Kind::Rendered);
99 }
100 KINDS
101 .iter()
102 .find(|(name, _)| *name == destination)
103 .map(|(_, kind)| *kind)
104}
105
106pub fn destinations() -> impl Iterator<Item = &'static str> {
110 KINDS
111 .iter()
112 .map(|(name, _)| *name)
113 .chain([AGENTS_DESTINATION, HOOKS_DESTINATION])
114}
115
116pub const OWNER_TOKEN: &[u8] = b"OWNER";
123
124pub const SCOPE_SHAPE_TOKEN: &[u8] = b"RK_SCOPE_SHAPE";
126
127pub const STYLE_TOKEN: &[u8] = b"RK_STYLE";
130
131#[must_use]
140pub fn render(baseline: &[u8], repo: &str, style: Option<Style>) -> Vec<u8> {
141 let owner = repo.split('/').next().unwrap_or(repo);
142 let mut out = substitute(baseline, OWNER_TOKEN, owner.as_bytes());
143 if let Some(style) = style {
144 out = substitute(&out, STYLE_TOKEN, style.as_str().as_bytes());
145 }
146 substitute(&out, SCOPE_SHAPE_TOKEN, SCOPE_SHAPE.as_bytes())
147}
148
149fn substitute(baseline: &[u8], token: &[u8], value: &[u8]) -> Vec<u8> {
151 let mut out = Vec::with_capacity(baseline.len());
152 let mut rest = baseline;
153 while let Some(at) = find(rest, token) {
154 out.extend_from_slice(&rest[..at]);
155 out.extend_from_slice(value);
156 rest = &rest[at + token.len()..];
157 }
158 out.extend_from_slice(rest);
159 out
160}
161
162fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
164 haystack
165 .windows(needle.len())
166 .position(|window| window == needle)
167}
168
169pub const AGENTS_DESTINATION: &str = "AGENTS.md";
171
172pub const BLOCK_BEGIN: &str = "<!-- BEGIN release-kit -->";
174
175pub const BLOCK_END: &str = "<!-- END release-kit -->";
177
178pub const HOOKS_DESTINATION: &str = ".pre-commit-config.yaml";
180
181pub const HOOKS_BEGIN: &str = "# BEGIN release-kit";
183
184pub const HOOKS_END: &str = "# END release-kit";
186
187pub const HOOK_TYPES_LINE: &str = "default_install_hook_types: [pre-commit, commit-msg, pre-push]";
191
192static AGENTS_BLOCK: &str = include_str!("../blocks/agents-block.md.in");
194
195static AGENTS_LINE_WORKTREE: &str = include_str!("../blocks/agents-line-worktree.md.in");
197
198static AGENTS_LINE_BRANCHES: &str = include_str!("../blocks/agents-line-branches.md.in");
200
201static PRE_COMMIT_BLOCK: &str = include_str!("../blocks/pre-commit-block.yaml.in");
203
204static PRE_COMMIT_WORKTREE_GUARD: &str =
206 include_str!("../blocks/pre-commit-worktree-guard.yaml.in");
207
208fn authored(text: &str) -> &str {
212 text.strip_suffix('\n').unwrap_or(text)
213}
214
215pub 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[-/].+)$";
223
224pub const SCOPE_SHAPE: &str = "[a-z0-9._/-]+";
234
235#[must_use]
242pub fn scope_is_shaped(scope: &str) -> bool {
243 !scope.is_empty()
244 && scope.chars().all(|c| {
245 c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '/' | '-')
246 })
247}
248
249#[must_use]
258pub fn routing_block(workflow: Workflow) -> String {
259 let line = match workflow {
260 Workflow::Worktree => authored(AGENTS_LINE_WORKTREE),
261 Workflow::Branches => authored(AGENTS_LINE_BRANCHES),
262 };
263 authored(AGENTS_BLOCK).replacen("RK_WORKFLOW_LINE", line, 1)
264}
265
266#[must_use]
278pub fn hooks_block(workflow: Workflow) -> String {
279 let (guard, skip) = match workflow {
280 Workflow::Worktree => (
281 format!("{}\n", authored(PRE_COMMIT_WORKTREE_GUARD)),
282 "no-commit-to-branch,rk-worktree-location",
283 ),
284 Workflow::Branches => (String::new(), "no-commit-to-branch"),
285 };
286 authored(PRE_COMMIT_BLOCK)
287 .replacen("RK_BRANCH_GRAMMAR", BRANCH_GRAMMAR, 1)
288 .replacen("RK_SWEEP_SKIP", skip, 1)
289 .replacen("RK_WORKTREE_GUARD", &guard, 1)
290}
291
292#[must_use]
294pub fn block_markers(destination: &str) -> Option<(&'static str, &'static str)> {
295 match destination {
296 AGENTS_DESTINATION => Some((BLOCK_BEGIN, BLOCK_END)),
297 HOOKS_DESTINATION => Some((HOOKS_BEGIN, HOOKS_END)),
298 _ => None,
299 }
300}
301
302#[must_use]
305pub fn extract_block<'a>(text: &'a str, begin: &str, end: &str) -> Option<&'a str> {
306 let start = text.find(begin)?;
307 let stop = text[start..].find(end)? + start + end.len();
308 Some(&text[start..stop])
309}
310
311#[must_use]
317pub fn splice_agents_block(existing: Option<&str>, block: &str) -> String {
318 existing.map_or_else(
319 || format!("{block}\n"),
320 |text| {
321 extract_block(text, BLOCK_BEGIN, BLOCK_END).map_or_else(
322 || format!("{}\n\n{block}\n", text.trim_end()),
323 |found| text.replacen(found, block, 1),
324 )
325 },
326 )
327}
328
329pub fn splice_hooks_block(existing: Option<&str>, block: &str) -> Result<String, String> {
342 let Some(text) = existing else {
343 return Ok(format!("{HOOK_TYPES_LINE}\n\nrepos:\n{block}\n"));
344 };
345 if let Some(defect) = hooks_marker_defect(text) {
346 return Err(defect);
347 }
348 if let Some(found) = extract_block(text, HOOKS_BEGIN, HOOKS_END) {
349 return Ok(text.replacen(found, block, 1));
350 }
351 let mut out = String::with_capacity(text.len() + block.len() + 1);
352 let mut placed = false;
353 for line in text.split_inclusive('\n') {
354 out.push_str(line);
355 if !placed && line.trim_end() == "repos:" {
356 if !out.ends_with('\n') {
357 out.push('\n');
358 }
359 out.push_str(block);
360 out.push('\n');
361 placed = true;
362 }
363 }
364 if placed {
365 Ok(out)
366 } else {
367 Err(format!(
368 "{HOOKS_DESTINATION} exists with no repos: line, so the hook block has nowhere to land"
369 ))
370 }
371}
372
373#[must_use]
382pub fn hooks_marker_defect(text: &str) -> Option<String> {
383 let begins = text.matches(HOOKS_BEGIN).count();
384 let ends = text.matches(HOOKS_END).count();
385 if begins > 1 || ends > 1 {
386 return Some(format!(
387 "{HOOKS_DESTINATION} carries more than one release-kit marker pair; release-kit owns exactly one block"
388 ));
389 }
390 match (text.find(HOOKS_BEGIN), text.find(HOOKS_END)) {
391 (Some(begin), Some(end)) if end > begin => None,
392 (None, None) => None,
393 _ => Some(format!(
394 "{HOOKS_DESTINATION} carries an unmatched or misordered release-kit marker, so the block's extent is ambiguous"
395 )),
396 }
397}
398
399#[derive(Debug, Clone, Copy, PartialEq, Eq)]
401pub enum Placement {
402 Whole,
404 Block,
406}
407
408#[derive(Debug)]
411pub struct Entry {
412 pub destination: String,
414 pub kind: Kind,
416 pub placement: Placement,
418 pub baseline: Vec<u8>,
421 pub rendered: Vec<u8>,
424}
425
426pub fn pair_files(tech: &str, forge: &str) -> Result<Vec<(String, &'static [u8])>, RkError> {
434 if tech.starts_with('_') || embedded::SNIPPETS.get_dir(tech).is_none() {
437 let known: Vec<String> = embedded::SNIPPETS
438 .dirs()
439 .map(|dir| dir.path().to_string_lossy().into_owned())
440 .filter(|name| !name.starts_with('_'))
441 .collect();
442 return Err(RkError::Usage(format!(
443 "unknown tech '{tech}'; the bindings are: {}",
444 known.join(", ")
445 )));
446 }
447 let pair = format!("{tech}/{forge}");
448 let pair_dir = embedded::SNIPPETS.get_dir(&pair).ok_or_else(|| {
449 let known: Vec<String> = embedded::SNIPPETS
450 .dirs()
451 .filter(|dir| !dir.path().to_string_lossy().starts_with('_'))
452 .flat_map(include_dir::Dir::dirs)
453 .map(|dir| dir.path().to_string_lossy().replace('/', ", "))
454 .collect();
455 RkError::Usage(format!(
456 "the pair ({tech}, {forge}) has no landable files; the supported pairs are: {}",
457 known.join("; ")
458 ))
459 })?;
460 let mut files: Vec<(String, &'static [u8])> = Vec::new();
464 let shared = format!("_shared/{forge}");
465 if let Some(shared_dir) = embedded::SNIPPETS.get_dir(&shared) {
466 for (path, contents) in embedded::walk(shared_dir) {
467 let rel = path
468 .strip_prefix(&format!("{shared}/"))
469 .map_or(path.as_str(), |rel| rel)
470 .to_owned();
471 files.push((rel, contents));
472 }
473 }
474 for (path, contents) in embedded::walk(pair_dir) {
475 let rel = path
476 .strip_prefix(&format!("{pair}/"))
477 .map_or(path.as_str(), |rel| rel)
478 .to_owned();
479 if files.iter().any(|(existing, _)| *existing == rel) {
480 return Err(anyhow::anyhow!(
481 "the shared zone and the pair ({tech}, {forge}) both ship {rel}; the payload is defective"
482 )
483 .into());
484 }
485 files.push((rel, contents));
486 }
487 Ok(files)
488}
489
490pub fn projection(
505 tech: &str,
506 forge: &str,
507 repo: &str,
508 workflow: Workflow,
509 style: Option<Style>,
510 nix: bool,
511) -> Result<Vec<Entry>, RkError> {
512 let mut entries = Vec::new();
513 for (destination, baseline) in pair_files(tech, forge)? {
514 if !nix && NIX_DESTINATIONS.contains(&destination.as_str()) {
515 continue;
516 }
517 let kind = kind_of(&destination).ok_or_else(|| {
518 anyhow::anyhow!("the payload does not classify {destination}; the kind table is stale")
519 })?;
520 let rendered = match kind {
521 Kind::Rendered => render(baseline, repo, style),
522 Kind::Seeded | Kind::State => baseline.to_vec(),
523 };
524 entries.push(Entry {
525 destination,
526 kind,
527 placement: Placement::Whole,
528 baseline: baseline.to_vec(),
529 rendered,
530 });
531 }
532 for (destination, template) in [
533 (AGENTS_DESTINATION, routing_block(workflow)),
534 (HOOKS_DESTINATION, hooks_block(workflow)),
535 ] {
536 entries.push(Entry {
537 destination: destination.to_owned(),
538 kind: Kind::Rendered,
539 placement: Placement::Block,
540 baseline: template.as_bytes().to_vec(),
541 rendered: render(template.as_bytes(), repo, style),
542 });
543 }
544 entries.sort_by(|a, b| a.destination.cmp(&b.destination));
545 Ok(entries)
546}
547
548#[must_use]
560pub fn nix_unsupported_shape(target: &Utf8Path) -> Option<String> {
561 let Ok(text) = std::fs::read_to_string(target.join("Cargo.toml")) else {
562 return Some(
563 "the target has no readable Cargo.toml, which the seeded package expression reads; no Nix file lands".to_owned(),
564 );
565 };
566 let Ok(table) = text.parse::<toml::Table>() else {
567 return Some(
568 "the target's Cargo.toml does not parse, and the seeded package expression reads it; no Nix file lands".to_owned(),
569 );
570 };
571 if !table.contains_key("package") {
572 return Some(
573 "the target's Cargo.toml has no [package] table; the seed supports a single crate, so no Nix file lands".to_owned(),
574 );
575 }
576 if !target.join("Cargo.lock").is_file() {
577 return Some(
578 "the target has no Cargo.lock, which the seeded package expression builds from; commit one, then opt in".to_owned(),
579 );
580 }
581 let implicit_bin = target.join("src/main.rs").is_file()
582 && table
583 .get("package")
584 .and_then(toml::Value::as_table)
585 .and_then(|package| package.get("autobins"))
586 .and_then(toml::Value::as_bool)
587 != Some(false);
588 let explicit_bins = table.get("bin").and_then(toml::Value::as_array);
589 if explicit_bins.is_none() && !implicit_bin {
590 return Some(
591 "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(),
592 );
593 }
594 if let Some(bins) = explicit_bins {
600 let required = bins
601 .first()
602 .and_then(toml::Value::as_table)
603 .and_then(|bin| bin.get("required-features"))
604 .and_then(toml::Value::as_array);
605 if let Some(required) = required {
606 let enabled = default_features(&table);
607 let missing = required
608 .iter()
609 .filter_map(toml::Value::as_str)
610 .any(|feature| !enabled.contains(feature));
611 if missing {
612 return Some(
613 "the target's first [[bin]] entry requires features a default build does not enable; no Nix file lands".to_owned(),
614 );
615 }
616 }
617 }
618 None
619}
620
621fn dep_edge_suppresses(features: &toml::Table, name: &str) -> bool {
624 let edge = format!("dep:{name}");
625 features.values().any(|list| {
626 list.as_array().is_some_and(|entries| {
627 entries
628 .iter()
629 .filter_map(toml::Value::as_str)
630 .any(|entry| entry == edge)
631 })
632 })
633}
634
635fn is_optional_dependency(table: &toml::Table, name: &str) -> bool {
638 ["dependencies", "build-dependencies"]
639 .iter()
640 .any(|section| {
641 table
642 .get(*section)
643 .and_then(toml::Value::as_table)
644 .and_then(|dependencies| dependencies.get(name))
645 .and_then(toml::Value::as_table)
646 .and_then(|dependency| dependency.get("optional"))
647 .and_then(toml::Value::as_bool)
648 == Some(true)
649 })
650}
651
652fn default_features(table: &toml::Table) -> std::collections::BTreeSet<String> {
659 let Some(features) = table.get("features").and_then(toml::Value::as_table) else {
660 return std::collections::BTreeSet::new();
661 };
662 let mut enabled = std::collections::BTreeSet::new();
663 let mut queue = vec!["default".to_owned()];
664 while let Some(name) = queue.pop() {
665 if !enabled.insert(name.clone()) {
666 continue;
667 }
668 if let Some(implies) = features.get(&name).and_then(toml::Value::as_array) {
669 for implied in implies.iter().filter_map(toml::Value::as_str) {
670 if implied.starts_with("dep:") || implied.contains("?/") {
671 continue;
675 }
676 if let Some((package, _)) = implied.split_once('/') {
677 let feature_exists =
685 features.contains_key(package) || !dep_edge_suppresses(features, package);
686 if is_optional_dependency(table, package) && feature_exists {
687 queue.push(package.to_owned());
688 }
689 } else {
690 queue.push(implied.to_owned());
691 }
692 }
693 }
694 }
695 enabled
696}
697
698pub fn nix_withheld(
710 target: &Utf8Path,
711 recorded: Option<&manifest::Manifest>,
712) -> std::io::Result<Option<String>> {
713 if recorded.is_some_and(|record| record.file("flake.nix").is_some()) {
714 return Ok(None);
715 }
716 let mut present = Vec::new();
717 for name in ["flake.nix", "flake.lock"] {
718 match std::fs::symlink_metadata(target.join(name).as_std_path()) {
719 Ok(_) => present.push(name),
720 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
721 Err(e) => return Err(e),
722 }
723 }
724 if present.is_empty() {
725 return Ok(None);
726 }
727 Ok(Some(format!(
728 "the target already carries {}; its flake pair stays its own",
729 present.join(" and ")
730 )))
731}
732
733#[derive(Debug, Serialize)]
735pub struct Withheld {
736 pub path: String,
738 pub reason: String,
741}
742
743pub fn withhold_nix(
756 target: &Utf8Path,
757 nix: bool,
758 recorded: Option<&manifest::Manifest>,
759 entries: &mut Vec<Entry>,
760) -> Result<Vec<Withheld>, RkError> {
761 if !nix {
762 return Ok(Vec::new());
763 }
764 let (set, reason): (&[&str], String) = if let Some(reason) = nix_unsupported_shape(target) {
765 (&NIX_DESTINATIONS[..], reason)
766 } else if let Some(reason) = nix_withheld(target, recorded)? {
767 (&NIX_WITHHOLDABLE[..], reason)
768 } else {
769 return Ok(Vec::new());
770 };
771 let mut withheld = Vec::new();
772 entries.retain(|entry| {
773 if set.contains(&entry.destination.as_str()) {
774 withheld.push(Withheld {
775 path: entry.destination.clone(),
776 reason: reason.clone(),
777 });
778 false
779 } else {
780 true
781 }
782 });
783 Ok(withheld)
784}
785
786pub fn read_destination(target: &Utf8Path, entry: &Entry) -> std::io::Result<Option<Vec<u8>>> {
794 read_recorded(target, &entry.destination)
795}
796
797pub fn read_recorded(target: &Utf8Path, destination: &str) -> std::io::Result<Option<Vec<u8>>> {
808 let path = target.join(destination);
809 let bytes = match std::fs::read(&path) {
810 Ok(bytes) => bytes,
811 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
812 Err(e) => return Err(e),
813 };
814 if let Some((begin, end)) = block_markers(destination) {
815 let text = String::from_utf8_lossy(&bytes);
816 Ok(extract_block(&text, begin, end).map(|block| block.as_bytes().to_vec()))
817 } else {
818 Ok(Some(bytes))
819 }
820}
821
822#[derive(Debug)]
825pub struct Resolved {
826 pub forge: String,
828 pub repo: Option<String>,
830}
831
832pub fn resolve(
844 target: &Utf8Path,
845 forge_flag: Option<&str>,
846 repo_flag: Option<&str>,
847) -> Result<Resolved, RkError> {
848 let forge_flag = forge_flag
849 .map(|name| {
850 crate::detect::Forge::parse(name).ok_or_else(|| {
851 RkError::Usage(format!(
852 "unknown forge '{name}'; the forges are: github, gitlab"
853 ))
854 })
855 })
856 .transpose()?;
857 let detected = crate::detect::detect(target.as_std_path());
858 let forge = forge_flag
859 .or(detected.forge)
860 .map(|forge| forge.as_str().to_owned())
861 .ok_or_else(|| {
862 let message = detected.host.map_or_else(
863 || "no forge detected: the target has no origin remote".to_owned(),
864 |host| format!("no forge detected: the host {host} is not recognized"),
865 );
866 RkError::refusal(
867 Diagnostic::new(Reason::ForgeUndetected, message)
868 .expected("a github.com or gitlab remote, or --forge")
869 .action("pass --forge <github|gitlab>"),
870 )
871 })?;
872 Ok(Resolved {
873 forge,
874 repo: repo_flag.map(str::to_owned).or(detected.repo),
875 })
876}
877
878#[must_use]
881pub fn repo_unresolved() -> RkError {
882 RkError::missing(
883 Diagnostic::new(
884 Reason::ForgeUndetected,
885 "no repository detected: the target has no origin remote",
886 )
887 .expected("an origin remote naming the project")
888 .action("pass --repo <path>"),
889 )
890}
891
892pub fn write_destination(target: &Utf8Path, entry: &Entry) -> std::io::Result<()> {
902 let path = target.join(&entry.destination);
903 match entry.placement {
904 Placement::Whole => atomic::write(path.as_std_path(), &entry.rendered),
905 Placement::Block => {
906 let existing = match std::fs::read(&path) {
907 Ok(bytes) => Some(String::from_utf8_lossy(&bytes).into_owned()),
908 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
909 Err(e) => return Err(e),
910 };
911 let block = String::from_utf8_lossy(&entry.rendered).into_owned();
912 let spliced = if entry.destination == HOOKS_DESTINATION {
913 splice_hooks_block(existing.as_deref(), &block).map_err(std::io::Error::other)?
914 } else {
915 splice_agents_block(existing.as_deref(), &block)
916 };
917 atomic::write(path.as_std_path(), spliced.as_bytes())
918 }
919 }
920}
921
922pub fn hooks_file_defect(target: &Utf8Path) -> std::io::Result<Option<String>> {
935 let path = target.join(HOOKS_DESTINATION);
936 match std::fs::read(&path) {
937 Ok(bytes) => {
938 let text = String::from_utf8_lossy(&bytes);
939 Ok(splice_hooks_block(Some(&text), authored(PRE_COMMIT_BLOCK)).err())
940 }
941 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
942 Err(e) => Err(e),
943 }
944}
945
946pub fn hooks_splice_refusal(target: &Utf8Path) -> Result<(), RkError> {
956 hooks_file_defect(target)?.map_or(Ok(()), |reason| {
957 Err(RkError::refusal(
958 Diagnostic::new(
959 Reason::StateDrift,
960 format!("{reason}, and nothing was written"),
961 )
962 .expected("a .pre-commit-config.yaml the block can land in, or none")
963 .action(format!(
964 "resolve it in {}, then re-run",
965 target.join(HOOKS_DESTINATION)
966 ))
967 .target_state("unchanged"),
968 ))
969 })
970}
971
972#[cfg(test)]
973mod tests {
974 #![allow(clippy::expect_used)]
975
976 use super::{
977 AGENTS_DESTINATION, BLOCK_BEGIN, BLOCK_END, BRANCH_GRAMMAR, HOOK_TYPES_LINE, HOOKS_BEGIN,
978 HOOKS_DESTINATION, HOOKS_END, Kind, SCOPE_SHAPE, Style, Workflow, extract_block,
979 hooks_block, kind_of, pair_files, projection, render, routing_block, splice_agents_block,
980 splice_hooks_block,
981 };
982 use crate::embedded;
983
984 #[test]
988 fn the_kind_table_closes_over_every_snippet() {
989 for tech_dir in embedded::SNIPPETS.dirs() {
990 for pair_dir in tech_dir.dirs() {
991 let prefix = format!("{}/", pair_dir.path().to_string_lossy());
992 for (path, _) in embedded::walk(pair_dir) {
993 let destination = path.strip_prefix(&prefix).unwrap_or(&path);
994 assert!(
995 kind_of(destination).is_some(),
996 "{destination}: no declared kind"
997 );
998 }
999 }
1000 }
1001 assert_eq!(kind_of(AGENTS_DESTINATION), Some(Kind::Rendered));
1002 assert_eq!(kind_of(HOOKS_DESTINATION), Some(Kind::Rendered));
1003 assert_eq!(kind_of("something-else.txt"), None);
1004 }
1005
1006 #[test]
1011 fn rendering_substitutes_every_owner_occurrence() {
1012 let baseline = b"if: repository_owner == 'OWNER'\n# OWNER again: OWNER\n";
1013 let rendered = render(baseline, "acme/sub/widget", None);
1014 let text = String::from_utf8(rendered).expect("rendered bytes stay text");
1015 assert_eq!(text, "if: repository_owner == 'acme'\n# acme again: acme\n");
1016
1017 let baseline = b"match (RK_SCOPE_SHAPE)\n";
1018 let rendered = render(baseline, "acme/widget", None);
1019 let text = String::from_utf8(rendered).expect("rendered bytes stay text");
1020 assert_eq!(text, format!("match ({SCOPE_SHAPE})\n"));
1021 }
1022
1023 #[test]
1027 fn the_scope_shape_drops_into_the_title_check() {
1028 assert_eq!(SCOPE_SHAPE, "[a-z0-9._/-]+");
1029 assert!(
1030 !SCOPE_SHAPE.contains('\''),
1031 "the title checks single-quote it"
1032 );
1033 }
1034
1035 #[test]
1040 fn the_scope_predicate_and_the_rendered_pattern_agree() {
1041 let body = SCOPE_SHAPE
1042 .strip_prefix('[')
1043 .and_then(|rest| rest.strip_suffix("]+"))
1044 .expect("the shape is one bracket expression, repeated");
1045 let chars: Vec<char> = body.chars().collect();
1046 let mut admitted = std::collections::BTreeSet::new();
1047 let mut at = 0;
1048 while at < chars.len() {
1049 if at + 2 < chars.len() && chars[at + 1] == '-' {
1052 for c in chars[at]..=chars[at + 2] {
1053 admitted.insert(c);
1054 }
1055 at += 3;
1056 } else {
1057 admitted.insert(chars[at]);
1058 at += 1;
1059 }
1060 }
1061 for byte in 0..=127u8 {
1062 let c = char::from(byte);
1063 assert_eq!(
1064 super::scope_is_shaped(&c.to_string()),
1065 admitted.contains(&c),
1066 "the predicate and {SCOPE_SHAPE} disagree on {c:?}"
1067 );
1068 }
1069 assert!(super::scope_is_shaped("guides/release"));
1070 assert!(!super::scope_is_shaped(""), "a scope is never empty");
1071 assert!(!super::scope_is_shaped("Specs Ugly"));
1072 }
1073
1074 #[test]
1077 fn the_shared_zone_composes_into_the_pair() {
1078 let files = pair_files("rust", "github").expect("the pair lists");
1079 assert!(
1080 files
1081 .iter()
1082 .any(|(dest, _)| dest == ".github/workflows/pr-title.yml"),
1083 "the shared title check lands with the pair"
1084 );
1085 let files = pair_files("rust", "gitlab").expect("the pair lists");
1086 assert!(
1087 files
1088 .iter()
1089 .any(|(dest, _)| dest == ".gitlab/ci/mr-title.yml"),
1090 "the shared title job lands with the pair"
1091 );
1092 let err = pair_files("_shared", "github").expect_err("the shared zone is no tech");
1093 let listing = err.to_string();
1094 let bindings = listing
1095 .split("the bindings are:")
1096 .nth(1)
1097 .expect("the refusal lists the bindings");
1098 assert!(!bindings.contains("_shared"), "{listing}");
1099 }
1100
1101 #[test]
1105 fn a_projection_renders_owned_files_and_keeps_seeded_judgment() {
1106 let entries = projection(
1107 "rust",
1108 "github",
1109 "acme/widget",
1110 Workflow::Branches,
1111 Some(Style::Trunk),
1112 false,
1113 )
1114 .expect("the pair projects");
1115 let workflow = entries
1116 .iter()
1117 .find(|entry| entry.destination.ends_with("release-plz.yml"))
1118 .expect("the workflow projects");
1119 assert_eq!(workflow.kind, Kind::Rendered);
1120 let text = String::from_utf8_lossy(&workflow.rendered);
1121 assert!(!text.contains("OWNER"), "an owner token survived rendering");
1122 assert!(text.contains("'acme'"));
1123 assert!(!text.contains("TODO(release-kit)"));
1124 let title = entries
1125 .iter()
1126 .find(|entry| entry.destination.ends_with("pr-title.yml"))
1127 .expect("the title check projects");
1128 let text = String::from_utf8_lossy(&title.rendered);
1129 assert!(text.contains(SCOPE_SHAPE), "{text}");
1130 assert!(
1131 !text.contains("RK_SCOPE_SHAPE"),
1132 "a scope token survived: {text}"
1133 );
1134 let seeded = entries
1135 .iter()
1136 .find(|entry| entry.destination == "release-plz.toml")
1137 .expect("the seeded file projects");
1138 assert_eq!(seeded.kind, Kind::Seeded);
1139 assert_eq!(seeded.rendered, seeded.baseline);
1140 assert!(String::from_utf8_lossy(&seeded.rendered).contains("TODO(release-kit)"));
1141 for block in [AGENTS_DESTINATION, HOOKS_DESTINATION] {
1142 let entry = entries
1143 .iter()
1144 .find(|entry| entry.destination == block)
1145 .expect("both blocks are part of the projection");
1146 let text = String::from_utf8_lossy(&entry.rendered);
1147 assert!(
1148 !text.contains("RK_SCOPE_SHAPE"),
1149 "{block} kept a token: {text}"
1150 );
1151 }
1152 }
1153
1154 #[test]
1159 fn the_nix_destinations_project_only_under_the_opt_in() {
1160 use super::NIX_DESTINATIONS;
1161 let paths = |nix: bool, forge: &str| -> Vec<String> {
1162 projection(
1163 "rust",
1164 forge,
1165 "acme/widget",
1166 Workflow::Worktree,
1167 Some(Style::Trunk),
1168 nix,
1169 )
1170 .expect("the pair projects")
1171 .into_iter()
1172 .map(|entry| entry.destination)
1173 .collect()
1174 };
1175 let off = paths(false, "github");
1176 for destination in NIX_DESTINATIONS {
1177 assert!(!off.contains(&destination.to_owned()), "{destination}");
1178 }
1179 let on = paths(true, "github");
1180 for destination in ["nix/package.nix", "flake.nix", "flake.lock"] {
1181 assert!(on.contains(&destination.to_owned()), "{destination}");
1182 }
1183 let gitlab = paths(true, "gitlab");
1188 assert!(gitlab.contains(&"nix/package.nix".to_owned()));
1189 assert!(
1190 !on.iter()
1191 .chain(gitlab.iter())
1192 .any(|destination| destination.contains("nix.yml"))
1193 );
1194 let bash = projection(
1195 "bash",
1196 "github",
1197 "acme/widget",
1198 Workflow::Worktree,
1199 Some(Style::Trunk),
1200 true,
1201 )
1202 .expect("an out-of-matrix pair projects the smaller product");
1203 assert!(
1204 bash.iter()
1205 .all(|entry| !NIX_DESTINATIONS.contains(&entry.destination.as_str()))
1206 );
1207 }
1208
1209 #[test]
1214 fn the_nix_seeds_are_identical_across_forge_pairs() {
1215 for name in ["nix/package.nix", "flake.nix", "flake.lock"] {
1216 let github = embedded::SNIPPETS
1217 .get_file(format!("rust/github/{name}"))
1218 .expect("the github copy ships")
1219 .contents();
1220 let gitlab = embedded::SNIPPETS
1221 .get_file(format!("rust/gitlab/{name}"))
1222 .expect("the gitlab copy ships")
1223 .contents();
1224 assert_eq!(github, gitlab, "{name} diverged between the pairs");
1225 }
1226 }
1227
1228 #[test]
1233 fn the_nix_withhold_judgment_covers_the_three_shapes() {
1234 use super::{NIX_DESTINATIONS, withhold_nix};
1235 let dir = tempfile::tempdir().expect("a scratch target exists");
1236 let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
1237 let entries = || {
1238 projection(
1239 "rust",
1240 "github",
1241 "acme/widget",
1242 Workflow::Worktree,
1243 Some(Style::Trunk),
1244 true,
1245 )
1246 .expect("the pair projects")
1247 };
1248
1249 let mut all = entries();
1251 let withheld = withhold_nix(target, true, None, &mut all).expect("the judgment runs");
1252 let paths: Vec<&str> = withheld.iter().map(|w| w.path.as_str()).collect();
1253 assert_eq!(paths, ["flake.lock", "flake.nix", "nix/package.nix"]);
1254 assert!(
1255 all.iter()
1256 .all(|entry| !NIX_DESTINATIONS.contains(&entry.destination.as_str()))
1257 );
1258
1259 std::fs::write(
1262 target.join("Cargo.toml"),
1263 "[package]\nname = \"widget\"\nversion = \"0.1.0\"\n",
1264 )
1265 .expect("the crate manifest writes");
1266 std::fs::write(target.join("Cargo.lock"), "version = 4\n").expect("the lock writes");
1267 std::fs::create_dir_all(target.join("src")).expect("the src dir exists");
1268 std::fs::write(target.join("src/main.rs"), "fn main() {}\n").expect("the main writes");
1269 std::fs::write(target.join("flake.nix"), "{ }\n").expect("the flake writes");
1270 let mut all = entries();
1271 let withheld = withhold_nix(target, true, None, &mut all).expect("the judgment runs");
1272 let paths: Vec<&str> = withheld.iter().map(|w| w.path.as_str()).collect();
1273 assert_eq!(paths, ["flake.lock", "flake.nix"]);
1274 assert!(
1275 all.iter()
1276 .any(|entry| entry.destination == "nix/package.nix")
1277 );
1278
1279 std::fs::remove_file(target.join("flake.nix")).expect("the flake removes");
1281 let mut all = entries();
1282 let withheld = withhold_nix(target, true, None, &mut all).expect("the judgment runs");
1283 assert!(withheld.is_empty());
1284 assert!(all.iter().any(|entry| entry.destination == "flake.nix"));
1285
1286 let mut all = entries();
1288 let withheld = withhold_nix(target, false, None, &mut all).expect("the judgment runs");
1289 assert!(withheld.is_empty());
1290 }
1291
1292 #[test]
1293 fn the_block_splices_into_every_agents_shape() {
1294 let owned = routing_block(Workflow::Branches);
1295 let block = owned.as_str();
1296 let fresh = splice_agents_block(None, block);
1297 assert_eq!(fresh, format!("{block}\n"));
1298 assert_eq!(extract_block(&fresh, BLOCK_BEGIN, BLOCK_END), Some(block));
1299
1300 let appended = splice_agents_block(Some("# My project\n\nOwn rules.\n"), block);
1301 assert!(appended.starts_with("# My project\n\nOwn rules.\n\n<!-- BEGIN release-kit -->"));
1302 assert_eq!(
1303 extract_block(&appended, BLOCK_BEGIN, BLOCK_END),
1304 Some(block)
1305 );
1306
1307 let stale = appended.replace("Never author a tag", "Do author a tag");
1308 let refreshed = splice_agents_block(Some(&stale), block);
1309 assert_eq!(
1310 extract_block(&refreshed, BLOCK_BEGIN, BLOCK_END),
1311 Some(block)
1312 );
1313 assert!(refreshed.starts_with("# My project"));
1314 assert_eq!(
1315 refreshed.matches("BEGIN release-kit").count(),
1316 1,
1317 "a re-splice must replace, not accumulate"
1318 );
1319 }
1320
1321 #[test]
1324 fn the_hook_block_splices_under_repos() {
1325 let owned = hooks_block(Workflow::Branches);
1326 let block = owned.as_str();
1327 let fresh = splice_hooks_block(None, block).expect("a fresh file splices");
1328 assert!(fresh.starts_with(HOOK_TYPES_LINE));
1329 assert!(fresh.contains("\nrepos:\n# BEGIN release-kit\n"));
1330 assert_eq!(extract_block(&fresh, HOOKS_BEGIN, HOOKS_END), Some(block));
1331
1332 let own =
1333 "repos:\n - repo: https://example.com/own\n rev: v1\n hooks:\n - id: own\n";
1334 let spliced = splice_hooks_block(Some(own), block).expect("an unmarked file splices");
1335 assert!(spliced.starts_with("repos:\n# BEGIN release-kit\n"));
1336 assert!(spliced.contains("- id: own"), "the target's hooks survive");
1337 assert!(
1338 !spliced.contains(HOOK_TYPES_LINE),
1339 "an existing file's top level is the skills' duty, not the splice's"
1340 );
1341
1342 let stale = spliced.replace("--force-scope", "--no-scope");
1343 let refreshed = splice_hooks_block(Some(&stale), block).expect("a marked file re-splices");
1344 assert_eq!(
1345 extract_block(&refreshed, HOOKS_BEGIN, HOOKS_END),
1346 Some(block)
1347 );
1348 assert_eq!(refreshed.matches(HOOKS_BEGIN).count(), 1);
1349
1350 let err = splice_hooks_block(Some("minimum_pre_commit_version: '3.2.0'\n"), block)
1351 .expect_err("no repos: line refuses");
1352 assert!(err.contains("repos:"), "{err}");
1353
1354 let doubled = format!("repos:\n{block}\n{block}\n");
1358 let err = splice_hooks_block(Some(&doubled), block).expect_err("a second block refuses");
1359 assert!(err.contains("one block"), "{err}");
1360 let unmatched = "repos:\n# BEGIN release-kit\n - repo: local\n";
1361 let err =
1362 splice_hooks_block(Some(unmatched), block).expect_err("an unmatched marker refuses");
1363 assert!(err.contains("unmatched"), "{err}");
1364 }
1365
1366 #[test]
1372 fn the_blocks_render_per_mode_and_carry_the_one_grammar() {
1373 let worktree_hooks = hooks_block(Workflow::Worktree);
1374 let branches_hooks = hooks_block(Workflow::Branches);
1375 assert!(worktree_hooks.contains("- id: rk-worktree-location"));
1376 assert!(
1377 worktree_hooks.contains("SKIP=no-commit-to-branch,rk-worktree-location"),
1378 "{worktree_hooks}"
1379 );
1380 assert!(!branches_hooks.contains("rk-worktree-location"));
1381 assert!(branches_hooks.contains("SKIP=no-commit-to-branch in"));
1382 for block in [&worktree_hooks, &branches_hooks] {
1383 assert!(block.contains(BRANCH_GRAMMAR), "the grammar has one owner");
1384 for token in ["RK_BRANCH_GRAMMAR", "RK_SWEEP_SKIP", "RK_WORKTREE_GUARD"] {
1385 assert!(!block.contains(token), "{token} survived: {block}");
1386 }
1387 }
1388 for block in [&worktree_hooks, &branches_hooks] {
1393 for line in block.lines() {
1394 if let Some(value) = line.trim_start().strip_prefix("entry: ") {
1395 assert!(
1396 !value.contains(": "),
1397 "an entry value breaks the YAML plain scalar: {line}"
1398 );
1399 }
1400 }
1401 }
1402 let guard_line = worktree_hooks
1403 .lines()
1404 .position(|line| line.contains("id: rk-worktree-location"))
1405 .expect("the guard entry exists");
1406 let name_line = worktree_hooks
1407 .lines()
1408 .position(|line| line.contains("id: rk-branch-name"))
1409 .expect("the name hook exists");
1410 assert!(
1411 guard_line > name_line,
1412 "the guard lands directly after rk-branch-name"
1413 );
1414
1415 let worktree_routing = routing_block(Workflow::Worktree);
1416 let branches_routing = routing_block(Workflow::Branches);
1417 assert!(worktree_routing.contains("This project works in worktrees"));
1418 assert!(branches_routing.contains("Branches are worked in the main checkout"));
1419 for block in [&worktree_routing, &branches_routing] {
1420 assert!(block.contains("Create or remove a worktree"));
1421 assert!(block.contains("`rk worktree add <branch>`"));
1422 assert!(!block.contains("RK_WORKFLOW_LINE"), "{block}");
1423 }
1424 let differing: Vec<(&str, &str)> = worktree_routing
1425 .lines()
1426 .zip(branches_routing.lines())
1427 .filter(|(a, b)| a != b)
1428 .collect();
1429 assert_eq!(
1430 differing.len(),
1431 1,
1432 "exactly one routing line differs per mode: {differing:?}"
1433 );
1434 }
1435
1436 #[test]
1439 fn the_hook_marker_defects_are_named() {
1440 use super::hooks_marker_defect;
1441 let owned = hooks_block(Workflow::Branches);
1442 let block = owned.as_str();
1443 assert_eq!(hooks_marker_defect(""), None);
1444 assert_eq!(hooks_marker_defect(&format!("repos:\n{block}\n")), None);
1445 for (case, text) in [
1446 (
1447 "a second begin",
1448 format!("repos:\n{block}\n# BEGIN release-kit\n"),
1449 ),
1450 (
1451 "a second end",
1452 format!("repos:\n{block}\n# END release-kit\n"),
1453 ),
1454 (
1455 "an unpaired begin",
1456 "repos:\n# BEGIN release-kit\n".to_owned(),
1457 ),
1458 ("an unpaired end", "repos:\n# END release-kit\n".to_owned()),
1459 (
1460 "an end before its begin",
1461 "repos:\n# END release-kit\n# BEGIN release-kit\n".to_owned(),
1462 ),
1463 ] {
1464 assert!(
1465 hooks_marker_defect(&text).is_some(),
1466 "{case} must be a defect"
1467 );
1468 }
1469 }
1470}