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 (".github/workflows/nix.yml", Kind::Rendered),
62 (".gitlab-ci.yml", 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; 4] = [
82 "nix/package.nix",
83 "flake.nix",
84 "flake.lock",
85 ".github/workflows/nix.yml",
86];
87
88pub const NIX_WITHHOLDABLE: [&str; 3] = ["flake.nix", "flake.lock", ".github/workflows/nix.yml"];
95
96#[must_use]
99pub fn kind_of(destination: &str) -> Option<Kind> {
100 if destination == AGENTS_DESTINATION || destination == HOOKS_DESTINATION {
101 return Some(Kind::Rendered);
102 }
103 KINDS
104 .iter()
105 .find(|(name, _)| *name == destination)
106 .map(|(_, kind)| *kind)
107}
108
109pub fn destinations() -> impl Iterator<Item = &'static str> {
113 KINDS
114 .iter()
115 .map(|(name, _)| *name)
116 .chain([AGENTS_DESTINATION, HOOKS_DESTINATION])
117}
118
119pub const OWNER_TOKEN: &[u8] = b"OWNER";
126
127pub const SCOPES_CSV_TOKEN: &[u8] = b"RK_SCOPES_CSV";
129
130pub const SCOPES_PIPE_TOKEN: &[u8] = b"RK_SCOPES_PIPE";
132
133pub const STYLE_TOKEN: &[u8] = b"RK_STYLE";
136
137#[must_use]
146pub fn render(baseline: &[u8], repo: &str, scopes: &[String], style: Option<Style>) -> Vec<u8> {
147 let owner = repo.split('/').next().unwrap_or(repo);
148 let mut out = substitute(baseline, OWNER_TOKEN, owner.as_bytes());
149 if let Some(style) = style {
150 out = substitute(&out, STYLE_TOKEN, style.as_str().as_bytes());
151 }
152 if !scopes.is_empty() {
153 out = substitute(&out, SCOPES_CSV_TOKEN, scopes.join(",").as_bytes());
154 let pipe: Vec<String> = scopes.iter().map(|s| s.replace('.', "\\.")).collect();
159 out = substitute(&out, SCOPES_PIPE_TOKEN, pipe.join("|").as_bytes());
160 }
161 out
162}
163
164fn substitute(baseline: &[u8], token: &[u8], value: &[u8]) -> Vec<u8> {
166 let mut out = Vec::with_capacity(baseline.len());
167 let mut rest = baseline;
168 while let Some(at) = find(rest, token) {
169 out.extend_from_slice(&rest[..at]);
170 out.extend_from_slice(value);
171 rest = &rest[at + token.len()..];
172 }
173 out.extend_from_slice(rest);
174 out
175}
176
177pub fn parse_scopes(raw: &str) -> Result<Vec<String>, RkError> {
188 let scopes: Vec<String> = raw
189 .split(',')
190 .map(str::trim)
191 .filter(|scope| !scope.is_empty())
192 .map(str::to_owned)
193 .collect();
194 if scopes.is_empty() {
195 return Err(RkError::Usage(
196 "--scopes names no scope; pass a comma-separated list, e.g. --scopes api,cli".into(),
197 ));
198 }
199 for scope in &scopes {
200 let clean = scope
201 .chars()
202 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '/' | '-'));
203 if !clean {
204 return Err(RkError::Usage(format!(
205 "the scope '{scope}' carries a character outside letters, digits, and _ . / -"
206 )));
207 }
208 }
209 Ok(scopes)
210}
211
212fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
214 haystack
215 .windows(needle.len())
216 .position(|window| window == needle)
217}
218
219pub const AGENTS_DESTINATION: &str = "AGENTS.md";
221
222pub const BLOCK_BEGIN: &str = "<!-- BEGIN release-kit -->";
224
225pub const BLOCK_END: &str = "<!-- END release-kit -->";
227
228pub const HOOKS_DESTINATION: &str = ".pre-commit-config.yaml";
230
231pub const HOOKS_BEGIN: &str = "# BEGIN release-kit";
233
234pub const HOOKS_END: &str = "# END release-kit";
236
237pub const HOOK_TYPES_LINE: &str = "default_install_hook_types: [pre-commit, commit-msg, pre-push]";
241
242static AGENTS_BLOCK: &str = include_str!("../blocks/agents-block.md.in");
244
245static AGENTS_LINE_WORKTREE: &str = include_str!("../blocks/agents-line-worktree.md.in");
247
248static AGENTS_LINE_BRANCHES: &str = include_str!("../blocks/agents-line-branches.md.in");
250
251static PRE_COMMIT_BLOCK: &str = include_str!("../blocks/pre-commit-block.yaml.in");
253
254static PRE_COMMIT_WORKTREE_GUARD: &str =
256 include_str!("../blocks/pre-commit-worktree-guard.yaml.in");
257
258fn authored(text: &str) -> &str {
262 text.strip_suffix('\n').unwrap_or(text)
263}
264
265pub 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[-/].+)$";
273
274#[must_use]
283pub fn routing_block(workflow: Workflow) -> String {
284 let line = match workflow {
285 Workflow::Worktree => authored(AGENTS_LINE_WORKTREE),
286 Workflow::Branches => authored(AGENTS_LINE_BRANCHES),
287 };
288 authored(AGENTS_BLOCK).replacen("RK_WORKFLOW_LINE", line, 1)
289}
290
291#[must_use]
303pub fn hooks_block(workflow: Workflow) -> String {
304 let (guard, skip) = match workflow {
305 Workflow::Worktree => (
306 format!("{}\n", authored(PRE_COMMIT_WORKTREE_GUARD)),
307 "no-commit-to-branch,rk-worktree-location",
308 ),
309 Workflow::Branches => (String::new(), "no-commit-to-branch"),
310 };
311 authored(PRE_COMMIT_BLOCK)
312 .replacen("RK_BRANCH_GRAMMAR", BRANCH_GRAMMAR, 1)
313 .replacen("RK_SWEEP_SKIP", skip, 1)
314 .replacen("RK_WORKTREE_GUARD", &guard, 1)
315}
316
317#[must_use]
319pub fn block_markers(destination: &str) -> Option<(&'static str, &'static str)> {
320 match destination {
321 AGENTS_DESTINATION => Some((BLOCK_BEGIN, BLOCK_END)),
322 HOOKS_DESTINATION => Some((HOOKS_BEGIN, HOOKS_END)),
323 _ => None,
324 }
325}
326
327#[must_use]
330pub fn extract_block<'a>(text: &'a str, begin: &str, end: &str) -> Option<&'a str> {
331 let start = text.find(begin)?;
332 let stop = text[start..].find(end)? + start + end.len();
333 Some(&text[start..stop])
334}
335
336#[must_use]
342pub fn splice_agents_block(existing: Option<&str>, block: &str) -> String {
343 existing.map_or_else(
344 || format!("{block}\n"),
345 |text| {
346 extract_block(text, BLOCK_BEGIN, BLOCK_END).map_or_else(
347 || format!("{}\n\n{block}\n", text.trim_end()),
348 |found| text.replacen(found, block, 1),
349 )
350 },
351 )
352}
353
354pub fn splice_hooks_block(existing: Option<&str>, block: &str) -> Result<String, String> {
367 let Some(text) = existing else {
368 return Ok(format!("{HOOK_TYPES_LINE}\n\nrepos:\n{block}\n"));
369 };
370 if let Some(defect) = hooks_marker_defect(text) {
371 return Err(defect);
372 }
373 if let Some(found) = extract_block(text, HOOKS_BEGIN, HOOKS_END) {
374 return Ok(text.replacen(found, block, 1));
375 }
376 let mut out = String::with_capacity(text.len() + block.len() + 1);
377 let mut placed = false;
378 for line in text.split_inclusive('\n') {
379 out.push_str(line);
380 if !placed && line.trim_end() == "repos:" {
381 if !out.ends_with('\n') {
382 out.push('\n');
383 }
384 out.push_str(block);
385 out.push('\n');
386 placed = true;
387 }
388 }
389 if placed {
390 Ok(out)
391 } else {
392 Err(format!(
393 "{HOOKS_DESTINATION} exists with no repos: line, so the hook block has nowhere to land"
394 ))
395 }
396}
397
398#[must_use]
407pub fn hooks_marker_defect(text: &str) -> Option<String> {
408 let begins = text.matches(HOOKS_BEGIN).count();
409 let ends = text.matches(HOOKS_END).count();
410 if begins > 1 || ends > 1 {
411 return Some(format!(
412 "{HOOKS_DESTINATION} carries more than one release-kit marker pair; release-kit owns exactly one block"
413 ));
414 }
415 match (text.find(HOOKS_BEGIN), text.find(HOOKS_END)) {
416 (Some(begin), Some(end)) if end > begin => None,
417 (None, None) => None,
418 _ => Some(format!(
419 "{HOOKS_DESTINATION} carries an unmatched or misordered release-kit marker, so the block's extent is ambiguous"
420 )),
421 }
422}
423
424#[derive(Debug, Clone, Copy, PartialEq, Eq)]
426pub enum Placement {
427 Whole,
429 Block,
431}
432
433#[derive(Debug)]
436pub struct Entry {
437 pub destination: String,
439 pub kind: Kind,
441 pub placement: Placement,
443 pub baseline: Vec<u8>,
446 pub rendered: Vec<u8>,
449}
450
451pub fn pair_files(tech: &str, forge: &str) -> Result<Vec<(String, &'static [u8])>, RkError> {
459 if tech.starts_with('_') || embedded::SNIPPETS.get_dir(tech).is_none() {
462 let known: Vec<String> = embedded::SNIPPETS
463 .dirs()
464 .map(|dir| dir.path().to_string_lossy().into_owned())
465 .filter(|name| !name.starts_with('_'))
466 .collect();
467 return Err(RkError::Usage(format!(
468 "unknown tech '{tech}'; the bindings are: {}",
469 known.join(", ")
470 )));
471 }
472 let pair = format!("{tech}/{forge}");
473 let pair_dir = embedded::SNIPPETS.get_dir(&pair).ok_or_else(|| {
474 let known: Vec<String> = embedded::SNIPPETS
475 .dirs()
476 .filter(|dir| !dir.path().to_string_lossy().starts_with('_'))
477 .flat_map(include_dir::Dir::dirs)
478 .map(|dir| dir.path().to_string_lossy().replace('/', ", "))
479 .collect();
480 RkError::Usage(format!(
481 "the pair ({tech}, {forge}) has no landable files; the supported pairs are: {}",
482 known.join("; ")
483 ))
484 })?;
485 let mut files: Vec<(String, &'static [u8])> = Vec::new();
489 let shared = format!("_shared/{forge}");
490 if let Some(shared_dir) = embedded::SNIPPETS.get_dir(&shared) {
491 for (path, contents) in embedded::walk(shared_dir) {
492 let rel = path
493 .strip_prefix(&format!("{shared}/"))
494 .map_or(path.as_str(), |rel| rel)
495 .to_owned();
496 files.push((rel, contents));
497 }
498 }
499 for (path, contents) in embedded::walk(pair_dir) {
500 let rel = path
501 .strip_prefix(&format!("{pair}/"))
502 .map_or(path.as_str(), |rel| rel)
503 .to_owned();
504 if files.iter().any(|(existing, _)| *existing == rel) {
505 return Err(anyhow::anyhow!(
506 "the shared zone and the pair ({tech}, {forge}) both ship {rel}; the payload is defective"
507 )
508 .into());
509 }
510 files.push((rel, contents));
511 }
512 Ok(files)
513}
514
515pub fn projection(
530 tech: &str,
531 forge: &str,
532 repo: &str,
533 scopes: &[String],
534 workflow: Workflow,
535 style: Option<Style>,
536 nix: bool,
537) -> Result<Vec<Entry>, RkError> {
538 let mut entries = Vec::new();
539 for (destination, baseline) in pair_files(tech, forge)? {
540 if !nix && NIX_DESTINATIONS.contains(&destination.as_str()) {
541 continue;
542 }
543 let kind = kind_of(&destination).ok_or_else(|| {
544 anyhow::anyhow!("the payload does not classify {destination}; the kind table is stale")
545 })?;
546 let rendered = match kind {
547 Kind::Rendered => render(baseline, repo, scopes, style),
548 Kind::Seeded | Kind::State => baseline.to_vec(),
549 };
550 entries.push(Entry {
551 destination,
552 kind,
553 placement: Placement::Whole,
554 baseline: baseline.to_vec(),
555 rendered,
556 });
557 }
558 for (destination, template) in [
559 (AGENTS_DESTINATION, routing_block(workflow)),
560 (HOOKS_DESTINATION, hooks_block(workflow)),
561 ] {
562 entries.push(Entry {
563 destination: destination.to_owned(),
564 kind: Kind::Rendered,
565 placement: Placement::Block,
566 baseline: template.as_bytes().to_vec(),
567 rendered: render(template.as_bytes(), repo, scopes, style),
568 });
569 }
570 entries.sort_by(|a, b| a.destination.cmp(&b.destination));
571 Ok(entries)
572}
573
574#[must_use]
586pub fn nix_unsupported_shape(target: &Utf8Path) -> Option<String> {
587 let Ok(text) = std::fs::read_to_string(target.join("Cargo.toml")) else {
588 return Some(
589 "the target has no readable Cargo.toml, which the seeded package expression reads; no Nix file lands".to_owned(),
590 );
591 };
592 let Ok(table) = text.parse::<toml::Table>() else {
593 return Some(
594 "the target's Cargo.toml does not parse, and the seeded package expression reads it; no Nix file lands".to_owned(),
595 );
596 };
597 if !table.contains_key("package") {
598 return Some(
599 "the target's Cargo.toml has no [package] table; the seed supports a single crate, so no Nix file lands".to_owned(),
600 );
601 }
602 if !target.join("Cargo.lock").is_file() {
603 return Some(
604 "the target has no Cargo.lock, which the seeded package expression builds from; commit one, then opt in".to_owned(),
605 );
606 }
607 let implicit_bin = target.join("src/main.rs").is_file()
608 && table
609 .get("package")
610 .and_then(toml::Value::as_table)
611 .and_then(|package| package.get("autobins"))
612 .and_then(toml::Value::as_bool)
613 != Some(false);
614 let explicit_bins = table.get("bin").and_then(toml::Value::as_array);
615 if explicit_bins.is_none() && !implicit_bin {
616 return Some(
617 "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(),
618 );
619 }
620 if let Some(bins) = explicit_bins {
626 let required = bins
627 .first()
628 .and_then(toml::Value::as_table)
629 .and_then(|bin| bin.get("required-features"))
630 .and_then(toml::Value::as_array);
631 if let Some(required) = required {
632 let enabled = default_features(&table);
633 let missing = required
634 .iter()
635 .filter_map(toml::Value::as_str)
636 .any(|feature| !enabled.contains(feature));
637 if missing {
638 return Some(
639 "the target's first [[bin]] entry requires features a default build does not enable; no Nix file lands".to_owned(),
640 );
641 }
642 }
643 }
644 None
645}
646
647fn dep_edge_suppresses(features: &toml::Table, name: &str) -> bool {
650 let edge = format!("dep:{name}");
651 features.values().any(|list| {
652 list.as_array().is_some_and(|entries| {
653 entries
654 .iter()
655 .filter_map(toml::Value::as_str)
656 .any(|entry| entry == edge)
657 })
658 })
659}
660
661fn is_optional_dependency(table: &toml::Table, name: &str) -> bool {
664 ["dependencies", "build-dependencies"]
665 .iter()
666 .any(|section| {
667 table
668 .get(*section)
669 .and_then(toml::Value::as_table)
670 .and_then(|dependencies| dependencies.get(name))
671 .and_then(toml::Value::as_table)
672 .and_then(|dependency| dependency.get("optional"))
673 .and_then(toml::Value::as_bool)
674 == Some(true)
675 })
676}
677
678fn default_features(table: &toml::Table) -> std::collections::BTreeSet<String> {
685 let Some(features) = table.get("features").and_then(toml::Value::as_table) else {
686 return std::collections::BTreeSet::new();
687 };
688 let mut enabled = std::collections::BTreeSet::new();
689 let mut queue = vec!["default".to_owned()];
690 while let Some(name) = queue.pop() {
691 if !enabled.insert(name.clone()) {
692 continue;
693 }
694 if let Some(implies) = features.get(&name).and_then(toml::Value::as_array) {
695 for implied in implies.iter().filter_map(toml::Value::as_str) {
696 if implied.starts_with("dep:") || implied.contains("?/") {
697 continue;
701 }
702 if let Some((package, _)) = implied.split_once('/') {
703 let feature_exists =
711 features.contains_key(package) || !dep_edge_suppresses(features, package);
712 if is_optional_dependency(table, package) && feature_exists {
713 queue.push(package.to_owned());
714 }
715 } else {
716 queue.push(implied.to_owned());
717 }
718 }
719 }
720 }
721 enabled
722}
723
724pub fn nix_withheld(
738 target: &Utf8Path,
739 recorded: Option<&manifest::Manifest>,
740) -> std::io::Result<Option<String>> {
741 if recorded.is_some_and(|record| record.file("flake.nix").is_some()) {
742 return Ok(None);
743 }
744 let mut present = Vec::new();
745 for name in ["flake.nix", "flake.lock"] {
746 match std::fs::symlink_metadata(target.join(name).as_std_path()) {
747 Ok(_) => present.push(name),
748 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
749 Err(e) => return Err(e),
750 }
751 }
752 if present.is_empty() {
753 return Ok(None);
754 }
755 Ok(Some(format!(
756 "the target already carries {}; its flake pair stays its own, and the nix workflow is withheld with it",
757 present.join(" and ")
758 )))
759}
760
761#[derive(Debug, Serialize)]
763pub struct Withheld {
764 pub path: String,
766 pub reason: String,
769}
770
771pub fn withhold_nix(
784 target: &Utf8Path,
785 nix: bool,
786 recorded: Option<&manifest::Manifest>,
787 entries: &mut Vec<Entry>,
788) -> Result<Vec<Withheld>, RkError> {
789 if !nix {
790 return Ok(Vec::new());
791 }
792 let (set, reason): (&[&str], String) = if let Some(reason) = nix_unsupported_shape(target) {
793 (&NIX_DESTINATIONS[..], reason)
794 } else if let Some(reason) = nix_withheld(target, recorded)? {
795 (&NIX_WITHHOLDABLE[..], reason)
796 } else {
797 return Ok(Vec::new());
798 };
799 let mut withheld = Vec::new();
800 entries.retain(|entry| {
801 if set.contains(&entry.destination.as_str()) {
802 withheld.push(Withheld {
803 path: entry.destination.clone(),
804 reason: reason.clone(),
805 });
806 false
807 } else {
808 true
809 }
810 });
811 Ok(withheld)
812}
813
814pub fn read_destination(target: &Utf8Path, entry: &Entry) -> std::io::Result<Option<Vec<u8>>> {
822 read_recorded(target, &entry.destination)
823}
824
825pub fn read_recorded(target: &Utf8Path, destination: &str) -> std::io::Result<Option<Vec<u8>>> {
836 let path = target.join(destination);
837 let bytes = match std::fs::read(&path) {
838 Ok(bytes) => bytes,
839 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
840 Err(e) => return Err(e),
841 };
842 if let Some((begin, end)) = block_markers(destination) {
843 let text = String::from_utf8_lossy(&bytes);
844 Ok(extract_block(&text, begin, end).map(|block| block.as_bytes().to_vec()))
845 } else {
846 Ok(Some(bytes))
847 }
848}
849
850#[derive(Debug)]
853pub struct Resolved {
854 pub forge: String,
856 pub repo: Option<String>,
858}
859
860pub fn resolve(
872 target: &Utf8Path,
873 forge_flag: Option<&str>,
874 repo_flag: Option<&str>,
875) -> Result<Resolved, RkError> {
876 let forge_flag = forge_flag
877 .map(|name| {
878 crate::detect::Forge::parse(name).ok_or_else(|| {
879 RkError::Usage(format!(
880 "unknown forge '{name}'; the forges are: github, gitlab"
881 ))
882 })
883 })
884 .transpose()?;
885 let detected = crate::detect::detect(target.as_std_path());
886 let forge = forge_flag
887 .or(detected.forge)
888 .map(|forge| forge.as_str().to_owned())
889 .ok_or_else(|| {
890 let message = detected.host.map_or_else(
891 || "no forge detected: the target has no origin remote".to_owned(),
892 |host| format!("no forge detected: the host {host} is not recognized"),
893 );
894 RkError::refusal(
895 Diagnostic::new(Reason::ForgeUndetected, message)
896 .expected("a github.com or gitlab remote, or --forge")
897 .action("pass --forge <github|gitlab>"),
898 )
899 })?;
900 Ok(Resolved {
901 forge,
902 repo: repo_flag.map(str::to_owned).or(detected.repo),
903 })
904}
905
906#[must_use]
909pub fn repo_unresolved() -> RkError {
910 RkError::missing(
911 Diagnostic::new(
912 Reason::ForgeUndetected,
913 "no repository detected: the target has no origin remote",
914 )
915 .expected("an origin remote naming the project")
916 .action("pass --repo <path>"),
917 )
918}
919
920pub fn write_destination(target: &Utf8Path, entry: &Entry) -> std::io::Result<()> {
930 let path = target.join(&entry.destination);
931 match entry.placement {
932 Placement::Whole => atomic::write(path.as_std_path(), &entry.rendered),
933 Placement::Block => {
934 let existing = match std::fs::read(&path) {
935 Ok(bytes) => Some(String::from_utf8_lossy(&bytes).into_owned()),
936 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
937 Err(e) => return Err(e),
938 };
939 let block = String::from_utf8_lossy(&entry.rendered).into_owned();
940 let spliced = if entry.destination == HOOKS_DESTINATION {
941 splice_hooks_block(existing.as_deref(), &block).map_err(std::io::Error::other)?
942 } else {
943 splice_agents_block(existing.as_deref(), &block)
944 };
945 atomic::write(path.as_std_path(), spliced.as_bytes())
946 }
947 }
948}
949
950pub fn hooks_file_defect(target: &Utf8Path) -> std::io::Result<Option<String>> {
963 let path = target.join(HOOKS_DESTINATION);
964 match std::fs::read(&path) {
965 Ok(bytes) => {
966 let text = String::from_utf8_lossy(&bytes);
967 Ok(splice_hooks_block(Some(&text), authored(PRE_COMMIT_BLOCK)).err())
968 }
969 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
970 Err(e) => Err(e),
971 }
972}
973
974pub fn hooks_splice_refusal(target: &Utf8Path) -> Result<(), RkError> {
984 hooks_file_defect(target)?.map_or(Ok(()), |reason| {
985 Err(RkError::refusal(
986 Diagnostic::new(
987 Reason::StateDrift,
988 format!("{reason}, and nothing was written"),
989 )
990 .expected("a .pre-commit-config.yaml the block can land in, or none")
991 .action(format!(
992 "resolve it in {}, then re-run",
993 target.join(HOOKS_DESTINATION)
994 ))
995 .target_state("unchanged"),
996 ))
997 })
998}
999
1000#[cfg(test)]
1001mod tests {
1002 #![allow(clippy::expect_used)]
1003
1004 use super::{
1005 AGENTS_DESTINATION, BLOCK_BEGIN, BLOCK_END, BRANCH_GRAMMAR, HOOK_TYPES_LINE, HOOKS_BEGIN,
1006 HOOKS_DESTINATION, HOOKS_END, Kind, Style, Workflow, extract_block, hooks_block, kind_of,
1007 pair_files, parse_scopes, projection, render, routing_block, splice_agents_block,
1008 splice_hooks_block,
1009 };
1010 use crate::embedded;
1011
1012 fn scopes(list: &[&str]) -> Vec<String> {
1013 list.iter().map(|s| (*s).to_owned()).collect()
1014 }
1015
1016 #[test]
1020 fn the_kind_table_closes_over_every_snippet() {
1021 for tech_dir in embedded::SNIPPETS.dirs() {
1022 for pair_dir in tech_dir.dirs() {
1023 let prefix = format!("{}/", pair_dir.path().to_string_lossy());
1024 for (path, _) in embedded::walk(pair_dir) {
1025 let destination = path.strip_prefix(&prefix).unwrap_or(&path);
1026 assert!(
1027 kind_of(destination).is_some(),
1028 "{destination}: no declared kind"
1029 );
1030 }
1031 }
1032 }
1033 assert_eq!(kind_of(AGENTS_DESTINATION), Some(Kind::Rendered));
1034 assert_eq!(kind_of(HOOKS_DESTINATION), Some(Kind::Rendered));
1035 assert_eq!(kind_of("something-else.txt"), None);
1036 }
1037
1038 #[test]
1042 fn rendering_substitutes_every_owner_occurrence() {
1043 let baseline = b"if: repository_owner == 'OWNER'\n# OWNER again: OWNER\n";
1044 let rendered = render(baseline, "acme/sub/widget", &[], None);
1045 let text = String::from_utf8(rendered).expect("rendered bytes stay text");
1046 assert_eq!(text, "if: repository_owner == 'acme'\n# acme again: acme\n");
1047
1048 let baseline = b"scopes 'RK_SCOPES_CSV' match (RK_SCOPES_PIPE)\n";
1049 let rendered = render(baseline, "acme/widget", &scopes(&["api", "cli"]), None);
1050 let text = String::from_utf8(rendered).expect("rendered bytes stay text");
1051 assert_eq!(text, "scopes 'api,cli' match (api|cli)\n");
1052
1053 let rendered = render(baseline, "acme/widget", &scopes(&["api.v1"]), None);
1056 let text = String::from_utf8(rendered).expect("rendered bytes stay text");
1057 assert_eq!(text, "scopes 'api.v1' match (api\\.v1)\n");
1058 }
1059
1060 #[test]
1063 fn scope_parsing_refuses_the_unusable() {
1064 assert_eq!(
1065 parse_scopes("api, cli,guides/release").expect("a clean list parses"),
1066 scopes(&["api", "cli", "guides/release"])
1067 );
1068 assert!(parse_scopes("").is_err());
1069 assert!(parse_scopes(" , ").is_err());
1070 assert!(parse_scopes("api|cli").is_err());
1071 assert!(parse_scopes("a b").is_err());
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 &scopes(&["api", "cli"]),
1111 Workflow::Branches,
1112 Some(Style::Trunk),
1113 false,
1114 )
1115 .expect("the pair projects");
1116 let workflow = entries
1117 .iter()
1118 .find(|entry| entry.destination.ends_with("release-plz.yml"))
1119 .expect("the workflow projects");
1120 assert_eq!(workflow.kind, Kind::Rendered);
1121 let text = String::from_utf8_lossy(&workflow.rendered);
1122 assert!(!text.contains("OWNER"), "an owner token survived rendering");
1123 assert!(text.contains("'acme'"));
1124 assert!(!text.contains("TODO(release-kit)"));
1125 let title = entries
1126 .iter()
1127 .find(|entry| entry.destination.ends_with("pr-title.yml"))
1128 .expect("the title check projects");
1129 let text = String::from_utf8_lossy(&title.rendered);
1130 assert!(text.contains("api|cli"), "{text}");
1131 assert!(
1132 !text.contains("RK_SCOPES"),
1133 "a scope token survived: {text}"
1134 );
1135 let seeded = entries
1136 .iter()
1137 .find(|entry| entry.destination == "release-plz.toml")
1138 .expect("the seeded file projects");
1139 assert_eq!(seeded.kind, Kind::Seeded);
1140 assert_eq!(seeded.rendered, seeded.baseline);
1141 assert!(String::from_utf8_lossy(&seeded.rendered).contains("TODO(release-kit)"));
1142 for block in [AGENTS_DESTINATION, HOOKS_DESTINATION] {
1143 let entry = entries
1144 .iter()
1145 .find(|entry| entry.destination == block)
1146 .expect("both blocks are part of the projection");
1147 let text = String::from_utf8_lossy(&entry.rendered);
1148 assert!(!text.contains("RK_SCOPES"), "{block} kept a token: {text}");
1149 assert!(text.contains("api,cli"), "{block} lost the scopes: {text}");
1150 }
1151 }
1152
1153 #[test]
1158 fn the_nix_destinations_project_only_under_the_opt_in() {
1159 use super::NIX_DESTINATIONS;
1160 let paths = |nix: bool, forge: &str| -> Vec<String> {
1161 projection(
1162 "rust",
1163 forge,
1164 "acme/widget",
1165 &scopes(&["api"]),
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");
1184 assert!(gitlab.contains(&"nix/package.nix".to_owned()));
1185 assert!(!gitlab.contains(&".github/workflows/nix.yml".to_owned()));
1186 let bash = projection(
1187 "bash",
1188 "github",
1189 "acme/widget",
1190 &scopes(&["api"]),
1191 Workflow::Worktree,
1192 Some(Style::Trunk),
1193 true,
1194 )
1195 .expect("an out-of-matrix pair projects the smaller product");
1196 assert!(
1197 bash.iter()
1198 .all(|entry| !NIX_DESTINATIONS.contains(&entry.destination.as_str()))
1199 );
1200 }
1201
1202 #[test]
1207 fn the_nix_seeds_are_identical_across_forge_pairs() {
1208 for name in ["nix/package.nix", "flake.nix", "flake.lock"] {
1209 let github = embedded::SNIPPETS
1210 .get_file(format!("rust/github/{name}"))
1211 .expect("the github copy ships")
1212 .contents();
1213 let gitlab = embedded::SNIPPETS
1214 .get_file(format!("rust/gitlab/{name}"))
1215 .expect("the gitlab copy ships")
1216 .contents();
1217 assert_eq!(github, gitlab, "{name} diverged between the pairs");
1218 }
1219 }
1220
1221 #[test]
1226 fn the_nix_withhold_judgment_covers_the_three_shapes() {
1227 use super::{NIX_DESTINATIONS, withhold_nix};
1228 let dir = tempfile::tempdir().expect("a scratch target exists");
1229 let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
1230 let entries = || {
1231 projection(
1232 "rust",
1233 "github",
1234 "acme/widget",
1235 &scopes(&["api"]),
1236 Workflow::Worktree,
1237 Some(Style::Trunk),
1238 true,
1239 )
1240 .expect("the pair projects")
1241 };
1242
1243 let mut all = entries();
1245 let withheld = withhold_nix(target, true, None, &mut all).expect("the judgment runs");
1246 let paths: Vec<&str> = withheld.iter().map(|w| w.path.as_str()).collect();
1247 assert_eq!(
1248 paths,
1249 [
1250 ".github/workflows/nix.yml",
1251 "flake.lock",
1252 "flake.nix",
1253 "nix/package.nix"
1254 ]
1255 );
1256 assert!(
1257 all.iter()
1258 .all(|entry| !NIX_DESTINATIONS.contains(&entry.destination.as_str()))
1259 );
1260
1261 std::fs::write(
1264 target.join("Cargo.toml"),
1265 "[package]\nname = \"widget\"\nversion = \"0.1.0\"\n",
1266 )
1267 .expect("the crate manifest writes");
1268 std::fs::write(target.join("Cargo.lock"), "version = 4\n").expect("the lock writes");
1269 std::fs::create_dir_all(target.join("src")).expect("the src dir exists");
1270 std::fs::write(target.join("src/main.rs"), "fn main() {}\n").expect("the main writes");
1271 std::fs::write(target.join("flake.nix"), "{ }\n").expect("the flake writes");
1272 let mut all = entries();
1273 let withheld = withhold_nix(target, true, None, &mut all).expect("the judgment runs");
1274 let paths: Vec<&str> = withheld.iter().map(|w| w.path.as_str()).collect();
1275 assert_eq!(
1276 paths,
1277 [".github/workflows/nix.yml", "flake.lock", "flake.nix"]
1278 );
1279 assert!(
1280 all.iter()
1281 .any(|entry| entry.destination == "nix/package.nix")
1282 );
1283
1284 std::fs::remove_file(target.join("flake.nix")).expect("the flake removes");
1286 let mut all = entries();
1287 let withheld = withhold_nix(target, true, None, &mut all).expect("the judgment runs");
1288 assert!(withheld.is_empty());
1289 assert!(all.iter().any(|entry| entry.destination == "flake.nix"));
1290
1291 let mut all = entries();
1293 let withheld = withhold_nix(target, false, None, &mut all).expect("the judgment runs");
1294 assert!(withheld.is_empty());
1295 }
1296
1297 #[test]
1298 fn the_block_splices_into_every_agents_shape() {
1299 let owned = routing_block(Workflow::Branches);
1300 let block = owned.as_str();
1301 let fresh = splice_agents_block(None, block);
1302 assert_eq!(fresh, format!("{block}\n"));
1303 assert_eq!(extract_block(&fresh, BLOCK_BEGIN, BLOCK_END), Some(block));
1304
1305 let appended = splice_agents_block(Some("# My project\n\nOwn rules.\n"), block);
1306 assert!(appended.starts_with("# My project\n\nOwn rules.\n\n<!-- BEGIN release-kit -->"));
1307 assert_eq!(
1308 extract_block(&appended, BLOCK_BEGIN, BLOCK_END),
1309 Some(block)
1310 );
1311
1312 let stale = appended.replace("Never author a tag", "Do author a tag");
1313 let refreshed = splice_agents_block(Some(&stale), block);
1314 assert_eq!(
1315 extract_block(&refreshed, BLOCK_BEGIN, BLOCK_END),
1316 Some(block)
1317 );
1318 assert!(refreshed.starts_with("# My project"));
1319 assert_eq!(
1320 refreshed.matches("BEGIN release-kit").count(),
1321 1,
1322 "a re-splice must replace, not accumulate"
1323 );
1324 }
1325
1326 #[test]
1329 fn the_hook_block_splices_under_repos() {
1330 let owned = hooks_block(Workflow::Branches);
1331 let block = owned.as_str();
1332 let fresh = splice_hooks_block(None, block).expect("a fresh file splices");
1333 assert!(fresh.starts_with(HOOK_TYPES_LINE));
1334 assert!(fresh.contains("\nrepos:\n# BEGIN release-kit\n"));
1335 assert_eq!(extract_block(&fresh, HOOKS_BEGIN, HOOKS_END), Some(block));
1336
1337 let own =
1338 "repos:\n - repo: https://example.com/own\n rev: v1\n hooks:\n - id: own\n";
1339 let spliced = splice_hooks_block(Some(own), block).expect("an unmarked file splices");
1340 assert!(spliced.starts_with("repos:\n# BEGIN release-kit\n"));
1341 assert!(spliced.contains("- id: own"), "the target's hooks survive");
1342 assert!(
1343 !spliced.contains(HOOK_TYPES_LINE),
1344 "an existing file's top level is the skills' duty, not the splice's"
1345 );
1346
1347 let stale = spliced.replace("--force-scope", "--no-scope");
1348 let refreshed = splice_hooks_block(Some(&stale), block).expect("a marked file re-splices");
1349 assert_eq!(
1350 extract_block(&refreshed, HOOKS_BEGIN, HOOKS_END),
1351 Some(block)
1352 );
1353 assert_eq!(refreshed.matches(HOOKS_BEGIN).count(), 1);
1354
1355 let err = splice_hooks_block(Some("minimum_pre_commit_version: '3.2.0'\n"), block)
1356 .expect_err("no repos: line refuses");
1357 assert!(err.contains("repos:"), "{err}");
1358
1359 let doubled = format!("repos:\n{block}\n{block}\n");
1363 let err = splice_hooks_block(Some(&doubled), block).expect_err("a second block refuses");
1364 assert!(err.contains("one block"), "{err}");
1365 let unmatched = "repos:\n# BEGIN release-kit\n - repo: local\n";
1366 let err =
1367 splice_hooks_block(Some(unmatched), block).expect_err("an unmatched marker refuses");
1368 assert!(err.contains("unmatched"), "{err}");
1369 }
1370
1371 #[test]
1377 fn the_blocks_render_per_mode_and_carry_the_one_grammar() {
1378 let worktree_hooks = hooks_block(Workflow::Worktree);
1379 let branches_hooks = hooks_block(Workflow::Branches);
1380 assert!(worktree_hooks.contains("- id: rk-worktree-location"));
1381 assert!(
1382 worktree_hooks.contains("SKIP=no-commit-to-branch,rk-worktree-location"),
1383 "{worktree_hooks}"
1384 );
1385 assert!(!branches_hooks.contains("rk-worktree-location"));
1386 assert!(branches_hooks.contains("SKIP=no-commit-to-branch in"));
1387 for block in [&worktree_hooks, &branches_hooks] {
1388 assert!(block.contains(BRANCH_GRAMMAR), "the grammar has one owner");
1389 for token in ["RK_BRANCH_GRAMMAR", "RK_SWEEP_SKIP", "RK_WORKTREE_GUARD"] {
1390 assert!(!block.contains(token), "{token} survived: {block}");
1391 }
1392 }
1393 for block in [&worktree_hooks, &branches_hooks] {
1398 for line in block.lines() {
1399 if let Some(value) = line.trim_start().strip_prefix("entry: ") {
1400 assert!(
1401 !value.contains(": "),
1402 "an entry value breaks the YAML plain scalar: {line}"
1403 );
1404 }
1405 }
1406 }
1407 let guard_line = worktree_hooks
1408 .lines()
1409 .position(|line| line.contains("id: rk-worktree-location"))
1410 .expect("the guard entry exists");
1411 let name_line = worktree_hooks
1412 .lines()
1413 .position(|line| line.contains("id: rk-branch-name"))
1414 .expect("the name hook exists");
1415 assert!(
1416 guard_line > name_line,
1417 "the guard lands directly after rk-branch-name"
1418 );
1419
1420 let worktree_routing = routing_block(Workflow::Worktree);
1421 let branches_routing = routing_block(Workflow::Branches);
1422 assert!(worktree_routing.contains("This project works in worktrees"));
1423 assert!(branches_routing.contains("Branches are worked in the main checkout"));
1424 for block in [&worktree_routing, &branches_routing] {
1425 assert!(block.contains("creating or removing a worktree"));
1426 assert!(block.contains("`rk worktree add <branch>`"));
1427 assert!(!block.contains("RK_WORKFLOW_LINE"), "{block}");
1428 }
1429 let differing: Vec<(&str, &str)> = worktree_routing
1430 .lines()
1431 .zip(branches_routing.lines())
1432 .filter(|(a, b)| a != b)
1433 .collect();
1434 assert_eq!(
1435 differing.len(),
1436 1,
1437 "exactly one routing line differs per mode: {differing:?}"
1438 );
1439 }
1440
1441 #[test]
1444 fn the_hook_marker_defects_are_named() {
1445 use super::hooks_marker_defect;
1446 let owned = hooks_block(Workflow::Branches);
1447 let block = owned.as_str();
1448 assert_eq!(hooks_marker_defect(""), None);
1449 assert_eq!(hooks_marker_defect(&format!("repos:\n{block}\n")), None);
1450 for (case, text) in [
1451 (
1452 "a second begin",
1453 format!("repos:\n{block}\n# BEGIN release-kit\n"),
1454 ),
1455 (
1456 "a second end",
1457 format!("repos:\n{block}\n# END release-kit\n"),
1458 ),
1459 (
1460 "an unpaired begin",
1461 "repos:\n# BEGIN release-kit\n".to_owned(),
1462 ),
1463 ("an unpaired end", "repos:\n# END release-kit\n".to_owned()),
1464 (
1465 "an end before its begin",
1466 "repos:\n# END release-kit\n# BEGIN release-kit\n".to_owned(),
1467 ),
1468 ] {
1469 assert!(
1470 hooks_marker_defect(&text).is_some(),
1471 "{case} must be a defect"
1472 );
1473 }
1474 }
1475}