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 SCOPES_CSV_TOKEN: &[u8] = b"RK_SCOPES_CSV";
126
127pub const SCOPES_PIPE_TOKEN: &[u8] = b"RK_SCOPES_PIPE";
129
130pub const STYLE_TOKEN: &[u8] = b"RK_STYLE";
133
134#[must_use]
143pub fn render(baseline: &[u8], repo: &str, scopes: &[String], style: Option<Style>) -> Vec<u8> {
144 let owner = repo.split('/').next().unwrap_or(repo);
145 let mut out = substitute(baseline, OWNER_TOKEN, owner.as_bytes());
146 if let Some(style) = style {
147 out = substitute(&out, STYLE_TOKEN, style.as_str().as_bytes());
148 }
149 if !scopes.is_empty() {
150 out = substitute(&out, SCOPES_CSV_TOKEN, scopes.join(",").as_bytes());
151 let pipe: Vec<String> = scopes.iter().map(|s| s.replace('.', "\\.")).collect();
156 out = substitute(&out, SCOPES_PIPE_TOKEN, pipe.join("|").as_bytes());
157 }
158 out
159}
160
161fn substitute(baseline: &[u8], token: &[u8], value: &[u8]) -> Vec<u8> {
163 let mut out = Vec::with_capacity(baseline.len());
164 let mut rest = baseline;
165 while let Some(at) = find(rest, token) {
166 out.extend_from_slice(&rest[..at]);
167 out.extend_from_slice(value);
168 rest = &rest[at + token.len()..];
169 }
170 out.extend_from_slice(rest);
171 out
172}
173
174pub fn parse_scopes(raw: &str) -> Result<Vec<String>, RkError> {
185 let scopes: Vec<String> = raw
186 .split(',')
187 .map(str::trim)
188 .filter(|scope| !scope.is_empty())
189 .map(str::to_owned)
190 .collect();
191 if scopes.is_empty() {
192 return Err(RkError::Usage(
193 "--scopes names no scope; pass a comma-separated list, e.g. --scopes api,cli".into(),
194 ));
195 }
196 for scope in &scopes {
197 let clean = scope
198 .chars()
199 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '/' | '-'));
200 if !clean {
201 return Err(RkError::Usage(format!(
202 "the scope '{scope}' carries a character outside letters, digits, and _ . / -"
203 )));
204 }
205 }
206 Ok(scopes)
207}
208
209fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
211 haystack
212 .windows(needle.len())
213 .position(|window| window == needle)
214}
215
216pub const AGENTS_DESTINATION: &str = "AGENTS.md";
218
219pub const BLOCK_BEGIN: &str = "<!-- BEGIN release-kit -->";
221
222pub const BLOCK_END: &str = "<!-- END release-kit -->";
224
225pub const HOOKS_DESTINATION: &str = ".pre-commit-config.yaml";
227
228pub const HOOKS_BEGIN: &str = "# BEGIN release-kit";
230
231pub const HOOKS_END: &str = "# END release-kit";
233
234pub const HOOK_TYPES_LINE: &str = "default_install_hook_types: [pre-commit, commit-msg, pre-push]";
238
239static AGENTS_BLOCK: &str = include_str!("../blocks/agents-block.md.in");
241
242static AGENTS_LINE_WORKTREE: &str = include_str!("../blocks/agents-line-worktree.md.in");
244
245static AGENTS_LINE_BRANCHES: &str = include_str!("../blocks/agents-line-branches.md.in");
247
248static PRE_COMMIT_BLOCK: &str = include_str!("../blocks/pre-commit-block.yaml.in");
250
251static PRE_COMMIT_WORKTREE_GUARD: &str =
253 include_str!("../blocks/pre-commit-worktree-guard.yaml.in");
254
255fn authored(text: &str) -> &str {
259 text.strip_suffix('\n').unwrap_or(text)
260}
261
262pub 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[-/].+)$";
270
271#[must_use]
280pub fn routing_block(workflow: Workflow) -> String {
281 let line = match workflow {
282 Workflow::Worktree => authored(AGENTS_LINE_WORKTREE),
283 Workflow::Branches => authored(AGENTS_LINE_BRANCHES),
284 };
285 authored(AGENTS_BLOCK).replacen("RK_WORKFLOW_LINE", line, 1)
286}
287
288#[must_use]
300pub fn hooks_block(workflow: Workflow) -> String {
301 let (guard, skip) = match workflow {
302 Workflow::Worktree => (
303 format!("{}\n", authored(PRE_COMMIT_WORKTREE_GUARD)),
304 "no-commit-to-branch,rk-worktree-location",
305 ),
306 Workflow::Branches => (String::new(), "no-commit-to-branch"),
307 };
308 authored(PRE_COMMIT_BLOCK)
309 .replacen("RK_BRANCH_GRAMMAR", BRANCH_GRAMMAR, 1)
310 .replacen("RK_SWEEP_SKIP", skip, 1)
311 .replacen("RK_WORKTREE_GUARD", &guard, 1)
312}
313
314#[must_use]
316pub fn block_markers(destination: &str) -> Option<(&'static str, &'static str)> {
317 match destination {
318 AGENTS_DESTINATION => Some((BLOCK_BEGIN, BLOCK_END)),
319 HOOKS_DESTINATION => Some((HOOKS_BEGIN, HOOKS_END)),
320 _ => None,
321 }
322}
323
324#[must_use]
327pub fn extract_block<'a>(text: &'a str, begin: &str, end: &str) -> Option<&'a str> {
328 let start = text.find(begin)?;
329 let stop = text[start..].find(end)? + start + end.len();
330 Some(&text[start..stop])
331}
332
333#[must_use]
339pub fn splice_agents_block(existing: Option<&str>, block: &str) -> String {
340 existing.map_or_else(
341 || format!("{block}\n"),
342 |text| {
343 extract_block(text, BLOCK_BEGIN, BLOCK_END).map_or_else(
344 || format!("{}\n\n{block}\n", text.trim_end()),
345 |found| text.replacen(found, block, 1),
346 )
347 },
348 )
349}
350
351pub fn splice_hooks_block(existing: Option<&str>, block: &str) -> Result<String, String> {
364 let Some(text) = existing else {
365 return Ok(format!("{HOOK_TYPES_LINE}\n\nrepos:\n{block}\n"));
366 };
367 if let Some(defect) = hooks_marker_defect(text) {
368 return Err(defect);
369 }
370 if let Some(found) = extract_block(text, HOOKS_BEGIN, HOOKS_END) {
371 return Ok(text.replacen(found, block, 1));
372 }
373 let mut out = String::with_capacity(text.len() + block.len() + 1);
374 let mut placed = false;
375 for line in text.split_inclusive('\n') {
376 out.push_str(line);
377 if !placed && line.trim_end() == "repos:" {
378 if !out.ends_with('\n') {
379 out.push('\n');
380 }
381 out.push_str(block);
382 out.push('\n');
383 placed = true;
384 }
385 }
386 if placed {
387 Ok(out)
388 } else {
389 Err(format!(
390 "{HOOKS_DESTINATION} exists with no repos: line, so the hook block has nowhere to land"
391 ))
392 }
393}
394
395#[must_use]
404pub fn hooks_marker_defect(text: &str) -> Option<String> {
405 let begins = text.matches(HOOKS_BEGIN).count();
406 let ends = text.matches(HOOKS_END).count();
407 if begins > 1 || ends > 1 {
408 return Some(format!(
409 "{HOOKS_DESTINATION} carries more than one release-kit marker pair; release-kit owns exactly one block"
410 ));
411 }
412 match (text.find(HOOKS_BEGIN), text.find(HOOKS_END)) {
413 (Some(begin), Some(end)) if end > begin => None,
414 (None, None) => None,
415 _ => Some(format!(
416 "{HOOKS_DESTINATION} carries an unmatched or misordered release-kit marker, so the block's extent is ambiguous"
417 )),
418 }
419}
420
421#[derive(Debug, Clone, Copy, PartialEq, Eq)]
423pub enum Placement {
424 Whole,
426 Block,
428}
429
430#[derive(Debug)]
433pub struct Entry {
434 pub destination: String,
436 pub kind: Kind,
438 pub placement: Placement,
440 pub baseline: Vec<u8>,
443 pub rendered: Vec<u8>,
446}
447
448pub fn pair_files(tech: &str, forge: &str) -> Result<Vec<(String, &'static [u8])>, RkError> {
456 if tech.starts_with('_') || embedded::SNIPPETS.get_dir(tech).is_none() {
459 let known: Vec<String> = embedded::SNIPPETS
460 .dirs()
461 .map(|dir| dir.path().to_string_lossy().into_owned())
462 .filter(|name| !name.starts_with('_'))
463 .collect();
464 return Err(RkError::Usage(format!(
465 "unknown tech '{tech}'; the bindings are: {}",
466 known.join(", ")
467 )));
468 }
469 let pair = format!("{tech}/{forge}");
470 let pair_dir = embedded::SNIPPETS.get_dir(&pair).ok_or_else(|| {
471 let known: Vec<String> = embedded::SNIPPETS
472 .dirs()
473 .filter(|dir| !dir.path().to_string_lossy().starts_with('_'))
474 .flat_map(include_dir::Dir::dirs)
475 .map(|dir| dir.path().to_string_lossy().replace('/', ", "))
476 .collect();
477 RkError::Usage(format!(
478 "the pair ({tech}, {forge}) has no landable files; the supported pairs are: {}",
479 known.join("; ")
480 ))
481 })?;
482 let mut files: Vec<(String, &'static [u8])> = Vec::new();
486 let shared = format!("_shared/{forge}");
487 if let Some(shared_dir) = embedded::SNIPPETS.get_dir(&shared) {
488 for (path, contents) in embedded::walk(shared_dir) {
489 let rel = path
490 .strip_prefix(&format!("{shared}/"))
491 .map_or(path.as_str(), |rel| rel)
492 .to_owned();
493 files.push((rel, contents));
494 }
495 }
496 for (path, contents) in embedded::walk(pair_dir) {
497 let rel = path
498 .strip_prefix(&format!("{pair}/"))
499 .map_or(path.as_str(), |rel| rel)
500 .to_owned();
501 if files.iter().any(|(existing, _)| *existing == rel) {
502 return Err(anyhow::anyhow!(
503 "the shared zone and the pair ({tech}, {forge}) both ship {rel}; the payload is defective"
504 )
505 .into());
506 }
507 files.push((rel, contents));
508 }
509 Ok(files)
510}
511
512pub fn projection(
527 tech: &str,
528 forge: &str,
529 repo: &str,
530 scopes: &[String],
531 workflow: Workflow,
532 style: Option<Style>,
533 nix: bool,
534) -> Result<Vec<Entry>, RkError> {
535 let mut entries = Vec::new();
536 for (destination, baseline) in pair_files(tech, forge)? {
537 if !nix && NIX_DESTINATIONS.contains(&destination.as_str()) {
538 continue;
539 }
540 let kind = kind_of(&destination).ok_or_else(|| {
541 anyhow::anyhow!("the payload does not classify {destination}; the kind table is stale")
542 })?;
543 let rendered = match kind {
544 Kind::Rendered => render(baseline, repo, scopes, style),
545 Kind::Seeded | Kind::State => baseline.to_vec(),
546 };
547 entries.push(Entry {
548 destination,
549 kind,
550 placement: Placement::Whole,
551 baseline: baseline.to_vec(),
552 rendered,
553 });
554 }
555 for (destination, template) in [
556 (AGENTS_DESTINATION, routing_block(workflow)),
557 (HOOKS_DESTINATION, hooks_block(workflow)),
558 ] {
559 entries.push(Entry {
560 destination: destination.to_owned(),
561 kind: Kind::Rendered,
562 placement: Placement::Block,
563 baseline: template.as_bytes().to_vec(),
564 rendered: render(template.as_bytes(), repo, scopes, style),
565 });
566 }
567 entries.sort_by(|a, b| a.destination.cmp(&b.destination));
568 Ok(entries)
569}
570
571#[must_use]
583pub fn nix_unsupported_shape(target: &Utf8Path) -> Option<String> {
584 let Ok(text) = std::fs::read_to_string(target.join("Cargo.toml")) else {
585 return Some(
586 "the target has no readable Cargo.toml, which the seeded package expression reads; no Nix file lands".to_owned(),
587 );
588 };
589 let Ok(table) = text.parse::<toml::Table>() else {
590 return Some(
591 "the target's Cargo.toml does not parse, and the seeded package expression reads it; no Nix file lands".to_owned(),
592 );
593 };
594 if !table.contains_key("package") {
595 return Some(
596 "the target's Cargo.toml has no [package] table; the seed supports a single crate, so no Nix file lands".to_owned(),
597 );
598 }
599 if !target.join("Cargo.lock").is_file() {
600 return Some(
601 "the target has no Cargo.lock, which the seeded package expression builds from; commit one, then opt in".to_owned(),
602 );
603 }
604 let implicit_bin = target.join("src/main.rs").is_file()
605 && table
606 .get("package")
607 .and_then(toml::Value::as_table)
608 .and_then(|package| package.get("autobins"))
609 .and_then(toml::Value::as_bool)
610 != Some(false);
611 let explicit_bins = table.get("bin").and_then(toml::Value::as_array);
612 if explicit_bins.is_none() && !implicit_bin {
613 return Some(
614 "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(),
615 );
616 }
617 if let Some(bins) = explicit_bins {
623 let required = bins
624 .first()
625 .and_then(toml::Value::as_table)
626 .and_then(|bin| bin.get("required-features"))
627 .and_then(toml::Value::as_array);
628 if let Some(required) = required {
629 let enabled = default_features(&table);
630 let missing = required
631 .iter()
632 .filter_map(toml::Value::as_str)
633 .any(|feature| !enabled.contains(feature));
634 if missing {
635 return Some(
636 "the target's first [[bin]] entry requires features a default build does not enable; no Nix file lands".to_owned(),
637 );
638 }
639 }
640 }
641 None
642}
643
644fn dep_edge_suppresses(features: &toml::Table, name: &str) -> bool {
647 let edge = format!("dep:{name}");
648 features.values().any(|list| {
649 list.as_array().is_some_and(|entries| {
650 entries
651 .iter()
652 .filter_map(toml::Value::as_str)
653 .any(|entry| entry == edge)
654 })
655 })
656}
657
658fn is_optional_dependency(table: &toml::Table, name: &str) -> bool {
661 ["dependencies", "build-dependencies"]
662 .iter()
663 .any(|section| {
664 table
665 .get(*section)
666 .and_then(toml::Value::as_table)
667 .and_then(|dependencies| dependencies.get(name))
668 .and_then(toml::Value::as_table)
669 .and_then(|dependency| dependency.get("optional"))
670 .and_then(toml::Value::as_bool)
671 == Some(true)
672 })
673}
674
675fn default_features(table: &toml::Table) -> std::collections::BTreeSet<String> {
682 let Some(features) = table.get("features").and_then(toml::Value::as_table) else {
683 return std::collections::BTreeSet::new();
684 };
685 let mut enabled = std::collections::BTreeSet::new();
686 let mut queue = vec!["default".to_owned()];
687 while let Some(name) = queue.pop() {
688 if !enabled.insert(name.clone()) {
689 continue;
690 }
691 if let Some(implies) = features.get(&name).and_then(toml::Value::as_array) {
692 for implied in implies.iter().filter_map(toml::Value::as_str) {
693 if implied.starts_with("dep:") || implied.contains("?/") {
694 continue;
698 }
699 if let Some((package, _)) = implied.split_once('/') {
700 let feature_exists =
708 features.contains_key(package) || !dep_edge_suppresses(features, package);
709 if is_optional_dependency(table, package) && feature_exists {
710 queue.push(package.to_owned());
711 }
712 } else {
713 queue.push(implied.to_owned());
714 }
715 }
716 }
717 }
718 enabled
719}
720
721pub fn nix_withheld(
733 target: &Utf8Path,
734 recorded: Option<&manifest::Manifest>,
735) -> std::io::Result<Option<String>> {
736 if recorded.is_some_and(|record| record.file("flake.nix").is_some()) {
737 return Ok(None);
738 }
739 let mut present = Vec::new();
740 for name in ["flake.nix", "flake.lock"] {
741 match std::fs::symlink_metadata(target.join(name).as_std_path()) {
742 Ok(_) => present.push(name),
743 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
744 Err(e) => return Err(e),
745 }
746 }
747 if present.is_empty() {
748 return Ok(None);
749 }
750 Ok(Some(format!(
751 "the target already carries {}; its flake pair stays its own",
752 present.join(" and ")
753 )))
754}
755
756#[derive(Debug, Serialize)]
758pub struct Withheld {
759 pub path: String,
761 pub reason: String,
764}
765
766pub fn withhold_nix(
779 target: &Utf8Path,
780 nix: bool,
781 recorded: Option<&manifest::Manifest>,
782 entries: &mut Vec<Entry>,
783) -> Result<Vec<Withheld>, RkError> {
784 if !nix {
785 return Ok(Vec::new());
786 }
787 let (set, reason): (&[&str], String) = if let Some(reason) = nix_unsupported_shape(target) {
788 (&NIX_DESTINATIONS[..], reason)
789 } else if let Some(reason) = nix_withheld(target, recorded)? {
790 (&NIX_WITHHOLDABLE[..], reason)
791 } else {
792 return Ok(Vec::new());
793 };
794 let mut withheld = Vec::new();
795 entries.retain(|entry| {
796 if set.contains(&entry.destination.as_str()) {
797 withheld.push(Withheld {
798 path: entry.destination.clone(),
799 reason: reason.clone(),
800 });
801 false
802 } else {
803 true
804 }
805 });
806 Ok(withheld)
807}
808
809pub fn read_destination(target: &Utf8Path, entry: &Entry) -> std::io::Result<Option<Vec<u8>>> {
817 read_recorded(target, &entry.destination)
818}
819
820pub fn read_recorded(target: &Utf8Path, destination: &str) -> std::io::Result<Option<Vec<u8>>> {
831 let path = target.join(destination);
832 let bytes = match std::fs::read(&path) {
833 Ok(bytes) => bytes,
834 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
835 Err(e) => return Err(e),
836 };
837 if let Some((begin, end)) = block_markers(destination) {
838 let text = String::from_utf8_lossy(&bytes);
839 Ok(extract_block(&text, begin, end).map(|block| block.as_bytes().to_vec()))
840 } else {
841 Ok(Some(bytes))
842 }
843}
844
845#[derive(Debug)]
848pub struct Resolved {
849 pub forge: String,
851 pub repo: Option<String>,
853}
854
855pub fn resolve(
867 target: &Utf8Path,
868 forge_flag: Option<&str>,
869 repo_flag: Option<&str>,
870) -> Result<Resolved, RkError> {
871 let forge_flag = forge_flag
872 .map(|name| {
873 crate::detect::Forge::parse(name).ok_or_else(|| {
874 RkError::Usage(format!(
875 "unknown forge '{name}'; the forges are: github, gitlab"
876 ))
877 })
878 })
879 .transpose()?;
880 let detected = crate::detect::detect(target.as_std_path());
881 let forge = forge_flag
882 .or(detected.forge)
883 .map(|forge| forge.as_str().to_owned())
884 .ok_or_else(|| {
885 let message = detected.host.map_or_else(
886 || "no forge detected: the target has no origin remote".to_owned(),
887 |host| format!("no forge detected: the host {host} is not recognized"),
888 );
889 RkError::refusal(
890 Diagnostic::new(Reason::ForgeUndetected, message)
891 .expected("a github.com or gitlab remote, or --forge")
892 .action("pass --forge <github|gitlab>"),
893 )
894 })?;
895 Ok(Resolved {
896 forge,
897 repo: repo_flag.map(str::to_owned).or(detected.repo),
898 })
899}
900
901#[must_use]
904pub fn repo_unresolved() -> RkError {
905 RkError::missing(
906 Diagnostic::new(
907 Reason::ForgeUndetected,
908 "no repository detected: the target has no origin remote",
909 )
910 .expected("an origin remote naming the project")
911 .action("pass --repo <path>"),
912 )
913}
914
915pub fn write_destination(target: &Utf8Path, entry: &Entry) -> std::io::Result<()> {
925 let path = target.join(&entry.destination);
926 match entry.placement {
927 Placement::Whole => atomic::write(path.as_std_path(), &entry.rendered),
928 Placement::Block => {
929 let existing = match std::fs::read(&path) {
930 Ok(bytes) => Some(String::from_utf8_lossy(&bytes).into_owned()),
931 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
932 Err(e) => return Err(e),
933 };
934 let block = String::from_utf8_lossy(&entry.rendered).into_owned();
935 let spliced = if entry.destination == HOOKS_DESTINATION {
936 splice_hooks_block(existing.as_deref(), &block).map_err(std::io::Error::other)?
937 } else {
938 splice_agents_block(existing.as_deref(), &block)
939 };
940 atomic::write(path.as_std_path(), spliced.as_bytes())
941 }
942 }
943}
944
945pub fn hooks_file_defect(target: &Utf8Path) -> std::io::Result<Option<String>> {
958 let path = target.join(HOOKS_DESTINATION);
959 match std::fs::read(&path) {
960 Ok(bytes) => {
961 let text = String::from_utf8_lossy(&bytes);
962 Ok(splice_hooks_block(Some(&text), authored(PRE_COMMIT_BLOCK)).err())
963 }
964 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
965 Err(e) => Err(e),
966 }
967}
968
969pub fn hooks_splice_refusal(target: &Utf8Path) -> Result<(), RkError> {
979 hooks_file_defect(target)?.map_or(Ok(()), |reason| {
980 Err(RkError::refusal(
981 Diagnostic::new(
982 Reason::StateDrift,
983 format!("{reason}, and nothing was written"),
984 )
985 .expected("a .pre-commit-config.yaml the block can land in, or none")
986 .action(format!(
987 "resolve it in {}, then re-run",
988 target.join(HOOKS_DESTINATION)
989 ))
990 .target_state("unchanged"),
991 ))
992 })
993}
994
995#[cfg(test)]
996mod tests {
997 #![allow(clippy::expect_used)]
998
999 use super::{
1000 AGENTS_DESTINATION, BLOCK_BEGIN, BLOCK_END, BRANCH_GRAMMAR, HOOK_TYPES_LINE, HOOKS_BEGIN,
1001 HOOKS_DESTINATION, HOOKS_END, Kind, Style, Workflow, extract_block, hooks_block, kind_of,
1002 pair_files, parse_scopes, projection, render, routing_block, splice_agents_block,
1003 splice_hooks_block,
1004 };
1005 use crate::embedded;
1006
1007 fn scopes(list: &[&str]) -> Vec<String> {
1008 list.iter().map(|s| (*s).to_owned()).collect()
1009 }
1010
1011 #[test]
1015 fn the_kind_table_closes_over_every_snippet() {
1016 for tech_dir in embedded::SNIPPETS.dirs() {
1017 for pair_dir in tech_dir.dirs() {
1018 let prefix = format!("{}/", pair_dir.path().to_string_lossy());
1019 for (path, _) in embedded::walk(pair_dir) {
1020 let destination = path.strip_prefix(&prefix).unwrap_or(&path);
1021 assert!(
1022 kind_of(destination).is_some(),
1023 "{destination}: no declared kind"
1024 );
1025 }
1026 }
1027 }
1028 assert_eq!(kind_of(AGENTS_DESTINATION), Some(Kind::Rendered));
1029 assert_eq!(kind_of(HOOKS_DESTINATION), Some(Kind::Rendered));
1030 assert_eq!(kind_of("something-else.txt"), None);
1031 }
1032
1033 #[test]
1037 fn rendering_substitutes_every_owner_occurrence() {
1038 let baseline = b"if: repository_owner == 'OWNER'\n# OWNER again: OWNER\n";
1039 let rendered = render(baseline, "acme/sub/widget", &[], None);
1040 let text = String::from_utf8(rendered).expect("rendered bytes stay text");
1041 assert_eq!(text, "if: repository_owner == 'acme'\n# acme again: acme\n");
1042
1043 let baseline = b"scopes 'RK_SCOPES_CSV' match (RK_SCOPES_PIPE)\n";
1044 let rendered = render(baseline, "acme/widget", &scopes(&["api", "cli"]), None);
1045 let text = String::from_utf8(rendered).expect("rendered bytes stay text");
1046 assert_eq!(text, "scopes 'api,cli' match (api|cli)\n");
1047
1048 let rendered = render(baseline, "acme/widget", &scopes(&["api.v1"]), None);
1051 let text = String::from_utf8(rendered).expect("rendered bytes stay text");
1052 assert_eq!(text, "scopes 'api.v1' match (api\\.v1)\n");
1053 }
1054
1055 #[test]
1058 fn scope_parsing_refuses_the_unusable() {
1059 assert_eq!(
1060 parse_scopes("api, cli,guides/release").expect("a clean list parses"),
1061 scopes(&["api", "cli", "guides/release"])
1062 );
1063 assert!(parse_scopes("").is_err());
1064 assert!(parse_scopes(" , ").is_err());
1065 assert!(parse_scopes("api|cli").is_err());
1066 assert!(parse_scopes("a b").is_err());
1067 }
1068
1069 #[test]
1072 fn the_shared_zone_composes_into_the_pair() {
1073 let files = pair_files("rust", "github").expect("the pair lists");
1074 assert!(
1075 files
1076 .iter()
1077 .any(|(dest, _)| dest == ".github/workflows/pr-title.yml"),
1078 "the shared title check lands with the pair"
1079 );
1080 let files = pair_files("rust", "gitlab").expect("the pair lists");
1081 assert!(
1082 files
1083 .iter()
1084 .any(|(dest, _)| dest == ".gitlab/ci/mr-title.yml"),
1085 "the shared title job lands with the pair"
1086 );
1087 let err = pair_files("_shared", "github").expect_err("the shared zone is no tech");
1088 let listing = err.to_string();
1089 let bindings = listing
1090 .split("the bindings are:")
1091 .nth(1)
1092 .expect("the refusal lists the bindings");
1093 assert!(!bindings.contains("_shared"), "{listing}");
1094 }
1095
1096 #[test]
1100 fn a_projection_renders_owned_files_and_keeps_seeded_judgment() {
1101 let entries = projection(
1102 "rust",
1103 "github",
1104 "acme/widget",
1105 &scopes(&["api", "cli"]),
1106 Workflow::Branches,
1107 Some(Style::Trunk),
1108 false,
1109 )
1110 .expect("the pair projects");
1111 let workflow = entries
1112 .iter()
1113 .find(|entry| entry.destination.ends_with("release-plz.yml"))
1114 .expect("the workflow projects");
1115 assert_eq!(workflow.kind, Kind::Rendered);
1116 let text = String::from_utf8_lossy(&workflow.rendered);
1117 assert!(!text.contains("OWNER"), "an owner token survived rendering");
1118 assert!(text.contains("'acme'"));
1119 assert!(!text.contains("TODO(release-kit)"));
1120 let title = entries
1121 .iter()
1122 .find(|entry| entry.destination.ends_with("pr-title.yml"))
1123 .expect("the title check projects");
1124 let text = String::from_utf8_lossy(&title.rendered);
1125 assert!(text.contains("api|cli"), "{text}");
1126 assert!(
1127 !text.contains("RK_SCOPES"),
1128 "a scope token survived: {text}"
1129 );
1130 let seeded = entries
1131 .iter()
1132 .find(|entry| entry.destination == "release-plz.toml")
1133 .expect("the seeded file projects");
1134 assert_eq!(seeded.kind, Kind::Seeded);
1135 assert_eq!(seeded.rendered, seeded.baseline);
1136 assert!(String::from_utf8_lossy(&seeded.rendered).contains("TODO(release-kit)"));
1137 for block in [AGENTS_DESTINATION, HOOKS_DESTINATION] {
1138 let entry = entries
1139 .iter()
1140 .find(|entry| entry.destination == block)
1141 .expect("both blocks are part of the projection");
1142 let text = String::from_utf8_lossy(&entry.rendered);
1143 assert!(!text.contains("RK_SCOPES"), "{block} kept a token: {text}");
1144 assert!(text.contains("api,cli"), "{block} lost the scopes: {text}");
1145 }
1146 }
1147
1148 #[test]
1153 fn the_nix_destinations_project_only_under_the_opt_in() {
1154 use super::NIX_DESTINATIONS;
1155 let paths = |nix: bool, forge: &str| -> Vec<String> {
1156 projection(
1157 "rust",
1158 forge,
1159 "acme/widget",
1160 &scopes(&["api"]),
1161 Workflow::Worktree,
1162 Some(Style::Trunk),
1163 nix,
1164 )
1165 .expect("the pair projects")
1166 .into_iter()
1167 .map(|entry| entry.destination)
1168 .collect()
1169 };
1170 let off = paths(false, "github");
1171 for destination in NIX_DESTINATIONS {
1172 assert!(!off.contains(&destination.to_owned()), "{destination}");
1173 }
1174 let on = paths(true, "github");
1175 for destination in ["nix/package.nix", "flake.nix", "flake.lock"] {
1176 assert!(on.contains(&destination.to_owned()), "{destination}");
1177 }
1178 let gitlab = paths(true, "gitlab");
1183 assert!(gitlab.contains(&"nix/package.nix".to_owned()));
1184 assert!(
1185 !on.iter()
1186 .chain(gitlab.iter())
1187 .any(|destination| destination.contains("nix.yml"))
1188 );
1189 let bash = projection(
1190 "bash",
1191 "github",
1192 "acme/widget",
1193 &scopes(&["api"]),
1194 Workflow::Worktree,
1195 Some(Style::Trunk),
1196 true,
1197 )
1198 .expect("an out-of-matrix pair projects the smaller product");
1199 assert!(
1200 bash.iter()
1201 .all(|entry| !NIX_DESTINATIONS.contains(&entry.destination.as_str()))
1202 );
1203 }
1204
1205 #[test]
1210 fn the_nix_seeds_are_identical_across_forge_pairs() {
1211 for name in ["nix/package.nix", "flake.nix", "flake.lock"] {
1212 let github = embedded::SNIPPETS
1213 .get_file(format!("rust/github/{name}"))
1214 .expect("the github copy ships")
1215 .contents();
1216 let gitlab = embedded::SNIPPETS
1217 .get_file(format!("rust/gitlab/{name}"))
1218 .expect("the gitlab copy ships")
1219 .contents();
1220 assert_eq!(github, gitlab, "{name} diverged between the pairs");
1221 }
1222 }
1223
1224 #[test]
1229 fn the_nix_withhold_judgment_covers_the_three_shapes() {
1230 use super::{NIX_DESTINATIONS, withhold_nix};
1231 let dir = tempfile::tempdir().expect("a scratch target exists");
1232 let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
1233 let entries = || {
1234 projection(
1235 "rust",
1236 "github",
1237 "acme/widget",
1238 &scopes(&["api"]),
1239 Workflow::Worktree,
1240 Some(Style::Trunk),
1241 true,
1242 )
1243 .expect("the pair projects")
1244 };
1245
1246 let mut all = entries();
1248 let withheld = withhold_nix(target, true, None, &mut all).expect("the judgment runs");
1249 let paths: Vec<&str> = withheld.iter().map(|w| w.path.as_str()).collect();
1250 assert_eq!(paths, ["flake.lock", "flake.nix", "nix/package.nix"]);
1251 assert!(
1252 all.iter()
1253 .all(|entry| !NIX_DESTINATIONS.contains(&entry.destination.as_str()))
1254 );
1255
1256 std::fs::write(
1259 target.join("Cargo.toml"),
1260 "[package]\nname = \"widget\"\nversion = \"0.1.0\"\n",
1261 )
1262 .expect("the crate manifest writes");
1263 std::fs::write(target.join("Cargo.lock"), "version = 4\n").expect("the lock writes");
1264 std::fs::create_dir_all(target.join("src")).expect("the src dir exists");
1265 std::fs::write(target.join("src/main.rs"), "fn main() {}\n").expect("the main writes");
1266 std::fs::write(target.join("flake.nix"), "{ }\n").expect("the flake writes");
1267 let mut all = entries();
1268 let withheld = withhold_nix(target, true, None, &mut all).expect("the judgment runs");
1269 let paths: Vec<&str> = withheld.iter().map(|w| w.path.as_str()).collect();
1270 assert_eq!(paths, ["flake.lock", "flake.nix"]);
1271 assert!(
1272 all.iter()
1273 .any(|entry| entry.destination == "nix/package.nix")
1274 );
1275
1276 std::fs::remove_file(target.join("flake.nix")).expect("the flake removes");
1278 let mut all = entries();
1279 let withheld = withhold_nix(target, true, None, &mut all).expect("the judgment runs");
1280 assert!(withheld.is_empty());
1281 assert!(all.iter().any(|entry| entry.destination == "flake.nix"));
1282
1283 let mut all = entries();
1285 let withheld = withhold_nix(target, false, None, &mut all).expect("the judgment runs");
1286 assert!(withheld.is_empty());
1287 }
1288
1289 #[test]
1290 fn the_block_splices_into_every_agents_shape() {
1291 let owned = routing_block(Workflow::Branches);
1292 let block = owned.as_str();
1293 let fresh = splice_agents_block(None, block);
1294 assert_eq!(fresh, format!("{block}\n"));
1295 assert_eq!(extract_block(&fresh, BLOCK_BEGIN, BLOCK_END), Some(block));
1296
1297 let appended = splice_agents_block(Some("# My project\n\nOwn rules.\n"), block);
1298 assert!(appended.starts_with("# My project\n\nOwn rules.\n\n<!-- BEGIN release-kit -->"));
1299 assert_eq!(
1300 extract_block(&appended, BLOCK_BEGIN, BLOCK_END),
1301 Some(block)
1302 );
1303
1304 let stale = appended.replace("Never author a tag", "Do author a tag");
1305 let refreshed = splice_agents_block(Some(&stale), block);
1306 assert_eq!(
1307 extract_block(&refreshed, BLOCK_BEGIN, BLOCK_END),
1308 Some(block)
1309 );
1310 assert!(refreshed.starts_with("# My project"));
1311 assert_eq!(
1312 refreshed.matches("BEGIN release-kit").count(),
1313 1,
1314 "a re-splice must replace, not accumulate"
1315 );
1316 }
1317
1318 #[test]
1321 fn the_hook_block_splices_under_repos() {
1322 let owned = hooks_block(Workflow::Branches);
1323 let block = owned.as_str();
1324 let fresh = splice_hooks_block(None, block).expect("a fresh file splices");
1325 assert!(fresh.starts_with(HOOK_TYPES_LINE));
1326 assert!(fresh.contains("\nrepos:\n# BEGIN release-kit\n"));
1327 assert_eq!(extract_block(&fresh, HOOKS_BEGIN, HOOKS_END), Some(block));
1328
1329 let own =
1330 "repos:\n - repo: https://example.com/own\n rev: v1\n hooks:\n - id: own\n";
1331 let spliced = splice_hooks_block(Some(own), block).expect("an unmarked file splices");
1332 assert!(spliced.starts_with("repos:\n# BEGIN release-kit\n"));
1333 assert!(spliced.contains("- id: own"), "the target's hooks survive");
1334 assert!(
1335 !spliced.contains(HOOK_TYPES_LINE),
1336 "an existing file's top level is the skills' duty, not the splice's"
1337 );
1338
1339 let stale = spliced.replace("--force-scope", "--no-scope");
1340 let refreshed = splice_hooks_block(Some(&stale), block).expect("a marked file re-splices");
1341 assert_eq!(
1342 extract_block(&refreshed, HOOKS_BEGIN, HOOKS_END),
1343 Some(block)
1344 );
1345 assert_eq!(refreshed.matches(HOOKS_BEGIN).count(), 1);
1346
1347 let err = splice_hooks_block(Some("minimum_pre_commit_version: '3.2.0'\n"), block)
1348 .expect_err("no repos: line refuses");
1349 assert!(err.contains("repos:"), "{err}");
1350
1351 let doubled = format!("repos:\n{block}\n{block}\n");
1355 let err = splice_hooks_block(Some(&doubled), block).expect_err("a second block refuses");
1356 assert!(err.contains("one block"), "{err}");
1357 let unmatched = "repos:\n# BEGIN release-kit\n - repo: local\n";
1358 let err =
1359 splice_hooks_block(Some(unmatched), block).expect_err("an unmatched marker refuses");
1360 assert!(err.contains("unmatched"), "{err}");
1361 }
1362
1363 #[test]
1369 fn the_blocks_render_per_mode_and_carry_the_one_grammar() {
1370 let worktree_hooks = hooks_block(Workflow::Worktree);
1371 let branches_hooks = hooks_block(Workflow::Branches);
1372 assert!(worktree_hooks.contains("- id: rk-worktree-location"));
1373 assert!(
1374 worktree_hooks.contains("SKIP=no-commit-to-branch,rk-worktree-location"),
1375 "{worktree_hooks}"
1376 );
1377 assert!(!branches_hooks.contains("rk-worktree-location"));
1378 assert!(branches_hooks.contains("SKIP=no-commit-to-branch in"));
1379 for block in [&worktree_hooks, &branches_hooks] {
1380 assert!(block.contains(BRANCH_GRAMMAR), "the grammar has one owner");
1381 for token in ["RK_BRANCH_GRAMMAR", "RK_SWEEP_SKIP", "RK_WORKTREE_GUARD"] {
1382 assert!(!block.contains(token), "{token} survived: {block}");
1383 }
1384 }
1385 for block in [&worktree_hooks, &branches_hooks] {
1390 for line in block.lines() {
1391 if let Some(value) = line.trim_start().strip_prefix("entry: ") {
1392 assert!(
1393 !value.contains(": "),
1394 "an entry value breaks the YAML plain scalar: {line}"
1395 );
1396 }
1397 }
1398 }
1399 let guard_line = worktree_hooks
1400 .lines()
1401 .position(|line| line.contains("id: rk-worktree-location"))
1402 .expect("the guard entry exists");
1403 let name_line = worktree_hooks
1404 .lines()
1405 .position(|line| line.contains("id: rk-branch-name"))
1406 .expect("the name hook exists");
1407 assert!(
1408 guard_line > name_line,
1409 "the guard lands directly after rk-branch-name"
1410 );
1411
1412 let worktree_routing = routing_block(Workflow::Worktree);
1413 let branches_routing = routing_block(Workflow::Branches);
1414 assert!(worktree_routing.contains("This project works in worktrees"));
1415 assert!(branches_routing.contains("Branches are worked in the main checkout"));
1416 for block in [&worktree_routing, &branches_routing] {
1417 assert!(block.contains("creating or removing a worktree"));
1418 assert!(block.contains("`rk worktree add <branch>`"));
1419 assert!(!block.contains("RK_WORKFLOW_LINE"), "{block}");
1420 }
1421 let differing: Vec<(&str, &str)> = worktree_routing
1422 .lines()
1423 .zip(branches_routing.lines())
1424 .filter(|(a, b)| a != b)
1425 .collect();
1426 assert_eq!(
1427 differing.len(),
1428 1,
1429 "exactly one routing line differs per mode: {differing:?}"
1430 );
1431 }
1432
1433 #[test]
1436 fn the_hook_marker_defects_are_named() {
1437 use super::hooks_marker_defect;
1438 let owned = hooks_block(Workflow::Branches);
1439 let block = owned.as_str();
1440 assert_eq!(hooks_marker_defect(""), None);
1441 assert_eq!(hooks_marker_defect(&format!("repos:\n{block}\n")), None);
1442 for (case, text) in [
1443 (
1444 "a second begin",
1445 format!("repos:\n{block}\n# BEGIN release-kit\n"),
1446 ),
1447 (
1448 "a second end",
1449 format!("repos:\n{block}\n# END release-kit\n"),
1450 ),
1451 (
1452 "an unpaired begin",
1453 "repos:\n# BEGIN release-kit\n".to_owned(),
1454 ),
1455 ("an unpaired end", "repos:\n# END release-kit\n".to_owned()),
1456 (
1457 "an end before its begin",
1458 "repos:\n# END release-kit\n# BEGIN release-kit\n".to_owned(),
1459 ),
1460 ] {
1461 assert!(
1462 hooks_marker_defect(&text).is_some(),
1463 "{case} must be a defect"
1464 );
1465 }
1466 }
1467}