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 security_contact: String,
37 security_response: String,
38}
39
40#[derive(Default)]
42pub struct Inputs<'a> {
43 pub tech: Option<&'a str>,
45 pub forge: Option<&'a str>,
47 pub repo: Option<&'a str>,
49 pub workflow: Option<Workflow>,
51 pub style: Option<Style>,
53 pub nix: Option<bool>,
55}
56
57#[derive(Clone, Copy, PartialEq, Eq)]
59pub enum Purpose {
60 Init,
62 Preview,
64 Upgrade,
66 Adopt,
68}
69
70impl Params {
71 #[must_use]
74 pub fn from_record(record: &manifest::Manifest) -> Self {
75 Self {
76 tech: record.tech.clone(),
77 forge: record.forge.clone(),
78 repo: record.parameters.repo.clone(),
79 workflow: record.parameters.workflow,
80 style: record.parameters.style,
81 nix: record.parameters.nix,
82 trunk: record.parameters.trunk.clone(),
83 line_prefix: record.parameters.line_prefix.clone(),
84 security_contact: record.parameters.security_contact.clone(),
85 security_response: record.parameters.security_response.clone(),
86 }
87 }
88
89 pub fn resolve(
95 target: &Utf8Path,
96 flags: &Inputs<'_>,
97 config: Option<&crate::config::Config>,
98 record: Option<&manifest::Manifest>,
99 purpose: Purpose,
100 ) -> Result<Self, RkError> {
101 let answer = |flag: Option<&str>, configured: Option<&str>, recorded: Option<&str>| {
102 flag.or_else(|| configured.filter(|value| !value.is_empty()))
103 .or(recorded)
104 .map(str::to_owned)
105 };
106 let forge = answer(
107 flags.forge,
108 config.map(|c| c.project.forge.as_str()),
109 record.map(|r| r.forge.as_str()),
110 );
111 let repo = answer(
112 flags.repo,
113 config.map(|c| c.project.repo.as_str()),
114 record.map(|r| r.parameters.repo.as_str()),
115 );
116 let resolved = resolve(target, forge.as_deref(), repo.as_deref())?;
117 let tech = answer(
118 flags.tech,
119 config.map(|c| c.project.tech.as_str()),
120 record.map(|r| r.tech.as_str()),
121 )
122 .or_else(|| crate::detect::tech_of(target.as_std_path()).map(str::to_owned))
123 .ok_or_else(|| {
124 RkError::missing(
125 Diagnostic::new(
126 Reason::TargetNotFound,
127 "no technology detected: the target has no version file",
128 )
129 .action("pass --tech <rust|python|bash>"),
130 )
131 })?;
132 pair_files(&tech, &resolved.forge)?;
133 let workflow = flags
134 .workflow
135 .or_else(|| config.and_then(|c| c.landing.workflow))
136 .or_else(|| record.map(|r| r.parameters.workflow))
137 .unwrap_or(if purpose == Purpose::Adopt {
138 Workflow::Branches
139 } else {
140 Workflow::Worktree
141 });
142 let style = flags
143 .style
144 .or_else(|| config.and_then(|c| c.landing.style))
145 .or_else(|| record.and_then(|r| r.parameters.style));
146 let style = match (style, purpose) {
147 (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())),
148 (value, _) => Some(value.unwrap_or(Style::Trunk)),
149 };
150 let repo = resolved
151 .repo
152 .or_else(|| (purpose == Purpose::Preview).then(|| "OWNER".to_owned()))
153 .ok_or_else(repo_unresolved)?;
154 let trunk = config
155 .and_then(|c| c.project.trunk.clone())
156 .or_else(|| record.map(|r| r.parameters.trunk.clone()))
157 .unwrap_or_else(|| crate::config::TRUNK_DEFAULT.to_owned());
158 let line_prefix = config
159 .and_then(|c| c.setup.line_prefix.clone())
160 .or_else(|| record.map(|r| r.parameters.line_prefix.clone()))
161 .unwrap_or_else(|| crate::config::LINE_PREFIX_DEFAULT.to_owned());
162 let security_contact = config
167 .and_then(|c| c.security.contact.clone())
168 .or_else(|| record.map(|r| r.parameters.security_contact.clone()))
169 .unwrap_or_default();
170 let security_contact =
171 crate::config::canonical_contact(&security_contact).map_err(crate::config::invalid)?;
172 let security_response = config
173 .and_then(|c| c.security.response.clone())
174 .or_else(|| record.map(|r| r.parameters.security_response.clone()))
175 .unwrap_or_else(|| crate::config::RESPONSE_DEFAULT.to_owned());
176 let security_response = crate::config::canonical_response(&security_response)
177 .map_err(crate::config::invalid)?;
178 Ok(Self {
179 tech,
180 forge: resolved.forge,
181 repo,
182 workflow,
183 style,
184 nix: flags
185 .nix
186 .or_else(|| config.and_then(|c| c.landing.nix))
187 .or_else(|| record.map(|r| r.parameters.nix))
188 .unwrap_or(false),
189 trunk,
190 line_prefix,
191 security_contact,
192 security_response,
193 })
194 }
195
196 #[must_use]
198 pub fn tech(&self) -> &str {
199 &self.tech
200 }
201
202 #[must_use]
204 pub fn forge(&self) -> &str {
205 &self.forge
206 }
207
208 #[must_use]
210 pub const fn nix(&self) -> bool {
211 self.nix
212 }
213
214 #[must_use]
216 pub fn repo(&self) -> &str {
217 &self.repo
218 }
219
220 #[must_use]
222 pub const fn workflow(&self) -> Workflow {
223 self.workflow
224 }
225
226 #[must_use]
228 pub const fn style(&self) -> Option<Style> {
229 self.style
230 }
231
232 #[must_use]
234 pub fn trunk(&self) -> &str {
235 &self.trunk
236 }
237
238 #[must_use]
240 pub fn line_prefix(&self) -> &str {
241 &self.line_prefix
242 }
243
244 #[must_use]
247 pub fn security_contact(&self) -> &str {
248 &self.security_contact
249 }
250
251 #[must_use]
253 pub fn security_response(&self) -> &str {
254 &self.security_response
255 }
256}
257
258#[cfg(test)]
259impl Params {
260 pub(crate) fn for_test(repo: &str, style: Option<Style>) -> Self {
264 Self {
265 tech: "rust".to_owned(),
266 forge: "github".to_owned(),
267 repo: repo.to_owned(),
268 workflow: Workflow::Worktree,
269 style,
270 nix: false,
271 trunk: crate::config::TRUNK_DEFAULT.to_owned(),
272 line_prefix: crate::config::LINE_PREFIX_DEFAULT.to_owned(),
273 security_contact: String::new(),
274 security_response: crate::config::RESPONSE_DEFAULT.to_owned(),
275 }
276 }
277
278 pub(crate) fn for_test_security(contact: &str, response: &str) -> Self {
280 Self {
281 security_contact: contact.to_owned(),
282 security_response: response.to_owned(),
283 ..Self::for_test("acme/widget", Some(Style::Trunk))
284 }
285 }
286}
287
288#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
290#[serde(rename_all = "lowercase")]
291pub enum Kind {
292 Rendered,
295 Seeded,
298 State,
301}
302
303impl Kind {
304 #[must_use]
306 pub const fn as_str(self) -> &'static str {
307 match self {
308 Self::Rendered => "rendered",
309 Self::Seeded => "seeded",
310 Self::State => "state",
311 }
312 }
313}
314
315const KINDS: [(&str, Kind); 16] = [
321 (".github/workflows/release-plz.yml", Kind::Rendered),
322 (".github/workflows/release-please.yml", Kind::Rendered),
323 (".github/workflows/release.yml", Kind::Rendered),
324 (".github/workflows/pr-title.yml", Kind::Rendered),
325 (".gitlab-ci.yml", Kind::Rendered),
326 ("SECURITY.md", Kind::Rendered),
327 (".gitlab/ci/mr-title.yml", Kind::Rendered),
328 ("release-plz.toml", Kind::Seeded),
329 ("dist-workspace.toml", Kind::Seeded),
330 ("release-please-config.json", Kind::Seeded),
331 ("cliff.toml", Kind::Seeded),
332 ("nix/package.nix", Kind::Seeded),
333 ("flake.nix", Kind::Seeded),
334 (".release-please-manifest.json", Kind::State),
335 ("VERSION", Kind::State),
336 ("flake.lock", Kind::State),
337];
338
339pub const NIX_DESTINATIONS: [&str; 3] = ["nix/package.nix", "flake.nix", "flake.lock"];
353
354pub const NIX_WITHHOLDABLE: [&str; 2] = ["flake.nix", "flake.lock"];
360
361#[must_use]
364pub fn kind_of(destination: &str) -> Option<Kind> {
365 if destination == AGENTS_DESTINATION || destination == HOOKS_DESTINATION {
366 return Some(Kind::Rendered);
367 }
368 KINDS
369 .iter()
370 .find(|(name, _)| *name == destination)
371 .map(|(_, kind)| *kind)
372}
373
374pub fn destinations() -> impl Iterator<Item = &'static str> {
378 KINDS
379 .iter()
380 .map(|(name, _)| *name)
381 .chain([AGENTS_DESTINATION, HOOKS_DESTINATION])
382}
383
384pub const OWNER_TOKEN: &[u8] = b"OWNER";
391
392pub const REPO_TOKEN: &[u8] = b"RK_REPO";
394
395pub const SCOPE_SHAPE_TOKEN: &[u8] = b"RK_SCOPE_SHAPE";
397
398pub const STYLE_TOKEN: &[u8] = b"RK_STYLE";
401
402pub const TRUNK_BRANCH_TOKEN: &[u8] = b"RK_TRUNK_BRANCH";
406
407pub const LINE_PREFIX_TOKEN: &[u8] = b"RK_LINE_PREFIX";
410
411pub const LINE_PREFIX_RE_TOKEN: &[u8] = b"RK_LINE_PREFIX_RE";
417
418pub const SECURITY_SPANS: [(&[u8], &[u8]); 3] = [
429 (
430 b"<!--RK_SECURITY_CONTACT_BEGIN-->",
431 b"<!--RK_SECURITY_CONTACT_END-->",
432 ),
433 (
434 b"<!--RK_SECURITY_RESPONSE_BEGIN-->",
435 b"<!--RK_SECURITY_RESPONSE_END-->",
436 ),
437 (
438 b"<!--RK_SECURITY_DEADLINE_BEGIN-->",
439 b"<!--RK_SECURITY_DEADLINE_END-->",
440 ),
441];
442
443fn acknowledgment(response: &str) -> String {
446 format!("Maintainers acknowledge a report within {response}.")
447}
448
449const DISCLOSURE_ONLY: &[u8] = b"This policy commits to no disclosure deadline.";
453
454fn security_replacements(params: &Params) -> [Option<Vec<u8>>; 3] {
457 let contact = (!params.security_contact().is_empty())
458 .then(|| params.security_contact().as_bytes().to_vec());
459 let promised = params.security_response() != crate::config::RESPONSE_DEFAULT;
460 [
461 contact,
462 promised.then(|| acknowledgment(params.security_response()).into_bytes()),
463 promised.then(|| DISCLOSURE_ONLY.to_vec()),
464 ]
465}
466
467fn replace_span(baseline: &[u8], begin: &[u8], end: &[u8], value: Option<&[u8]>) -> Vec<u8> {
473 let ordered = find(baseline, begin)
474 .zip(find(baseline, end))
475 .filter(|(start, stop)| stop > start);
476 let Some((start, stop)) = ordered else {
477 return baseline.to_vec();
478 };
479 let mut out = Vec::with_capacity(baseline.len());
480 out.extend_from_slice(&baseline[..start]);
481 out.extend_from_slice(value.unwrap_or_else(|| &baseline[start + begin.len()..stop]));
482 out.extend_from_slice(&baseline[stop + end.len()..]);
483 out
484}
485
486#[must_use]
504pub fn render(baseline: &[u8], params: &Params) -> Vec<u8> {
505 let repo = params.repo();
506 let owner = repo.split('/').next().unwrap_or(repo);
507 let mut out = substitute(baseline, OWNER_TOKEN, owner.as_bytes());
508 if let Some(style) = params.style() {
509 out = substitute(&out, STYLE_TOKEN, style.as_str().as_bytes());
510 }
511 out = substitute(&out, SCOPE_SHAPE_TOKEN, SCOPE_SHAPE.as_bytes());
512 out = substitute(&out, TRUNK_BRANCH_TOKEN, params.trunk().as_bytes());
513 let escaped = params.line_prefix().replace('/', "\\/");
514 out = substitute(&out, LINE_PREFIX_RE_TOKEN, escaped.as_bytes());
515 out = substitute(&out, LINE_PREFIX_TOKEN, params.line_prefix().as_bytes());
516 out = substitute(&out, REPO_TOKEN, repo.as_bytes());
517 for ((begin, end), value) in SECURITY_SPANS.iter().zip(security_replacements(params)) {
518 out = replace_span(&out, begin, end, value.as_deref());
519 }
520 out
521}
522
523pub(crate) fn substitute(baseline: &[u8], token: &[u8], value: &[u8]) -> Vec<u8> {
525 let mut out = Vec::with_capacity(baseline.len());
526 let mut rest = baseline;
527 while let Some(at) = find(rest, token) {
528 out.extend_from_slice(&rest[..at]);
529 out.extend_from_slice(value);
530 rest = &rest[at + token.len()..];
531 }
532 out.extend_from_slice(rest);
533 out
534}
535
536fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
538 haystack
539 .windows(needle.len())
540 .position(|window| window == needle)
541}
542
543pub const AGENTS_DESTINATION: &str = "AGENTS.md";
545
546pub const BLOCK_BEGIN: &str = "<!-- BEGIN release-kit -->";
548
549pub const BLOCK_END: &str = "<!-- END release-kit -->";
551
552pub const HOOKS_DESTINATION: &str = ".pre-commit-config.yaml";
554
555pub const HOOKS_BEGIN: &str = "# BEGIN release-kit";
557
558pub const HOOKS_END: &str = "# END release-kit";
560
561pub const HOOK_TYPES_LINE: &str = "default_install_hook_types: [pre-commit, commit-msg, pre-push]";
565
566static AGENTS_BLOCK: &str = include_str!("../blocks/agents-block.md.in");
568
569static AGENTS_LINE_WORKTREE: &str = include_str!("../blocks/agents-line-worktree.md.in");
571
572static AGENTS_LINE_BRANCHES: &str = include_str!("../blocks/agents-line-branches.md.in");
574
575static PRE_COMMIT_BLOCK: &str = include_str!("../blocks/pre-commit-block.yaml.in");
577
578static PRE_COMMIT_WORKTREE_GUARD: &str =
580 include_str!("../blocks/pre-commit-worktree-guard.yaml.in");
581
582fn authored(text: &str) -> &str {
586 text.strip_suffix('\n').unwrap_or(text)
587}
588
589pub 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[-/].+)$";
597
598pub const SCOPE_SHAPE: &str = "[a-z0-9._/-]+";
608
609#[must_use]
616pub fn scope_is_shaped(scope: &str) -> bool {
617 !scope.is_empty()
618 && scope.chars().all(|c| {
619 c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '/' | '-')
620 })
621}
622
623#[must_use]
632pub fn routing_block(workflow: Workflow) -> String {
633 let line = match workflow {
634 Workflow::Worktree => authored(AGENTS_LINE_WORKTREE),
635 Workflow::Branches => authored(AGENTS_LINE_BRANCHES),
636 };
637 authored(AGENTS_BLOCK).replacen("RK_WORKFLOW_LINE", line, 1)
638}
639
640#[must_use]
652pub fn hooks_block(workflow: Workflow) -> String {
653 let (guard, skip) = match workflow {
654 Workflow::Worktree => (
655 format!("{}\n", authored(PRE_COMMIT_WORKTREE_GUARD)),
656 "no-commit-to-branch,rk-worktree-location",
657 ),
658 Workflow::Branches => (String::new(), "no-commit-to-branch"),
659 };
660 authored(PRE_COMMIT_BLOCK)
661 .replacen("RK_BRANCH_GRAMMAR", BRANCH_GRAMMAR, 1)
662 .replacen("RK_SWEEP_SKIP", skip, 1)
663 .replacen("RK_WORKTREE_GUARD", &guard, 1)
664}
665
666#[must_use]
668pub fn block_markers(destination: &str) -> Option<(&'static str, &'static str)> {
669 match destination {
670 AGENTS_DESTINATION => Some((BLOCK_BEGIN, BLOCK_END)),
671 HOOKS_DESTINATION => Some((HOOKS_BEGIN, HOOKS_END)),
672 _ => None,
673 }
674}
675
676#[must_use]
679pub fn extract_block<'a>(text: &'a str, begin: &str, end: &str) -> Option<&'a str> {
680 let start = text.find(begin)?;
681 let stop = text[start..].find(end)? + start + end.len();
682 Some(&text[start..stop])
683}
684
685#[must_use]
691pub fn splice_agents_block(existing: Option<&str>, block: &str) -> String {
692 existing.map_or_else(
693 || format!("{block}\n"),
694 |text| {
695 extract_block(text, BLOCK_BEGIN, BLOCK_END).map_or_else(
696 || format!("{}\n\n{block}\n", text.trim_end()),
697 |found| text.replacen(found, block, 1),
698 )
699 },
700 )
701}
702
703pub fn splice_hooks_block(existing: Option<&str>, block: &str) -> Result<String, String> {
716 let Some(text) = existing else {
717 return Ok(format!("{HOOK_TYPES_LINE}\n\nrepos:\n{block}\n"));
718 };
719 if let Some(defect) = hooks_marker_defect(text) {
720 return Err(defect);
721 }
722 if let Some(found) = extract_block(text, HOOKS_BEGIN, HOOKS_END) {
723 return Ok(text.replacen(found, block, 1));
724 }
725 let mut out = String::with_capacity(text.len() + block.len() + 1);
726 let mut placed = false;
727 for line in text.split_inclusive('\n') {
728 out.push_str(line);
729 if !placed && line.trim_end() == "repos:" {
730 if !out.ends_with('\n') {
731 out.push('\n');
732 }
733 out.push_str(block);
734 out.push('\n');
735 placed = true;
736 }
737 }
738 if placed {
739 Ok(out)
740 } else {
741 Err(format!(
742 "{HOOKS_DESTINATION} exists with no repos: line, so the hook block has nowhere to land"
743 ))
744 }
745}
746
747#[must_use]
756pub fn hooks_marker_defect(text: &str) -> Option<String> {
757 let begins = text.matches(HOOKS_BEGIN).count();
758 let ends = text.matches(HOOKS_END).count();
759 if begins > 1 || ends > 1 {
760 return Some(format!(
761 "{HOOKS_DESTINATION} carries more than one release-kit marker pair; release-kit owns exactly one block"
762 ));
763 }
764 match (text.find(HOOKS_BEGIN), text.find(HOOKS_END)) {
765 (Some(begin), Some(end)) if end > begin => None,
766 (None, None) => None,
767 _ => Some(format!(
768 "{HOOKS_DESTINATION} carries an unmatched or misordered release-kit marker, so the block's extent is ambiguous"
769 )),
770 }
771}
772
773#[derive(Debug, Clone, Copy, PartialEq, Eq)]
775pub enum Placement {
776 Whole,
778 Block,
780}
781
782#[derive(Debug)]
785pub struct Entry {
786 pub destination: String,
788 pub kind: Kind,
790 pub placement: Placement,
792 pub baseline: Vec<u8>,
795 pub rendered: Vec<u8>,
798}
799
800pub fn pair_files(tech: &str, forge: &str) -> Result<Vec<(String, &'static [u8])>, RkError> {
808 if tech.starts_with('_') || embedded::SNIPPETS.get_dir(tech).is_none() {
811 let known: Vec<String> = embedded::SNIPPETS
812 .dirs()
813 .map(|dir| dir.path().to_string_lossy().into_owned())
814 .filter(|name| !name.starts_with('_'))
815 .collect();
816 return Err(RkError::Usage(format!(
817 "unknown tech '{tech}'; the bindings are: {}",
818 known.join(", ")
819 )));
820 }
821 let pair = format!("{tech}/{forge}");
822 let pair_dir = embedded::SNIPPETS.get_dir(&pair).ok_or_else(|| {
823 let known: Vec<String> = embedded::SNIPPETS
824 .dirs()
825 .filter(|dir| !dir.path().to_string_lossy().starts_with('_'))
826 .flat_map(include_dir::Dir::dirs)
827 .map(|dir| dir.path().to_string_lossy().replace('/', ", "))
828 .collect();
829 RkError::Usage(format!(
830 "the pair ({tech}, {forge}) has no landable files; the supported pairs are: {}",
831 known.join("; ")
832 ))
833 })?;
834 let mut files: Vec<(String, &'static [u8])> = Vec::new();
838 let shared = format!("_shared/{forge}");
839 if let Some(shared_dir) = embedded::SNIPPETS.get_dir(&shared) {
840 for (path, contents) in embedded::walk(shared_dir) {
841 let rel = path
842 .strip_prefix(&format!("{shared}/"))
843 .map_or(path.as_str(), |rel| rel)
844 .to_owned();
845 files.push((rel, contents));
846 }
847 }
848 for (path, contents) in embedded::walk(pair_dir) {
849 let rel = path
850 .strip_prefix(&format!("{pair}/"))
851 .map_or(path.as_str(), |rel| rel)
852 .to_owned();
853 if files.iter().any(|(existing, _)| *existing == rel) {
854 return Err(anyhow::anyhow!(
855 "the shared zone and the pair ({tech}, {forge}) both ship {rel}; the payload is defective"
856 )
857 .into());
858 }
859 files.push((rel, contents));
860 }
861 Ok(files)
862}
863
864pub fn projection(params: &Params) -> Result<Vec<Entry>, RkError> {
879 let mut entries = Vec::new();
880 for (destination, baseline) in pair_files(¶ms.tech, ¶ms.forge)? {
881 if !params.nix && NIX_DESTINATIONS.contains(&destination.as_str()) {
882 continue;
883 }
884 let kind = kind_of(&destination).ok_or_else(|| {
885 anyhow::anyhow!("the payload does not classify {destination}; the kind table is stale")
886 })?;
887 let rendered = match kind {
888 Kind::Rendered => render(baseline, params),
889 Kind::Seeded | Kind::State => baseline.to_vec(),
890 };
891 entries.push(Entry {
892 destination,
893 kind,
894 placement: Placement::Whole,
895 baseline: baseline.to_vec(),
896 rendered,
897 });
898 }
899 for (destination, template) in [
900 (AGENTS_DESTINATION, routing_block(params.workflow)),
901 (HOOKS_DESTINATION, hooks_block(params.workflow)),
902 ] {
903 entries.push(Entry {
904 destination: destination.to_owned(),
905 kind: Kind::Rendered,
906 placement: Placement::Block,
907 baseline: template.as_bytes().to_vec(),
908 rendered: render(template.as_bytes(), params),
909 });
910 }
911 entries.sort_by(|a, b| a.destination.cmp(&b.destination));
912 Ok(entries)
913}
914
915#[must_use]
927pub fn nix_unsupported_shape(target: &Utf8Path) -> Option<String> {
928 let Ok(text) = std::fs::read_to_string(target.join("Cargo.toml")) else {
929 return Some(
930 "the target has no readable Cargo.toml, which the seeded package expression reads; no Nix file lands".to_owned(),
931 );
932 };
933 let Ok(table) = text.parse::<toml::Table>() else {
934 return Some(
935 "the target's Cargo.toml does not parse, and the seeded package expression reads it; no Nix file lands".to_owned(),
936 );
937 };
938 if !table.contains_key("package") {
939 return Some(
940 "the target's Cargo.toml has no [package] table; the seed supports a single crate, so no Nix file lands".to_owned(),
941 );
942 }
943 if !target.join("Cargo.lock").is_file() {
944 return Some(
945 "the target has no Cargo.lock, which the seeded package expression builds from; commit one, then opt in".to_owned(),
946 );
947 }
948 let implicit_bin = target.join("src/main.rs").is_file()
949 && table
950 .get("package")
951 .and_then(toml::Value::as_table)
952 .and_then(|package| package.get("autobins"))
953 .and_then(toml::Value::as_bool)
954 != Some(false);
955 let explicit_bins = table.get("bin").and_then(toml::Value::as_array);
956 if explicit_bins.is_none() && !implicit_bin {
957 return Some(
958 "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(),
959 );
960 }
961 if let Some(bins) = explicit_bins {
967 let required = bins
968 .first()
969 .and_then(toml::Value::as_table)
970 .and_then(|bin| bin.get("required-features"))
971 .and_then(toml::Value::as_array);
972 if let Some(required) = required {
973 let enabled = default_features(&table);
974 let missing = required
975 .iter()
976 .filter_map(toml::Value::as_str)
977 .any(|feature| !enabled.contains(feature));
978 if missing {
979 return Some(
980 "the target's first [[bin]] entry requires features a default build does not enable; no Nix file lands".to_owned(),
981 );
982 }
983 }
984 }
985 None
986}
987
988fn dep_edge_suppresses(features: &toml::Table, name: &str) -> bool {
991 let edge = format!("dep:{name}");
992 features.values().any(|list| {
993 list.as_array().is_some_and(|entries| {
994 entries
995 .iter()
996 .filter_map(toml::Value::as_str)
997 .any(|entry| entry == edge)
998 })
999 })
1000}
1001
1002fn is_optional_dependency(table: &toml::Table, name: &str) -> bool {
1005 ["dependencies", "build-dependencies"]
1006 .iter()
1007 .any(|section| {
1008 table
1009 .get(*section)
1010 .and_then(toml::Value::as_table)
1011 .and_then(|dependencies| dependencies.get(name))
1012 .and_then(toml::Value::as_table)
1013 .and_then(|dependency| dependency.get("optional"))
1014 .and_then(toml::Value::as_bool)
1015 == Some(true)
1016 })
1017}
1018
1019fn default_features(table: &toml::Table) -> std::collections::BTreeSet<String> {
1026 let Some(features) = table.get("features").and_then(toml::Value::as_table) else {
1027 return std::collections::BTreeSet::new();
1028 };
1029 let mut enabled = std::collections::BTreeSet::new();
1030 let mut queue = vec!["default".to_owned()];
1031 while let Some(name) = queue.pop() {
1032 if !enabled.insert(name.clone()) {
1033 continue;
1034 }
1035 if let Some(implies) = features.get(&name).and_then(toml::Value::as_array) {
1036 for implied in implies.iter().filter_map(toml::Value::as_str) {
1037 if implied.starts_with("dep:") || implied.contains("?/") {
1038 continue;
1042 }
1043 if let Some((package, _)) = implied.split_once('/') {
1044 let feature_exists =
1052 features.contains_key(package) || !dep_edge_suppresses(features, package);
1053 if is_optional_dependency(table, package) && feature_exists {
1054 queue.push(package.to_owned());
1055 }
1056 } else {
1057 queue.push(implied.to_owned());
1058 }
1059 }
1060 }
1061 }
1062 enabled
1063}
1064
1065pub fn nix_withheld(
1077 target: &Utf8Path,
1078 recorded: Option<&manifest::Manifest>,
1079) -> std::io::Result<Option<String>> {
1080 if recorded.is_some_and(|record| record.file("flake.nix").is_some()) {
1081 return Ok(None);
1082 }
1083 let mut present = Vec::new();
1084 for name in ["flake.nix", "flake.lock"] {
1085 match std::fs::symlink_metadata(target.join(name).as_std_path()) {
1086 Ok(_) => present.push(name),
1087 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
1088 Err(e) => return Err(e),
1089 }
1090 }
1091 if present.is_empty() {
1092 return Ok(None);
1093 }
1094 Ok(Some(format!(
1095 "the target already carries {}; its flake pair stays its own",
1096 present.join(" and ")
1097 )))
1098}
1099
1100#[derive(Debug, Serialize)]
1102pub struct Withheld {
1103 pub path: String,
1105 pub reason: String,
1108}
1109
1110pub fn withhold_nix(
1123 target: &Utf8Path,
1124 nix: bool,
1125 recorded: Option<&manifest::Manifest>,
1126 entries: &mut Vec<Entry>,
1127) -> Result<Vec<Withheld>, RkError> {
1128 if !nix {
1129 return Ok(Vec::new());
1130 }
1131 let (set, reason): (&[&str], String) = if let Some(reason) = nix_unsupported_shape(target) {
1132 (&NIX_DESTINATIONS[..], reason)
1133 } else if let Some(reason) = nix_withheld(target, recorded)? {
1134 (&NIX_WITHHOLDABLE[..], reason)
1135 } else {
1136 return Ok(Vec::new());
1137 };
1138 let mut withheld = Vec::new();
1139 entries.retain(|entry| {
1140 if set.contains(&entry.destination.as_str()) {
1141 withheld.push(Withheld {
1142 path: entry.destination.clone(),
1143 reason: reason.clone(),
1144 });
1145 false
1146 } else {
1147 true
1148 }
1149 });
1150 Ok(withheld)
1151}
1152
1153pub fn read_destination(target: &Utf8Path, entry: &Entry) -> std::io::Result<Option<Vec<u8>>> {
1161 read_recorded(target, &entry.destination)
1162}
1163
1164pub fn read_recorded(target: &Utf8Path, destination: &str) -> std::io::Result<Option<Vec<u8>>> {
1175 let path = target.join(destination);
1176 let bytes = match std::fs::read(&path) {
1177 Ok(bytes) => bytes,
1178 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1179 Err(e) => return Err(e),
1180 };
1181 if let Some((begin, end)) = block_markers(destination) {
1182 let text = String::from_utf8_lossy(&bytes);
1183 Ok(extract_block(&text, begin, end).map(|block| block.as_bytes().to_vec()))
1184 } else {
1185 Ok(Some(bytes))
1186 }
1187}
1188
1189#[derive(Debug)]
1192pub struct Resolved {
1193 pub forge: String,
1195 pub repo: Option<String>,
1197}
1198
1199pub fn resolve(
1211 target: &Utf8Path,
1212 forge_flag: Option<&str>,
1213 repo_flag: Option<&str>,
1214) -> Result<Resolved, RkError> {
1215 let forge_flag = forge_flag
1216 .map(|name| {
1217 crate::detect::Forge::parse(name).ok_or_else(|| {
1218 RkError::Usage(format!(
1219 "unknown forge '{name}'; the forges are: github, gitlab"
1220 ))
1221 })
1222 })
1223 .transpose()?;
1224 let detected = crate::detect::detect(target.as_std_path());
1225 let forge = forge_flag
1226 .or(detected.forge)
1227 .map(|forge| forge.as_str().to_owned())
1228 .ok_or_else(|| {
1229 let message = detected.host.map_or_else(
1230 || "no forge detected: the target has no origin remote".to_owned(),
1231 |host| format!("no forge detected: the host {host} is not recognized"),
1232 );
1233 RkError::refusal(
1234 Diagnostic::new(Reason::ForgeUndetected, message)
1235 .expected("a github.com or gitlab remote, or --forge")
1236 .action("pass --forge <github|gitlab>"),
1237 )
1238 })?;
1239 Ok(Resolved {
1240 forge,
1241 repo: repo_flag.map(str::to_owned).or(detected.repo),
1242 })
1243}
1244
1245#[must_use]
1248pub fn repo_unresolved() -> RkError {
1249 RkError::missing(
1250 Diagnostic::new(
1251 Reason::ForgeUndetected,
1252 "no repository detected: the target has no origin remote",
1253 )
1254 .expected("an origin remote naming the project")
1255 .action("pass --repo <path>"),
1256 )
1257}
1258
1259pub fn write_destination(target: &Utf8Path, entry: &Entry) -> std::io::Result<()> {
1269 let path = target.join(&entry.destination);
1270 match entry.placement {
1271 Placement::Whole => atomic::write(path.as_std_path(), &entry.rendered),
1272 Placement::Block => {
1273 let existing = match std::fs::read(&path) {
1274 Ok(bytes) => Some(String::from_utf8_lossy(&bytes).into_owned()),
1275 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
1276 Err(e) => return Err(e),
1277 };
1278 let block = String::from_utf8_lossy(&entry.rendered).into_owned();
1279 let spliced = if entry.destination == HOOKS_DESTINATION {
1280 splice_hooks_block(existing.as_deref(), &block).map_err(std::io::Error::other)?
1281 } else {
1282 splice_agents_block(existing.as_deref(), &block)
1283 };
1284 atomic::write(path.as_std_path(), spliced.as_bytes())
1285 }
1286 }
1287}
1288
1289pub fn hooks_file_defect(target: &Utf8Path) -> std::io::Result<Option<String>> {
1302 let path = target.join(HOOKS_DESTINATION);
1303 match std::fs::read(&path) {
1304 Ok(bytes) => {
1305 let text = String::from_utf8_lossy(&bytes);
1306 Ok(splice_hooks_block(Some(&text), authored(PRE_COMMIT_BLOCK)).err())
1307 }
1308 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
1309 Err(e) => Err(e),
1310 }
1311}
1312
1313pub fn hooks_splice_refusal(target: &Utf8Path) -> Result<(), RkError> {
1323 hooks_file_defect(target)?.map_or(Ok(()), |reason| {
1324 Err(RkError::refusal(
1325 Diagnostic::new(
1326 Reason::StateDrift,
1327 format!("{reason}, and nothing was written"),
1328 )
1329 .expected("a .pre-commit-config.yaml the block can land in, or none")
1330 .action(format!(
1331 "resolve it in {}, then re-run",
1332 target.join(HOOKS_DESTINATION)
1333 ))
1334 .target_state("unchanged"),
1335 ))
1336 })
1337}
1338
1339#[cfg(test)]
1340mod tests {
1341 use super::{
1342 AGENTS_DESTINATION, BLOCK_BEGIN, BLOCK_END, BRANCH_GRAMMAR, HOOK_TYPES_LINE, HOOKS_BEGIN,
1343 HOOKS_DESTINATION, HOOKS_END, Kind, SCOPE_SHAPE, Style, Workflow, extract_block,
1344 hooks_block, kind_of, pair_files, projection, render, routing_block, splice_agents_block,
1345 splice_hooks_block,
1346 };
1347 use crate::embedded;
1348
1349 #[test]
1350 fn private_reporting_path_tokens_are_reproducible() {
1351 for repo in [
1352 "acme/widget",
1353 "acme/group/widget",
1354 "acme/OWNER-RK_STYLE-RK_SCOPE_SHAPE",
1355 ] {
1356 assert_eq!(
1357 super::render(
1358 b"RK_REPO RK_REPO OWNER RK_STYLE RK_SCOPE_SHAPE",
1359 &super::Params::for_test(repo, Some(super::Style::Trunk))
1360 ),
1361 format!("{repo} {repo} acme trunk {}", super::SCOPE_SHAPE).as_bytes()
1362 );
1363 }
1364 assert_eq!(super::kind_of("SECURITY.md"), Some(super::Kind::Rendered));
1365 }
1366
1367 #[test]
1372 fn each_forge_policy_carries_one_ordered_pair_of_every_span() {
1373 for forge in ["github", "gitlab"] {
1374 let bytes = embedded::SNIPPETS
1375 .get_file(format!("_shared/{forge}/SECURITY.md"))
1376 .expect("the policy ships")
1377 .contents();
1378 let text = String::from_utf8_lossy(bytes);
1379 for (begin, end) in super::SECURITY_SPANS {
1380 let begin = String::from_utf8_lossy(begin);
1381 let end = String::from_utf8_lossy(end);
1382 assert_eq!(text.matches(begin.as_ref()).count(), 1, "{forge} {begin}");
1383 assert_eq!(text.matches(end.as_ref()).count(), 1, "{forge} {end}");
1384 assert!(
1385 text.find(begin.as_ref()) < text.find(end.as_ref()),
1386 "{forge}: {begin} must precede {end}"
1387 );
1388 }
1389 }
1390 }
1391
1392 #[test]
1397 fn the_security_spans_render_per_answer() {
1398 for forge in ["github", "gitlab"] {
1399 let bytes = embedded::SNIPPETS
1400 .get_file(format!("_shared/{forge}/SECURITY.md"))
1401 .expect("the policy ships")
1402 .contents();
1403 let authored = String::from_utf8_lossy(bytes);
1404 let stripped = {
1405 let mut text = authored.clone().into_owned();
1406 for (begin, end) in super::SECURITY_SPANS {
1407 text = text.replace(&String::from_utf8_lossy(begin).into_owned(), "");
1408 text = text.replace(&String::from_utf8_lossy(end).into_owned(), "");
1409 }
1410 text
1411 };
1412 let default = super::Params {
1413 forge: forge.to_owned(),
1414 ..super::Params::for_test_security("", crate::config::RESPONSE_DEFAULT)
1415 };
1416 let rendered = String::from_utf8(render(bytes, &default)).expect("text");
1417 assert_eq!(
1418 rendered,
1419 stripped.replace("RK_REPO", "acme/widget"),
1420 "{forge}: the default answers must reproduce the authored policy"
1421 );
1422 assert!(!rendered.contains("RK_SECURITY"), "{forge}: {rendered}");
1423
1424 let answered = super::Params {
1425 forge: forge.to_owned(),
1426 ..super::Params::for_test_security("OWNER RK_REPO <team@acme.example>", "14 days")
1427 };
1428 let rendered = String::from_utf8(render(bytes, &answered)).expect("text");
1429 assert!(
1430 rendered.contains("OWNER RK_REPO <team@acme.example>"),
1431 "{forge}: a contact spelling a token name lands literally: {rendered}"
1432 );
1433 assert!(
1434 rendered.contains("Maintainers acknowledge a report within 14 days."),
1435 "{forge}: {rendered}"
1436 );
1437 assert!(
1438 rendered.contains("This policy commits to no disclosure deadline."),
1439 "{forge}: {rendered}"
1440 );
1441 assert!(
1442 !rendered.contains("best-effort basis"),
1443 "{forge}: a stated window replaces the best-effort sentence: {rendered}"
1444 );
1445 assert!(
1446 !rendered.contains("no response or disclosure deadline"),
1447 "{forge}: a stated window contradicts the response disclaimer: {rendered}"
1448 );
1449 }
1450 }
1451
1452 #[test]
1455 fn a_defective_span_renders_unchanged() {
1456 let (begin, end) = super::SECURITY_SPANS[0];
1457 let begin = String::from_utf8_lossy(begin).into_owned();
1458 let end = String::from_utf8_lossy(end).into_owned();
1459 let params = super::Params::for_test_security("team@acme.example", "1 day");
1460 for baseline in [
1461 format!("contact {begin}a maintainer\n"),
1462 format!("contact a maintainer{end}\n"),
1463 format!("contact {end}a maintainer{begin}\n"),
1464 "contact a maintainer\n".to_owned(),
1465 ] {
1466 assert_eq!(
1467 render(baseline.as_bytes(), ¶ms),
1468 baseline.as_bytes(),
1469 "{baseline}"
1470 );
1471 }
1472 }
1473
1474 #[test]
1478 fn the_kind_table_closes_over_every_snippet() {
1479 for tech_dir in embedded::SNIPPETS.dirs() {
1480 for pair_dir in tech_dir.dirs() {
1481 let prefix = format!("{}/", pair_dir.path().to_string_lossy());
1482 for (path, _) in embedded::walk(pair_dir) {
1483 let destination = path.strip_prefix(&prefix).unwrap_or(&path);
1484 assert!(
1485 kind_of(destination).is_some(),
1486 "{destination}: no declared kind"
1487 );
1488 }
1489 }
1490 }
1491 assert_eq!(kind_of(AGENTS_DESTINATION), Some(Kind::Rendered));
1492 assert_eq!(kind_of(HOOKS_DESTINATION), Some(Kind::Rendered));
1493 assert_eq!(kind_of("something-else.txt"), None);
1494 }
1495
1496 #[test]
1501 fn rendering_substitutes_every_owner_occurrence() {
1502 let baseline = b"if: repository_owner == 'OWNER'\n# OWNER again: OWNER\n";
1503 let rendered = render(baseline, &super::Params::for_test("acme/sub/widget", None));
1504 let text = String::from_utf8(rendered).expect("rendered bytes stay text");
1505 assert_eq!(text, "if: repository_owner == 'acme'\n# acme again: acme\n");
1506
1507 let baseline = b"match (RK_SCOPE_SHAPE)\n";
1508 let rendered = render(baseline, &super::Params::for_test("acme/widget", None));
1509 let text = String::from_utf8(rendered).expect("rendered bytes stay text");
1510 assert_eq!(text, format!("match ({SCOPE_SHAPE})\n"));
1511 }
1512
1513 #[test]
1517 fn the_scope_shape_drops_into_the_title_check() {
1518 assert_eq!(SCOPE_SHAPE, "[a-z0-9._/-]+");
1519 assert!(
1520 !SCOPE_SHAPE.contains('\''),
1521 "the title checks single-quote it"
1522 );
1523 }
1524
1525 #[test]
1530 fn the_scope_predicate_and_the_rendered_pattern_agree() {
1531 let body = SCOPE_SHAPE
1532 .strip_prefix('[')
1533 .and_then(|rest| rest.strip_suffix("]+"))
1534 .expect("the shape is one bracket expression, repeated");
1535 let chars: Vec<char> = body.chars().collect();
1536 let mut admitted = std::collections::BTreeSet::new();
1537 let mut at = 0;
1538 while at < chars.len() {
1539 if at + 2 < chars.len() && chars[at + 1] == '-' {
1542 for c in chars[at]..=chars[at + 2] {
1543 admitted.insert(c);
1544 }
1545 at += 3;
1546 } else {
1547 admitted.insert(chars[at]);
1548 at += 1;
1549 }
1550 }
1551 for byte in 0..=127u8 {
1552 let c = char::from(byte);
1553 assert_eq!(
1554 super::scope_is_shaped(&c.to_string()),
1555 admitted.contains(&c),
1556 "the predicate and {SCOPE_SHAPE} disagree on {c:?}"
1557 );
1558 }
1559 assert!(super::scope_is_shaped("guides/release"));
1560 assert!(!super::scope_is_shaped(""), "a scope is never empty");
1561 assert!(!super::scope_is_shaped("Specs Ugly"));
1562 }
1563
1564 #[test]
1567 fn the_shared_zone_composes_into_the_pair() {
1568 let files = pair_files("rust", "github").expect("the pair lists");
1569 assert!(
1570 files
1571 .iter()
1572 .any(|(dest, _)| dest == ".github/workflows/pr-title.yml"),
1573 "the shared title check lands with the pair"
1574 );
1575 let files = pair_files("rust", "gitlab").expect("the pair lists");
1576 assert!(
1577 files
1578 .iter()
1579 .any(|(dest, _)| dest == ".gitlab/ci/mr-title.yml"),
1580 "the shared title job lands with the pair"
1581 );
1582 let err = pair_files("_shared", "github").expect_err("the shared zone is no tech");
1583 let listing = err.to_string();
1584 let bindings = listing
1585 .split("the bindings are:")
1586 .nth(1)
1587 .expect("the refusal lists the bindings");
1588 assert!(!bindings.contains("_shared"), "{listing}");
1589 }
1590
1591 #[test]
1594 fn params_from_a_record_round_trips() {
1595 use super::{Params, manifest};
1596 let dir = tempfile::tempdir().expect("a scratch target exists");
1597 let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
1598 for tech in ["rust", "bash"] {
1599 for forge in ["github", "gitlab"] {
1600 for workflow in [Workflow::Branches, Workflow::Worktree] {
1601 for style in [None, Some(Style::Trunk), Some(Style::Lines)] {
1602 for nix in [false, true] {
1603 let record = manifest::Manifest {
1604 schema_version: manifest::SCHEMA_VERSION,
1605 rk_version: "0.1.0".to_owned(),
1606 payload_sha256: crate::digest::Digest::of(b""),
1607 origin: "init".to_owned(),
1608 tech: tech.to_owned(),
1609 forge: forge.to_owned(),
1610 landed_at: "2026-08-29T00:00:00Z".to_owned(),
1611 parameters: manifest::Parameters {
1612 repo: "acme/team/widget".to_owned(),
1613 workflow,
1614 style,
1615 nix,
1616 trunk: crate::config::TRUNK_DEFAULT.to_owned(),
1617 line_prefix: crate::config::LINE_PREFIX_DEFAULT.to_owned(),
1618 security_contact: String::new(),
1619 security_response: crate::config::RESPONSE_DEFAULT.to_owned(),
1620 },
1621 files: Vec::new(),
1622 pins: std::collections::BTreeMap::new(),
1623 };
1624 manifest::write(target, &record).expect("the record writes");
1625 let loaded = manifest::load(target)
1626 .expect("the record loads")
1627 .expect("the record exists");
1628 let params = Params::from_record(&loaded);
1629 assert_eq!(params.tech, tech);
1630 assert_eq!(params.forge, forge);
1631 assert_eq!(params.repo(), "acme/team/widget");
1632 assert_eq!(params.workflow(), workflow);
1633 assert_eq!(params.style(), style);
1634 assert_eq!(params.nix, nix);
1635 let entries = projection(¶ms).expect("the record projects");
1636 let mut expected: Vec<_> = pair_files(tech, forge)
1637 .expect("the pair lists")
1638 .into_iter()
1639 .filter(|(path, _)| {
1640 nix || !super::NIX_DESTINATIONS.contains(&path.as_str())
1641 })
1642 .collect();
1643 let routing = super::routing_block(workflow);
1644 let hooks = super::hooks_block(workflow);
1645 expected.push((AGENTS_DESTINATION.to_owned(), routing.as_bytes()));
1646 expected.push((HOOKS_DESTINATION.to_owned(), hooks.as_bytes()));
1647 expected.sort_by(|a, b| a.0.cmp(&b.0));
1648 assert_eq!(entries.len(), expected.len());
1649 for (entry, (destination, baseline)) in entries.iter().zip(expected) {
1650 assert_eq!(entry.destination, destination);
1651 assert_eq!(entry.baseline, baseline);
1652 let rendered = match entry.kind {
1653 Kind::Rendered => super::render(
1654 baseline,
1655 &super::Params::for_test("acme/team/widget", style),
1656 ),
1657 Kind::Seeded | Kind::State => baseline.to_vec(),
1658 };
1659 assert_eq!(entry.rendered, rendered, "{destination}");
1660 }
1661 }
1662 }
1663 }
1664 }
1665 }
1666 }
1667
1668 fn resolved_test_params(
1669 tech: &str,
1670 resolved: &super::Resolved,
1671 workflow: Workflow,
1672 style: Option<Style>,
1673 nix: bool,
1674 ) -> Result<super::Params, crate::error::RkError> {
1675 super::Params::resolve(
1676 camino::Utf8Path::new("."),
1677 &super::Inputs {
1678 tech: Some(tech),
1679 forge: Some(&resolved.forge),
1680 repo: resolved.repo.as_deref(),
1681 workflow: Some(workflow),
1682 style,
1683 nix: Some(nix),
1684 },
1685 None,
1686 None,
1687 super::Purpose::Init,
1688 )
1689 }
1690
1691 #[test]
1695 fn a_projection_renders_owned_files_and_keeps_seeded_judgment() {
1696 let entries = projection(
1697 &resolved_test_params(
1698 "rust",
1699 &super::Resolved {
1700 forge: "github".to_owned(),
1701 repo: Some("acme/widget".to_owned()),
1702 },
1703 Workflow::Branches,
1704 Some(Style::Trunk),
1705 false,
1706 )
1707 .expect("the parameters resolve"),
1708 )
1709 .expect("the pair projects");
1710 let workflow = entries
1711 .iter()
1712 .find(|entry| entry.destination.ends_with("release-plz.yml"))
1713 .expect("the workflow projects");
1714 assert_eq!(workflow.kind, Kind::Rendered);
1715 let text = String::from_utf8_lossy(&workflow.rendered);
1716 assert!(!text.contains("OWNER"), "an owner token survived rendering");
1717 assert!(text.contains("'acme'"));
1718 assert!(!text.contains("TODO(release-kit)"));
1719 let title = entries
1720 .iter()
1721 .find(|entry| entry.destination.ends_with("pr-title.yml"))
1722 .expect("the title check projects");
1723 let text = String::from_utf8_lossy(&title.rendered);
1724 assert!(text.contains(SCOPE_SHAPE), "{text}");
1725 assert!(
1726 !text.contains("RK_SCOPE_SHAPE"),
1727 "a scope token survived: {text}"
1728 );
1729 let seeded = entries
1730 .iter()
1731 .find(|entry| entry.destination == "release-plz.toml")
1732 .expect("the seeded file projects");
1733 assert_eq!(seeded.kind, Kind::Seeded);
1734 assert_eq!(seeded.rendered, seeded.baseline);
1735 assert!(String::from_utf8_lossy(&seeded.rendered).contains("TODO(release-kit)"));
1736 for block in [AGENTS_DESTINATION, HOOKS_DESTINATION] {
1737 let entry = entries
1738 .iter()
1739 .find(|entry| entry.destination == block)
1740 .expect("both blocks are part of the projection");
1741 let text = String::from_utf8_lossy(&entry.rendered);
1742 assert!(
1743 !text.contains("RK_SCOPE_SHAPE"),
1744 "{block} kept a token: {text}"
1745 );
1746 }
1747 }
1748
1749 #[test]
1754 fn the_nix_destinations_project_only_under_the_opt_in() {
1755 use super::NIX_DESTINATIONS;
1756 let paths = |nix: bool, forge: &str| -> Vec<String> {
1757 projection(
1758 &resolved_test_params(
1759 "rust",
1760 &super::Resolved {
1761 forge: forge.to_owned(),
1762 repo: Some("acme/widget".to_owned()),
1763 },
1764 Workflow::Worktree,
1765 Some(Style::Trunk),
1766 nix,
1767 )
1768 .expect("the parameters resolve"),
1769 )
1770 .expect("the pair projects")
1771 .into_iter()
1772 .map(|entry| entry.destination)
1773 .collect()
1774 };
1775 let off = paths(false, "github");
1776 for destination in NIX_DESTINATIONS {
1777 assert!(!off.contains(&destination.to_owned()), "{destination}");
1778 }
1779 let on = paths(true, "github");
1780 for destination in ["nix/package.nix", "flake.nix", "flake.lock"] {
1781 assert!(on.contains(&destination.to_owned()), "{destination}");
1782 }
1783 let gitlab = paths(true, "gitlab");
1788 assert!(gitlab.contains(&"nix/package.nix".to_owned()));
1789 assert!(
1790 !on.iter()
1791 .chain(gitlab.iter())
1792 .any(|destination| destination.contains("nix.yml"))
1793 );
1794 let bash = projection(
1795 &resolved_test_params(
1796 "bash",
1797 &super::Resolved {
1798 forge: "github".to_owned(),
1799 repo: Some("acme/widget".to_owned()),
1800 },
1801 Workflow::Worktree,
1802 Some(Style::Trunk),
1803 true,
1804 )
1805 .expect("the parameters resolve"),
1806 )
1807 .expect("an out-of-matrix pair projects the smaller product");
1808 assert!(
1809 bash.iter()
1810 .all(|entry| !NIX_DESTINATIONS.contains(&entry.destination.as_str()))
1811 );
1812 }
1813
1814 #[test]
1819 fn the_nix_seeds_are_identical_across_forge_pairs() {
1820 for name in ["nix/package.nix", "flake.nix", "flake.lock"] {
1821 let github = embedded::SNIPPETS
1822 .get_file(format!("rust/github/{name}"))
1823 .expect("the github copy ships")
1824 .contents();
1825 let gitlab = embedded::SNIPPETS
1826 .get_file(format!("rust/gitlab/{name}"))
1827 .expect("the gitlab copy ships")
1828 .contents();
1829 assert_eq!(github, gitlab, "{name} diverged between the pairs");
1830 }
1831 }
1832
1833 #[test]
1838 fn the_nix_withhold_judgment_covers_the_three_shapes() {
1839 use super::{NIX_DESTINATIONS, withhold_nix};
1840 let dir = tempfile::tempdir().expect("a scratch target exists");
1841 let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
1842 let entries = || {
1843 projection(
1844 &resolved_test_params(
1845 "rust",
1846 &super::Resolved {
1847 forge: "github".to_owned(),
1848 repo: Some("acme/widget".to_owned()),
1849 },
1850 Workflow::Worktree,
1851 Some(Style::Trunk),
1852 true,
1853 )
1854 .expect("the parameters resolve"),
1855 )
1856 .expect("the pair projects")
1857 };
1858
1859 let mut all = entries();
1861 let withheld = withhold_nix(target, true, None, &mut all).expect("the judgment runs");
1862 let paths: Vec<&str> = withheld.iter().map(|w| w.path.as_str()).collect();
1863 assert_eq!(paths, ["flake.lock", "flake.nix", "nix/package.nix"]);
1864 assert!(
1865 all.iter()
1866 .all(|entry| !NIX_DESTINATIONS.contains(&entry.destination.as_str()))
1867 );
1868
1869 std::fs::write(
1872 target.join("Cargo.toml"),
1873 "[package]\nname = \"widget\"\nversion = \"0.1.0\"\n",
1874 )
1875 .expect("the crate manifest writes");
1876 std::fs::write(target.join("Cargo.lock"), "version = 4\n").expect("the lock writes");
1877 std::fs::create_dir_all(target.join("src")).expect("the src dir exists");
1878 std::fs::write(target.join("src/main.rs"), "fn main() {}\n").expect("the main writes");
1879 std::fs::write(target.join("flake.nix"), "{ }\n").expect("the flake writes");
1880 let mut all = entries();
1881 let withheld = withhold_nix(target, true, None, &mut all).expect("the judgment runs");
1882 let paths: Vec<&str> = withheld.iter().map(|w| w.path.as_str()).collect();
1883 assert_eq!(paths, ["flake.lock", "flake.nix"]);
1884 assert!(
1885 all.iter()
1886 .any(|entry| entry.destination == "nix/package.nix")
1887 );
1888
1889 std::fs::remove_file(target.join("flake.nix")).expect("the flake removes");
1891 let mut all = entries();
1892 let withheld = withhold_nix(target, true, None, &mut all).expect("the judgment runs");
1893 assert!(withheld.is_empty());
1894 assert!(all.iter().any(|entry| entry.destination == "flake.nix"));
1895
1896 let mut all = entries();
1898 let withheld = withhold_nix(target, false, None, &mut all).expect("the judgment runs");
1899 assert!(withheld.is_empty());
1900 }
1901
1902 #[test]
1903 fn the_block_splices_into_every_agents_shape() {
1904 let owned = routing_block(Workflow::Branches);
1905 let block = owned.as_str();
1906 let fresh = splice_agents_block(None, block);
1907 assert_eq!(fresh, format!("{block}\n"));
1908 assert_eq!(extract_block(&fresh, BLOCK_BEGIN, BLOCK_END), Some(block));
1909
1910 let appended = splice_agents_block(Some("# My project\n\nOwn rules.\n"), block);
1911 assert!(appended.starts_with("# My project\n\nOwn rules.\n\n<!-- BEGIN release-kit -->"));
1912 assert_eq!(
1913 extract_block(&appended, BLOCK_BEGIN, BLOCK_END),
1914 Some(block)
1915 );
1916
1917 let stale = appended.replace("Never author a tag", "Do author a tag");
1918 let refreshed = splice_agents_block(Some(&stale), block);
1919 assert_eq!(
1920 extract_block(&refreshed, BLOCK_BEGIN, BLOCK_END),
1921 Some(block)
1922 );
1923 assert!(refreshed.starts_with("# My project"));
1924 assert_eq!(
1925 refreshed.matches("BEGIN release-kit").count(),
1926 1,
1927 "a re-splice must replace, not accumulate"
1928 );
1929 }
1930
1931 #[test]
1934 fn the_hook_block_splices_under_repos() {
1935 let owned = hooks_block(Workflow::Branches);
1936 let block = owned.as_str();
1937 let fresh = splice_hooks_block(None, block).expect("a fresh file splices");
1938 assert!(fresh.starts_with(HOOK_TYPES_LINE));
1939 assert!(fresh.contains("\nrepos:\n# BEGIN release-kit\n"));
1940 assert_eq!(extract_block(&fresh, HOOKS_BEGIN, HOOKS_END), Some(block));
1941
1942 let own =
1943 "repos:\n - repo: https://example.com/own\n rev: v1\n hooks:\n - id: own\n";
1944 let spliced = splice_hooks_block(Some(own), block).expect("an unmarked file splices");
1945 assert!(spliced.starts_with("repos:\n# BEGIN release-kit\n"));
1946 assert!(spliced.contains("- id: own"), "the target's hooks survive");
1947 assert!(
1948 !spliced.contains(HOOK_TYPES_LINE),
1949 "an existing file's top level is the skills' duty, not the splice's"
1950 );
1951
1952 let stale = spliced.replace("--force-scope", "--no-scope");
1953 let refreshed = splice_hooks_block(Some(&stale), block).expect("a marked file re-splices");
1954 assert_eq!(
1955 extract_block(&refreshed, HOOKS_BEGIN, HOOKS_END),
1956 Some(block)
1957 );
1958 assert_eq!(refreshed.matches(HOOKS_BEGIN).count(), 1);
1959
1960 let err = splice_hooks_block(Some("minimum_pre_commit_version: '3.2.0'\n"), block)
1961 .expect_err("no repos: line refuses");
1962 assert!(err.contains("repos:"), "{err}");
1963
1964 let doubled = format!("repos:\n{block}\n{block}\n");
1968 let err = splice_hooks_block(Some(&doubled), block).expect_err("a second block refuses");
1969 assert!(err.contains("one block"), "{err}");
1970 let unmatched = "repos:\n# BEGIN release-kit\n - repo: local\n";
1971 let err =
1972 splice_hooks_block(Some(unmatched), block).expect_err("an unmatched marker refuses");
1973 assert!(err.contains("unmatched"), "{err}");
1974 }
1975
1976 #[test]
1982 fn the_blocks_render_per_mode_and_carry_the_one_grammar() {
1983 let worktree_hooks = hooks_block(Workflow::Worktree);
1984 let branches_hooks = hooks_block(Workflow::Branches);
1985 assert!(worktree_hooks.contains("- id: rk-worktree-location"));
1986 assert!(
1987 worktree_hooks.contains("SKIP=no-commit-to-branch,rk-worktree-location"),
1988 "{worktree_hooks}"
1989 );
1990 assert!(!branches_hooks.contains("rk-worktree-location"));
1991 assert!(branches_hooks.contains("SKIP=no-commit-to-branch in"));
1992 for block in [&worktree_hooks, &branches_hooks] {
1993 assert!(block.contains(BRANCH_GRAMMAR), "the grammar has one owner");
1994 for token in ["RK_BRANCH_GRAMMAR", "RK_SWEEP_SKIP", "RK_WORKTREE_GUARD"] {
1995 assert!(!block.contains(token), "{token} survived: {block}");
1996 }
1997 }
1998 for block in [&worktree_hooks, &branches_hooks] {
2003 for line in block.lines() {
2004 if let Some(value) = line.trim_start().strip_prefix("entry: ") {
2005 assert!(
2006 !value.contains(": "),
2007 "an entry value breaks the YAML plain scalar: {line}"
2008 );
2009 }
2010 }
2011 }
2012 let guard_line = worktree_hooks
2013 .lines()
2014 .position(|line| line.contains("id: rk-worktree-location"))
2015 .expect("the guard entry exists");
2016 let name_line = worktree_hooks
2017 .lines()
2018 .position(|line| line.contains("id: rk-branch-name"))
2019 .expect("the name hook exists");
2020 assert!(
2021 guard_line > name_line,
2022 "the guard lands directly after rk-branch-name"
2023 );
2024
2025 let worktree_routing = routing_block(Workflow::Worktree);
2026 let branches_routing = routing_block(Workflow::Branches);
2027 assert!(worktree_routing.contains("This project works in worktrees"));
2028 assert!(branches_routing.contains("Branches are worked in the main checkout"));
2029 for block in [&worktree_routing, &branches_routing] {
2030 assert!(block.contains("Create or remove a worktree"));
2031 assert!(block.contains("`rk worktree add <branch>`"));
2032 assert!(!block.contains("RK_WORKFLOW_LINE"), "{block}");
2033 }
2034 let differing: Vec<(&str, &str)> = worktree_routing
2035 .lines()
2036 .zip(branches_routing.lines())
2037 .filter(|(a, b)| a != b)
2038 .collect();
2039 assert_eq!(
2040 differing.len(),
2041 1,
2042 "exactly one routing line differs per mode: {differing:?}"
2043 );
2044 }
2045
2046 #[test]
2049 fn the_hook_marker_defects_are_named() {
2050 use super::hooks_marker_defect;
2051 let owned = hooks_block(Workflow::Branches);
2052 let block = owned.as_str();
2053 assert_eq!(hooks_marker_defect(""), None);
2054 assert_eq!(hooks_marker_defect(&format!("repos:\n{block}\n")), None);
2055 for (case, text) in [
2056 (
2057 "a second begin",
2058 format!("repos:\n{block}\n# BEGIN release-kit\n"),
2059 ),
2060 (
2061 "a second end",
2062 format!("repos:\n{block}\n# END release-kit\n"),
2063 ),
2064 (
2065 "an unpaired begin",
2066 "repos:\n# BEGIN release-kit\n".to_owned(),
2067 ),
2068 ("an unpaired end", "repos:\n# END release-kit\n".to_owned()),
2069 (
2070 "an end before its begin",
2071 "repos:\n# END release-kit\n# BEGIN release-kit\n".to_owned(),
2072 ),
2073 ] {
2074 assert!(
2075 hooks_marker_defect(&text).is_some(),
2076 "{case} must be a defect"
2077 );
2078 }
2079 }
2080}