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)]
27pub struct Params {
28 tech: String,
29 forge: String,
30 repo: String,
31 workflow: Workflow,
32 style: Option<Style>,
33 nix: bool,
34 trunk: String,
35 line_prefix: String,
36}
37
38#[derive(Default)]
40pub struct Inputs<'a> {
41 pub tech: Option<&'a str>,
43 pub forge: Option<&'a str>,
45 pub repo: Option<&'a str>,
47 pub workflow: Option<Workflow>,
49 pub style: Option<Style>,
51 pub nix: Option<bool>,
53}
54
55#[derive(Clone, Copy, PartialEq, Eq)]
57pub enum Purpose {
58 Init,
60 Preview,
62 Upgrade,
64 Adopt,
66}
67
68impl Params {
69 #[must_use]
72 pub fn from_record(record: &manifest::Manifest) -> Self {
73 Self {
74 tech: record.tech.clone(),
75 forge: record.forge.clone(),
76 repo: record.parameters.repo.clone(),
77 workflow: record.parameters.workflow,
78 style: record.parameters.style,
79 nix: record.parameters.nix,
80 trunk: record.parameters.trunk.clone(),
81 line_prefix: record.parameters.line_prefix.clone(),
82 }
83 }
84
85 pub fn resolve(
91 target: &Utf8Path,
92 flags: &Inputs<'_>,
93 config: Option<&crate::config::Config>,
94 record: Option<&manifest::Manifest>,
95 purpose: Purpose,
96 ) -> Result<Self, RkError> {
97 let answer = |flag: Option<&str>, configured: Option<&str>, recorded: Option<&str>| {
98 flag.or_else(|| configured.filter(|value| !value.is_empty()))
99 .or(recorded)
100 .map(str::to_owned)
101 };
102 let forge = answer(
103 flags.forge,
104 config.map(|c| c.project.forge.as_str()),
105 record.map(|r| r.forge.as_str()),
106 );
107 let repo = answer(
108 flags.repo,
109 config.map(|c| c.project.repo.as_str()),
110 record.map(|r| r.parameters.repo.as_str()),
111 );
112 let resolved = resolve(target, forge.as_deref(), repo.as_deref())?;
113 let tech = answer(
114 flags.tech,
115 config.map(|c| c.project.tech.as_str()),
116 record.map(|r| r.tech.as_str()),
117 )
118 .or_else(|| crate::detect::tech_of(target.as_std_path()).map(str::to_owned))
119 .ok_or_else(|| {
120 RkError::missing(
121 Diagnostic::new(
122 Reason::TargetNotFound,
123 "no technology detected: the target has no version file",
124 )
125 .action("pass --tech <rust|python|bash>"),
126 )
127 })?;
128 pair_files(&tech, &resolved.forge)?;
129 let workflow = flags
130 .workflow
131 .or_else(|| config.and_then(|c| c.landing.workflow))
132 .or_else(|| record.map(|r| r.parameters.workflow))
133 .unwrap_or(if purpose == Purpose::Adopt {
134 Workflow::Branches
135 } else {
136 Workflow::Worktree
137 });
138 let style = flags
139 .style
140 .or_else(|| config.and_then(|c| c.landing.style))
141 .or_else(|| record.and_then(|r| r.parameters.style));
142 let style = match (style, purpose) {
143 (None, Purpose::Upgrade | Purpose::Adopt) => return Err(RkError::Usage("the target carries no style parameter; set landing.style in .release-kit/config.toml or pass --style <trunk|lines>".into())),
144 (value, _) => Some(value.unwrap_or(Style::Trunk)),
145 };
146 let repo = resolved
147 .repo
148 .or_else(|| (purpose == Purpose::Preview).then(|| "OWNER".to_owned()))
149 .ok_or_else(repo_unresolved)?;
150 let trunk = config
151 .and_then(|c| c.project.trunk.clone())
152 .or_else(|| record.map(|r| r.parameters.trunk.clone()))
153 .unwrap_or_else(|| crate::config::TRUNK_DEFAULT.to_owned());
154 let line_prefix = config
155 .and_then(|c| c.setup.line_prefix.clone())
156 .or_else(|| record.map(|r| r.parameters.line_prefix.clone()))
157 .unwrap_or_else(|| crate::config::LINE_PREFIX_DEFAULT.to_owned());
158 Ok(Self {
159 tech,
160 forge: resolved.forge,
161 repo,
162 workflow,
163 style,
164 nix: flags
165 .nix
166 .or_else(|| config.and_then(|c| c.landing.nix))
167 .or_else(|| record.map(|r| r.parameters.nix))
168 .unwrap_or(false),
169 trunk,
170 line_prefix,
171 })
172 }
173
174 #[must_use]
176 pub fn tech(&self) -> &str {
177 &self.tech
178 }
179
180 #[must_use]
182 pub fn forge(&self) -> &str {
183 &self.forge
184 }
185
186 #[must_use]
188 pub const fn nix(&self) -> bool {
189 self.nix
190 }
191
192 #[must_use]
194 pub fn repo(&self) -> &str {
195 &self.repo
196 }
197
198 #[must_use]
200 pub const fn workflow(&self) -> Workflow {
201 self.workflow
202 }
203
204 #[must_use]
206 pub const fn style(&self) -> Option<Style> {
207 self.style
208 }
209
210 #[must_use]
212 pub fn trunk(&self) -> &str {
213 &self.trunk
214 }
215
216 #[must_use]
218 pub fn line_prefix(&self) -> &str {
219 &self.line_prefix
220 }
221}
222
223#[cfg(test)]
224impl Params {
225 pub(crate) fn for_test(repo: &str, style: Option<Style>) -> Self {
229 Self {
230 tech: "rust".to_owned(),
231 forge: "github".to_owned(),
232 repo: repo.to_owned(),
233 workflow: Workflow::Worktree,
234 style,
235 nix: false,
236 trunk: crate::config::TRUNK_DEFAULT.to_owned(),
237 line_prefix: crate::config::LINE_PREFIX_DEFAULT.to_owned(),
238 }
239 }
240}
241
242#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
244#[serde(rename_all = "lowercase")]
245pub enum Kind {
246 Rendered,
249 Seeded,
252 State,
255}
256
257impl Kind {
258 #[must_use]
260 pub const fn as_str(self) -> &'static str {
261 match self {
262 Self::Rendered => "rendered",
263 Self::Seeded => "seeded",
264 Self::State => "state",
265 }
266 }
267}
268
269const KINDS: [(&str, Kind); 16] = [
275 (".github/workflows/release-plz.yml", Kind::Rendered),
276 (".github/workflows/release-please.yml", Kind::Rendered),
277 (".github/workflows/release.yml", Kind::Rendered),
278 (".github/workflows/pr-title.yml", Kind::Rendered),
279 (".gitlab-ci.yml", Kind::Rendered),
280 ("SECURITY.md", Kind::Rendered),
281 (".gitlab/ci/mr-title.yml", Kind::Rendered),
282 ("release-plz.toml", Kind::Seeded),
283 ("dist-workspace.toml", Kind::Seeded),
284 ("release-please-config.json", Kind::Seeded),
285 ("cliff.toml", Kind::Seeded),
286 ("nix/package.nix", Kind::Seeded),
287 ("flake.nix", Kind::Seeded),
288 (".release-please-manifest.json", Kind::State),
289 ("VERSION", Kind::State),
290 ("flake.lock", Kind::State),
291];
292
293pub const NIX_DESTINATIONS: [&str; 3] = ["nix/package.nix", "flake.nix", "flake.lock"];
307
308pub const NIX_WITHHOLDABLE: [&str; 2] = ["flake.nix", "flake.lock"];
314
315#[must_use]
318pub fn kind_of(destination: &str) -> Option<Kind> {
319 if destination == AGENTS_DESTINATION || destination == HOOKS_DESTINATION {
320 return Some(Kind::Rendered);
321 }
322 KINDS
323 .iter()
324 .find(|(name, _)| *name == destination)
325 .map(|(_, kind)| *kind)
326}
327
328pub fn destinations() -> impl Iterator<Item = &'static str> {
332 KINDS
333 .iter()
334 .map(|(name, _)| *name)
335 .chain([AGENTS_DESTINATION, HOOKS_DESTINATION])
336}
337
338pub const OWNER_TOKEN: &[u8] = b"OWNER";
345
346pub const REPO_TOKEN: &[u8] = b"RK_REPO";
348
349pub const SCOPE_SHAPE_TOKEN: &[u8] = b"RK_SCOPE_SHAPE";
351
352pub const STYLE_TOKEN: &[u8] = b"RK_STYLE";
355
356pub const TRUNK_BRANCH_TOKEN: &[u8] = b"RK_TRUNK_BRANCH";
360
361pub const LINE_PREFIX_TOKEN: &[u8] = b"RK_LINE_PREFIX";
364
365pub const LINE_PREFIX_RE_TOKEN: &[u8] = b"RK_LINE_PREFIX_RE";
371
372#[must_use]
386pub fn render(baseline: &[u8], params: &Params) -> Vec<u8> {
387 let repo = params.repo();
388 let owner = repo.split('/').next().unwrap_or(repo);
389 let mut out = substitute(baseline, OWNER_TOKEN, owner.as_bytes());
390 if let Some(style) = params.style() {
391 out = substitute(&out, STYLE_TOKEN, style.as_str().as_bytes());
392 }
393 out = substitute(&out, SCOPE_SHAPE_TOKEN, SCOPE_SHAPE.as_bytes());
394 out = substitute(&out, TRUNK_BRANCH_TOKEN, params.trunk().as_bytes());
395 let escaped = params.line_prefix().replace('/', "\\/");
396 out = substitute(&out, LINE_PREFIX_RE_TOKEN, escaped.as_bytes());
397 out = substitute(&out, LINE_PREFIX_TOKEN, params.line_prefix().as_bytes());
398 substitute(&out, REPO_TOKEN, repo.as_bytes())
399}
400
401pub(crate) fn substitute(baseline: &[u8], token: &[u8], value: &[u8]) -> Vec<u8> {
403 let mut out = Vec::with_capacity(baseline.len());
404 let mut rest = baseline;
405 while let Some(at) = find(rest, token) {
406 out.extend_from_slice(&rest[..at]);
407 out.extend_from_slice(value);
408 rest = &rest[at + token.len()..];
409 }
410 out.extend_from_slice(rest);
411 out
412}
413
414fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
416 haystack
417 .windows(needle.len())
418 .position(|window| window == needle)
419}
420
421pub const AGENTS_DESTINATION: &str = "AGENTS.md";
423
424pub const BLOCK_BEGIN: &str = "<!-- BEGIN release-kit -->";
426
427pub const BLOCK_END: &str = "<!-- END release-kit -->";
429
430pub const HOOKS_DESTINATION: &str = ".pre-commit-config.yaml";
432
433pub const HOOKS_BEGIN: &str = "# BEGIN release-kit";
435
436pub const HOOKS_END: &str = "# END release-kit";
438
439pub const HOOK_TYPES_LINE: &str = "default_install_hook_types: [pre-commit, commit-msg, pre-push]";
443
444static AGENTS_BLOCK: &str = include_str!("../blocks/agents-block.md.in");
446
447static AGENTS_LINE_WORKTREE: &str = include_str!("../blocks/agents-line-worktree.md.in");
449
450static AGENTS_LINE_BRANCHES: &str = include_str!("../blocks/agents-line-branches.md.in");
452
453static PRE_COMMIT_BLOCK: &str = include_str!("../blocks/pre-commit-block.yaml.in");
455
456static PRE_COMMIT_WORKTREE_GUARD: &str =
458 include_str!("../blocks/pre-commit-worktree-guard.yaml.in");
459
460fn authored(text: &str) -> &str {
464 text.strip_suffix('\n').unwrap_or(text)
465}
466
467pub 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[-/].+)$";
475
476pub const SCOPE_SHAPE: &str = "[a-z0-9._/-]+";
486
487#[must_use]
494pub fn scope_is_shaped(scope: &str) -> bool {
495 !scope.is_empty()
496 && scope.chars().all(|c| {
497 c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '/' | '-')
498 })
499}
500
501#[must_use]
510pub fn routing_block(workflow: Workflow) -> String {
511 let line = match workflow {
512 Workflow::Worktree => authored(AGENTS_LINE_WORKTREE),
513 Workflow::Branches => authored(AGENTS_LINE_BRANCHES),
514 };
515 authored(AGENTS_BLOCK).replacen("RK_WORKFLOW_LINE", line, 1)
516}
517
518#[must_use]
530pub fn hooks_block(workflow: Workflow) -> String {
531 let (guard, skip) = match workflow {
532 Workflow::Worktree => (
533 format!("{}\n", authored(PRE_COMMIT_WORKTREE_GUARD)),
534 "no-commit-to-branch,rk-worktree-location",
535 ),
536 Workflow::Branches => (String::new(), "no-commit-to-branch"),
537 };
538 authored(PRE_COMMIT_BLOCK)
539 .replacen("RK_BRANCH_GRAMMAR", BRANCH_GRAMMAR, 1)
540 .replacen("RK_SWEEP_SKIP", skip, 1)
541 .replacen("RK_WORKTREE_GUARD", &guard, 1)
542}
543
544#[must_use]
546pub fn block_markers(destination: &str) -> Option<(&'static str, &'static str)> {
547 match destination {
548 AGENTS_DESTINATION => Some((BLOCK_BEGIN, BLOCK_END)),
549 HOOKS_DESTINATION => Some((HOOKS_BEGIN, HOOKS_END)),
550 _ => None,
551 }
552}
553
554#[must_use]
557pub fn extract_block<'a>(text: &'a str, begin: &str, end: &str) -> Option<&'a str> {
558 let start = text.find(begin)?;
559 let stop = text[start..].find(end)? + start + end.len();
560 Some(&text[start..stop])
561}
562
563#[must_use]
569pub fn splice_agents_block(existing: Option<&str>, block: &str) -> String {
570 existing.map_or_else(
571 || format!("{block}\n"),
572 |text| {
573 extract_block(text, BLOCK_BEGIN, BLOCK_END).map_or_else(
574 || format!("{}\n\n{block}\n", text.trim_end()),
575 |found| text.replacen(found, block, 1),
576 )
577 },
578 )
579}
580
581pub fn splice_hooks_block(existing: Option<&str>, block: &str) -> Result<String, String> {
594 let Some(text) = existing else {
595 return Ok(format!("{HOOK_TYPES_LINE}\n\nrepos:\n{block}\n"));
596 };
597 if let Some(defect) = hooks_marker_defect(text) {
598 return Err(defect);
599 }
600 if let Some(found) = extract_block(text, HOOKS_BEGIN, HOOKS_END) {
601 return Ok(text.replacen(found, block, 1));
602 }
603 let mut out = String::with_capacity(text.len() + block.len() + 1);
604 let mut placed = false;
605 for line in text.split_inclusive('\n') {
606 out.push_str(line);
607 if !placed && line.trim_end() == "repos:" {
608 if !out.ends_with('\n') {
609 out.push('\n');
610 }
611 out.push_str(block);
612 out.push('\n');
613 placed = true;
614 }
615 }
616 if placed {
617 Ok(out)
618 } else {
619 Err(format!(
620 "{HOOKS_DESTINATION} exists with no repos: line, so the hook block has nowhere to land"
621 ))
622 }
623}
624
625#[must_use]
634pub fn hooks_marker_defect(text: &str) -> Option<String> {
635 let begins = text.matches(HOOKS_BEGIN).count();
636 let ends = text.matches(HOOKS_END).count();
637 if begins > 1 || ends > 1 {
638 return Some(format!(
639 "{HOOKS_DESTINATION} carries more than one release-kit marker pair; release-kit owns exactly one block"
640 ));
641 }
642 match (text.find(HOOKS_BEGIN), text.find(HOOKS_END)) {
643 (Some(begin), Some(end)) if end > begin => None,
644 (None, None) => None,
645 _ => Some(format!(
646 "{HOOKS_DESTINATION} carries an unmatched or misordered release-kit marker, so the block's extent is ambiguous"
647 )),
648 }
649}
650
651#[derive(Debug, Clone, Copy, PartialEq, Eq)]
653pub enum Placement {
654 Whole,
656 Block,
658}
659
660#[derive(Debug)]
663pub struct Entry {
664 pub destination: String,
666 pub kind: Kind,
668 pub placement: Placement,
670 pub baseline: Vec<u8>,
673 pub rendered: Vec<u8>,
676}
677
678pub fn pair_files(tech: &str, forge: &str) -> Result<Vec<(String, &'static [u8])>, RkError> {
686 if tech.starts_with('_') || embedded::SNIPPETS.get_dir(tech).is_none() {
689 let known: Vec<String> = embedded::SNIPPETS
690 .dirs()
691 .map(|dir| dir.path().to_string_lossy().into_owned())
692 .filter(|name| !name.starts_with('_'))
693 .collect();
694 return Err(RkError::Usage(format!(
695 "unknown tech '{tech}'; the bindings are: {}",
696 known.join(", ")
697 )));
698 }
699 let pair = format!("{tech}/{forge}");
700 let pair_dir = embedded::SNIPPETS.get_dir(&pair).ok_or_else(|| {
701 let known: Vec<String> = embedded::SNIPPETS
702 .dirs()
703 .filter(|dir| !dir.path().to_string_lossy().starts_with('_'))
704 .flat_map(include_dir::Dir::dirs)
705 .map(|dir| dir.path().to_string_lossy().replace('/', ", "))
706 .collect();
707 RkError::Usage(format!(
708 "the pair ({tech}, {forge}) has no landable files; the supported pairs are: {}",
709 known.join("; ")
710 ))
711 })?;
712 let mut files: Vec<(String, &'static [u8])> = Vec::new();
716 let shared = format!("_shared/{forge}");
717 if let Some(shared_dir) = embedded::SNIPPETS.get_dir(&shared) {
718 for (path, contents) in embedded::walk(shared_dir) {
719 let rel = path
720 .strip_prefix(&format!("{shared}/"))
721 .map_or(path.as_str(), |rel| rel)
722 .to_owned();
723 files.push((rel, contents));
724 }
725 }
726 for (path, contents) in embedded::walk(pair_dir) {
727 let rel = path
728 .strip_prefix(&format!("{pair}/"))
729 .map_or(path.as_str(), |rel| rel)
730 .to_owned();
731 if files.iter().any(|(existing, _)| *existing == rel) {
732 return Err(anyhow::anyhow!(
733 "the shared zone and the pair ({tech}, {forge}) both ship {rel}; the payload is defective"
734 )
735 .into());
736 }
737 files.push((rel, contents));
738 }
739 Ok(files)
740}
741
742pub fn projection(params: &Params) -> Result<Vec<Entry>, RkError> {
757 let mut entries = Vec::new();
758 for (destination, baseline) in pair_files(¶ms.tech, ¶ms.forge)? {
759 if !params.nix && NIX_DESTINATIONS.contains(&destination.as_str()) {
760 continue;
761 }
762 let kind = kind_of(&destination).ok_or_else(|| {
763 anyhow::anyhow!("the payload does not classify {destination}; the kind table is stale")
764 })?;
765 let rendered = match kind {
766 Kind::Rendered => render(baseline, params),
767 Kind::Seeded | Kind::State => baseline.to_vec(),
768 };
769 entries.push(Entry {
770 destination,
771 kind,
772 placement: Placement::Whole,
773 baseline: baseline.to_vec(),
774 rendered,
775 });
776 }
777 for (destination, template) in [
778 (AGENTS_DESTINATION, routing_block(params.workflow)),
779 (HOOKS_DESTINATION, hooks_block(params.workflow)),
780 ] {
781 entries.push(Entry {
782 destination: destination.to_owned(),
783 kind: Kind::Rendered,
784 placement: Placement::Block,
785 baseline: template.as_bytes().to_vec(),
786 rendered: render(template.as_bytes(), params),
787 });
788 }
789 entries.sort_by(|a, b| a.destination.cmp(&b.destination));
790 Ok(entries)
791}
792
793#[must_use]
805pub fn nix_unsupported_shape(target: &Utf8Path) -> Option<String> {
806 let Ok(text) = std::fs::read_to_string(target.join("Cargo.toml")) else {
807 return Some(
808 "the target has no readable Cargo.toml, which the seeded package expression reads; no Nix file lands".to_owned(),
809 );
810 };
811 let Ok(table) = text.parse::<toml::Table>() else {
812 return Some(
813 "the target's Cargo.toml does not parse, and the seeded package expression reads it; no Nix file lands".to_owned(),
814 );
815 };
816 if !table.contains_key("package") {
817 return Some(
818 "the target's Cargo.toml has no [package] table; the seed supports a single crate, so no Nix file lands".to_owned(),
819 );
820 }
821 if !target.join("Cargo.lock").is_file() {
822 return Some(
823 "the target has no Cargo.lock, which the seeded package expression builds from; commit one, then opt in".to_owned(),
824 );
825 }
826 let implicit_bin = target.join("src/main.rs").is_file()
827 && table
828 .get("package")
829 .and_then(toml::Value::as_table)
830 .and_then(|package| package.get("autobins"))
831 .and_then(toml::Value::as_bool)
832 != Some(false);
833 let explicit_bins = table.get("bin").and_then(toml::Value::as_array);
834 if explicit_bins.is_none() && !implicit_bin {
835 return Some(
836 "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(),
837 );
838 }
839 if let Some(bins) = explicit_bins {
845 let required = bins
846 .first()
847 .and_then(toml::Value::as_table)
848 .and_then(|bin| bin.get("required-features"))
849 .and_then(toml::Value::as_array);
850 if let Some(required) = required {
851 let enabled = default_features(&table);
852 let missing = required
853 .iter()
854 .filter_map(toml::Value::as_str)
855 .any(|feature| !enabled.contains(feature));
856 if missing {
857 return Some(
858 "the target's first [[bin]] entry requires features a default build does not enable; no Nix file lands".to_owned(),
859 );
860 }
861 }
862 }
863 None
864}
865
866fn dep_edge_suppresses(features: &toml::Table, name: &str) -> bool {
869 let edge = format!("dep:{name}");
870 features.values().any(|list| {
871 list.as_array().is_some_and(|entries| {
872 entries
873 .iter()
874 .filter_map(toml::Value::as_str)
875 .any(|entry| entry == edge)
876 })
877 })
878}
879
880fn is_optional_dependency(table: &toml::Table, name: &str) -> bool {
883 ["dependencies", "build-dependencies"]
884 .iter()
885 .any(|section| {
886 table
887 .get(*section)
888 .and_then(toml::Value::as_table)
889 .and_then(|dependencies| dependencies.get(name))
890 .and_then(toml::Value::as_table)
891 .and_then(|dependency| dependency.get("optional"))
892 .and_then(toml::Value::as_bool)
893 == Some(true)
894 })
895}
896
897fn default_features(table: &toml::Table) -> std::collections::BTreeSet<String> {
904 let Some(features) = table.get("features").and_then(toml::Value::as_table) else {
905 return std::collections::BTreeSet::new();
906 };
907 let mut enabled = std::collections::BTreeSet::new();
908 let mut queue = vec!["default".to_owned()];
909 while let Some(name) = queue.pop() {
910 if !enabled.insert(name.clone()) {
911 continue;
912 }
913 if let Some(implies) = features.get(&name).and_then(toml::Value::as_array) {
914 for implied in implies.iter().filter_map(toml::Value::as_str) {
915 if implied.starts_with("dep:") || implied.contains("?/") {
916 continue;
920 }
921 if let Some((package, _)) = implied.split_once('/') {
922 let feature_exists =
930 features.contains_key(package) || !dep_edge_suppresses(features, package);
931 if is_optional_dependency(table, package) && feature_exists {
932 queue.push(package.to_owned());
933 }
934 } else {
935 queue.push(implied.to_owned());
936 }
937 }
938 }
939 }
940 enabled
941}
942
943pub fn nix_withheld(
955 target: &Utf8Path,
956 recorded: Option<&manifest::Manifest>,
957) -> std::io::Result<Option<String>> {
958 if recorded.is_some_and(|record| record.file("flake.nix").is_some()) {
959 return Ok(None);
960 }
961 let mut present = Vec::new();
962 for name in ["flake.nix", "flake.lock"] {
963 match std::fs::symlink_metadata(target.join(name).as_std_path()) {
964 Ok(_) => present.push(name),
965 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
966 Err(e) => return Err(e),
967 }
968 }
969 if present.is_empty() {
970 return Ok(None);
971 }
972 Ok(Some(format!(
973 "the target already carries {}; its flake pair stays its own",
974 present.join(" and ")
975 )))
976}
977
978#[derive(Debug, Serialize)]
980pub struct Withheld {
981 pub path: String,
983 pub reason: String,
986}
987
988pub fn withhold_nix(
1001 target: &Utf8Path,
1002 nix: bool,
1003 recorded: Option<&manifest::Manifest>,
1004 entries: &mut Vec<Entry>,
1005) -> Result<Vec<Withheld>, RkError> {
1006 if !nix {
1007 return Ok(Vec::new());
1008 }
1009 let (set, reason): (&[&str], String) = if let Some(reason) = nix_unsupported_shape(target) {
1010 (&NIX_DESTINATIONS[..], reason)
1011 } else if let Some(reason) = nix_withheld(target, recorded)? {
1012 (&NIX_WITHHOLDABLE[..], reason)
1013 } else {
1014 return Ok(Vec::new());
1015 };
1016 let mut withheld = Vec::new();
1017 entries.retain(|entry| {
1018 if set.contains(&entry.destination.as_str()) {
1019 withheld.push(Withheld {
1020 path: entry.destination.clone(),
1021 reason: reason.clone(),
1022 });
1023 false
1024 } else {
1025 true
1026 }
1027 });
1028 Ok(withheld)
1029}
1030
1031pub fn read_destination(target: &Utf8Path, entry: &Entry) -> std::io::Result<Option<Vec<u8>>> {
1039 read_recorded(target, &entry.destination)
1040}
1041
1042pub fn read_recorded(target: &Utf8Path, destination: &str) -> std::io::Result<Option<Vec<u8>>> {
1053 let path = target.join(destination);
1054 let bytes = match std::fs::read(&path) {
1055 Ok(bytes) => bytes,
1056 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1057 Err(e) => return Err(e),
1058 };
1059 if let Some((begin, end)) = block_markers(destination) {
1060 let text = String::from_utf8_lossy(&bytes);
1061 Ok(extract_block(&text, begin, end).map(|block| block.as_bytes().to_vec()))
1062 } else {
1063 Ok(Some(bytes))
1064 }
1065}
1066
1067#[derive(Debug)]
1070pub struct Resolved {
1071 pub forge: String,
1073 pub repo: Option<String>,
1075}
1076
1077pub fn resolve(
1089 target: &Utf8Path,
1090 forge_flag: Option<&str>,
1091 repo_flag: Option<&str>,
1092) -> Result<Resolved, RkError> {
1093 let forge_flag = forge_flag
1094 .map(|name| {
1095 crate::detect::Forge::parse(name).ok_or_else(|| {
1096 RkError::Usage(format!(
1097 "unknown forge '{name}'; the forges are: github, gitlab"
1098 ))
1099 })
1100 })
1101 .transpose()?;
1102 let detected = crate::detect::detect(target.as_std_path());
1103 let forge = forge_flag
1104 .or(detected.forge)
1105 .map(|forge| forge.as_str().to_owned())
1106 .ok_or_else(|| {
1107 let message = detected.host.map_or_else(
1108 || "no forge detected: the target has no origin remote".to_owned(),
1109 |host| format!("no forge detected: the host {host} is not recognized"),
1110 );
1111 RkError::refusal(
1112 Diagnostic::new(Reason::ForgeUndetected, message)
1113 .expected("a github.com or gitlab remote, or --forge")
1114 .action("pass --forge <github|gitlab>"),
1115 )
1116 })?;
1117 Ok(Resolved {
1118 forge,
1119 repo: repo_flag.map(str::to_owned).or(detected.repo),
1120 })
1121}
1122
1123#[must_use]
1126pub fn repo_unresolved() -> RkError {
1127 RkError::missing(
1128 Diagnostic::new(
1129 Reason::ForgeUndetected,
1130 "no repository detected: the target has no origin remote",
1131 )
1132 .expected("an origin remote naming the project")
1133 .action("pass --repo <path>"),
1134 )
1135}
1136
1137pub fn write_destination(target: &Utf8Path, entry: &Entry) -> std::io::Result<()> {
1147 let path = target.join(&entry.destination);
1148 match entry.placement {
1149 Placement::Whole => atomic::write(path.as_std_path(), &entry.rendered),
1150 Placement::Block => {
1151 let existing = match std::fs::read(&path) {
1152 Ok(bytes) => Some(String::from_utf8_lossy(&bytes).into_owned()),
1153 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
1154 Err(e) => return Err(e),
1155 };
1156 let block = String::from_utf8_lossy(&entry.rendered).into_owned();
1157 let spliced = if entry.destination == HOOKS_DESTINATION {
1158 splice_hooks_block(existing.as_deref(), &block).map_err(std::io::Error::other)?
1159 } else {
1160 splice_agents_block(existing.as_deref(), &block)
1161 };
1162 atomic::write(path.as_std_path(), spliced.as_bytes())
1163 }
1164 }
1165}
1166
1167pub fn hooks_file_defect(target: &Utf8Path) -> std::io::Result<Option<String>> {
1180 let path = target.join(HOOKS_DESTINATION);
1181 match std::fs::read(&path) {
1182 Ok(bytes) => {
1183 let text = String::from_utf8_lossy(&bytes);
1184 Ok(splice_hooks_block(Some(&text), authored(PRE_COMMIT_BLOCK)).err())
1185 }
1186 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
1187 Err(e) => Err(e),
1188 }
1189}
1190
1191pub fn hooks_splice_refusal(target: &Utf8Path) -> Result<(), RkError> {
1201 hooks_file_defect(target)?.map_or(Ok(()), |reason| {
1202 Err(RkError::refusal(
1203 Diagnostic::new(
1204 Reason::StateDrift,
1205 format!("{reason}, and nothing was written"),
1206 )
1207 .expected("a .pre-commit-config.yaml the block can land in, or none")
1208 .action(format!(
1209 "resolve it in {}, then re-run",
1210 target.join(HOOKS_DESTINATION)
1211 ))
1212 .target_state("unchanged"),
1213 ))
1214 })
1215}
1216
1217#[cfg(test)]
1218mod tests {
1219 #![allow(clippy::expect_used)]
1220
1221 use super::{
1222 AGENTS_DESTINATION, BLOCK_BEGIN, BLOCK_END, BRANCH_GRAMMAR, HOOK_TYPES_LINE, HOOKS_BEGIN,
1223 HOOKS_DESTINATION, HOOKS_END, Kind, SCOPE_SHAPE, Style, Workflow, extract_block,
1224 hooks_block, kind_of, pair_files, projection, render, routing_block, splice_agents_block,
1225 splice_hooks_block,
1226 };
1227 use crate::embedded;
1228
1229 #[test]
1230 fn private_reporting_path_tokens_are_reproducible() {
1231 for repo in [
1232 "acme/widget",
1233 "acme/group/widget",
1234 "acme/OWNER-RK_STYLE-RK_SCOPE_SHAPE",
1235 ] {
1236 assert_eq!(
1237 super::render(
1238 b"RK_REPO RK_REPO OWNER RK_STYLE RK_SCOPE_SHAPE",
1239 &super::Params::for_test(repo, Some(super::Style::Trunk))
1240 ),
1241 format!("{repo} {repo} acme trunk {}", super::SCOPE_SHAPE).as_bytes()
1242 );
1243 }
1244 assert_eq!(super::kind_of("SECURITY.md"), Some(super::Kind::Rendered));
1245 }
1246
1247 #[test]
1251 fn the_kind_table_closes_over_every_snippet() {
1252 for tech_dir in embedded::SNIPPETS.dirs() {
1253 for pair_dir in tech_dir.dirs() {
1254 let prefix = format!("{}/", pair_dir.path().to_string_lossy());
1255 for (path, _) in embedded::walk(pair_dir) {
1256 let destination = path.strip_prefix(&prefix).unwrap_or(&path);
1257 assert!(
1258 kind_of(destination).is_some(),
1259 "{destination}: no declared kind"
1260 );
1261 }
1262 }
1263 }
1264 assert_eq!(kind_of(AGENTS_DESTINATION), Some(Kind::Rendered));
1265 assert_eq!(kind_of(HOOKS_DESTINATION), Some(Kind::Rendered));
1266 assert_eq!(kind_of("something-else.txt"), None);
1267 }
1268
1269 #[test]
1274 fn rendering_substitutes_every_owner_occurrence() {
1275 let baseline = b"if: repository_owner == 'OWNER'\n# OWNER again: OWNER\n";
1276 let rendered = render(baseline, &super::Params::for_test("acme/sub/widget", None));
1277 let text = String::from_utf8(rendered).expect("rendered bytes stay text");
1278 assert_eq!(text, "if: repository_owner == 'acme'\n# acme again: acme\n");
1279
1280 let baseline = b"match (RK_SCOPE_SHAPE)\n";
1281 let rendered = render(baseline, &super::Params::for_test("acme/widget", None));
1282 let text = String::from_utf8(rendered).expect("rendered bytes stay text");
1283 assert_eq!(text, format!("match ({SCOPE_SHAPE})\n"));
1284 }
1285
1286 #[test]
1290 fn the_scope_shape_drops_into_the_title_check() {
1291 assert_eq!(SCOPE_SHAPE, "[a-z0-9._/-]+");
1292 assert!(
1293 !SCOPE_SHAPE.contains('\''),
1294 "the title checks single-quote it"
1295 );
1296 }
1297
1298 #[test]
1303 fn the_scope_predicate_and_the_rendered_pattern_agree() {
1304 let body = SCOPE_SHAPE
1305 .strip_prefix('[')
1306 .and_then(|rest| rest.strip_suffix("]+"))
1307 .expect("the shape is one bracket expression, repeated");
1308 let chars: Vec<char> = body.chars().collect();
1309 let mut admitted = std::collections::BTreeSet::new();
1310 let mut at = 0;
1311 while at < chars.len() {
1312 if at + 2 < chars.len() && chars[at + 1] == '-' {
1315 for c in chars[at]..=chars[at + 2] {
1316 admitted.insert(c);
1317 }
1318 at += 3;
1319 } else {
1320 admitted.insert(chars[at]);
1321 at += 1;
1322 }
1323 }
1324 for byte in 0..=127u8 {
1325 let c = char::from(byte);
1326 assert_eq!(
1327 super::scope_is_shaped(&c.to_string()),
1328 admitted.contains(&c),
1329 "the predicate and {SCOPE_SHAPE} disagree on {c:?}"
1330 );
1331 }
1332 assert!(super::scope_is_shaped("guides/release"));
1333 assert!(!super::scope_is_shaped(""), "a scope is never empty");
1334 assert!(!super::scope_is_shaped("Specs Ugly"));
1335 }
1336
1337 #[test]
1340 fn the_shared_zone_composes_into_the_pair() {
1341 let files = pair_files("rust", "github").expect("the pair lists");
1342 assert!(
1343 files
1344 .iter()
1345 .any(|(dest, _)| dest == ".github/workflows/pr-title.yml"),
1346 "the shared title check lands with the pair"
1347 );
1348 let files = pair_files("rust", "gitlab").expect("the pair lists");
1349 assert!(
1350 files
1351 .iter()
1352 .any(|(dest, _)| dest == ".gitlab/ci/mr-title.yml"),
1353 "the shared title job lands with the pair"
1354 );
1355 let err = pair_files("_shared", "github").expect_err("the shared zone is no tech");
1356 let listing = err.to_string();
1357 let bindings = listing
1358 .split("the bindings are:")
1359 .nth(1)
1360 .expect("the refusal lists the bindings");
1361 assert!(!bindings.contains("_shared"), "{listing}");
1362 }
1363
1364 #[test]
1367 fn params_from_a_record_round_trips() {
1368 use super::{Params, manifest};
1369 let dir = tempfile::tempdir().expect("a scratch target exists");
1370 let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
1371 for tech in ["rust", "bash"] {
1372 for forge in ["github", "gitlab"] {
1373 for workflow in [Workflow::Branches, Workflow::Worktree] {
1374 for style in [None, Some(Style::Trunk), Some(Style::Lines)] {
1375 for nix in [false, true] {
1376 let record = manifest::Manifest {
1377 schema_version: manifest::SCHEMA_VERSION,
1378 rk_version: "0.1.0".to_owned(),
1379 payload_sha256: crate::digest::Digest::of(b""),
1380 origin: "init".to_owned(),
1381 tech: tech.to_owned(),
1382 forge: forge.to_owned(),
1383 landed_at: "2026-08-29T00:00:00Z".to_owned(),
1384 parameters: manifest::Parameters {
1385 repo: "acme/team/widget".to_owned(),
1386 workflow,
1387 style,
1388 nix,
1389 trunk: crate::config::TRUNK_DEFAULT.to_owned(),
1390 line_prefix: crate::config::LINE_PREFIX_DEFAULT.to_owned(),
1391 },
1392 files: Vec::new(),
1393 pins: std::collections::BTreeMap::new(),
1394 };
1395 manifest::write(target, &record).expect("the record writes");
1396 let loaded = manifest::load(target)
1397 .expect("the record loads")
1398 .expect("the record exists");
1399 let params = Params::from_record(&loaded);
1400 assert_eq!(params.tech, tech);
1401 assert_eq!(params.forge, forge);
1402 assert_eq!(params.repo(), "acme/team/widget");
1403 assert_eq!(params.workflow(), workflow);
1404 assert_eq!(params.style(), style);
1405 assert_eq!(params.nix, nix);
1406 let entries = projection(¶ms).expect("the record projects");
1407 let mut expected: Vec<_> = pair_files(tech, forge)
1408 .expect("the pair lists")
1409 .into_iter()
1410 .filter(|(path, _)| {
1411 nix || !super::NIX_DESTINATIONS.contains(&path.as_str())
1412 })
1413 .collect();
1414 let routing = super::routing_block(workflow);
1415 let hooks = super::hooks_block(workflow);
1416 expected.push((AGENTS_DESTINATION.to_owned(), routing.as_bytes()));
1417 expected.push((HOOKS_DESTINATION.to_owned(), hooks.as_bytes()));
1418 expected.sort_by(|a, b| a.0.cmp(&b.0));
1419 assert_eq!(entries.len(), expected.len());
1420 for (entry, (destination, baseline)) in entries.iter().zip(expected) {
1421 assert_eq!(entry.destination, destination);
1422 assert_eq!(entry.baseline, baseline);
1423 let rendered = match entry.kind {
1424 Kind::Rendered => super::render(
1425 baseline,
1426 &super::Params::for_test("acme/team/widget", style),
1427 ),
1428 Kind::Seeded | Kind::State => baseline.to_vec(),
1429 };
1430 assert_eq!(entry.rendered, rendered, "{destination}");
1431 }
1432 }
1433 }
1434 }
1435 }
1436 }
1437 }
1438
1439 fn resolved_test_params(
1440 tech: &str,
1441 resolved: &super::Resolved,
1442 workflow: Workflow,
1443 style: Option<Style>,
1444 nix: bool,
1445 ) -> Result<super::Params, crate::error::RkError> {
1446 super::Params::resolve(
1447 camino::Utf8Path::new("."),
1448 &super::Inputs {
1449 tech: Some(tech),
1450 forge: Some(&resolved.forge),
1451 repo: resolved.repo.as_deref(),
1452 workflow: Some(workflow),
1453 style,
1454 nix: Some(nix),
1455 },
1456 None,
1457 None,
1458 super::Purpose::Init,
1459 )
1460 }
1461
1462 #[test]
1466 fn a_projection_renders_owned_files_and_keeps_seeded_judgment() {
1467 let entries = projection(
1468 &resolved_test_params(
1469 "rust",
1470 &super::Resolved {
1471 forge: "github".to_owned(),
1472 repo: Some("acme/widget".to_owned()),
1473 },
1474 Workflow::Branches,
1475 Some(Style::Trunk),
1476 false,
1477 )
1478 .expect("the parameters resolve"),
1479 )
1480 .expect("the pair projects");
1481 let workflow = entries
1482 .iter()
1483 .find(|entry| entry.destination.ends_with("release-plz.yml"))
1484 .expect("the workflow projects");
1485 assert_eq!(workflow.kind, Kind::Rendered);
1486 let text = String::from_utf8_lossy(&workflow.rendered);
1487 assert!(!text.contains("OWNER"), "an owner token survived rendering");
1488 assert!(text.contains("'acme'"));
1489 assert!(!text.contains("TODO(release-kit)"));
1490 let title = entries
1491 .iter()
1492 .find(|entry| entry.destination.ends_with("pr-title.yml"))
1493 .expect("the title check projects");
1494 let text = String::from_utf8_lossy(&title.rendered);
1495 assert!(text.contains(SCOPE_SHAPE), "{text}");
1496 assert!(
1497 !text.contains("RK_SCOPE_SHAPE"),
1498 "a scope token survived: {text}"
1499 );
1500 let seeded = entries
1501 .iter()
1502 .find(|entry| entry.destination == "release-plz.toml")
1503 .expect("the seeded file projects");
1504 assert_eq!(seeded.kind, Kind::Seeded);
1505 assert_eq!(seeded.rendered, seeded.baseline);
1506 assert!(String::from_utf8_lossy(&seeded.rendered).contains("TODO(release-kit)"));
1507 for block in [AGENTS_DESTINATION, HOOKS_DESTINATION] {
1508 let entry = entries
1509 .iter()
1510 .find(|entry| entry.destination == block)
1511 .expect("both blocks are part of the projection");
1512 let text = String::from_utf8_lossy(&entry.rendered);
1513 assert!(
1514 !text.contains("RK_SCOPE_SHAPE"),
1515 "{block} kept a token: {text}"
1516 );
1517 }
1518 }
1519
1520 #[test]
1525 fn the_nix_destinations_project_only_under_the_opt_in() {
1526 use super::NIX_DESTINATIONS;
1527 let paths = |nix: bool, forge: &str| -> Vec<String> {
1528 projection(
1529 &resolved_test_params(
1530 "rust",
1531 &super::Resolved {
1532 forge: forge.to_owned(),
1533 repo: Some("acme/widget".to_owned()),
1534 },
1535 Workflow::Worktree,
1536 Some(Style::Trunk),
1537 nix,
1538 )
1539 .expect("the parameters resolve"),
1540 )
1541 .expect("the pair projects")
1542 .into_iter()
1543 .map(|entry| entry.destination)
1544 .collect()
1545 };
1546 let off = paths(false, "github");
1547 for destination in NIX_DESTINATIONS {
1548 assert!(!off.contains(&destination.to_owned()), "{destination}");
1549 }
1550 let on = paths(true, "github");
1551 for destination in ["nix/package.nix", "flake.nix", "flake.lock"] {
1552 assert!(on.contains(&destination.to_owned()), "{destination}");
1553 }
1554 let gitlab = paths(true, "gitlab");
1559 assert!(gitlab.contains(&"nix/package.nix".to_owned()));
1560 assert!(
1561 !on.iter()
1562 .chain(gitlab.iter())
1563 .any(|destination| destination.contains("nix.yml"))
1564 );
1565 let bash = projection(
1566 &resolved_test_params(
1567 "bash",
1568 &super::Resolved {
1569 forge: "github".to_owned(),
1570 repo: Some("acme/widget".to_owned()),
1571 },
1572 Workflow::Worktree,
1573 Some(Style::Trunk),
1574 true,
1575 )
1576 .expect("the parameters resolve"),
1577 )
1578 .expect("an out-of-matrix pair projects the smaller product");
1579 assert!(
1580 bash.iter()
1581 .all(|entry| !NIX_DESTINATIONS.contains(&entry.destination.as_str()))
1582 );
1583 }
1584
1585 #[test]
1590 fn the_nix_seeds_are_identical_across_forge_pairs() {
1591 for name in ["nix/package.nix", "flake.nix", "flake.lock"] {
1592 let github = embedded::SNIPPETS
1593 .get_file(format!("rust/github/{name}"))
1594 .expect("the github copy ships")
1595 .contents();
1596 let gitlab = embedded::SNIPPETS
1597 .get_file(format!("rust/gitlab/{name}"))
1598 .expect("the gitlab copy ships")
1599 .contents();
1600 assert_eq!(github, gitlab, "{name} diverged between the pairs");
1601 }
1602 }
1603
1604 #[test]
1609 fn the_nix_withhold_judgment_covers_the_three_shapes() {
1610 use super::{NIX_DESTINATIONS, withhold_nix};
1611 let dir = tempfile::tempdir().expect("a scratch target exists");
1612 let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
1613 let entries = || {
1614 projection(
1615 &resolved_test_params(
1616 "rust",
1617 &super::Resolved {
1618 forge: "github".to_owned(),
1619 repo: Some("acme/widget".to_owned()),
1620 },
1621 Workflow::Worktree,
1622 Some(Style::Trunk),
1623 true,
1624 )
1625 .expect("the parameters resolve"),
1626 )
1627 .expect("the pair projects")
1628 };
1629
1630 let mut all = entries();
1632 let withheld = withhold_nix(target, true, None, &mut all).expect("the judgment runs");
1633 let paths: Vec<&str> = withheld.iter().map(|w| w.path.as_str()).collect();
1634 assert_eq!(paths, ["flake.lock", "flake.nix", "nix/package.nix"]);
1635 assert!(
1636 all.iter()
1637 .all(|entry| !NIX_DESTINATIONS.contains(&entry.destination.as_str()))
1638 );
1639
1640 std::fs::write(
1643 target.join("Cargo.toml"),
1644 "[package]\nname = \"widget\"\nversion = \"0.1.0\"\n",
1645 )
1646 .expect("the crate manifest writes");
1647 std::fs::write(target.join("Cargo.lock"), "version = 4\n").expect("the lock writes");
1648 std::fs::create_dir_all(target.join("src")).expect("the src dir exists");
1649 std::fs::write(target.join("src/main.rs"), "fn main() {}\n").expect("the main writes");
1650 std::fs::write(target.join("flake.nix"), "{ }\n").expect("the flake writes");
1651 let mut all = entries();
1652 let withheld = withhold_nix(target, true, None, &mut all).expect("the judgment runs");
1653 let paths: Vec<&str> = withheld.iter().map(|w| w.path.as_str()).collect();
1654 assert_eq!(paths, ["flake.lock", "flake.nix"]);
1655 assert!(
1656 all.iter()
1657 .any(|entry| entry.destination == "nix/package.nix")
1658 );
1659
1660 std::fs::remove_file(target.join("flake.nix")).expect("the flake removes");
1662 let mut all = entries();
1663 let withheld = withhold_nix(target, true, None, &mut all).expect("the judgment runs");
1664 assert!(withheld.is_empty());
1665 assert!(all.iter().any(|entry| entry.destination == "flake.nix"));
1666
1667 let mut all = entries();
1669 let withheld = withhold_nix(target, false, None, &mut all).expect("the judgment runs");
1670 assert!(withheld.is_empty());
1671 }
1672
1673 #[test]
1674 fn the_block_splices_into_every_agents_shape() {
1675 let owned = routing_block(Workflow::Branches);
1676 let block = owned.as_str();
1677 let fresh = splice_agents_block(None, block);
1678 assert_eq!(fresh, format!("{block}\n"));
1679 assert_eq!(extract_block(&fresh, BLOCK_BEGIN, BLOCK_END), Some(block));
1680
1681 let appended = splice_agents_block(Some("# My project\n\nOwn rules.\n"), block);
1682 assert!(appended.starts_with("# My project\n\nOwn rules.\n\n<!-- BEGIN release-kit -->"));
1683 assert_eq!(
1684 extract_block(&appended, BLOCK_BEGIN, BLOCK_END),
1685 Some(block)
1686 );
1687
1688 let stale = appended.replace("Never author a tag", "Do author a tag");
1689 let refreshed = splice_agents_block(Some(&stale), block);
1690 assert_eq!(
1691 extract_block(&refreshed, BLOCK_BEGIN, BLOCK_END),
1692 Some(block)
1693 );
1694 assert!(refreshed.starts_with("# My project"));
1695 assert_eq!(
1696 refreshed.matches("BEGIN release-kit").count(),
1697 1,
1698 "a re-splice must replace, not accumulate"
1699 );
1700 }
1701
1702 #[test]
1705 fn the_hook_block_splices_under_repos() {
1706 let owned = hooks_block(Workflow::Branches);
1707 let block = owned.as_str();
1708 let fresh = splice_hooks_block(None, block).expect("a fresh file splices");
1709 assert!(fresh.starts_with(HOOK_TYPES_LINE));
1710 assert!(fresh.contains("\nrepos:\n# BEGIN release-kit\n"));
1711 assert_eq!(extract_block(&fresh, HOOKS_BEGIN, HOOKS_END), Some(block));
1712
1713 let own =
1714 "repos:\n - repo: https://example.com/own\n rev: v1\n hooks:\n - id: own\n";
1715 let spliced = splice_hooks_block(Some(own), block).expect("an unmarked file splices");
1716 assert!(spliced.starts_with("repos:\n# BEGIN release-kit\n"));
1717 assert!(spliced.contains("- id: own"), "the target's hooks survive");
1718 assert!(
1719 !spliced.contains(HOOK_TYPES_LINE),
1720 "an existing file's top level is the skills' duty, not the splice's"
1721 );
1722
1723 let stale = spliced.replace("--force-scope", "--no-scope");
1724 let refreshed = splice_hooks_block(Some(&stale), block).expect("a marked file re-splices");
1725 assert_eq!(
1726 extract_block(&refreshed, HOOKS_BEGIN, HOOKS_END),
1727 Some(block)
1728 );
1729 assert_eq!(refreshed.matches(HOOKS_BEGIN).count(), 1);
1730
1731 let err = splice_hooks_block(Some("minimum_pre_commit_version: '3.2.0'\n"), block)
1732 .expect_err("no repos: line refuses");
1733 assert!(err.contains("repos:"), "{err}");
1734
1735 let doubled = format!("repos:\n{block}\n{block}\n");
1739 let err = splice_hooks_block(Some(&doubled), block).expect_err("a second block refuses");
1740 assert!(err.contains("one block"), "{err}");
1741 let unmatched = "repos:\n# BEGIN release-kit\n - repo: local\n";
1742 let err =
1743 splice_hooks_block(Some(unmatched), block).expect_err("an unmatched marker refuses");
1744 assert!(err.contains("unmatched"), "{err}");
1745 }
1746
1747 #[test]
1753 fn the_blocks_render_per_mode_and_carry_the_one_grammar() {
1754 let worktree_hooks = hooks_block(Workflow::Worktree);
1755 let branches_hooks = hooks_block(Workflow::Branches);
1756 assert!(worktree_hooks.contains("- id: rk-worktree-location"));
1757 assert!(
1758 worktree_hooks.contains("SKIP=no-commit-to-branch,rk-worktree-location"),
1759 "{worktree_hooks}"
1760 );
1761 assert!(!branches_hooks.contains("rk-worktree-location"));
1762 assert!(branches_hooks.contains("SKIP=no-commit-to-branch in"));
1763 for block in [&worktree_hooks, &branches_hooks] {
1764 assert!(block.contains(BRANCH_GRAMMAR), "the grammar has one owner");
1765 for token in ["RK_BRANCH_GRAMMAR", "RK_SWEEP_SKIP", "RK_WORKTREE_GUARD"] {
1766 assert!(!block.contains(token), "{token} survived: {block}");
1767 }
1768 }
1769 for block in [&worktree_hooks, &branches_hooks] {
1774 for line in block.lines() {
1775 if let Some(value) = line.trim_start().strip_prefix("entry: ") {
1776 assert!(
1777 !value.contains(": "),
1778 "an entry value breaks the YAML plain scalar: {line}"
1779 );
1780 }
1781 }
1782 }
1783 let guard_line = worktree_hooks
1784 .lines()
1785 .position(|line| line.contains("id: rk-worktree-location"))
1786 .expect("the guard entry exists");
1787 let name_line = worktree_hooks
1788 .lines()
1789 .position(|line| line.contains("id: rk-branch-name"))
1790 .expect("the name hook exists");
1791 assert!(
1792 guard_line > name_line,
1793 "the guard lands directly after rk-branch-name"
1794 );
1795
1796 let worktree_routing = routing_block(Workflow::Worktree);
1797 let branches_routing = routing_block(Workflow::Branches);
1798 assert!(worktree_routing.contains("This project works in worktrees"));
1799 assert!(branches_routing.contains("Branches are worked in the main checkout"));
1800 for block in [&worktree_routing, &branches_routing] {
1801 assert!(block.contains("Create or remove a worktree"));
1802 assert!(block.contains("`rk worktree add <branch>`"));
1803 assert!(!block.contains("RK_WORKFLOW_LINE"), "{block}");
1804 }
1805 let differing: Vec<(&str, &str)> = worktree_routing
1806 .lines()
1807 .zip(branches_routing.lines())
1808 .filter(|(a, b)| a != b)
1809 .collect();
1810 assert_eq!(
1811 differing.len(),
1812 1,
1813 "exactly one routing line differs per mode: {differing:?}"
1814 );
1815 }
1816
1817 #[test]
1820 fn the_hook_marker_defects_are_named() {
1821 use super::hooks_marker_defect;
1822 let owned = hooks_block(Workflow::Branches);
1823 let block = owned.as_str();
1824 assert_eq!(hooks_marker_defect(""), None);
1825 assert_eq!(hooks_marker_defect(&format!("repos:\n{block}\n")), None);
1826 for (case, text) in [
1827 (
1828 "a second begin",
1829 format!("repos:\n{block}\n# BEGIN release-kit\n"),
1830 ),
1831 (
1832 "a second end",
1833 format!("repos:\n{block}\n# END release-kit\n"),
1834 ),
1835 (
1836 "an unpaired begin",
1837 "repos:\n# BEGIN release-kit\n".to_owned(),
1838 ),
1839 ("an unpaired end", "repos:\n# END release-kit\n".to_owned()),
1840 (
1841 "an end before its begin",
1842 "repos:\n# END release-kit\n# BEGIN release-kit\n".to_owned(),
1843 ),
1844 ] {
1845 assert!(
1846 hooks_marker_defect(&text).is_some(),
1847 "{case} must be a defect"
1848 );
1849 }
1850 }
1851}