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