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 const OWNER_TOKEN: &[u8] = b"OWNER";
116
117pub const SCOPES_CSV_TOKEN: &[u8] = b"RK_SCOPES_CSV";
119
120pub const SCOPES_PIPE_TOKEN: &[u8] = b"RK_SCOPES_PIPE";
122
123pub const STYLE_TOKEN: &[u8] = b"RK_STYLE";
126
127#[must_use]
136pub fn render(baseline: &[u8], repo: &str, scopes: &[String], style: Option<Style>) -> Vec<u8> {
137 let owner = repo.split('/').next().unwrap_or(repo);
138 let mut out = substitute(baseline, OWNER_TOKEN, owner.as_bytes());
139 if let Some(style) = style {
140 out = substitute(&out, STYLE_TOKEN, style.as_str().as_bytes());
141 }
142 if !scopes.is_empty() {
143 out = substitute(&out, SCOPES_CSV_TOKEN, scopes.join(",").as_bytes());
144 let pipe: Vec<String> = scopes.iter().map(|s| s.replace('.', "\\.")).collect();
149 out = substitute(&out, SCOPES_PIPE_TOKEN, pipe.join("|").as_bytes());
150 }
151 out
152}
153
154fn substitute(baseline: &[u8], token: &[u8], value: &[u8]) -> Vec<u8> {
156 let mut out = Vec::with_capacity(baseline.len());
157 let mut rest = baseline;
158 while let Some(at) = find(rest, token) {
159 out.extend_from_slice(&rest[..at]);
160 out.extend_from_slice(value);
161 rest = &rest[at + token.len()..];
162 }
163 out.extend_from_slice(rest);
164 out
165}
166
167pub fn parse_scopes(raw: &str) -> Result<Vec<String>, RkError> {
178 let scopes: Vec<String> = raw
179 .split(',')
180 .map(str::trim)
181 .filter(|scope| !scope.is_empty())
182 .map(str::to_owned)
183 .collect();
184 if scopes.is_empty() {
185 return Err(RkError::Usage(
186 "--scopes names no scope; pass a comma-separated list, e.g. --scopes api,cli".into(),
187 ));
188 }
189 for scope in &scopes {
190 let clean = scope
191 .chars()
192 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '/' | '-'));
193 if !clean {
194 return Err(RkError::Usage(format!(
195 "the scope '{scope}' carries a character outside letters, digits, and _ . / -"
196 )));
197 }
198 }
199 Ok(scopes)
200}
201
202fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
204 haystack
205 .windows(needle.len())
206 .position(|window| window == needle)
207}
208
209pub const AGENTS_DESTINATION: &str = "AGENTS.md";
211
212pub const BLOCK_BEGIN: &str = "<!-- BEGIN release-kit -->";
214
215pub const BLOCK_END: &str = "<!-- END release-kit -->";
217
218pub const HOOKS_DESTINATION: &str = ".pre-commit-config.yaml";
220
221pub const HOOKS_BEGIN: &str = "# BEGIN release-kit";
223
224pub const HOOKS_END: &str = "# END release-kit";
226
227pub const HOOK_TYPES_LINE: &str = "default_install_hook_types: [pre-commit, commit-msg, pre-push]";
231
232static AGENTS_BLOCK: &str = include_str!("../blocks/agents-block.md.in");
234
235static AGENTS_LINE_WORKTREE: &str = include_str!("../blocks/agents-line-worktree.md.in");
237
238static AGENTS_LINE_BRANCHES: &str = include_str!("../blocks/agents-line-branches.md.in");
240
241static PRE_COMMIT_BLOCK: &str = include_str!("../blocks/pre-commit-block.yaml.in");
243
244static PRE_COMMIT_WORKTREE_GUARD: &str =
246 include_str!("../blocks/pre-commit-worktree-guard.yaml.in");
247
248fn authored(text: &str) -> &str {
252 text.strip_suffix('\n').unwrap_or(text)
253}
254
255pub 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[-/].+)$";
263
264#[must_use]
273pub fn routing_block(workflow: Workflow) -> String {
274 let line = match workflow {
275 Workflow::Worktree => authored(AGENTS_LINE_WORKTREE),
276 Workflow::Branches => authored(AGENTS_LINE_BRANCHES),
277 };
278 authored(AGENTS_BLOCK).replacen("RK_WORKFLOW_LINE", line, 1)
279}
280
281#[must_use]
293pub fn hooks_block(workflow: Workflow) -> String {
294 let (guard, skip) = match workflow {
295 Workflow::Worktree => (
296 format!("{}\n", authored(PRE_COMMIT_WORKTREE_GUARD)),
297 "no-commit-to-branch,rk-worktree-location",
298 ),
299 Workflow::Branches => (String::new(), "no-commit-to-branch"),
300 };
301 authored(PRE_COMMIT_BLOCK)
302 .replacen("RK_BRANCH_GRAMMAR", BRANCH_GRAMMAR, 1)
303 .replacen("RK_SWEEP_SKIP", skip, 1)
304 .replacen("RK_WORKTREE_GUARD", &guard, 1)
305}
306
307#[must_use]
309pub fn block_markers(destination: &str) -> Option<(&'static str, &'static str)> {
310 match destination {
311 AGENTS_DESTINATION => Some((BLOCK_BEGIN, BLOCK_END)),
312 HOOKS_DESTINATION => Some((HOOKS_BEGIN, HOOKS_END)),
313 _ => None,
314 }
315}
316
317#[must_use]
320pub fn extract_block<'a>(text: &'a str, begin: &str, end: &str) -> Option<&'a str> {
321 let start = text.find(begin)?;
322 let stop = text[start..].find(end)? + start + end.len();
323 Some(&text[start..stop])
324}
325
326#[must_use]
332pub fn splice_agents_block(existing: Option<&str>, block: &str) -> String {
333 existing.map_or_else(
334 || format!("{block}\n"),
335 |text| {
336 extract_block(text, BLOCK_BEGIN, BLOCK_END).map_or_else(
337 || format!("{}\n\n{block}\n", text.trim_end()),
338 |found| text.replacen(found, block, 1),
339 )
340 },
341 )
342}
343
344pub fn splice_hooks_block(existing: Option<&str>, block: &str) -> Result<String, String> {
357 let Some(text) = existing else {
358 return Ok(format!("{HOOK_TYPES_LINE}\n\nrepos:\n{block}\n"));
359 };
360 if let Some(defect) = hooks_marker_defect(text) {
361 return Err(defect);
362 }
363 if let Some(found) = extract_block(text, HOOKS_BEGIN, HOOKS_END) {
364 return Ok(text.replacen(found, block, 1));
365 }
366 let mut out = String::with_capacity(text.len() + block.len() + 1);
367 let mut placed = false;
368 for line in text.split_inclusive('\n') {
369 out.push_str(line);
370 if !placed && line.trim_end() == "repos:" {
371 if !out.ends_with('\n') {
372 out.push('\n');
373 }
374 out.push_str(block);
375 out.push('\n');
376 placed = true;
377 }
378 }
379 if placed {
380 Ok(out)
381 } else {
382 Err(format!(
383 "{HOOKS_DESTINATION} exists with no repos: line, so the hook block has nowhere to land"
384 ))
385 }
386}
387
388#[must_use]
397pub fn hooks_marker_defect(text: &str) -> Option<String> {
398 let begins = text.matches(HOOKS_BEGIN).count();
399 let ends = text.matches(HOOKS_END).count();
400 if begins > 1 || ends > 1 {
401 return Some(format!(
402 "{HOOKS_DESTINATION} carries more than one release-kit marker pair; release-kit owns exactly one block"
403 ));
404 }
405 match (text.find(HOOKS_BEGIN), text.find(HOOKS_END)) {
406 (Some(begin), Some(end)) if end > begin => None,
407 (None, None) => None,
408 _ => Some(format!(
409 "{HOOKS_DESTINATION} carries an unmatched or misordered release-kit marker, so the block's extent is ambiguous"
410 )),
411 }
412}
413
414#[derive(Debug, Clone, Copy, PartialEq, Eq)]
416pub enum Placement {
417 Whole,
419 Block,
421}
422
423#[derive(Debug)]
426pub struct Entry {
427 pub destination: String,
429 pub kind: Kind,
431 pub placement: Placement,
433 pub baseline: Vec<u8>,
436 pub rendered: Vec<u8>,
439}
440
441pub fn pair_files(tech: &str, forge: &str) -> Result<Vec<(String, &'static [u8])>, RkError> {
449 if tech.starts_with('_') || embedded::SNIPPETS.get_dir(tech).is_none() {
452 let known: Vec<String> = embedded::SNIPPETS
453 .dirs()
454 .map(|dir| dir.path().to_string_lossy().into_owned())
455 .filter(|name| !name.starts_with('_'))
456 .collect();
457 return Err(RkError::Usage(format!(
458 "unknown tech '{tech}'; the bindings are: {}",
459 known.join(", ")
460 )));
461 }
462 let pair = format!("{tech}/{forge}");
463 let pair_dir = embedded::SNIPPETS.get_dir(&pair).ok_or_else(|| {
464 let known: Vec<String> = embedded::SNIPPETS
465 .dirs()
466 .filter(|dir| !dir.path().to_string_lossy().starts_with('_'))
467 .flat_map(include_dir::Dir::dirs)
468 .map(|dir| dir.path().to_string_lossy().replace('/', ", "))
469 .collect();
470 RkError::Usage(format!(
471 "the pair ({tech}, {forge}) has no landable files; the supported pairs are: {}",
472 known.join("; ")
473 ))
474 })?;
475 let mut files: Vec<(String, &'static [u8])> = Vec::new();
479 let shared = format!("_shared/{forge}");
480 if let Some(shared_dir) = embedded::SNIPPETS.get_dir(&shared) {
481 for (path, contents) in embedded::walk(shared_dir) {
482 let rel = path
483 .strip_prefix(&format!("{shared}/"))
484 .map_or(path.as_str(), |rel| rel)
485 .to_owned();
486 files.push((rel, contents));
487 }
488 }
489 for (path, contents) in embedded::walk(pair_dir) {
490 let rel = path
491 .strip_prefix(&format!("{pair}/"))
492 .map_or(path.as_str(), |rel| rel)
493 .to_owned();
494 if files.iter().any(|(existing, _)| *existing == rel) {
495 return Err(anyhow::anyhow!(
496 "the shared zone and the pair ({tech}, {forge}) both ship {rel}; the payload is defective"
497 )
498 .into());
499 }
500 files.push((rel, contents));
501 }
502 Ok(files)
503}
504
505pub fn projection(
520 tech: &str,
521 forge: &str,
522 repo: &str,
523 scopes: &[String],
524 workflow: Workflow,
525 style: Option<Style>,
526 nix: bool,
527) -> Result<Vec<Entry>, RkError> {
528 let mut entries = Vec::new();
529 for (destination, baseline) in pair_files(tech, forge)? {
530 if !nix && NIX_DESTINATIONS.contains(&destination.as_str()) {
531 continue;
532 }
533 let kind = kind_of(&destination).ok_or_else(|| {
534 anyhow::anyhow!("the payload does not classify {destination}; the kind table is stale")
535 })?;
536 let rendered = match kind {
537 Kind::Rendered => render(baseline, repo, scopes, style),
538 Kind::Seeded | Kind::State => baseline.to_vec(),
539 };
540 entries.push(Entry {
541 destination,
542 kind,
543 placement: Placement::Whole,
544 baseline: baseline.to_vec(),
545 rendered,
546 });
547 }
548 for (destination, template) in [
549 (AGENTS_DESTINATION, routing_block(workflow)),
550 (HOOKS_DESTINATION, hooks_block(workflow)),
551 ] {
552 entries.push(Entry {
553 destination: destination.to_owned(),
554 kind: Kind::Rendered,
555 placement: Placement::Block,
556 baseline: template.as_bytes().to_vec(),
557 rendered: render(template.as_bytes(), repo, scopes, style),
558 });
559 }
560 entries.sort_by(|a, b| a.destination.cmp(&b.destination));
561 Ok(entries)
562}
563
564#[must_use]
576pub fn nix_unsupported_shape(target: &Utf8Path) -> Option<String> {
577 let Ok(text) = std::fs::read_to_string(target.join("Cargo.toml")) else {
578 return Some(
579 "the target has no readable Cargo.toml, which the seeded package expression reads; no Nix file lands".to_owned(),
580 );
581 };
582 let Ok(table) = text.parse::<toml::Table>() else {
583 return Some(
584 "the target's Cargo.toml does not parse, and the seeded package expression reads it; no Nix file lands".to_owned(),
585 );
586 };
587 if !table.contains_key("package") {
588 return Some(
589 "the target's Cargo.toml has no [package] table; the seed supports a single crate, so no Nix file lands".to_owned(),
590 );
591 }
592 if !target.join("Cargo.lock").is_file() {
593 return Some(
594 "the target has no Cargo.lock, which the seeded package expression builds from; commit one, then opt in".to_owned(),
595 );
596 }
597 let implicit_bin = target.join("src/main.rs").is_file()
598 && table
599 .get("package")
600 .and_then(toml::Value::as_table)
601 .and_then(|package| package.get("autobins"))
602 .and_then(toml::Value::as_bool)
603 != Some(false);
604 let explicit_bins = table.get("bin").and_then(toml::Value::as_array);
605 if explicit_bins.is_none() && !implicit_bin {
606 return Some(
607 "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(),
608 );
609 }
610 if let Some(bins) = explicit_bins {
616 let required = bins
617 .first()
618 .and_then(toml::Value::as_table)
619 .and_then(|bin| bin.get("required-features"))
620 .and_then(toml::Value::as_array);
621 if let Some(required) = required {
622 let enabled = default_features(&table);
623 let missing = required
624 .iter()
625 .filter_map(toml::Value::as_str)
626 .any(|feature| !enabled.contains(feature));
627 if missing {
628 return Some(
629 "the target's first [[bin]] entry requires features a default build does not enable; no Nix file lands".to_owned(),
630 );
631 }
632 }
633 }
634 None
635}
636
637fn dep_edge_suppresses(features: &toml::Table, name: &str) -> bool {
640 let edge = format!("dep:{name}");
641 features.values().any(|list| {
642 list.as_array().is_some_and(|entries| {
643 entries
644 .iter()
645 .filter_map(toml::Value::as_str)
646 .any(|entry| entry == edge)
647 })
648 })
649}
650
651fn is_optional_dependency(table: &toml::Table, name: &str) -> bool {
654 ["dependencies", "build-dependencies"]
655 .iter()
656 .any(|section| {
657 table
658 .get(*section)
659 .and_then(toml::Value::as_table)
660 .and_then(|dependencies| dependencies.get(name))
661 .and_then(toml::Value::as_table)
662 .and_then(|dependency| dependency.get("optional"))
663 .and_then(toml::Value::as_bool)
664 == Some(true)
665 })
666}
667
668fn default_features(table: &toml::Table) -> std::collections::BTreeSet<String> {
675 let Some(features) = table.get("features").and_then(toml::Value::as_table) else {
676 return std::collections::BTreeSet::new();
677 };
678 let mut enabled = std::collections::BTreeSet::new();
679 let mut queue = vec!["default".to_owned()];
680 while let Some(name) = queue.pop() {
681 if !enabled.insert(name.clone()) {
682 continue;
683 }
684 if let Some(implies) = features.get(&name).and_then(toml::Value::as_array) {
685 for implied in implies.iter().filter_map(toml::Value::as_str) {
686 if implied.starts_with("dep:") || implied.contains("?/") {
687 continue;
691 }
692 if let Some((package, _)) = implied.split_once('/') {
693 let feature_exists =
701 features.contains_key(package) || !dep_edge_suppresses(features, package);
702 if is_optional_dependency(table, package) && feature_exists {
703 queue.push(package.to_owned());
704 }
705 } else {
706 queue.push(implied.to_owned());
707 }
708 }
709 }
710 }
711 enabled
712}
713
714pub fn nix_withheld(
728 target: &Utf8Path,
729 recorded: Option<&manifest::Manifest>,
730) -> std::io::Result<Option<String>> {
731 if recorded.is_some_and(|record| record.file("flake.nix").is_some()) {
732 return Ok(None);
733 }
734 let mut present = Vec::new();
735 for name in ["flake.nix", "flake.lock"] {
736 match std::fs::symlink_metadata(target.join(name).as_std_path()) {
737 Ok(_) => present.push(name),
738 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
739 Err(e) => return Err(e),
740 }
741 }
742 if present.is_empty() {
743 return Ok(None);
744 }
745 Ok(Some(format!(
746 "the target already carries {}; its flake pair stays its own, and the nix workflow is withheld with it",
747 present.join(" and ")
748 )))
749}
750
751#[derive(Debug, Serialize)]
753pub struct Withheld {
754 pub path: String,
756 pub reason: String,
759}
760
761pub fn withhold_nix(
774 target: &Utf8Path,
775 nix: bool,
776 recorded: Option<&manifest::Manifest>,
777 entries: &mut Vec<Entry>,
778) -> Result<Vec<Withheld>, RkError> {
779 if !nix {
780 return Ok(Vec::new());
781 }
782 let (set, reason): (&[&str], String) = if let Some(reason) = nix_unsupported_shape(target) {
783 (&NIX_DESTINATIONS[..], reason)
784 } else if let Some(reason) = nix_withheld(target, recorded)? {
785 (&NIX_WITHHOLDABLE[..], reason)
786 } else {
787 return Ok(Vec::new());
788 };
789 let mut withheld = Vec::new();
790 entries.retain(|entry| {
791 if set.contains(&entry.destination.as_str()) {
792 withheld.push(Withheld {
793 path: entry.destination.clone(),
794 reason: reason.clone(),
795 });
796 false
797 } else {
798 true
799 }
800 });
801 Ok(withheld)
802}
803
804pub fn read_destination(target: &Utf8Path, entry: &Entry) -> std::io::Result<Option<Vec<u8>>> {
812 read_recorded(target, &entry.destination)
813}
814
815pub fn read_recorded(target: &Utf8Path, destination: &str) -> std::io::Result<Option<Vec<u8>>> {
826 let path = target.join(destination);
827 let bytes = match std::fs::read(&path) {
828 Ok(bytes) => bytes,
829 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
830 Err(e) => return Err(e),
831 };
832 if let Some((begin, end)) = block_markers(destination) {
833 let text = String::from_utf8_lossy(&bytes);
834 Ok(extract_block(&text, begin, end).map(|block| block.as_bytes().to_vec()))
835 } else {
836 Ok(Some(bytes))
837 }
838}
839
840#[derive(Debug)]
843pub struct Resolved {
844 pub forge: String,
846 pub repo: Option<String>,
848}
849
850pub fn resolve(
862 target: &Utf8Path,
863 forge_flag: Option<&str>,
864 repo_flag: Option<&str>,
865) -> Result<Resolved, RkError> {
866 let forge_flag = forge_flag
867 .map(|name| {
868 crate::detect::Forge::parse(name).ok_or_else(|| {
869 RkError::Usage(format!(
870 "unknown forge '{name}'; the forges are: github, gitlab"
871 ))
872 })
873 })
874 .transpose()?;
875 let detected = crate::detect::detect(target.as_std_path());
876 let forge = forge_flag
877 .or(detected.forge)
878 .map(|forge| forge.as_str().to_owned())
879 .ok_or_else(|| {
880 let message = detected.host.map_or_else(
881 || "no forge detected: the target has no origin remote".to_owned(),
882 |host| format!("no forge detected: the host {host} is not recognized"),
883 );
884 RkError::refusal(
885 Diagnostic::new(Reason::ForgeUndetected, message)
886 .expected("a github.com or gitlab remote, or --forge")
887 .action("pass --forge <github|gitlab>"),
888 )
889 })?;
890 Ok(Resolved {
891 forge,
892 repo: repo_flag.map(str::to_owned).or(detected.repo),
893 })
894}
895
896#[must_use]
899pub fn repo_unresolved() -> RkError {
900 RkError::missing(
901 Diagnostic::new(
902 Reason::ForgeUndetected,
903 "no repository detected: the target has no origin remote",
904 )
905 .expected("an origin remote naming the project")
906 .action("pass --repo <path>"),
907 )
908}
909
910pub fn write_destination(target: &Utf8Path, entry: &Entry) -> std::io::Result<()> {
920 let path = target.join(&entry.destination);
921 match entry.placement {
922 Placement::Whole => atomic::write(path.as_std_path(), &entry.rendered),
923 Placement::Block => {
924 let existing = match std::fs::read(&path) {
925 Ok(bytes) => Some(String::from_utf8_lossy(&bytes).into_owned()),
926 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
927 Err(e) => return Err(e),
928 };
929 let block = String::from_utf8_lossy(&entry.rendered).into_owned();
930 let spliced = if entry.destination == HOOKS_DESTINATION {
931 splice_hooks_block(existing.as_deref(), &block).map_err(std::io::Error::other)?
932 } else {
933 splice_agents_block(existing.as_deref(), &block)
934 };
935 atomic::write(path.as_std_path(), spliced.as_bytes())
936 }
937 }
938}
939
940pub fn hooks_file_defect(target: &Utf8Path) -> std::io::Result<Option<String>> {
953 let path = target.join(HOOKS_DESTINATION);
954 match std::fs::read(&path) {
955 Ok(bytes) => {
956 let text = String::from_utf8_lossy(&bytes);
957 Ok(splice_hooks_block(Some(&text), authored(PRE_COMMIT_BLOCK)).err())
958 }
959 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
960 Err(e) => Err(e),
961 }
962}
963
964pub fn hooks_splice_refusal(target: &Utf8Path) -> Result<(), RkError> {
974 hooks_file_defect(target)?.map_or(Ok(()), |reason| {
975 Err(RkError::refusal(
976 Diagnostic::new(
977 Reason::StateDrift,
978 format!("{reason}, and nothing was written"),
979 )
980 .expected("a .pre-commit-config.yaml the block can land in, or none")
981 .action(format!(
982 "resolve it in {}, then re-run",
983 target.join(HOOKS_DESTINATION)
984 ))
985 .target_state("unchanged"),
986 ))
987 })
988}
989
990#[cfg(test)]
991mod tests {
992 #![allow(clippy::expect_used)]
993
994 use super::{
995 AGENTS_DESTINATION, BLOCK_BEGIN, BLOCK_END, BRANCH_GRAMMAR, HOOK_TYPES_LINE, HOOKS_BEGIN,
996 HOOKS_DESTINATION, HOOKS_END, Kind, Style, Workflow, extract_block, hooks_block, kind_of,
997 pair_files, parse_scopes, projection, render, routing_block, splice_agents_block,
998 splice_hooks_block,
999 };
1000 use crate::embedded;
1001
1002 fn scopes(list: &[&str]) -> Vec<String> {
1003 list.iter().map(|s| (*s).to_owned()).collect()
1004 }
1005
1006 #[test]
1010 fn the_kind_table_closes_over_every_snippet() {
1011 for tech_dir in embedded::SNIPPETS.dirs() {
1012 for pair_dir in tech_dir.dirs() {
1013 let prefix = format!("{}/", pair_dir.path().to_string_lossy());
1014 for (path, _) in embedded::walk(pair_dir) {
1015 let destination = path.strip_prefix(&prefix).unwrap_or(&path);
1016 assert!(
1017 kind_of(destination).is_some(),
1018 "{destination}: no declared kind"
1019 );
1020 }
1021 }
1022 }
1023 assert_eq!(kind_of(AGENTS_DESTINATION), Some(Kind::Rendered));
1024 assert_eq!(kind_of(HOOKS_DESTINATION), Some(Kind::Rendered));
1025 assert_eq!(kind_of("something-else.txt"), None);
1026 }
1027
1028 #[test]
1032 fn rendering_substitutes_every_owner_occurrence() {
1033 let baseline = b"if: repository_owner == 'OWNER'\n# OWNER again: OWNER\n";
1034 let rendered = render(baseline, "acme/sub/widget", &[], None);
1035 let text = String::from_utf8(rendered).expect("rendered bytes stay text");
1036 assert_eq!(text, "if: repository_owner == 'acme'\n# acme again: acme\n");
1037
1038 let baseline = b"scopes 'RK_SCOPES_CSV' match (RK_SCOPES_PIPE)\n";
1039 let rendered = render(baseline, "acme/widget", &scopes(&["api", "cli"]), None);
1040 let text = String::from_utf8(rendered).expect("rendered bytes stay text");
1041 assert_eq!(text, "scopes 'api,cli' match (api|cli)\n");
1042
1043 let rendered = render(baseline, "acme/widget", &scopes(&["api.v1"]), None);
1046 let text = String::from_utf8(rendered).expect("rendered bytes stay text");
1047 assert_eq!(text, "scopes 'api.v1' match (api\\.v1)\n");
1048 }
1049
1050 #[test]
1053 fn scope_parsing_refuses_the_unusable() {
1054 assert_eq!(
1055 parse_scopes("api, cli,guides/release").expect("a clean list parses"),
1056 scopes(&["api", "cli", "guides/release"])
1057 );
1058 assert!(parse_scopes("").is_err());
1059 assert!(parse_scopes(" , ").is_err());
1060 assert!(parse_scopes("api|cli").is_err());
1061 assert!(parse_scopes("a b").is_err());
1062 }
1063
1064 #[test]
1067 fn the_shared_zone_composes_into_the_pair() {
1068 let files = pair_files("rust", "github").expect("the pair lists");
1069 assert!(
1070 files
1071 .iter()
1072 .any(|(dest, _)| dest == ".github/workflows/pr-title.yml"),
1073 "the shared title check lands with the pair"
1074 );
1075 let files = pair_files("rust", "gitlab").expect("the pair lists");
1076 assert!(
1077 files
1078 .iter()
1079 .any(|(dest, _)| dest == ".gitlab/ci/mr-title.yml"),
1080 "the shared title job lands with the pair"
1081 );
1082 let err = pair_files("_shared", "github").expect_err("the shared zone is no tech");
1083 let listing = err.to_string();
1084 let bindings = listing
1085 .split("the bindings are:")
1086 .nth(1)
1087 .expect("the refusal lists the bindings");
1088 assert!(!bindings.contains("_shared"), "{listing}");
1089 }
1090
1091 #[test]
1095 fn a_projection_renders_owned_files_and_keeps_seeded_judgment() {
1096 let entries = projection(
1097 "rust",
1098 "github",
1099 "acme/widget",
1100 &scopes(&["api", "cli"]),
1101 Workflow::Branches,
1102 Some(Style::Trunk),
1103 false,
1104 )
1105 .expect("the pair projects");
1106 let workflow = entries
1107 .iter()
1108 .find(|entry| entry.destination.ends_with("release-plz.yml"))
1109 .expect("the workflow projects");
1110 assert_eq!(workflow.kind, Kind::Rendered);
1111 let text = String::from_utf8_lossy(&workflow.rendered);
1112 assert!(!text.contains("OWNER"), "an owner token survived rendering");
1113 assert!(text.contains("'acme'"));
1114 assert!(!text.contains("TODO(release-kit)"));
1115 let title = entries
1116 .iter()
1117 .find(|entry| entry.destination.ends_with("pr-title.yml"))
1118 .expect("the title check projects");
1119 let text = String::from_utf8_lossy(&title.rendered);
1120 assert!(text.contains("api|cli"), "{text}");
1121 assert!(
1122 !text.contains("RK_SCOPES"),
1123 "a scope token survived: {text}"
1124 );
1125 let seeded = entries
1126 .iter()
1127 .find(|entry| entry.destination == "release-plz.toml")
1128 .expect("the seeded file projects");
1129 assert_eq!(seeded.kind, Kind::Seeded);
1130 assert_eq!(seeded.rendered, seeded.baseline);
1131 assert!(String::from_utf8_lossy(&seeded.rendered).contains("TODO(release-kit)"));
1132 for block in [AGENTS_DESTINATION, HOOKS_DESTINATION] {
1133 let entry = entries
1134 .iter()
1135 .find(|entry| entry.destination == block)
1136 .expect("both blocks are part of the projection");
1137 let text = String::from_utf8_lossy(&entry.rendered);
1138 assert!(!text.contains("RK_SCOPES"), "{block} kept a token: {text}");
1139 assert!(text.contains("api,cli"), "{block} lost the scopes: {text}");
1140 }
1141 }
1142
1143 #[test]
1148 fn the_nix_destinations_project_only_under_the_opt_in() {
1149 use super::NIX_DESTINATIONS;
1150 let paths = |nix: bool, forge: &str| -> Vec<String> {
1151 projection(
1152 "rust",
1153 forge,
1154 "acme/widget",
1155 &scopes(&["api"]),
1156 Workflow::Worktree,
1157 Some(Style::Trunk),
1158 nix,
1159 )
1160 .expect("the pair projects")
1161 .into_iter()
1162 .map(|entry| entry.destination)
1163 .collect()
1164 };
1165 let off = paths(false, "github");
1166 for destination in NIX_DESTINATIONS {
1167 assert!(!off.contains(&destination.to_owned()), "{destination}");
1168 }
1169 let on = paths(true, "github");
1170 for destination in ["nix/package.nix", "flake.nix", "flake.lock"] {
1171 assert!(on.contains(&destination.to_owned()), "{destination}");
1172 }
1173 let gitlab = paths(true, "gitlab");
1174 assert!(gitlab.contains(&"nix/package.nix".to_owned()));
1175 assert!(!gitlab.contains(&".github/workflows/nix.yml".to_owned()));
1176 let bash = projection(
1177 "bash",
1178 "github",
1179 "acme/widget",
1180 &scopes(&["api"]),
1181 Workflow::Worktree,
1182 Some(Style::Trunk),
1183 true,
1184 )
1185 .expect("an out-of-matrix pair projects the smaller product");
1186 assert!(
1187 bash.iter()
1188 .all(|entry| !NIX_DESTINATIONS.contains(&entry.destination.as_str()))
1189 );
1190 }
1191
1192 #[test]
1197 fn the_nix_seeds_are_identical_across_forge_pairs() {
1198 for name in ["nix/package.nix", "flake.nix", "flake.lock"] {
1199 let github = embedded::SNIPPETS
1200 .get_file(format!("rust/github/{name}"))
1201 .expect("the github copy ships")
1202 .contents();
1203 let gitlab = embedded::SNIPPETS
1204 .get_file(format!("rust/gitlab/{name}"))
1205 .expect("the gitlab copy ships")
1206 .contents();
1207 assert_eq!(github, gitlab, "{name} diverged between the pairs");
1208 }
1209 }
1210
1211 #[test]
1216 fn the_nix_withhold_judgment_covers_the_three_shapes() {
1217 use super::{NIX_DESTINATIONS, withhold_nix};
1218 let dir = tempfile::tempdir().expect("a scratch target exists");
1219 let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
1220 let entries = || {
1221 projection(
1222 "rust",
1223 "github",
1224 "acme/widget",
1225 &scopes(&["api"]),
1226 Workflow::Worktree,
1227 Some(Style::Trunk),
1228 true,
1229 )
1230 .expect("the pair projects")
1231 };
1232
1233 let mut all = entries();
1235 let withheld = withhold_nix(target, true, None, &mut all).expect("the judgment runs");
1236 let paths: Vec<&str> = withheld.iter().map(|w| w.path.as_str()).collect();
1237 assert_eq!(
1238 paths,
1239 [
1240 ".github/workflows/nix.yml",
1241 "flake.lock",
1242 "flake.nix",
1243 "nix/package.nix"
1244 ]
1245 );
1246 assert!(
1247 all.iter()
1248 .all(|entry| !NIX_DESTINATIONS.contains(&entry.destination.as_str()))
1249 );
1250
1251 std::fs::write(
1254 target.join("Cargo.toml"),
1255 "[package]\nname = \"widget\"\nversion = \"0.1.0\"\n",
1256 )
1257 .expect("the crate manifest writes");
1258 std::fs::write(target.join("Cargo.lock"), "version = 4\n").expect("the lock writes");
1259 std::fs::create_dir_all(target.join("src")).expect("the src dir exists");
1260 std::fs::write(target.join("src/main.rs"), "fn main() {}\n").expect("the main writes");
1261 std::fs::write(target.join("flake.nix"), "{ }\n").expect("the flake writes");
1262 let mut all = entries();
1263 let withheld = withhold_nix(target, true, None, &mut all).expect("the judgment runs");
1264 let paths: Vec<&str> = withheld.iter().map(|w| w.path.as_str()).collect();
1265 assert_eq!(
1266 paths,
1267 [".github/workflows/nix.yml", "flake.lock", "flake.nix"]
1268 );
1269 assert!(
1270 all.iter()
1271 .any(|entry| entry.destination == "nix/package.nix")
1272 );
1273
1274 std::fs::remove_file(target.join("flake.nix")).expect("the flake removes");
1276 let mut all = entries();
1277 let withheld = withhold_nix(target, true, None, &mut all).expect("the judgment runs");
1278 assert!(withheld.is_empty());
1279 assert!(all.iter().any(|entry| entry.destination == "flake.nix"));
1280
1281 let mut all = entries();
1283 let withheld = withhold_nix(target, false, None, &mut all).expect("the judgment runs");
1284 assert!(withheld.is_empty());
1285 }
1286
1287 #[test]
1288 fn the_block_splices_into_every_agents_shape() {
1289 let owned = routing_block(Workflow::Branches);
1290 let block = owned.as_str();
1291 let fresh = splice_agents_block(None, block);
1292 assert_eq!(fresh, format!("{block}\n"));
1293 assert_eq!(extract_block(&fresh, BLOCK_BEGIN, BLOCK_END), Some(block));
1294
1295 let appended = splice_agents_block(Some("# My project\n\nOwn rules.\n"), block);
1296 assert!(appended.starts_with("# My project\n\nOwn rules.\n\n<!-- BEGIN release-kit -->"));
1297 assert_eq!(
1298 extract_block(&appended, BLOCK_BEGIN, BLOCK_END),
1299 Some(block)
1300 );
1301
1302 let stale = appended.replace("Never author a tag", "Do author a tag");
1303 let refreshed = splice_agents_block(Some(&stale), block);
1304 assert_eq!(
1305 extract_block(&refreshed, BLOCK_BEGIN, BLOCK_END),
1306 Some(block)
1307 );
1308 assert!(refreshed.starts_with("# My project"));
1309 assert_eq!(
1310 refreshed.matches("BEGIN release-kit").count(),
1311 1,
1312 "a re-splice must replace, not accumulate"
1313 );
1314 }
1315
1316 #[test]
1319 fn the_hook_block_splices_under_repos() {
1320 let owned = hooks_block(Workflow::Branches);
1321 let block = owned.as_str();
1322 let fresh = splice_hooks_block(None, block).expect("a fresh file splices");
1323 assert!(fresh.starts_with(HOOK_TYPES_LINE));
1324 assert!(fresh.contains("\nrepos:\n# BEGIN release-kit\n"));
1325 assert_eq!(extract_block(&fresh, HOOKS_BEGIN, HOOKS_END), Some(block));
1326
1327 let own =
1328 "repos:\n - repo: https://example.com/own\n rev: v1\n hooks:\n - id: own\n";
1329 let spliced = splice_hooks_block(Some(own), block).expect("an unmarked file splices");
1330 assert!(spliced.starts_with("repos:\n# BEGIN release-kit\n"));
1331 assert!(spliced.contains("- id: own"), "the target's hooks survive");
1332 assert!(
1333 !spliced.contains(HOOK_TYPES_LINE),
1334 "an existing file's top level is the skills' duty, not the splice's"
1335 );
1336
1337 let stale = spliced.replace("--force-scope", "--no-scope");
1338 let refreshed = splice_hooks_block(Some(&stale), block).expect("a marked file re-splices");
1339 assert_eq!(
1340 extract_block(&refreshed, HOOKS_BEGIN, HOOKS_END),
1341 Some(block)
1342 );
1343 assert_eq!(refreshed.matches(HOOKS_BEGIN).count(), 1);
1344
1345 let err = splice_hooks_block(Some("minimum_pre_commit_version: '3.2.0'\n"), block)
1346 .expect_err("no repos: line refuses");
1347 assert!(err.contains("repos:"), "{err}");
1348
1349 let doubled = format!("repos:\n{block}\n{block}\n");
1353 let err = splice_hooks_block(Some(&doubled), block).expect_err("a second block refuses");
1354 assert!(err.contains("one block"), "{err}");
1355 let unmatched = "repos:\n# BEGIN release-kit\n - repo: local\n";
1356 let err =
1357 splice_hooks_block(Some(unmatched), block).expect_err("an unmatched marker refuses");
1358 assert!(err.contains("unmatched"), "{err}");
1359 }
1360
1361 #[test]
1367 fn the_blocks_render_per_mode_and_carry_the_one_grammar() {
1368 let worktree_hooks = hooks_block(Workflow::Worktree);
1369 let branches_hooks = hooks_block(Workflow::Branches);
1370 assert!(worktree_hooks.contains("- id: rk-worktree-location"));
1371 assert!(
1372 worktree_hooks.contains("SKIP=no-commit-to-branch,rk-worktree-location"),
1373 "{worktree_hooks}"
1374 );
1375 assert!(!branches_hooks.contains("rk-worktree-location"));
1376 assert!(branches_hooks.contains("SKIP=no-commit-to-branch in"));
1377 for block in [&worktree_hooks, &branches_hooks] {
1378 assert!(block.contains(BRANCH_GRAMMAR), "the grammar has one owner");
1379 for token in ["RK_BRANCH_GRAMMAR", "RK_SWEEP_SKIP", "RK_WORKTREE_GUARD"] {
1380 assert!(!block.contains(token), "{token} survived: {block}");
1381 }
1382 }
1383 for block in [&worktree_hooks, &branches_hooks] {
1388 for line in block.lines() {
1389 if let Some(value) = line.trim_start().strip_prefix("entry: ") {
1390 assert!(
1391 !value.contains(": "),
1392 "an entry value breaks the YAML plain scalar: {line}"
1393 );
1394 }
1395 }
1396 }
1397 let guard_line = worktree_hooks
1398 .lines()
1399 .position(|line| line.contains("id: rk-worktree-location"))
1400 .expect("the guard entry exists");
1401 let name_line = worktree_hooks
1402 .lines()
1403 .position(|line| line.contains("id: rk-branch-name"))
1404 .expect("the name hook exists");
1405 assert!(
1406 guard_line > name_line,
1407 "the guard lands directly after rk-branch-name"
1408 );
1409
1410 let worktree_routing = routing_block(Workflow::Worktree);
1411 let branches_routing = routing_block(Workflow::Branches);
1412 assert!(worktree_routing.contains("This project works in worktrees"));
1413 assert!(branches_routing.contains("Branches are worked in the main checkout"));
1414 for block in [&worktree_routing, &branches_routing] {
1415 assert!(block.contains("creating or removing a worktree"));
1416 assert!(block.contains("`rk worktree add <branch>`"));
1417 assert!(!block.contains("RK_WORKFLOW_LINE"), "{block}");
1418 }
1419 let differing: Vec<(&str, &str)> = worktree_routing
1420 .lines()
1421 .zip(branches_routing.lines())
1422 .filter(|(a, b)| a != b)
1423 .collect();
1424 assert_eq!(
1425 differing.len(),
1426 1,
1427 "exactly one routing line differs per mode: {differing:?}"
1428 );
1429 }
1430
1431 #[test]
1434 fn the_hook_marker_defects_are_named() {
1435 use super::hooks_marker_defect;
1436 let owned = hooks_block(Workflow::Branches);
1437 let block = owned.as_str();
1438 assert_eq!(hooks_marker_defect(""), None);
1439 assert_eq!(hooks_marker_defect(&format!("repos:\n{block}\n")), None);
1440 for (case, text) in [
1441 (
1442 "a second begin",
1443 format!("repos:\n{block}\n# BEGIN release-kit\n"),
1444 ),
1445 (
1446 "a second end",
1447 format!("repos:\n{block}\n# END release-kit\n"),
1448 ),
1449 (
1450 "an unpaired begin",
1451 "repos:\n# BEGIN release-kit\n".to_owned(),
1452 ),
1453 ("an unpaired end", "repos:\n# END release-kit\n".to_owned()),
1454 (
1455 "an end before its begin",
1456 "repos:\n# END release-kit\n# BEGIN release-kit\n".to_owned(),
1457 ),
1458 ] {
1459 assert!(
1460 hooks_marker_defect(&text).is_some(),
1461 "{case} must be a defect"
1462 );
1463 }
1464 }
1465}