1pub mod invariants;
13pub mod manifest;
14
15use camino::Utf8Path;
16use serde::{Deserialize, Serialize};
17
18pub use manifest::{Style, Workflow};
19
20use crate::atomic;
21use crate::diagnostic::{Diagnostic, Reason};
22use crate::error::RkError;
23use crate::release::{self, ReleaseManifest, ReleaseSource};
24
25#[derive(Debug)]
28pub struct Params {
29 tech: String,
30 forge: String,
31 repo: String,
32 workflow: Workflow,
33 style: Option<Style>,
34 nix: bool,
35 trunk: String,
36 line_prefix: String,
37 security_contact: String,
38 security_response: String,
39}
40
41#[derive(Default)]
43pub struct Inputs<'a> {
44 pub tech: Option<&'a str>,
46 pub forge: Option<&'a str>,
48 pub repo: Option<&'a str>,
50 pub workflow: Option<Workflow>,
52 pub style: Option<Style>,
54 pub nix: Option<bool>,
56}
57
58#[derive(Clone, Copy, PartialEq, Eq)]
60pub enum Purpose {
61 Init,
63 Preview,
65 Upgrade,
67 Adopt,
69}
70
71impl Params {
72 #[must_use]
75 pub fn from_record(record: &manifest::Manifest) -> Self {
76 Self {
77 tech: record.tech.clone(),
78 forge: record.forge.clone(),
79 repo: record.parameters.repo.clone(),
80 workflow: record.parameters.workflow,
81 style: record.parameters.style,
82 nix: record.parameters.nix,
83 trunk: record.parameters.trunk.clone(),
84 line_prefix: record.parameters.line_prefix.clone(),
85 security_contact: record.parameters.security_contact.clone(),
86 security_response: record.parameters.security_response.clone(),
87 }
88 }
89
90 pub fn resolve(
96 source: &dyn ReleaseSource,
97 target: &Utf8Path,
98 flags: &Inputs<'_>,
99 config: Option<&crate::config::Config>,
100 record: Option<&manifest::Manifest>,
101 purpose: Purpose,
102 ) -> Result<Self, RkError> {
103 let answer = |flag: Option<&str>, configured: Option<&str>, recorded: Option<&str>| {
104 flag.or_else(|| configured.filter(|value| !value.is_empty()))
105 .or(recorded)
106 .map(str::to_owned)
107 };
108 let forge = answer(
109 flags.forge,
110 config.map(|c| c.project.forge.as_str()),
111 record.map(|r| r.forge.as_str()),
112 );
113 let repo = answer(
114 flags.repo,
115 config.map(|c| c.project.repo.as_str()),
116 record.map(|r| r.parameters.repo.as_str()),
117 );
118 let resolved = resolve(target, forge.as_deref(), repo.as_deref())?;
119 let tech = answer(
120 flags.tech,
121 config.map(|c| c.project.tech.as_str()),
122 record.map(|r| r.tech.as_str()),
123 )
124 .or_else(|| crate::detect::tech_of(target.as_std_path()).map(str::to_owned))
125 .ok_or_else(|| {
126 RkError::missing(
127 Diagnostic::new(
128 Reason::TargetNotFound,
129 "no technology detected: the target has no version file",
130 )
131 .action("pass --tech <rust|python|bash>"),
132 )
133 })?;
134 pair_files(source, &tech, &resolved.forge)?;
135 let workflow = flags
136 .workflow
137 .or_else(|| config.and_then(|c| c.landing.workflow))
138 .or_else(|| record.map(|r| r.parameters.workflow))
139 .unwrap_or(if purpose == Purpose::Adopt {
140 Workflow::Branches
141 } else {
142 Workflow::Worktree
143 });
144 let style = flags
145 .style
146 .or_else(|| config.and_then(|c| c.landing.style))
147 .or_else(|| record.and_then(|r| r.parameters.style));
148 let style = match (style, purpose) {
149 (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())),
150 (value, _) => Some(value.unwrap_or(Style::Trunk)),
151 };
152 let repo = resolved
153 .repo
154 .or_else(|| (purpose == Purpose::Preview).then(|| "OWNER".to_owned()))
155 .ok_or_else(repo_unresolved)?;
156 let trunk = config
157 .and_then(|c| c.project.trunk.clone())
158 .or_else(|| record.map(|r| r.parameters.trunk.clone()))
159 .unwrap_or_else(|| crate::config::TRUNK_DEFAULT.to_owned());
160 let line_prefix = config
161 .and_then(|c| c.setup.line_prefix.clone())
162 .or_else(|| record.map(|r| r.parameters.line_prefix.clone()))
163 .unwrap_or_else(|| crate::config::LINE_PREFIX_DEFAULT.to_owned());
164 let security_contact = config
169 .and_then(|c| c.security.contact.clone())
170 .or_else(|| record.map(|r| r.parameters.security_contact.clone()))
171 .unwrap_or_default();
172 let security_contact =
173 crate::config::canonical_contact(&security_contact).map_err(crate::config::invalid)?;
174 let security_response = config
175 .and_then(|c| c.security.response.clone())
176 .or_else(|| record.map(|r| r.parameters.security_response.clone()))
177 .unwrap_or_else(|| crate::config::RESPONSE_DEFAULT.to_owned());
178 let security_response = crate::config::canonical_response(&security_response)
179 .map_err(crate::config::invalid)?;
180 Ok(Self {
181 tech,
182 forge: resolved.forge,
183 repo,
184 workflow,
185 style,
186 nix: flags
187 .nix
188 .or_else(|| config.and_then(|c| c.landing.nix))
189 .or_else(|| record.map(|r| r.parameters.nix))
190 .unwrap_or(false),
191 trunk,
192 line_prefix,
193 security_contact,
194 security_response,
195 })
196 }
197
198 #[must_use]
200 pub fn tech(&self) -> &str {
201 &self.tech
202 }
203
204 #[must_use]
206 pub fn forge(&self) -> &str {
207 &self.forge
208 }
209
210 #[must_use]
212 pub const fn nix(&self) -> bool {
213 self.nix
214 }
215
216 #[must_use]
218 pub fn repo(&self) -> &str {
219 &self.repo
220 }
221
222 #[must_use]
224 pub const fn workflow(&self) -> Workflow {
225 self.workflow
226 }
227
228 #[must_use]
230 pub const fn style(&self) -> Option<Style> {
231 self.style
232 }
233
234 #[must_use]
236 pub fn trunk(&self) -> &str {
237 &self.trunk
238 }
239
240 #[must_use]
242 pub fn line_prefix(&self) -> &str {
243 &self.line_prefix
244 }
245
246 #[must_use]
249 pub fn security_contact(&self) -> &str {
250 &self.security_contact
251 }
252
253 #[must_use]
255 pub fn security_response(&self) -> &str {
256 &self.security_response
257 }
258}
259
260#[cfg(test)]
261impl Params {
262 pub(crate) fn for_test(repo: &str, style: Option<Style>) -> Self {
266 Self {
267 tech: "rust".to_owned(),
268 forge: "github".to_owned(),
269 repo: repo.to_owned(),
270 workflow: Workflow::Worktree,
271 style,
272 nix: false,
273 trunk: crate::config::TRUNK_DEFAULT.to_owned(),
274 line_prefix: crate::config::LINE_PREFIX_DEFAULT.to_owned(),
275 security_contact: String::new(),
276 security_response: crate::config::RESPONSE_DEFAULT.to_owned(),
277 }
278 }
279
280 pub(crate) fn for_test_security(contact: &str, response: &str) -> Self {
282 Self {
283 security_contact: contact.to_owned(),
284 security_response: response.to_owned(),
285 ..Self::for_test("acme/widget", Some(Style::Trunk))
286 }
287 }
288}
289
290#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
292#[serde(rename_all = "lowercase")]
293pub enum Kind {
294 Rendered,
297 Seeded,
300 State,
303}
304
305impl Kind {
306 #[must_use]
308 pub const fn as_str(self) -> &'static str {
309 match self {
310 Self::Rendered => "rendered",
311 Self::Seeded => "seeded",
312 Self::State => "state",
313 }
314 }
315}
316
317const KINDS: [(&str, Kind); 16] = [
323 (".github/workflows/release-plz.yml", Kind::Rendered),
324 (".github/workflows/release-please.yml", Kind::Rendered),
325 (".github/workflows/release.yml", Kind::Rendered),
326 (".github/workflows/pr-title.yml", Kind::Rendered),
327 (".gitlab-ci.yml", Kind::Rendered),
328 ("SECURITY.md", Kind::Rendered),
329 (".gitlab/ci/mr-title.yml", Kind::Rendered),
330 ("release-plz.toml", Kind::Seeded),
331 ("dist-workspace.toml", Kind::Seeded),
332 ("release-please-config.json", Kind::Seeded),
333 ("cliff.toml", Kind::Seeded),
334 ("nix/package.nix", Kind::Seeded),
335 ("flake.nix", Kind::Seeded),
336 (".release-please-manifest.json", Kind::State),
337 ("VERSION", Kind::State),
338 ("flake.lock", Kind::State),
339];
340
341pub const NIX_DESTINATIONS: [&str; 3] = ["nix/package.nix", "flake.nix", "flake.lock"];
355
356pub const NIX_WITHHOLDABLE: [&str; 2] = ["flake.nix", "flake.lock"];
362
363#[must_use]
366pub fn kind_of(destination: &str) -> Option<Kind> {
367 if destination == AGENTS_DESTINATION || destination == HOOKS_DESTINATION {
368 return Some(Kind::Rendered);
369 }
370 KINDS
371 .iter()
372 .find(|(name, _)| *name == destination)
373 .map(|(_, kind)| *kind)
374}
375
376pub fn destinations() -> impl Iterator<Item = &'static str> {
380 KINDS
381 .iter()
382 .map(|(name, _)| *name)
383 .chain([AGENTS_DESTINATION, HOOKS_DESTINATION])
384}
385
386pub const OWNER_TOKEN: &[u8] = b"OWNER";
393
394pub const REPO_TOKEN: &[u8] = b"RK_REPO";
396
397pub const SCOPE_SHAPE_TOKEN: &[u8] = b"RK_SCOPE_SHAPE";
399
400pub const STYLE_TOKEN: &[u8] = b"RK_STYLE";
403
404pub const TRUNK_BRANCH_TOKEN: &[u8] = b"RK_TRUNK_BRANCH";
408
409pub const LINE_PREFIX_TOKEN: &[u8] = b"RK_LINE_PREFIX";
412
413pub const LINE_PREFIX_RE_TOKEN: &[u8] = b"RK_LINE_PREFIX_RE";
419
420pub const SECURITY_SPANS: [(&[u8], &[u8]); 3] = [
431 (
432 b"<!--RK_SECURITY_CONTACT_BEGIN-->",
433 b"<!--RK_SECURITY_CONTACT_END-->",
434 ),
435 (
436 b"<!--RK_SECURITY_RESPONSE_BEGIN-->",
437 b"<!--RK_SECURITY_RESPONSE_END-->",
438 ),
439 (
440 b"<!--RK_SECURITY_DEADLINE_BEGIN-->",
441 b"<!--RK_SECURITY_DEADLINE_END-->",
442 ),
443];
444
445fn acknowledgment(response: &str) -> String {
448 format!("Maintainers acknowledge a report within {response}.")
449}
450
451const DISCLOSURE_ONLY: &[u8] = b"This policy commits to no disclosure deadline.";
455
456fn security_replacements(params: &Params) -> [Option<Vec<u8>>; 3] {
459 let contact = (!params.security_contact().is_empty())
460 .then(|| params.security_contact().as_bytes().to_vec());
461 let promised = params.security_response() != crate::config::RESPONSE_DEFAULT;
462 [
463 contact,
464 promised.then(|| acknowledgment(params.security_response()).into_bytes()),
465 promised.then(|| DISCLOSURE_ONLY.to_vec()),
466 ]
467}
468
469fn replace_span(baseline: &[u8], begin: &[u8], end: &[u8], value: Option<&[u8]>) -> Vec<u8> {
475 let ordered = find(baseline, begin)
476 .zip(find(baseline, end))
477 .filter(|(start, stop)| stop > start);
478 let Some((start, stop)) = ordered else {
479 return baseline.to_vec();
480 };
481 let mut out = Vec::with_capacity(baseline.len());
482 out.extend_from_slice(&baseline[..start]);
483 out.extend_from_slice(value.unwrap_or_else(|| &baseline[start + begin.len()..stop]));
484 out.extend_from_slice(&baseline[stop + end.len()..]);
485 out
486}
487
488#[must_use]
506pub fn render(baseline: &[u8], params: &Params) -> Vec<u8> {
507 let repo = params.repo();
508 let owner = repo.split('/').next().unwrap_or(repo);
509 let mut out = substitute(baseline, OWNER_TOKEN, owner.as_bytes());
510 if let Some(style) = params.style() {
511 out = substitute(&out, STYLE_TOKEN, style.as_str().as_bytes());
512 }
513 out = substitute(&out, SCOPE_SHAPE_TOKEN, SCOPE_SHAPE.as_bytes());
514 out = substitute(&out, TRUNK_BRANCH_TOKEN, params.trunk().as_bytes());
515 let escaped = params.line_prefix().replace('/', "\\/");
516 out = substitute(&out, LINE_PREFIX_RE_TOKEN, escaped.as_bytes());
517 out = substitute(&out, LINE_PREFIX_TOKEN, params.line_prefix().as_bytes());
518 out = substitute(&out, REPO_TOKEN, repo.as_bytes());
519 for ((begin, end), value) in SECURITY_SPANS.iter().zip(security_replacements(params)) {
520 out = replace_span(&out, begin, end, value.as_deref());
521 }
522 out
523}
524
525pub(crate) fn substitute(baseline: &[u8], token: &[u8], value: &[u8]) -> Vec<u8> {
527 let mut out = Vec::with_capacity(baseline.len());
528 let mut rest = baseline;
529 while let Some(at) = find(rest, token) {
530 out.extend_from_slice(&rest[..at]);
531 out.extend_from_slice(value);
532 rest = &rest[at + token.len()..];
533 }
534 out.extend_from_slice(rest);
535 out
536}
537
538fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
540 haystack
541 .windows(needle.len())
542 .position(|window| window == needle)
543}
544
545pub const AGENTS_DESTINATION: &str = "AGENTS.md";
547
548pub const BLOCK_BEGIN: &str = "<!-- BEGIN release-kit -->";
550
551pub const BLOCK_END: &str = "<!-- END release-kit -->";
553
554pub const HOOKS_DESTINATION: &str = ".pre-commit-config.yaml";
556
557pub const HOOKS_BEGIN: &str = "# BEGIN release-kit";
559
560pub const HOOKS_END: &str = "# END release-kit";
562
563pub const HOOK_TYPES_LINE: &str = "default_install_hook_types: [pre-commit, commit-msg, pre-push]";
567
568const AGENTS_BLOCK: &str = "blocks/agents-block.md.in";
570
571const AGENTS_LINE_WORKTREE: &str = "blocks/agents-line-worktree.md.in";
573
574const AGENTS_LINE_BRANCHES: &str = "blocks/agents-line-branches.md.in";
576
577const PRE_COMMIT_BLOCK: &str = "blocks/pre-commit-block.yaml.in";
579
580const PRE_COMMIT_WORKTREE_GUARD: &str = "blocks/pre-commit-worktree-guard.yaml.in";
582
583fn block(
585 source: &dyn ReleaseSource,
586 manifest: &ReleaseManifest,
587 path: &str,
588) -> Result<String, RkError> {
589 let bytes = release::read(source, manifest, path)?;
590 String::from_utf8(bytes).map_err(|_| anyhow::anyhow!("{path}: a block is UTF-8").into())
591}
592
593fn authored(text: &str) -> &str {
597 text.strip_suffix('\n').unwrap_or(text)
598}
599
600pub 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[-/].+)$";
608
609pub const SCOPE_SHAPE: &str = "[a-z0-9._/-]+";
619
620#[must_use]
627pub fn scope_is_shaped(scope: &str) -> bool {
628 !scope.is_empty()
629 && scope.chars().all(|c| {
630 c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '/' | '-')
631 })
632}
633
634pub fn routing_block(source: &dyn ReleaseSource, workflow: Workflow) -> Result<String, RkError> {
648 let manifest = source.manifest()?;
649 let line = block(
650 source,
651 &manifest,
652 match workflow {
653 Workflow::Worktree => AGENTS_LINE_WORKTREE,
654 Workflow::Branches => AGENTS_LINE_BRANCHES,
655 },
656 )?;
657 let template = block(source, &manifest, AGENTS_BLOCK)?;
658 Ok(authored(&template).replacen("RK_WORKFLOW_LINE", authored(&line), 1))
659}
660
661pub fn hooks_block(source: &dyn ReleaseSource, workflow: Workflow) -> Result<String, RkError> {
679 let manifest = source.manifest()?;
680 let (guard, skip) = match workflow {
681 Workflow::Worktree => (
682 format!(
683 "{}\n",
684 authored(&block(source, &manifest, PRE_COMMIT_WORKTREE_GUARD)?)
685 ),
686 "no-commit-to-branch,rk-worktree-location",
687 ),
688 Workflow::Branches => (String::new(), "no-commit-to-branch"),
689 };
690 let template = block(source, &manifest, PRE_COMMIT_BLOCK)?;
691 Ok(authored(&template)
692 .replacen("RK_BRANCH_GRAMMAR", BRANCH_GRAMMAR, 1)
693 .replacen("RK_SWEEP_SKIP", skip, 1)
694 .replacen("RK_WORKTREE_GUARD", &guard, 1))
695}
696
697#[must_use]
699pub fn block_markers(destination: &str) -> Option<(&'static str, &'static str)> {
700 match destination {
701 AGENTS_DESTINATION => Some((BLOCK_BEGIN, BLOCK_END)),
702 HOOKS_DESTINATION => Some((HOOKS_BEGIN, HOOKS_END)),
703 _ => None,
704 }
705}
706
707#[must_use]
710pub fn extract_block<'a>(text: &'a str, begin: &str, end: &str) -> Option<&'a str> {
711 let start = text.find(begin)?;
712 let stop = text[start..].find(end)? + start + end.len();
713 Some(&text[start..stop])
714}
715
716#[must_use]
722pub fn splice_agents_block(existing: Option<&str>, block: &str) -> String {
723 existing.map_or_else(
724 || format!("{block}\n"),
725 |text| {
726 extract_block(text, BLOCK_BEGIN, BLOCK_END).map_or_else(
727 || format!("{}\n\n{block}\n", text.trim_end()),
728 |found| text.replacen(found, block, 1),
729 )
730 },
731 )
732}
733
734pub fn splice_hooks_block(existing: Option<&str>, block: &str) -> Result<String, String> {
747 let Some(text) = existing else {
748 return Ok(format!("{HOOK_TYPES_LINE}\n\nrepos:\n{block}\n"));
749 };
750 if let Some(defect) = hooks_marker_defect(text) {
751 return Err(defect);
752 }
753 if let Some(found) = extract_block(text, HOOKS_BEGIN, HOOKS_END) {
754 return Ok(text.replacen(found, block, 1));
755 }
756 let mut out = String::with_capacity(text.len() + block.len() + 1);
757 let mut placed = false;
758 for line in text.split_inclusive('\n') {
759 out.push_str(line);
760 if !placed && line.trim_end() == "repos:" {
761 if !out.ends_with('\n') {
762 out.push('\n');
763 }
764 out.push_str(block);
765 out.push('\n');
766 placed = true;
767 }
768 }
769 if placed {
770 Ok(out)
771 } else {
772 Err(format!(
773 "{HOOKS_DESTINATION} exists with no repos: line, so the hook block has nowhere to land"
774 ))
775 }
776}
777
778#[must_use]
787pub fn hooks_marker_defect(text: &str) -> Option<String> {
788 let begins = text.matches(HOOKS_BEGIN).count();
789 let ends = text.matches(HOOKS_END).count();
790 if begins > 1 || ends > 1 {
791 return Some(format!(
792 "{HOOKS_DESTINATION} carries more than one release-kit marker pair; release-kit owns exactly one block"
793 ));
794 }
795 match (text.find(HOOKS_BEGIN), text.find(HOOKS_END)) {
796 (Some(begin), Some(end)) if end > begin => None,
797 (None, None) => None,
798 _ => Some(format!(
799 "{HOOKS_DESTINATION} carries an unmatched or misordered release-kit marker, so the block's extent is ambiguous"
800 )),
801 }
802}
803
804#[derive(Debug, Clone, Copy, PartialEq, Eq)]
806pub enum Placement {
807 Whole,
809 Block,
811}
812
813#[derive(Debug)]
816pub struct Entry {
817 pub destination: String,
819 pub kind: Kind,
821 pub placement: Placement,
823 pub baseline: Vec<u8>,
826 pub rendered: Vec<u8>,
829}
830
831pub fn pair_files(
839 source: &dyn ReleaseSource,
840 tech: &str,
841 forge: &str,
842) -> Result<Vec<(String, Vec<u8>)>, RkError> {
843 let manifest = source.manifest()?;
844 let techs: Vec<String> = manifest
845 .dirs_under("snippets")
846 .into_iter()
847 .filter(|name| !name.starts_with('_'))
848 .collect();
849 if tech.starts_with('_') || !techs.iter().any(|known| known == tech) {
852 return Err(RkError::Usage(format!(
853 "unknown tech '{tech}'; the bindings are: {}",
854 techs.join(", ")
855 )));
856 }
857 let pair = format!("snippets/{tech}/{forge}");
858 if manifest.under(&pair).next().is_none() {
859 let known: Vec<String> = techs
860 .iter()
861 .flat_map(|tech| {
862 manifest
863 .dirs_under(&format!("snippets/{tech}"))
864 .into_iter()
865 .map(move |forge| format!("{tech}, {forge}"))
866 })
867 .collect();
868 return Err(RkError::Usage(format!(
869 "the pair ({tech}, {forge}) has no landable files; the supported pairs are: {}",
870 known.join("; ")
871 )));
872 }
873 let mut files: Vec<(String, Vec<u8>)> = Vec::new();
877 for (rel, artifact) in manifest.under(&format!("snippets/_shared/{forge}")) {
878 files.push((rel.to_owned(), source.blob(&artifact.sha256)?));
879 }
880 for (rel, artifact) in manifest.under(&pair) {
881 if files.iter().any(|(existing, _)| existing == rel) {
882 return Err(anyhow::anyhow!(
883 "the shared zone and the pair ({tech}, {forge}) both ship {rel}; the payload is defective"
884 )
885 .into());
886 }
887 files.push((rel.to_owned(), source.blob(&artifact.sha256)?));
888 }
889 Ok(files)
890}
891
892pub fn projection(source: &dyn ReleaseSource, params: &Params) -> Result<Vec<Entry>, RkError> {
908 let mut entries = Vec::new();
909 for (destination, baseline) in pair_files(source, ¶ms.tech, ¶ms.forge)? {
910 if !params.nix && NIX_DESTINATIONS.contains(&destination.as_str()) {
911 continue;
912 }
913 let kind = kind_of(&destination).ok_or_else(|| {
914 anyhow::anyhow!("the payload does not classify {destination}; the kind table is stale")
915 })?;
916 let rendered = match kind {
917 Kind::Rendered => render(&baseline, params),
918 Kind::Seeded | Kind::State => baseline.clone(),
919 };
920 entries.push(Entry {
921 destination,
922 kind,
923 placement: Placement::Whole,
924 baseline,
925 rendered,
926 });
927 }
928 for (destination, template) in [
929 (AGENTS_DESTINATION, routing_block(source, params.workflow)?),
930 (HOOKS_DESTINATION, hooks_block(source, params.workflow)?),
931 ] {
932 entries.push(Entry {
933 destination: destination.to_owned(),
934 kind: Kind::Rendered,
935 placement: Placement::Block,
936 baseline: template.as_bytes().to_vec(),
937 rendered: render(template.as_bytes(), params),
938 });
939 }
940 entries.sort_by(|a, b| a.destination.cmp(&b.destination));
941 Ok(entries)
942}
943
944#[must_use]
956pub fn nix_unsupported_shape(target: &Utf8Path) -> Option<String> {
957 let Ok(text) = std::fs::read_to_string(target.join("Cargo.toml")) else {
958 return Some(
959 "the target has no readable Cargo.toml, which the seeded package expression reads; no Nix file lands".to_owned(),
960 );
961 };
962 let Ok(table) = text.parse::<toml::Table>() else {
963 return Some(
964 "the target's Cargo.toml does not parse, and the seeded package expression reads it; no Nix file lands".to_owned(),
965 );
966 };
967 if !table.contains_key("package") {
968 return Some(
969 "the target's Cargo.toml has no [package] table; the seed supports a single crate, so no Nix file lands".to_owned(),
970 );
971 }
972 if !target.join("Cargo.lock").is_file() {
973 return Some(
974 "the target has no Cargo.lock, which the seeded package expression builds from; commit one, then opt in".to_owned(),
975 );
976 }
977 let implicit_bin = target.join("src/main.rs").is_file()
978 && table
979 .get("package")
980 .and_then(toml::Value::as_table)
981 .and_then(|package| package.get("autobins"))
982 .and_then(toml::Value::as_bool)
983 != Some(false);
984 let explicit_bins = table.get("bin").and_then(toml::Value::as_array);
985 if explicit_bins.is_none() && !implicit_bin {
986 return Some(
987 "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(),
988 );
989 }
990 if let Some(bins) = explicit_bins {
996 let required = bins
997 .first()
998 .and_then(toml::Value::as_table)
999 .and_then(|bin| bin.get("required-features"))
1000 .and_then(toml::Value::as_array);
1001 if let Some(required) = required {
1002 let enabled = default_features(&table);
1003 let missing = required
1004 .iter()
1005 .filter_map(toml::Value::as_str)
1006 .any(|feature| !enabled.contains(feature));
1007 if missing {
1008 return Some(
1009 "the target's first [[bin]] entry requires features a default build does not enable; no Nix file lands".to_owned(),
1010 );
1011 }
1012 }
1013 }
1014 None
1015}
1016
1017fn dep_edge_suppresses(features: &toml::Table, name: &str) -> bool {
1020 let edge = format!("dep:{name}");
1021 features.values().any(|list| {
1022 list.as_array().is_some_and(|entries| {
1023 entries
1024 .iter()
1025 .filter_map(toml::Value::as_str)
1026 .any(|entry| entry == edge)
1027 })
1028 })
1029}
1030
1031fn is_optional_dependency(table: &toml::Table, name: &str) -> bool {
1034 ["dependencies", "build-dependencies"]
1035 .iter()
1036 .any(|section| {
1037 table
1038 .get(*section)
1039 .and_then(toml::Value::as_table)
1040 .and_then(|dependencies| dependencies.get(name))
1041 .and_then(toml::Value::as_table)
1042 .and_then(|dependency| dependency.get("optional"))
1043 .and_then(toml::Value::as_bool)
1044 == Some(true)
1045 })
1046}
1047
1048fn default_features(table: &toml::Table) -> std::collections::BTreeSet<String> {
1055 let Some(features) = table.get("features").and_then(toml::Value::as_table) else {
1056 return std::collections::BTreeSet::new();
1057 };
1058 let mut enabled = std::collections::BTreeSet::new();
1059 let mut queue = vec!["default".to_owned()];
1060 while let Some(name) = queue.pop() {
1061 if !enabled.insert(name.clone()) {
1062 continue;
1063 }
1064 if let Some(implies) = features.get(&name).and_then(toml::Value::as_array) {
1065 for implied in implies.iter().filter_map(toml::Value::as_str) {
1066 if implied.starts_with("dep:") || implied.contains("?/") {
1067 continue;
1071 }
1072 if let Some((package, _)) = implied.split_once('/') {
1073 let feature_exists =
1081 features.contains_key(package) || !dep_edge_suppresses(features, package);
1082 if is_optional_dependency(table, package) && feature_exists {
1083 queue.push(package.to_owned());
1084 }
1085 } else {
1086 queue.push(implied.to_owned());
1087 }
1088 }
1089 }
1090 }
1091 enabled
1092}
1093
1094pub fn nix_withheld(
1106 target: &Utf8Path,
1107 recorded: Option<&manifest::Manifest>,
1108) -> std::io::Result<Option<String>> {
1109 if recorded.is_some_and(|record| record.file("flake.nix").is_some()) {
1110 return Ok(None);
1111 }
1112 let mut present = Vec::new();
1113 for name in ["flake.nix", "flake.lock"] {
1114 match std::fs::symlink_metadata(target.join(name).as_std_path()) {
1115 Ok(_) => present.push(name),
1116 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
1117 Err(e) => return Err(e),
1118 }
1119 }
1120 if present.is_empty() {
1121 return Ok(None);
1122 }
1123 Ok(Some(format!(
1124 "the target already carries {}; its flake pair stays its own",
1125 present.join(" and ")
1126 )))
1127}
1128
1129#[derive(Debug, Clone, Serialize)]
1131pub struct Withheld {
1132 pub path: String,
1134 pub reason: String,
1137}
1138
1139pub fn nix_withholding(
1151 target: &Utf8Path,
1152 recorded: Option<&manifest::Manifest>,
1153) -> Result<Option<(&'static [&'static str], String)>, RkError> {
1154 if let Some(reason) = nix_unsupported_shape(target) {
1155 return Ok(Some((&NIX_DESTINATIONS[..], reason)));
1156 }
1157 if let Some(reason) = nix_withheld(target, recorded)? {
1158 return Ok(Some((&NIX_WITHHOLDABLE[..], reason)));
1159 }
1160 Ok(None)
1161}
1162
1163pub fn withhold_nix(
1176 target: &Utf8Path,
1177 nix: bool,
1178 recorded: Option<&manifest::Manifest>,
1179 entries: &mut Vec<Entry>,
1180) -> Result<Vec<Withheld>, RkError> {
1181 if !nix {
1182 return Ok(Vec::new());
1183 }
1184 let Some((set, reason)) = nix_withholding(target, recorded)? else {
1185 return Ok(Vec::new());
1186 };
1187 let mut withheld = Vec::new();
1188 entries.retain(|entry| {
1189 if set.contains(&entry.destination.as_str()) {
1190 withheld.push(Withheld {
1191 path: entry.destination.clone(),
1192 reason: reason.clone(),
1193 });
1194 false
1195 } else {
1196 true
1197 }
1198 });
1199 Ok(withheld)
1200}
1201
1202pub fn read_destination(target: &Utf8Path, entry: &Entry) -> std::io::Result<Option<Vec<u8>>> {
1210 read_recorded(target, &entry.destination)
1211}
1212
1213pub fn read_recorded(target: &Utf8Path, destination: &str) -> std::io::Result<Option<Vec<u8>>> {
1224 let path = target.join(destination);
1225 let bytes = match std::fs::read(&path) {
1226 Ok(bytes) => bytes,
1227 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1228 Err(e) => return Err(e),
1229 };
1230 if let Some((begin, end)) = block_markers(destination) {
1231 let text = String::from_utf8_lossy(&bytes);
1232 Ok(extract_block(&text, begin, end).map(|block| block.as_bytes().to_vec()))
1233 } else {
1234 Ok(Some(bytes))
1235 }
1236}
1237
1238#[derive(Debug)]
1241pub struct Resolved {
1242 pub forge: String,
1244 pub repo: Option<String>,
1246}
1247
1248pub fn resolve(
1260 target: &Utf8Path,
1261 forge_flag: Option<&str>,
1262 repo_flag: Option<&str>,
1263) -> Result<Resolved, RkError> {
1264 let forge_flag = forge_flag
1265 .map(|name| {
1266 crate::detect::Forge::parse(name).ok_or_else(|| {
1267 RkError::Usage(format!(
1268 "unknown forge '{name}'; the forges are: github, gitlab"
1269 ))
1270 })
1271 })
1272 .transpose()?;
1273 let detected = crate::detect::detect(target.as_std_path());
1274 let forge = forge_flag
1275 .or(detected.forge)
1276 .map(|forge| forge.as_str().to_owned())
1277 .ok_or_else(|| {
1278 let message = detected.host.map_or_else(
1279 || "no forge detected: the target has no origin remote".to_owned(),
1280 |host| format!("no forge detected: the host {host} is not recognized"),
1281 );
1282 RkError::refusal(
1283 Diagnostic::new(Reason::ForgeUndetected, message)
1284 .expected("a github.com or gitlab remote, or --forge")
1285 .action("pass --forge <github|gitlab>"),
1286 )
1287 })?;
1288 Ok(Resolved {
1289 forge,
1290 repo: repo_flag.map(str::to_owned).or(detected.repo),
1291 })
1292}
1293
1294#[must_use]
1297pub fn repo_unresolved() -> RkError {
1298 RkError::missing(
1299 Diagnostic::new(
1300 Reason::ForgeUndetected,
1301 "no repository detected: the target has no origin remote",
1302 )
1303 .expected("an origin remote naming the project")
1304 .action("pass --repo <path>"),
1305 )
1306}
1307
1308pub fn write_destination(target: &Utf8Path, entry: &Entry) -> std::io::Result<()> {
1318 let path = target.join(&entry.destination);
1319 match entry.placement {
1320 Placement::Whole => atomic::write(path.as_std_path(), &entry.rendered),
1321 Placement::Block => {
1322 let existing = match std::fs::read(&path) {
1323 Ok(bytes) => Some(String::from_utf8_lossy(&bytes).into_owned()),
1324 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
1325 Err(e) => return Err(e),
1326 };
1327 let block = String::from_utf8_lossy(&entry.rendered).into_owned();
1328 let spliced = if entry.destination == HOOKS_DESTINATION {
1329 splice_hooks_block(existing.as_deref(), &block).map_err(std::io::Error::other)?
1330 } else {
1331 splice_agents_block(existing.as_deref(), &block)
1332 };
1333 atomic::write(path.as_std_path(), spliced.as_bytes())
1334 }
1335 }
1336}
1337
1338pub fn hooks_file_defect(
1351 source: &dyn ReleaseSource,
1352 target: &Utf8Path,
1353) -> Result<Option<String>, RkError> {
1354 let path = target.join(HOOKS_DESTINATION);
1355 match std::fs::read(&path) {
1356 Ok(bytes) => {
1357 let text = String::from_utf8_lossy(&bytes);
1358 let manifest = source.manifest()?;
1359 let template = block(source, &manifest, PRE_COMMIT_BLOCK)?;
1360 Ok(splice_hooks_block(Some(&text), authored(&template)).err())
1361 }
1362 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
1363 Err(e) => Err(e.into()),
1364 }
1365}
1366
1367pub fn hooks_splice_refusal(source: &dyn ReleaseSource, target: &Utf8Path) -> Result<(), RkError> {
1377 hooks_file_defect(source, target)?.map_or(Ok(()), |reason| {
1378 Err(RkError::refusal(
1379 Diagnostic::new(
1380 Reason::StateDrift,
1381 format!("{reason}, and nothing was written"),
1382 )
1383 .expected("a .pre-commit-config.yaml the block can land in, or none")
1384 .action(format!(
1385 "resolve it in {}, then re-run",
1386 target.join(HOOKS_DESTINATION)
1387 ))
1388 .target_state("unchanged"),
1389 ))
1390 })
1391}
1392
1393#[cfg(test)]
1394mod tests {
1395 use super::{
1396 AGENTS_DESTINATION, BLOCK_BEGIN, BLOCK_END, BRANCH_GRAMMAR, HOOK_TYPES_LINE, HOOKS_BEGIN,
1397 HOOKS_DESTINATION, HOOKS_END, Kind, SCOPE_SHAPE, Style, Workflow, extract_block, kind_of,
1398 render, splice_agents_block, splice_hooks_block,
1399 };
1400 use crate::embedded;
1401 use crate::release::EmbeddedReleaseSource;
1402
1403 const SOURCE: EmbeddedReleaseSource = EmbeddedReleaseSource;
1405
1406 fn pair_files(
1407 tech: &str,
1408 forge: &str,
1409 ) -> Result<Vec<(String, Vec<u8>)>, crate::error::RkError> {
1410 super::pair_files(&SOURCE, tech, forge)
1411 }
1412
1413 fn projection(params: &super::Params) -> Result<Vec<super::Entry>, crate::error::RkError> {
1414 super::projection(&SOURCE, params)
1415 }
1416
1417 fn routing_block(workflow: Workflow) -> String {
1418 super::routing_block(&SOURCE, workflow).expect("the embedded bundle carries the block")
1419 }
1420
1421 fn hooks_block(workflow: Workflow) -> String {
1422 super::hooks_block(&SOURCE, workflow).expect("the embedded bundle carries the block")
1423 }
1424
1425 #[test]
1426 fn private_reporting_path_tokens_are_reproducible() {
1427 for repo in [
1428 "acme/widget",
1429 "acme/group/widget",
1430 "acme/OWNER-RK_STYLE-RK_SCOPE_SHAPE",
1431 ] {
1432 assert_eq!(
1433 super::render(
1434 b"RK_REPO RK_REPO OWNER RK_STYLE RK_SCOPE_SHAPE",
1435 &super::Params::for_test(repo, Some(super::Style::Trunk))
1436 ),
1437 format!("{repo} {repo} acme trunk {}", super::SCOPE_SHAPE).as_bytes()
1438 );
1439 }
1440 assert_eq!(super::kind_of("SECURITY.md"), Some(super::Kind::Rendered));
1441 }
1442
1443 #[test]
1448 fn each_forge_policy_carries_one_ordered_pair_of_every_span() {
1449 for forge in ["github", "gitlab"] {
1450 let bytes = embedded::SNIPPETS
1451 .get_file(format!("_shared/{forge}/SECURITY.md"))
1452 .expect("the policy ships")
1453 .contents();
1454 let text = String::from_utf8_lossy(bytes);
1455 for (begin, end) in super::SECURITY_SPANS {
1456 let begin = String::from_utf8_lossy(begin);
1457 let end = String::from_utf8_lossy(end);
1458 assert_eq!(text.matches(begin.as_ref()).count(), 1, "{forge} {begin}");
1459 assert_eq!(text.matches(end.as_ref()).count(), 1, "{forge} {end}");
1460 assert!(
1461 text.find(begin.as_ref()) < text.find(end.as_ref()),
1462 "{forge}: {begin} must precede {end}"
1463 );
1464 }
1465 }
1466 }
1467
1468 #[test]
1473 fn the_security_spans_render_per_answer() {
1474 for forge in ["github", "gitlab"] {
1475 let bytes = embedded::SNIPPETS
1476 .get_file(format!("_shared/{forge}/SECURITY.md"))
1477 .expect("the policy ships")
1478 .contents();
1479 let authored = String::from_utf8_lossy(bytes);
1480 let stripped = {
1481 let mut text = authored.clone().into_owned();
1482 for (begin, end) in super::SECURITY_SPANS {
1483 text = text.replace(&String::from_utf8_lossy(begin).into_owned(), "");
1484 text = text.replace(&String::from_utf8_lossy(end).into_owned(), "");
1485 }
1486 text
1487 };
1488 let default = super::Params {
1489 forge: forge.to_owned(),
1490 ..super::Params::for_test_security("", crate::config::RESPONSE_DEFAULT)
1491 };
1492 let rendered = String::from_utf8(render(bytes, &default)).expect("text");
1493 assert_eq!(
1494 rendered,
1495 stripped.replace("RK_REPO", "acme/widget"),
1496 "{forge}: the default answers must reproduce the authored policy"
1497 );
1498 assert!(!rendered.contains("RK_SECURITY"), "{forge}: {rendered}");
1499
1500 let answered = super::Params {
1501 forge: forge.to_owned(),
1502 ..super::Params::for_test_security("OWNER RK_REPO <team@acme.example>", "14 days")
1503 };
1504 let rendered = String::from_utf8(render(bytes, &answered)).expect("text");
1505 assert!(
1506 rendered.contains("OWNER RK_REPO <team@acme.example>"),
1507 "{forge}: a contact spelling a token name lands literally: {rendered}"
1508 );
1509 assert!(
1510 rendered.contains("Maintainers acknowledge a report within 14 days."),
1511 "{forge}: {rendered}"
1512 );
1513 assert!(
1514 rendered.contains("This policy commits to no disclosure deadline."),
1515 "{forge}: {rendered}"
1516 );
1517 assert!(
1518 !rendered.contains("best-effort basis"),
1519 "{forge}: a stated window replaces the best-effort sentence: {rendered}"
1520 );
1521 assert!(
1522 !rendered.contains("no response or disclosure deadline"),
1523 "{forge}: a stated window contradicts the response disclaimer: {rendered}"
1524 );
1525 }
1526 }
1527
1528 #[test]
1531 fn a_defective_span_renders_unchanged() {
1532 let (begin, end) = super::SECURITY_SPANS[0];
1533 let begin = String::from_utf8_lossy(begin).into_owned();
1534 let end = String::from_utf8_lossy(end).into_owned();
1535 let params = super::Params::for_test_security("team@acme.example", "1 day");
1536 for baseline in [
1537 format!("contact {begin}a maintainer\n"),
1538 format!("contact a maintainer{end}\n"),
1539 format!("contact {end}a maintainer{begin}\n"),
1540 "contact a maintainer\n".to_owned(),
1541 ] {
1542 assert_eq!(
1543 render(baseline.as_bytes(), ¶ms),
1544 baseline.as_bytes(),
1545 "{baseline}"
1546 );
1547 }
1548 }
1549
1550 #[test]
1554 fn the_kind_table_closes_over_every_snippet() {
1555 for tech_dir in embedded::SNIPPETS.dirs() {
1556 for pair_dir in tech_dir.dirs() {
1557 let prefix = format!("{}/", pair_dir.path().to_string_lossy());
1558 for (path, _) in embedded::walk(pair_dir) {
1559 let destination = path.strip_prefix(&prefix).unwrap_or(&path);
1560 assert!(
1561 kind_of(destination).is_some(),
1562 "{destination}: no declared kind"
1563 );
1564 }
1565 }
1566 }
1567 assert_eq!(kind_of(AGENTS_DESTINATION), Some(Kind::Rendered));
1568 assert_eq!(kind_of(HOOKS_DESTINATION), Some(Kind::Rendered));
1569 assert_eq!(kind_of("something-else.txt"), None);
1570 }
1571
1572 #[test]
1577 fn rendering_substitutes_every_owner_occurrence() {
1578 let baseline = b"if: repository_owner == 'OWNER'\n# OWNER again: OWNER\n";
1579 let rendered = render(baseline, &super::Params::for_test("acme/sub/widget", None));
1580 let text = String::from_utf8(rendered).expect("rendered bytes stay text");
1581 assert_eq!(text, "if: repository_owner == 'acme'\n# acme again: acme\n");
1582
1583 let baseline = b"match (RK_SCOPE_SHAPE)\n";
1584 let rendered = render(baseline, &super::Params::for_test("acme/widget", None));
1585 let text = String::from_utf8(rendered).expect("rendered bytes stay text");
1586 assert_eq!(text, format!("match ({SCOPE_SHAPE})\n"));
1587 }
1588
1589 #[test]
1593 fn the_scope_shape_drops_into_the_title_check() {
1594 assert_eq!(SCOPE_SHAPE, "[a-z0-9._/-]+");
1595 assert!(
1596 !SCOPE_SHAPE.contains('\''),
1597 "the title checks single-quote it"
1598 );
1599 }
1600
1601 #[test]
1606 fn the_scope_predicate_and_the_rendered_pattern_agree() {
1607 let body = SCOPE_SHAPE
1608 .strip_prefix('[')
1609 .and_then(|rest| rest.strip_suffix("]+"))
1610 .expect("the shape is one bracket expression, repeated");
1611 let chars: Vec<char> = body.chars().collect();
1612 let mut admitted = std::collections::BTreeSet::new();
1613 let mut at = 0;
1614 while at < chars.len() {
1615 if at + 2 < chars.len() && chars[at + 1] == '-' {
1618 for c in chars[at]..=chars[at + 2] {
1619 admitted.insert(c);
1620 }
1621 at += 3;
1622 } else {
1623 admitted.insert(chars[at]);
1624 at += 1;
1625 }
1626 }
1627 for byte in 0..=127u8 {
1628 let c = char::from(byte);
1629 assert_eq!(
1630 super::scope_is_shaped(&c.to_string()),
1631 admitted.contains(&c),
1632 "the predicate and {SCOPE_SHAPE} disagree on {c:?}"
1633 );
1634 }
1635 assert!(super::scope_is_shaped("guides/release"));
1636 assert!(!super::scope_is_shaped(""), "a scope is never empty");
1637 assert!(!super::scope_is_shaped("Specs Ugly"));
1638 }
1639
1640 #[test]
1643 fn the_shared_zone_composes_into_the_pair() {
1644 let files = pair_files("rust", "github").expect("the pair lists");
1645 assert!(
1646 files
1647 .iter()
1648 .any(|(dest, _)| dest == ".github/workflows/pr-title.yml"),
1649 "the shared title check lands with the pair"
1650 );
1651 let files = pair_files("rust", "gitlab").expect("the pair lists");
1652 assert!(
1653 files
1654 .iter()
1655 .any(|(dest, _)| dest == ".gitlab/ci/mr-title.yml"),
1656 "the shared title job lands with the pair"
1657 );
1658 let err = pair_files("_shared", "github").expect_err("the shared zone is no tech");
1659 let listing = err.to_string();
1660 let bindings = listing
1661 .split("the bindings are:")
1662 .nth(1)
1663 .expect("the refusal lists the bindings");
1664 assert!(!bindings.contains("_shared"), "{listing}");
1665 }
1666
1667 #[test]
1670 fn params_from_a_record_round_trips() {
1671 use super::{Params, manifest};
1672 let dir = tempfile::tempdir().expect("a scratch target exists");
1673 let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
1674 for tech in ["rust", "bash"] {
1675 for forge in ["github", "gitlab"] {
1676 for workflow in [Workflow::Branches, Workflow::Worktree] {
1677 for style in [None, Some(Style::Trunk), Some(Style::Lines)] {
1678 for nix in [false, true] {
1679 let record = manifest::Manifest {
1680 schema_version: manifest::SCHEMA_VERSION,
1681 rk_version: "0.1.0".to_owned(),
1682 payload_sha256: crate::digest::Digest::of(b""),
1683 origin: "init".to_owned(),
1684 tech: tech.to_owned(),
1685 forge: forge.to_owned(),
1686 landed_at: "2026-08-29T00:00:00Z".to_owned(),
1687 parameters: manifest::Parameters {
1688 repo: "acme/team/widget".to_owned(),
1689 workflow,
1690 style,
1691 nix,
1692 trunk: crate::config::TRUNK_DEFAULT.to_owned(),
1693 line_prefix: crate::config::LINE_PREFIX_DEFAULT.to_owned(),
1694 security_contact: String::new(),
1695 security_response: crate::config::RESPONSE_DEFAULT.to_owned(),
1696 },
1697 files: Vec::new(),
1698 pins: std::collections::BTreeMap::new(),
1699 };
1700 manifest::write(target, &record).expect("the record writes");
1701 let loaded = manifest::load(target)
1702 .expect("the record loads")
1703 .expect("the record exists");
1704 let params = Params::from_record(&loaded);
1705 assert_eq!(params.tech, tech);
1706 assert_eq!(params.forge, forge);
1707 assert_eq!(params.repo(), "acme/team/widget");
1708 assert_eq!(params.workflow(), workflow);
1709 assert_eq!(params.style(), style);
1710 assert_eq!(params.nix, nix);
1711 let entries = projection(¶ms).expect("the record projects");
1712 let mut expected: Vec<_> = pair_files(tech, forge)
1713 .expect("the pair lists")
1714 .into_iter()
1715 .filter(|(path, _)| {
1716 nix || !super::NIX_DESTINATIONS.contains(&path.as_str())
1717 })
1718 .collect();
1719 let routing = routing_block(workflow);
1720 let hooks = hooks_block(workflow);
1721 expected.push((AGENTS_DESTINATION.to_owned(), routing.into_bytes()));
1722 expected.push((HOOKS_DESTINATION.to_owned(), hooks.into_bytes()));
1723 expected.sort_by(|a, b| a.0.cmp(&b.0));
1724 assert_eq!(entries.len(), expected.len());
1725 for (entry, (destination, baseline)) in entries.iter().zip(expected) {
1726 assert_eq!(entry.destination, destination);
1727 assert_eq!(entry.baseline, baseline);
1728 let rendered = match entry.kind {
1729 Kind::Rendered => super::render(
1730 &baseline,
1731 &super::Params::for_test("acme/team/widget", style),
1732 ),
1733 Kind::Seeded | Kind::State => baseline.clone(),
1734 };
1735 assert_eq!(entry.rendered, rendered, "{destination}");
1736 }
1737 }
1738 }
1739 }
1740 }
1741 }
1742 }
1743
1744 fn resolved_test_params(
1745 tech: &str,
1746 resolved: &super::Resolved,
1747 workflow: Workflow,
1748 style: Option<Style>,
1749 nix: bool,
1750 ) -> Result<super::Params, crate::error::RkError> {
1751 super::Params::resolve(
1752 &SOURCE,
1753 camino::Utf8Path::new("."),
1754 &super::Inputs {
1755 tech: Some(tech),
1756 forge: Some(&resolved.forge),
1757 repo: resolved.repo.as_deref(),
1758 workflow: Some(workflow),
1759 style,
1760 nix: Some(nix),
1761 },
1762 None,
1763 None,
1764 super::Purpose::Init,
1765 )
1766 }
1767
1768 #[test]
1772 fn a_projection_renders_owned_files_and_keeps_seeded_judgment() {
1773 let entries = projection(
1774 &resolved_test_params(
1775 "rust",
1776 &super::Resolved {
1777 forge: "github".to_owned(),
1778 repo: Some("acme/widget".to_owned()),
1779 },
1780 Workflow::Branches,
1781 Some(Style::Trunk),
1782 false,
1783 )
1784 .expect("the parameters resolve"),
1785 )
1786 .expect("the pair projects");
1787 let workflow = entries
1788 .iter()
1789 .find(|entry| entry.destination.ends_with("release-plz.yml"))
1790 .expect("the workflow projects");
1791 assert_eq!(workflow.kind, Kind::Rendered);
1792 let text = String::from_utf8_lossy(&workflow.rendered);
1793 assert!(!text.contains("OWNER"), "an owner token survived rendering");
1794 assert!(text.contains("'acme'"));
1795 assert!(!text.contains("TODO(release-kit)"));
1796 let title = entries
1797 .iter()
1798 .find(|entry| entry.destination.ends_with("pr-title.yml"))
1799 .expect("the title check projects");
1800 let text = String::from_utf8_lossy(&title.rendered);
1801 assert!(text.contains(SCOPE_SHAPE), "{text}");
1802 assert!(
1803 !text.contains("RK_SCOPE_SHAPE"),
1804 "a scope token survived: {text}"
1805 );
1806 let seeded = entries
1807 .iter()
1808 .find(|entry| entry.destination == "release-plz.toml")
1809 .expect("the seeded file projects");
1810 assert_eq!(seeded.kind, Kind::Seeded);
1811 assert_eq!(seeded.rendered, seeded.baseline);
1812 assert!(String::from_utf8_lossy(&seeded.rendered).contains("TODO(release-kit)"));
1813 for block in [AGENTS_DESTINATION, HOOKS_DESTINATION] {
1814 let entry = entries
1815 .iter()
1816 .find(|entry| entry.destination == block)
1817 .expect("both blocks are part of the projection");
1818 let text = String::from_utf8_lossy(&entry.rendered);
1819 assert!(
1820 !text.contains("RK_SCOPE_SHAPE"),
1821 "{block} kept a token: {text}"
1822 );
1823 }
1824 }
1825
1826 #[test]
1831 fn the_nix_destinations_project_only_under_the_opt_in() {
1832 use super::NIX_DESTINATIONS;
1833 let paths = |nix: bool, forge: &str| -> Vec<String> {
1834 projection(
1835 &resolved_test_params(
1836 "rust",
1837 &super::Resolved {
1838 forge: forge.to_owned(),
1839 repo: Some("acme/widget".to_owned()),
1840 },
1841 Workflow::Worktree,
1842 Some(Style::Trunk),
1843 nix,
1844 )
1845 .expect("the parameters resolve"),
1846 )
1847 .expect("the pair projects")
1848 .into_iter()
1849 .map(|entry| entry.destination)
1850 .collect()
1851 };
1852 let off = paths(false, "github");
1853 for destination in NIX_DESTINATIONS {
1854 assert!(!off.contains(&destination.to_owned()), "{destination}");
1855 }
1856 let on = paths(true, "github");
1857 for destination in ["nix/package.nix", "flake.nix", "flake.lock"] {
1858 assert!(on.contains(&destination.to_owned()), "{destination}");
1859 }
1860 let gitlab = paths(true, "gitlab");
1865 assert!(gitlab.contains(&"nix/package.nix".to_owned()));
1866 assert!(
1867 !on.iter()
1868 .chain(gitlab.iter())
1869 .any(|destination| destination.contains("nix.yml"))
1870 );
1871 let bash = projection(
1872 &resolved_test_params(
1873 "bash",
1874 &super::Resolved {
1875 forge: "github".to_owned(),
1876 repo: Some("acme/widget".to_owned()),
1877 },
1878 Workflow::Worktree,
1879 Some(Style::Trunk),
1880 true,
1881 )
1882 .expect("the parameters resolve"),
1883 )
1884 .expect("an out-of-matrix pair projects the smaller product");
1885 assert!(
1886 bash.iter()
1887 .all(|entry| !NIX_DESTINATIONS.contains(&entry.destination.as_str()))
1888 );
1889 }
1890
1891 #[test]
1896 fn the_nix_seeds_are_identical_across_forge_pairs() {
1897 for name in ["nix/package.nix", "flake.nix", "flake.lock"] {
1898 let github = embedded::SNIPPETS
1899 .get_file(format!("rust/github/{name}"))
1900 .expect("the github copy ships")
1901 .contents();
1902 let gitlab = embedded::SNIPPETS
1903 .get_file(format!("rust/gitlab/{name}"))
1904 .expect("the gitlab copy ships")
1905 .contents();
1906 assert_eq!(github, gitlab, "{name} diverged between the pairs");
1907 }
1908 }
1909
1910 #[test]
1915 fn the_nix_withhold_judgment_covers_the_three_shapes() {
1916 use super::{NIX_DESTINATIONS, withhold_nix};
1917 let dir = tempfile::tempdir().expect("a scratch target exists");
1918 let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
1919 let entries = || {
1920 projection(
1921 &resolved_test_params(
1922 "rust",
1923 &super::Resolved {
1924 forge: "github".to_owned(),
1925 repo: Some("acme/widget".to_owned()),
1926 },
1927 Workflow::Worktree,
1928 Some(Style::Trunk),
1929 true,
1930 )
1931 .expect("the parameters resolve"),
1932 )
1933 .expect("the pair projects")
1934 };
1935
1936 let mut all = entries();
1938 let withheld = withhold_nix(target, true, None, &mut all).expect("the judgment runs");
1939 let paths: Vec<&str> = withheld.iter().map(|w| w.path.as_str()).collect();
1940 assert_eq!(paths, ["flake.lock", "flake.nix", "nix/package.nix"]);
1941 assert!(
1942 all.iter()
1943 .all(|entry| !NIX_DESTINATIONS.contains(&entry.destination.as_str()))
1944 );
1945
1946 std::fs::write(
1949 target.join("Cargo.toml"),
1950 "[package]\nname = \"widget\"\nversion = \"0.1.0\"\n",
1951 )
1952 .expect("the crate manifest writes");
1953 std::fs::write(target.join("Cargo.lock"), "version = 4\n").expect("the lock writes");
1954 std::fs::create_dir_all(target.join("src")).expect("the src dir exists");
1955 std::fs::write(target.join("src/main.rs"), "fn main() {}\n").expect("the main writes");
1956 std::fs::write(target.join("flake.nix"), "{ }\n").expect("the flake writes");
1957 let mut all = entries();
1958 let withheld = withhold_nix(target, true, None, &mut all).expect("the judgment runs");
1959 let paths: Vec<&str> = withheld.iter().map(|w| w.path.as_str()).collect();
1960 assert_eq!(paths, ["flake.lock", "flake.nix"]);
1961 assert!(
1962 all.iter()
1963 .any(|entry| entry.destination == "nix/package.nix")
1964 );
1965
1966 std::fs::remove_file(target.join("flake.nix")).expect("the flake removes");
1968 let mut all = entries();
1969 let withheld = withhold_nix(target, true, None, &mut all).expect("the judgment runs");
1970 assert!(withheld.is_empty());
1971 assert!(all.iter().any(|entry| entry.destination == "flake.nix"));
1972
1973 let mut all = entries();
1975 let withheld = withhold_nix(target, false, None, &mut all).expect("the judgment runs");
1976 assert!(withheld.is_empty());
1977 }
1978
1979 #[test]
1980 fn the_block_splices_into_every_agents_shape() {
1981 let owned = routing_block(Workflow::Branches);
1982 let block = owned.as_str();
1983 let fresh = splice_agents_block(None, block);
1984 assert_eq!(fresh, format!("{block}\n"));
1985 assert_eq!(extract_block(&fresh, BLOCK_BEGIN, BLOCK_END), Some(block));
1986
1987 let appended = splice_agents_block(Some("# My project\n\nOwn rules.\n"), block);
1988 assert!(appended.starts_with("# My project\n\nOwn rules.\n\n<!-- BEGIN release-kit -->"));
1989 assert_eq!(
1990 extract_block(&appended, BLOCK_BEGIN, BLOCK_END),
1991 Some(block)
1992 );
1993
1994 let stale = appended.replace("Never author a tag", "Do author a tag");
1995 let refreshed = splice_agents_block(Some(&stale), block);
1996 assert_eq!(
1997 extract_block(&refreshed, BLOCK_BEGIN, BLOCK_END),
1998 Some(block)
1999 );
2000 assert!(refreshed.starts_with("# My project"));
2001 assert_eq!(
2002 refreshed.matches("BEGIN release-kit").count(),
2003 1,
2004 "a re-splice must replace, not accumulate"
2005 );
2006 }
2007
2008 #[test]
2011 fn the_hook_block_splices_under_repos() {
2012 let owned = hooks_block(Workflow::Branches);
2013 let block = owned.as_str();
2014 let fresh = splice_hooks_block(None, block).expect("a fresh file splices");
2015 assert!(fresh.starts_with(HOOK_TYPES_LINE));
2016 assert!(fresh.contains("\nrepos:\n# BEGIN release-kit\n"));
2017 assert_eq!(extract_block(&fresh, HOOKS_BEGIN, HOOKS_END), Some(block));
2018
2019 let own =
2020 "repos:\n - repo: https://example.com/own\n rev: v1\n hooks:\n - id: own\n";
2021 let spliced = splice_hooks_block(Some(own), block).expect("an unmarked file splices");
2022 assert!(spliced.starts_with("repos:\n# BEGIN release-kit\n"));
2023 assert!(spliced.contains("- id: own"), "the target's hooks survive");
2024 assert!(
2025 !spliced.contains(HOOK_TYPES_LINE),
2026 "an existing file's top level is the skills' duty, not the splice's"
2027 );
2028
2029 let stale = spliced.replace("--force-scope", "--no-scope");
2030 let refreshed = splice_hooks_block(Some(&stale), block).expect("a marked file re-splices");
2031 assert_eq!(
2032 extract_block(&refreshed, HOOKS_BEGIN, HOOKS_END),
2033 Some(block)
2034 );
2035 assert_eq!(refreshed.matches(HOOKS_BEGIN).count(), 1);
2036
2037 let err = splice_hooks_block(Some("minimum_pre_commit_version: '3.2.0'\n"), block)
2038 .expect_err("no repos: line refuses");
2039 assert!(err.contains("repos:"), "{err}");
2040
2041 let doubled = format!("repos:\n{block}\n{block}\n");
2045 let err = splice_hooks_block(Some(&doubled), block).expect_err("a second block refuses");
2046 assert!(err.contains("one block"), "{err}");
2047 let unmatched = "repos:\n# BEGIN release-kit\n - repo: local\n";
2048 let err =
2049 splice_hooks_block(Some(unmatched), block).expect_err("an unmatched marker refuses");
2050 assert!(err.contains("unmatched"), "{err}");
2051 }
2052
2053 #[test]
2059 fn the_blocks_render_per_mode_and_carry_the_one_grammar() {
2060 let worktree_hooks = hooks_block(Workflow::Worktree);
2061 let branches_hooks = hooks_block(Workflow::Branches);
2062 assert!(worktree_hooks.contains("- id: rk-worktree-location"));
2063 assert!(
2064 worktree_hooks.contains("SKIP=no-commit-to-branch,rk-worktree-location"),
2065 "{worktree_hooks}"
2066 );
2067 assert!(!branches_hooks.contains("rk-worktree-location"));
2068 assert!(branches_hooks.contains("SKIP=no-commit-to-branch in"));
2069 for block in [&worktree_hooks, &branches_hooks] {
2070 assert!(block.contains(BRANCH_GRAMMAR), "the grammar has one owner");
2071 for token in ["RK_BRANCH_GRAMMAR", "RK_SWEEP_SKIP", "RK_WORKTREE_GUARD"] {
2072 assert!(!block.contains(token), "{token} survived: {block}");
2073 }
2074 }
2075 for block in [&worktree_hooks, &branches_hooks] {
2080 for line in block.lines() {
2081 if let Some(value) = line.trim_start().strip_prefix("entry: ") {
2082 assert!(
2083 !value.contains(": "),
2084 "an entry value breaks the YAML plain scalar: {line}"
2085 );
2086 }
2087 }
2088 }
2089 let guard_line = worktree_hooks
2090 .lines()
2091 .position(|line| line.contains("id: rk-worktree-location"))
2092 .expect("the guard entry exists");
2093 let name_line = worktree_hooks
2094 .lines()
2095 .position(|line| line.contains("id: rk-branch-name"))
2096 .expect("the name hook exists");
2097 assert!(
2098 guard_line > name_line,
2099 "the guard lands directly after rk-branch-name"
2100 );
2101
2102 let worktree_routing = routing_block(Workflow::Worktree);
2103 let branches_routing = routing_block(Workflow::Branches);
2104 assert!(worktree_routing.contains("This project works in worktrees"));
2105 assert!(branches_routing.contains("Branches are worked in the main checkout"));
2106 for block in [&worktree_routing, &branches_routing] {
2107 assert!(block.contains("Create or remove a worktree"));
2108 assert!(block.contains("`rk worktree add <branch>`"));
2109 assert!(!block.contains("RK_WORKFLOW_LINE"), "{block}");
2110 }
2111 let differing: Vec<(&str, &str)> = worktree_routing
2112 .lines()
2113 .zip(branches_routing.lines())
2114 .filter(|(a, b)| a != b)
2115 .collect();
2116 assert_eq!(
2117 differing.len(),
2118 1,
2119 "exactly one routing line differs per mode: {differing:?}"
2120 );
2121 }
2122
2123 #[test]
2126 fn the_hook_marker_defects_are_named() {
2127 use super::hooks_marker_defect;
2128 let owned = hooks_block(Workflow::Branches);
2129 let block = owned.as_str();
2130 assert_eq!(hooks_marker_defect(""), None);
2131 assert_eq!(hooks_marker_defect(&format!("repos:\n{block}\n")), None);
2132 for (case, text) in [
2133 (
2134 "a second begin",
2135 format!("repos:\n{block}\n# BEGIN release-kit\n"),
2136 ),
2137 (
2138 "a second end",
2139 format!("repos:\n{block}\n# END release-kit\n"),
2140 ),
2141 (
2142 "an unpaired begin",
2143 "repos:\n# BEGIN release-kit\n".to_owned(),
2144 ),
2145 ("an unpaired end", "repos:\n# END release-kit\n".to_owned()),
2146 (
2147 "an end before its begin",
2148 "repos:\n# END release-kit\n# BEGIN release-kit\n".to_owned(),
2149 ),
2150 ] {
2151 assert!(
2152 hooks_marker_defect(&text).is_some(),
2153 "{case} must be a defect"
2154 );
2155 }
2156 }
2157}