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(|| REPO_PLACEHOLDER.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
368 || destination == GLOSSARY_DESTINATION
369 || destination == HOOKS_DESTINATION
370 {
371 return Some(Kind::Rendered);
372 }
373 KINDS
374 .iter()
375 .find(|(name, _)| *name == destination)
376 .map(|(_, kind)| *kind)
377}
378
379pub fn destinations() -> impl Iterator<Item = &'static str> {
384 KINDS
385 .iter()
386 .map(|(name, _)| *name)
387 .chain(BLOCK_DESTINATIONS)
388}
389
390pub const OWNER_TOKEN: &[u8] = b"OWNER";
397
398pub const REPO_PLACEHOLDER: &str = "OWNER";
403
404pub const REPO_TOKEN: &[u8] = b"RK_REPO";
406
407pub const SCOPE_SHAPE_TOKEN: &[u8] = b"RK_SCOPE_SHAPE";
409
410pub const STYLE_TOKEN: &[u8] = b"RK_STYLE";
413
414pub const TRUNK_BRANCH_TOKEN: &[u8] = b"RK_TRUNK_BRANCH";
418
419pub const LINE_PREFIX_TOKEN: &[u8] = b"RK_LINE_PREFIX";
422
423pub const LINE_PREFIX_RE_TOKEN: &[u8] = b"RK_LINE_PREFIX_RE";
429
430pub const SECURITY_SPANS: [(&[u8], &[u8]); 3] = [
441 (
442 b"<!--RK_SECURITY_CONTACT_BEGIN-->",
443 b"<!--RK_SECURITY_CONTACT_END-->",
444 ),
445 (
446 b"<!--RK_SECURITY_RESPONSE_BEGIN-->",
447 b"<!--RK_SECURITY_RESPONSE_END-->",
448 ),
449 (
450 b"<!--RK_SECURITY_DEADLINE_BEGIN-->",
451 b"<!--RK_SECURITY_DEADLINE_END-->",
452 ),
453];
454
455fn acknowledgment(response: &str) -> String {
458 format!("Maintainers acknowledge a report within {response}.")
459}
460
461const DISCLOSURE_ONLY: &[u8] = b"This policy commits to no disclosure deadline.";
465
466fn security_replacements(params: &Params) -> [Option<Vec<u8>>; 3] {
469 let contact = (!params.security_contact().is_empty())
470 .then(|| params.security_contact().as_bytes().to_vec());
471 let promised = params.security_response() != crate::config::RESPONSE_DEFAULT;
472 [
473 contact,
474 promised.then(|| acknowledgment(params.security_response()).into_bytes()),
475 promised.then(|| DISCLOSURE_ONLY.to_vec()),
476 ]
477}
478
479fn replace_span(baseline: &[u8], begin: &[u8], end: &[u8], value: Option<&[u8]>) -> Vec<u8> {
485 let ordered = find(baseline, begin)
486 .zip(find(baseline, end))
487 .filter(|(start, stop)| stop > start);
488 let Some((start, stop)) = ordered else {
489 return baseline.to_vec();
490 };
491 let mut out = Vec::with_capacity(baseline.len());
492 out.extend_from_slice(&baseline[..start]);
493 out.extend_from_slice(value.unwrap_or_else(|| &baseline[start + begin.len()..stop]));
494 out.extend_from_slice(&baseline[stop + end.len()..]);
495 out
496}
497
498#[must_use]
516pub fn render(baseline: &[u8], params: &Params) -> Vec<u8> {
517 let repo = params.repo();
518 let owner = repo.split('/').next().unwrap_or(repo);
519 let mut out = substitute(baseline, OWNER_TOKEN, owner.as_bytes());
520 if let Some(style) = params.style() {
521 out = substitute(&out, STYLE_TOKEN, style.as_str().as_bytes());
522 }
523 out = substitute(&out, SCOPE_SHAPE_TOKEN, SCOPE_SHAPE.as_bytes());
524 out = substitute(&out, TRUNK_BRANCH_TOKEN, params.trunk().as_bytes());
525 let escaped = params.line_prefix().replace('/', "\\/");
526 out = substitute(&out, LINE_PREFIX_RE_TOKEN, escaped.as_bytes());
527 out = substitute(&out, LINE_PREFIX_TOKEN, params.line_prefix().as_bytes());
528 out = substitute(&out, REPO_TOKEN, repo.as_bytes());
529 for ((begin, end), value) in SECURITY_SPANS.iter().zip(security_replacements(params)) {
530 out = replace_span(&out, begin, end, value.as_deref());
531 }
532 out
533}
534
535pub(crate) fn substitute(baseline: &[u8], token: &[u8], value: &[u8]) -> Vec<u8> {
537 let mut out = Vec::with_capacity(baseline.len());
538 let mut rest = baseline;
539 while let Some(at) = find(rest, token) {
540 out.extend_from_slice(&rest[..at]);
541 out.extend_from_slice(value);
542 rest = &rest[at + token.len()..];
543 }
544 out.extend_from_slice(rest);
545 out
546}
547
548fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
550 haystack
551 .windows(needle.len())
552 .position(|window| window == needle)
553}
554
555pub const AGENTS_DESTINATION: &str = "AGENTS.md";
557
558pub const BLOCK_BEGIN: &str = "<!-- BEGIN release-kit -->";
560
561pub const BLOCK_END: &str = "<!-- END release-kit -->";
563
564pub const GLOSSARY_DESTINATION: &str = "GLOSSARY.md";
569
570pub const HOOKS_DESTINATION: &str = ".pre-commit-config.yaml";
572
573pub const BLOCK_DESTINATIONS: [&str; 3] =
579 [AGENTS_DESTINATION, GLOSSARY_DESTINATION, HOOKS_DESTINATION];
580
581pub const HOOKS_BEGIN: &str = "# BEGIN release-kit";
583
584pub const HOOKS_END: &str = "# END release-kit";
586
587pub const HOOK_TYPES_LINE: &str = "default_install_hook_types: [pre-commit, commit-msg, pre-push]";
591
592const AGENTS_BLOCK: &str = "blocks/agents-block.md.in";
594
595const GLOSSARY_BLOCK: &str = "blocks/glossary.md.in";
597
598const AGENTS_LINE_WORKTREE: &str = "blocks/agents-line-worktree.md.in";
600
601const AGENTS_LINE_BRANCHES: &str = "blocks/agents-line-branches.md.in";
603
604const PRE_COMMIT_BLOCK: &str = "blocks/pre-commit-block.yaml.in";
606
607const PRE_COMMIT_WORKTREE_GUARD: &str = "blocks/pre-commit-worktree-guard.yaml.in";
609
610fn block(
612 source: &dyn ReleaseSource,
613 manifest: &ReleaseManifest,
614 path: &str,
615) -> Result<String, RkError> {
616 let bytes = release::read(source, manifest, path)?;
617 String::from_utf8(bytes).map_err(|_| anyhow::anyhow!("{path}: a block is UTF-8").into())
618}
619
620fn authored(text: &str) -> &str {
624 text.strip_suffix('\n').unwrap_or(text)
625}
626
627pub 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[-/].+)$";
635
636pub const SCOPE_SHAPE: &str = "[a-z0-9._/-]+";
646
647#[must_use]
654pub fn scope_is_shaped(scope: &str) -> bool {
655 !scope.is_empty()
656 && scope.chars().all(|c| {
657 c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '/' | '-')
658 })
659}
660
661pub fn routing_block(source: &dyn ReleaseSource, workflow: Workflow) -> Result<String, RkError> {
675 let manifest = source.manifest()?;
676 let line = block(
677 source,
678 &manifest,
679 match workflow {
680 Workflow::Worktree => AGENTS_LINE_WORKTREE,
681 Workflow::Branches => AGENTS_LINE_BRANCHES,
682 },
683 )?;
684 let template = block(source, &manifest, AGENTS_BLOCK)?;
685 Ok(authored(&template).replacen("RK_WORKFLOW_LINE", authored(&line), 1))
686}
687
688pub fn glossary_block(source: &dyn ReleaseSource) -> Result<String, RkError> {
699 let manifest = source.manifest()?;
700 Ok(authored(&block(source, &manifest, GLOSSARY_BLOCK)?).to_owned())
701}
702
703pub fn hooks_block(source: &dyn ReleaseSource, workflow: Workflow) -> Result<String, RkError> {
721 let manifest = source.manifest()?;
722 let (guard, skip) = match workflow {
723 Workflow::Worktree => (
724 format!(
725 "{}\n",
726 authored(&block(source, &manifest, PRE_COMMIT_WORKTREE_GUARD)?)
727 ),
728 "no-commit-to-branch,rk-worktree-location",
729 ),
730 Workflow::Branches => (String::new(), "no-commit-to-branch"),
731 };
732 let template = block(source, &manifest, PRE_COMMIT_BLOCK)?;
733 Ok(authored(&template)
734 .replacen("RK_BRANCH_GRAMMAR", BRANCH_GRAMMAR, 1)
735 .replacen("RK_SWEEP_SKIP", skip, 1)
736 .replacen("RK_WORKTREE_GUARD", &guard, 1))
737}
738
739#[must_use]
741pub fn block_markers(destination: &str) -> Option<(&'static str, &'static str)> {
742 match destination {
743 AGENTS_DESTINATION | GLOSSARY_DESTINATION => Some((BLOCK_BEGIN, BLOCK_END)),
744 HOOKS_DESTINATION => Some((HOOKS_BEGIN, HOOKS_END)),
745 _ => None,
746 }
747}
748
749#[must_use]
752pub fn extract_block<'a>(text: &'a str, begin: &str, end: &str) -> Option<&'a str> {
753 let start = text.find(begin)?;
754 let stop = text[start..].find(end)? + start + end.len();
755 Some(&text[start..stop])
756}
757
758#[must_use]
765pub fn splice_marked_block(existing: Option<&[u8]>, block: &str) -> Vec<u8> {
766 let block = block.as_bytes();
767 let Some(text) = existing else {
768 return [block, b"\n"].concat();
769 };
770 if let Some(start) = find(text, BLOCK_BEGIN.as_bytes())
774 && let Some(offset) = find(&text[start..], BLOCK_END.as_bytes())
775 {
776 let stop = start + offset + BLOCK_END.len();
777 return [&text[..start], block, &text[stop..]].concat();
778 }
779 let separator: &[u8] = if text.ends_with(b"\n") {
783 b"\n"
784 } else {
785 b"\n\n"
786 };
787 [text, separator, block, b"\n"].concat()
788}
789
790pub fn splice_hooks_block(existing: Option<&str>, block: &str) -> Result<String, String> {
803 let Some(text) = existing else {
804 return Ok(format!("{HOOK_TYPES_LINE}\n\nrepos:\n{block}\n"));
805 };
806 if let Some(defect) = hooks_marker_defect(text) {
807 return Err(defect);
808 }
809 if let Some(found) = extract_block(text, HOOKS_BEGIN, HOOKS_END) {
810 return Ok(text.replacen(found, block, 1));
811 }
812 let mut out = String::with_capacity(text.len() + block.len() + 1);
813 let mut placed = false;
814 for line in text.split_inclusive('\n') {
815 out.push_str(line);
816 if !placed && line.trim_end() == "repos:" {
817 if !out.ends_with('\n') {
818 out.push('\n');
819 }
820 out.push_str(block);
821 out.push('\n');
822 placed = true;
823 }
824 }
825 if placed {
826 Ok(out)
827 } else {
828 Err(format!(
829 "{HOOKS_DESTINATION} exists with no repos: line, so the hook block has nowhere to land"
830 ))
831 }
832}
833
834#[must_use]
843pub fn hooks_marker_defect(text: &str) -> Option<String> {
844 let begins = text.matches(HOOKS_BEGIN).count();
845 let ends = text.matches(HOOKS_END).count();
846 if begins > 1 || ends > 1 {
847 return Some(format!(
848 "{HOOKS_DESTINATION} carries more than one release-kit marker pair; release-kit owns exactly one block"
849 ));
850 }
851 match (text.find(HOOKS_BEGIN), text.find(HOOKS_END)) {
852 (Some(begin), Some(end)) if end > begin => None,
853 (None, None) => None,
854 _ => Some(format!(
855 "{HOOKS_DESTINATION} carries an unmatched or misordered release-kit marker, so the block's extent is ambiguous"
856 )),
857 }
858}
859
860#[derive(Debug, Clone, Copy, PartialEq, Eq)]
862pub enum Placement {
863 Whole,
865 Block,
867}
868
869#[derive(Debug)]
872pub struct Entry {
873 pub destination: String,
875 pub kind: Kind,
877 pub placement: Placement,
879 pub baseline: Vec<u8>,
882 pub rendered: Vec<u8>,
885}
886
887pub fn pair_files(
895 source: &dyn ReleaseSource,
896 tech: &str,
897 forge: &str,
898) -> Result<Vec<(String, Vec<u8>)>, RkError> {
899 let manifest = source.manifest()?;
900 let techs: Vec<String> = manifest
901 .dirs_under("snippets")
902 .into_iter()
903 .filter(|name| !name.starts_with('_'))
904 .collect();
905 if tech.starts_with('_') || !techs.iter().any(|known| known == tech) {
908 return Err(RkError::Usage(format!(
909 "unknown tech '{tech}'; the bindings are: {}",
910 techs.join(", ")
911 )));
912 }
913 let pair = format!("snippets/{tech}/{forge}");
914 if manifest.under(&pair).next().is_none() {
915 let known: Vec<String> = techs
916 .iter()
917 .flat_map(|tech| {
918 manifest
919 .dirs_under(&format!("snippets/{tech}"))
920 .into_iter()
921 .map(move |forge| format!("{tech}, {forge}"))
922 })
923 .collect();
924 return Err(RkError::Usage(format!(
925 "the pair ({tech}, {forge}) has no landable files; the supported pairs are: {}",
926 known.join("; ")
927 )));
928 }
929 let mut files: Vec<(String, Vec<u8>)> = Vec::new();
933 for (rel, artifact) in manifest.under(&format!("snippets/_shared/{forge}")) {
934 files.push((rel.to_owned(), source.blob(&artifact.sha256)?));
935 }
936 for (rel, artifact) in manifest.under(&pair) {
937 if files.iter().any(|(existing, _)| existing == rel) {
938 return Err(anyhow::anyhow!(
939 "the shared zone and the pair ({tech}, {forge}) both ship {rel}; the payload is defective"
940 )
941 .into());
942 }
943 files.push((rel.to_owned(), source.blob(&artifact.sha256)?));
944 }
945 Ok(files)
946}
947
948pub fn projection(source: &dyn ReleaseSource, params: &Params) -> Result<Vec<Entry>, RkError> {
964 let mut entries = Vec::new();
965 for (destination, baseline) in pair_files(source, ¶ms.tech, ¶ms.forge)? {
966 if !params.nix && NIX_DESTINATIONS.contains(&destination.as_str()) {
967 continue;
968 }
969 let kind = kind_of(&destination).ok_or_else(|| {
970 anyhow::anyhow!("the payload does not classify {destination}; the kind table is stale")
971 })?;
972 let rendered = match kind {
973 Kind::Rendered => render(&baseline, params),
974 Kind::Seeded | Kind::State => baseline.clone(),
975 };
976 entries.push(Entry {
977 destination,
978 kind,
979 placement: Placement::Whole,
980 baseline,
981 rendered,
982 });
983 }
984 let mut blocks = vec![(AGENTS_DESTINATION, routing_block(source, params.workflow)?)];
988 if source.manifest()?.artifact(GLOSSARY_BLOCK).is_some() {
989 blocks.push((GLOSSARY_DESTINATION, glossary_block(source)?));
990 }
991 blocks.push((HOOKS_DESTINATION, hooks_block(source, params.workflow)?));
992 for (destination, template) in blocks {
993 entries.push(Entry {
994 destination: destination.to_owned(),
995 kind: Kind::Rendered,
996 placement: Placement::Block,
997 baseline: template.as_bytes().to_vec(),
998 rendered: render(template.as_bytes(), params),
999 });
1000 }
1001 entries.sort_by(|a, b| a.destination.cmp(&b.destination));
1002 Ok(entries)
1003}
1004
1005#[must_use]
1017pub fn nix_unsupported_shape(target: &Utf8Path) -> Option<String> {
1018 let Ok(text) = std::fs::read_to_string(target.join("Cargo.toml")) else {
1019 return Some(
1020 "the target has no readable Cargo.toml, which the seeded package expression reads; no Nix file lands".to_owned(),
1021 );
1022 };
1023 let Ok(table) = text.parse::<toml::Table>() else {
1024 return Some(
1025 "the target's Cargo.toml does not parse, and the seeded package expression reads it; no Nix file lands".to_owned(),
1026 );
1027 };
1028 if !table.contains_key("package") {
1029 return Some(
1030 "the target's Cargo.toml has no [package] table; the seed supports a single crate, so no Nix file lands".to_owned(),
1031 );
1032 }
1033 if !target.join("Cargo.lock").is_file() {
1034 return Some(
1035 "the target has no Cargo.lock, which the seeded package expression builds from; commit one, then opt in".to_owned(),
1036 );
1037 }
1038 let implicit_bin = target.join("src/main.rs").is_file()
1039 && table
1040 .get("package")
1041 .and_then(toml::Value::as_table)
1042 .and_then(|package| package.get("autobins"))
1043 .and_then(toml::Value::as_bool)
1044 != Some(false);
1045 let explicit_bins = table.get("bin").and_then(toml::Value::as_array);
1046 if explicit_bins.is_none() && !implicit_bin {
1047 return Some(
1048 "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(),
1049 );
1050 }
1051 if let Some(bins) = explicit_bins {
1057 let required = bins
1058 .first()
1059 .and_then(toml::Value::as_table)
1060 .and_then(|bin| bin.get("required-features"))
1061 .and_then(toml::Value::as_array);
1062 if let Some(required) = required {
1063 let enabled = default_features(&table);
1064 let missing = required
1065 .iter()
1066 .filter_map(toml::Value::as_str)
1067 .any(|feature| !enabled.contains(feature));
1068 if missing {
1069 return Some(
1070 "the target's first [[bin]] entry requires features a default build does not enable; no Nix file lands".to_owned(),
1071 );
1072 }
1073 }
1074 }
1075 None
1076}
1077
1078fn dep_edge_suppresses(features: &toml::Table, name: &str) -> bool {
1081 let edge = format!("dep:{name}");
1082 features.values().any(|list| {
1083 list.as_array().is_some_and(|entries| {
1084 entries
1085 .iter()
1086 .filter_map(toml::Value::as_str)
1087 .any(|entry| entry == edge)
1088 })
1089 })
1090}
1091
1092fn is_optional_dependency(table: &toml::Table, name: &str) -> bool {
1095 ["dependencies", "build-dependencies"]
1096 .iter()
1097 .any(|section| {
1098 table
1099 .get(*section)
1100 .and_then(toml::Value::as_table)
1101 .and_then(|dependencies| dependencies.get(name))
1102 .and_then(toml::Value::as_table)
1103 .and_then(|dependency| dependency.get("optional"))
1104 .and_then(toml::Value::as_bool)
1105 == Some(true)
1106 })
1107}
1108
1109fn default_features(table: &toml::Table) -> std::collections::BTreeSet<String> {
1116 let Some(features) = table.get("features").and_then(toml::Value::as_table) else {
1117 return std::collections::BTreeSet::new();
1118 };
1119 let mut enabled = std::collections::BTreeSet::new();
1120 let mut queue = vec!["default".to_owned()];
1121 while let Some(name) = queue.pop() {
1122 if !enabled.insert(name.clone()) {
1123 continue;
1124 }
1125 if let Some(implies) = features.get(&name).and_then(toml::Value::as_array) {
1126 for implied in implies.iter().filter_map(toml::Value::as_str) {
1127 if implied.starts_with("dep:") || implied.contains("?/") {
1128 continue;
1132 }
1133 if let Some((package, _)) = implied.split_once('/') {
1134 let feature_exists =
1142 features.contains_key(package) || !dep_edge_suppresses(features, package);
1143 if is_optional_dependency(table, package) && feature_exists {
1144 queue.push(package.to_owned());
1145 }
1146 } else {
1147 queue.push(implied.to_owned());
1148 }
1149 }
1150 }
1151 }
1152 enabled
1153}
1154
1155pub fn nix_withheld(
1167 target: &Utf8Path,
1168 recorded: Option<&manifest::Manifest>,
1169) -> std::io::Result<Option<String>> {
1170 if recorded.is_some_and(|record| record.file("flake.nix").is_some()) {
1171 return Ok(None);
1172 }
1173 let mut present = Vec::new();
1174 for name in ["flake.nix", "flake.lock"] {
1175 match std::fs::symlink_metadata(target.join(name).as_std_path()) {
1176 Ok(_) => present.push(name),
1177 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
1178 Err(e) => return Err(e),
1179 }
1180 }
1181 if present.is_empty() {
1182 return Ok(None);
1183 }
1184 Ok(Some(format!(
1185 "the target already carries {}; its flake pair stays its own",
1186 present.join(" and ")
1187 )))
1188}
1189
1190#[derive(Debug, Clone, Serialize)]
1192pub struct Withheld {
1193 pub path: String,
1195 pub reason: String,
1198}
1199
1200pub fn nix_withholding(
1212 target: &Utf8Path,
1213 recorded: Option<&manifest::Manifest>,
1214) -> Result<Option<(&'static [&'static str], String)>, RkError> {
1215 if let Some(reason) = nix_unsupported_shape(target) {
1216 return Ok(Some((&NIX_DESTINATIONS[..], reason)));
1217 }
1218 if let Some(reason) = nix_withheld(target, recorded)? {
1219 return Ok(Some((&NIX_WITHHOLDABLE[..], reason)));
1220 }
1221 Ok(None)
1222}
1223
1224pub fn withhold_nix(
1237 target: &Utf8Path,
1238 nix: bool,
1239 recorded: Option<&manifest::Manifest>,
1240 entries: &mut Vec<Entry>,
1241) -> Result<Vec<Withheld>, RkError> {
1242 if !nix {
1243 return Ok(Vec::new());
1244 }
1245 let Some((set, reason)) = nix_withholding(target, recorded)? else {
1246 return Ok(Vec::new());
1247 };
1248 let mut withheld = Vec::new();
1249 entries.retain(|entry| {
1250 if set.contains(&entry.destination.as_str()) {
1251 withheld.push(Withheld {
1252 path: entry.destination.clone(),
1253 reason: reason.clone(),
1254 });
1255 false
1256 } else {
1257 true
1258 }
1259 });
1260 Ok(withheld)
1261}
1262
1263pub fn read_destination(target: &Utf8Path, entry: &Entry) -> std::io::Result<Option<Vec<u8>>> {
1271 read_recorded(target, &entry.destination)
1272}
1273
1274pub fn read_recorded(target: &Utf8Path, destination: &str) -> std::io::Result<Option<Vec<u8>>> {
1285 let path = target.join(destination);
1286 let bytes = match std::fs::read(&path) {
1287 Ok(bytes) => bytes,
1288 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1289 Err(e) => return Err(e),
1290 };
1291 if let Some((begin, end)) = block_markers(destination) {
1292 let text = String::from_utf8_lossy(&bytes);
1293 Ok(extract_block(&text, begin, end).map(|block| block.as_bytes().to_vec()))
1294 } else {
1295 Ok(Some(bytes))
1296 }
1297}
1298
1299#[derive(Debug)]
1302pub struct Resolved {
1303 pub forge: String,
1305 pub repo: Option<String>,
1307}
1308
1309pub fn resolve(
1321 target: &Utf8Path,
1322 forge_flag: Option<&str>,
1323 repo_flag: Option<&str>,
1324) -> Result<Resolved, RkError> {
1325 let forge_flag = forge_flag
1326 .map(|name| {
1327 crate::detect::Forge::parse(name).ok_or_else(|| {
1328 RkError::Usage(format!(
1329 "unknown forge '{name}'; the forges are: github, gitlab"
1330 ))
1331 })
1332 })
1333 .transpose()?;
1334 let detected = crate::detect::detect(target.as_std_path());
1335 let forge = forge_flag
1336 .or(detected.forge)
1337 .map(|forge| forge.as_str().to_owned())
1338 .ok_or_else(|| {
1339 let message = detected.host.map_or_else(
1340 || "no forge detected: the target has no origin remote".to_owned(),
1341 |host| format!("no forge detected: the host {host} is not recognized"),
1342 );
1343 RkError::refusal(
1344 Diagnostic::new(Reason::ForgeUndetected, message)
1345 .expected("a github.com or gitlab remote, or --forge")
1346 .action("pass --forge <github|gitlab>"),
1347 )
1348 })?;
1349 Ok(Resolved {
1350 forge,
1351 repo: repo_flag.map(str::to_owned).or(detected.repo),
1352 })
1353}
1354
1355#[must_use]
1358pub fn repo_unresolved() -> RkError {
1359 RkError::missing(
1360 Diagnostic::new(
1361 Reason::ForgeUndetected,
1362 "no repository detected: the target has no origin remote",
1363 )
1364 .expected("an origin remote naming the project")
1365 .action("pass --repo <path>"),
1366 )
1367}
1368
1369pub fn write_destination(target: &Utf8Path, entry: &Entry) -> std::io::Result<()> {
1379 let path = target.join(&entry.destination);
1380 match entry.placement {
1381 Placement::Whole => atomic::write(path.as_std_path(), &entry.rendered),
1382 Placement::Block => {
1383 let existing = match std::fs::read(&path) {
1384 Ok(bytes) => Some(bytes),
1385 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
1386 Err(e) => return Err(e),
1387 };
1388 let block = String::from_utf8_lossy(&entry.rendered).into_owned();
1391 if entry.destination == HOOKS_DESTINATION {
1392 let text = existing.map(|bytes| String::from_utf8_lossy(&bytes).into_owned());
1393 let spliced =
1394 splice_hooks_block(text.as_deref(), &block).map_err(std::io::Error::other)?;
1395 atomic::write(path.as_std_path(), spliced.as_bytes())
1396 } else {
1397 let spliced = splice_marked_block(existing.as_deref(), &block);
1398 atomic::write(path.as_std_path(), &spliced)
1399 }
1400 }
1401 }
1402}
1403
1404pub fn hooks_file_defect(
1417 source: &dyn ReleaseSource,
1418 target: &Utf8Path,
1419) -> Result<Option<String>, RkError> {
1420 let path = target.join(HOOKS_DESTINATION);
1421 match std::fs::read(&path) {
1422 Ok(bytes) => {
1423 let text = String::from_utf8_lossy(&bytes);
1424 let manifest = source.manifest()?;
1425 let template = block(source, &manifest, PRE_COMMIT_BLOCK)?;
1426 Ok(splice_hooks_block(Some(&text), authored(&template)).err())
1427 }
1428 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
1429 Err(e) => Err(e.into()),
1430 }
1431}
1432
1433pub fn hooks_splice_refusal(source: &dyn ReleaseSource, target: &Utf8Path) -> Result<(), RkError> {
1443 hooks_file_defect(source, target)?.map_or(Ok(()), |reason| {
1444 Err(RkError::refusal(
1445 Diagnostic::new(
1446 Reason::StateDrift,
1447 format!("{reason}, and nothing was written"),
1448 )
1449 .expected("a .pre-commit-config.yaml the block can land in, or none")
1450 .action(format!(
1451 "resolve it in {}, then re-run",
1452 target.join(HOOKS_DESTINATION)
1453 ))
1454 .target_state("unchanged"),
1455 ))
1456 })
1457}
1458
1459#[cfg(test)]
1460mod tests {
1461 use super::{
1462 AGENTS_DESTINATION, BLOCK_BEGIN, BLOCK_DESTINATIONS, BLOCK_END, BRANCH_GRAMMAR,
1463 GLOSSARY_DESTINATION, HOOK_TYPES_LINE, HOOKS_BEGIN, HOOKS_DESTINATION, HOOKS_END, Kind,
1464 SCOPE_SHAPE, Style, Workflow, extract_block, kind_of, render, splice_hooks_block,
1465 splice_marked_block,
1466 };
1467 use crate::embedded;
1468 use crate::release::EmbeddedReleaseSource;
1469
1470 const SOURCE: EmbeddedReleaseSource = EmbeddedReleaseSource;
1472
1473 fn pair_files(
1474 tech: &str,
1475 forge: &str,
1476 ) -> Result<Vec<(String, Vec<u8>)>, crate::error::RkError> {
1477 super::pair_files(&SOURCE, tech, forge)
1478 }
1479
1480 fn projection(params: &super::Params) -> Result<Vec<super::Entry>, crate::error::RkError> {
1481 super::projection(&SOURCE, params)
1482 }
1483
1484 fn routing_block(workflow: Workflow) -> String {
1485 super::routing_block(&SOURCE, workflow).expect("the embedded bundle carries the block")
1486 }
1487
1488 fn hooks_block(workflow: Workflow) -> String {
1489 super::hooks_block(&SOURCE, workflow).expect("the embedded bundle carries the block")
1490 }
1491
1492 fn glossary_block() -> String {
1493 super::glossary_block(&SOURCE).expect("the embedded bundle carries the block")
1494 }
1495
1496 fn spliced(existing: Option<&str>, block: &str) -> String {
1499 String::from_utf8(splice_marked_block(existing.map(str::as_bytes), block))
1500 .expect("the fixtures are text")
1501 }
1502
1503 #[test]
1504 fn private_reporting_path_tokens_are_reproducible() {
1505 for repo in [
1506 "acme/widget",
1507 "acme/group/widget",
1508 "acme/OWNER-RK_STYLE-RK_SCOPE_SHAPE",
1509 ] {
1510 assert_eq!(
1511 super::render(
1512 b"RK_REPO RK_REPO OWNER RK_STYLE RK_SCOPE_SHAPE",
1513 &super::Params::for_test(repo, Some(super::Style::Trunk))
1514 ),
1515 format!("{repo} {repo} acme trunk {}", super::SCOPE_SHAPE).as_bytes()
1516 );
1517 }
1518 assert_eq!(super::kind_of("SECURITY.md"), Some(super::Kind::Rendered));
1519 }
1520
1521 #[test]
1526 fn each_forge_policy_carries_one_ordered_pair_of_every_span() {
1527 for forge in ["github", "gitlab"] {
1528 let bytes = embedded::SNIPPETS
1529 .get_file(format!("_shared/{forge}/SECURITY.md"))
1530 .expect("the policy ships")
1531 .contents();
1532 let text = String::from_utf8_lossy(bytes);
1533 for (begin, end) in super::SECURITY_SPANS {
1534 let begin = String::from_utf8_lossy(begin);
1535 let end = String::from_utf8_lossy(end);
1536 assert_eq!(text.matches(begin.as_ref()).count(), 1, "{forge} {begin}");
1537 assert_eq!(text.matches(end.as_ref()).count(), 1, "{forge} {end}");
1538 assert!(
1539 text.find(begin.as_ref()) < text.find(end.as_ref()),
1540 "{forge}: {begin} must precede {end}"
1541 );
1542 }
1543 }
1544 }
1545
1546 #[test]
1551 fn the_security_spans_render_per_answer() {
1552 for forge in ["github", "gitlab"] {
1553 let bytes = embedded::SNIPPETS
1554 .get_file(format!("_shared/{forge}/SECURITY.md"))
1555 .expect("the policy ships")
1556 .contents();
1557 let authored = String::from_utf8_lossy(bytes);
1558 let stripped = {
1559 let mut text = authored.clone().into_owned();
1560 for (begin, end) in super::SECURITY_SPANS {
1561 text = text.replace(&String::from_utf8_lossy(begin).into_owned(), "");
1562 text = text.replace(&String::from_utf8_lossy(end).into_owned(), "");
1563 }
1564 text
1565 };
1566 let default = super::Params {
1567 forge: forge.to_owned(),
1568 ..super::Params::for_test_security("", crate::config::RESPONSE_DEFAULT)
1569 };
1570 let rendered = String::from_utf8(render(bytes, &default)).expect("text");
1571 assert_eq!(
1572 rendered,
1573 stripped.replace("RK_REPO", "acme/widget"),
1574 "{forge}: the default answers must reproduce the authored policy"
1575 );
1576 assert!(!rendered.contains("RK_SECURITY"), "{forge}: {rendered}");
1577
1578 let answered = super::Params {
1579 forge: forge.to_owned(),
1580 ..super::Params::for_test_security("OWNER RK_REPO <team@acme.example>", "14 days")
1581 };
1582 let rendered = String::from_utf8(render(bytes, &answered)).expect("text");
1583 assert!(
1584 rendered.contains("OWNER RK_REPO <team@acme.example>"),
1585 "{forge}: a contact spelling a token name lands literally: {rendered}"
1586 );
1587 assert!(
1588 rendered.contains("Maintainers acknowledge a report within 14 days."),
1589 "{forge}: {rendered}"
1590 );
1591 assert!(
1592 rendered.contains("This policy commits to no disclosure deadline."),
1593 "{forge}: {rendered}"
1594 );
1595 assert!(
1596 !rendered.contains("best-effort basis"),
1597 "{forge}: a stated window replaces the best-effort sentence: {rendered}"
1598 );
1599 assert!(
1600 !rendered.contains("no response or disclosure deadline"),
1601 "{forge}: a stated window contradicts the response disclaimer: {rendered}"
1602 );
1603 }
1604 }
1605
1606 #[test]
1609 fn a_defective_span_renders_unchanged() {
1610 let (begin, end) = super::SECURITY_SPANS[0];
1611 let begin = String::from_utf8_lossy(begin).into_owned();
1612 let end = String::from_utf8_lossy(end).into_owned();
1613 let params = super::Params::for_test_security("team@acme.example", "1 day");
1614 for baseline in [
1615 format!("contact {begin}a maintainer\n"),
1616 format!("contact a maintainer{end}\n"),
1617 format!("contact {end}a maintainer{begin}\n"),
1618 "contact a maintainer\n".to_owned(),
1619 ] {
1620 assert_eq!(
1621 render(baseline.as_bytes(), ¶ms),
1622 baseline.as_bytes(),
1623 "{baseline}"
1624 );
1625 }
1626 }
1627
1628 #[test]
1632 fn the_kind_table_closes_over_every_snippet() {
1633 for tech_dir in embedded::SNIPPETS.dirs() {
1634 for pair_dir in tech_dir.dirs() {
1635 let prefix = format!("{}/", pair_dir.path().to_string_lossy());
1636 for (path, _) in embedded::walk(pair_dir) {
1637 let destination = path.strip_prefix(&prefix).unwrap_or(&path);
1638 assert!(
1639 kind_of(destination).is_some(),
1640 "{destination}: no declared kind"
1641 );
1642 }
1643 }
1644 }
1645 for block in BLOCK_DESTINATIONS {
1646 assert_eq!(kind_of(block), Some(Kind::Rendered), "{block}");
1647 }
1648 assert_eq!(kind_of("something-else.txt"), None);
1649 }
1650
1651 #[test]
1656 fn rendering_substitutes_every_owner_occurrence() {
1657 let baseline = b"if: repository_owner == 'OWNER'\n# OWNER again: OWNER\n";
1658 let rendered = render(baseline, &super::Params::for_test("acme/sub/widget", None));
1659 let text = String::from_utf8(rendered).expect("rendered bytes stay text");
1660 assert_eq!(text, "if: repository_owner == 'acme'\n# acme again: acme\n");
1661
1662 let baseline = b"match (RK_SCOPE_SHAPE)\n";
1663 let rendered = render(baseline, &super::Params::for_test("acme/widget", None));
1664 let text = String::from_utf8(rendered).expect("rendered bytes stay text");
1665 assert_eq!(text, format!("match ({SCOPE_SHAPE})\n"));
1666 }
1667
1668 #[test]
1672 fn the_scope_shape_drops_into_the_title_check() {
1673 assert_eq!(SCOPE_SHAPE, "[a-z0-9._/-]+");
1674 assert!(
1675 !SCOPE_SHAPE.contains('\''),
1676 "the title checks single-quote it"
1677 );
1678 }
1679
1680 #[test]
1685 fn the_scope_predicate_and_the_rendered_pattern_agree() {
1686 let body = SCOPE_SHAPE
1687 .strip_prefix('[')
1688 .and_then(|rest| rest.strip_suffix("]+"))
1689 .expect("the shape is one bracket expression, repeated");
1690 let chars: Vec<char> = body.chars().collect();
1691 let mut admitted = std::collections::BTreeSet::new();
1692 let mut at = 0;
1693 while at < chars.len() {
1694 if at + 2 < chars.len() && chars[at + 1] == '-' {
1697 for c in chars[at]..=chars[at + 2] {
1698 admitted.insert(c);
1699 }
1700 at += 3;
1701 } else {
1702 admitted.insert(chars[at]);
1703 at += 1;
1704 }
1705 }
1706 for byte in 0..=127u8 {
1707 let c = char::from(byte);
1708 assert_eq!(
1709 super::scope_is_shaped(&c.to_string()),
1710 admitted.contains(&c),
1711 "the predicate and {SCOPE_SHAPE} disagree on {c:?}"
1712 );
1713 }
1714 assert!(super::scope_is_shaped("guides/release"));
1715 assert!(!super::scope_is_shaped(""), "a scope is never empty");
1716 assert!(!super::scope_is_shaped("Specs Ugly"));
1717 }
1718
1719 #[test]
1722 fn the_shared_zone_composes_into_the_pair() {
1723 let files = pair_files("rust", "github").expect("the pair lists");
1724 assert!(
1725 files
1726 .iter()
1727 .any(|(dest, _)| dest == ".github/workflows/pr-title.yml"),
1728 "the shared title check lands with the pair"
1729 );
1730 let files = pair_files("rust", "gitlab").expect("the pair lists");
1731 assert!(
1732 files
1733 .iter()
1734 .any(|(dest, _)| dest == ".gitlab/ci/mr-title.yml"),
1735 "the shared title job lands with the pair"
1736 );
1737 let err = pair_files("_shared", "github").expect_err("the shared zone is no tech");
1738 let listing = err.to_string();
1739 let bindings = listing
1740 .split("the bindings are:")
1741 .nth(1)
1742 .expect("the refusal lists the bindings");
1743 assert!(!bindings.contains("_shared"), "{listing}");
1744 }
1745
1746 #[test]
1749 fn params_from_a_record_round_trips() {
1750 use super::{Params, manifest};
1751 let dir = tempfile::tempdir().expect("a scratch target exists");
1752 let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
1753 for tech in ["rust", "bash"] {
1754 for forge in ["github", "gitlab"] {
1755 for workflow in [Workflow::Branches, Workflow::Worktree] {
1756 for style in [None, Some(Style::Trunk), Some(Style::Lines)] {
1757 for nix in [false, true] {
1758 let record = manifest::Manifest {
1759 schema_version: manifest::SCHEMA_VERSION,
1760 rk_version: "0.1.0".to_owned(),
1761 payload_sha256: crate::digest::Digest::of(b""),
1762 origin: "init".to_owned(),
1763 tech: tech.to_owned(),
1764 forge: forge.to_owned(),
1765 landed_at: "2026-08-29T00:00:00Z".to_owned(),
1766 parameters: manifest::Parameters {
1767 repo: "acme/team/widget".to_owned(),
1768 workflow,
1769 style,
1770 nix,
1771 trunk: crate::config::TRUNK_DEFAULT.to_owned(),
1772 line_prefix: crate::config::LINE_PREFIX_DEFAULT.to_owned(),
1773 security_contact: String::new(),
1774 security_response: crate::config::RESPONSE_DEFAULT.to_owned(),
1775 },
1776 files: Vec::new(),
1777 pins: std::collections::BTreeMap::new(),
1778 };
1779 manifest::write(target, &record).expect("the record writes");
1780 let loaded = manifest::load(target)
1781 .expect("the record loads")
1782 .expect("the record exists");
1783 let params = Params::from_record(&loaded);
1784 assert_eq!(params.tech, tech);
1785 assert_eq!(params.forge, forge);
1786 assert_eq!(params.repo(), "acme/team/widget");
1787 assert_eq!(params.workflow(), workflow);
1788 assert_eq!(params.style(), style);
1789 assert_eq!(params.nix, nix);
1790 let entries = projection(¶ms).expect("the record projects");
1791 let mut expected: Vec<_> = pair_files(tech, forge)
1792 .expect("the pair lists")
1793 .into_iter()
1794 .filter(|(path, _)| {
1795 nix || !super::NIX_DESTINATIONS.contains(&path.as_str())
1796 })
1797 .collect();
1798 let routing = routing_block(workflow);
1799 let hooks = hooks_block(workflow);
1800 let glossary = glossary_block();
1801 expected.push((AGENTS_DESTINATION.to_owned(), routing.into_bytes()));
1802 expected.push((GLOSSARY_DESTINATION.to_owned(), glossary.into_bytes()));
1803 expected.push((HOOKS_DESTINATION.to_owned(), hooks.into_bytes()));
1804 expected.sort_by(|a, b| a.0.cmp(&b.0));
1805 assert_eq!(entries.len(), expected.len());
1806 for (entry, (destination, baseline)) in entries.iter().zip(expected) {
1807 assert_eq!(entry.destination, destination);
1808 assert_eq!(entry.baseline, baseline);
1809 let rendered = match entry.kind {
1810 Kind::Rendered => super::render(
1811 &baseline,
1812 &super::Params::for_test("acme/team/widget", style),
1813 ),
1814 Kind::Seeded | Kind::State => baseline.clone(),
1815 };
1816 assert_eq!(entry.rendered, rendered, "{destination}");
1817 }
1818 }
1819 }
1820 }
1821 }
1822 }
1823 }
1824
1825 fn resolved_test_params(
1826 tech: &str,
1827 resolved: &super::Resolved,
1828 workflow: Workflow,
1829 style: Option<Style>,
1830 nix: bool,
1831 ) -> Result<super::Params, crate::error::RkError> {
1832 super::Params::resolve(
1833 &SOURCE,
1834 camino::Utf8Path::new("."),
1835 &super::Inputs {
1836 tech: Some(tech),
1837 forge: Some(&resolved.forge),
1838 repo: resolved.repo.as_deref(),
1839 workflow: Some(workflow),
1840 style,
1841 nix: Some(nix),
1842 },
1843 None,
1844 None,
1845 super::Purpose::Init,
1846 )
1847 }
1848
1849 #[test]
1853 fn a_projection_renders_owned_files_and_keeps_seeded_judgment() {
1854 let entries = projection(
1855 &resolved_test_params(
1856 "rust",
1857 &super::Resolved {
1858 forge: "github".to_owned(),
1859 repo: Some("acme/widget".to_owned()),
1860 },
1861 Workflow::Branches,
1862 Some(Style::Trunk),
1863 false,
1864 )
1865 .expect("the parameters resolve"),
1866 )
1867 .expect("the pair projects");
1868 let workflow = entries
1869 .iter()
1870 .find(|entry| entry.destination.ends_with("release-plz.yml"))
1871 .expect("the workflow projects");
1872 assert_eq!(workflow.kind, Kind::Rendered);
1873 let text = String::from_utf8_lossy(&workflow.rendered);
1874 assert!(!text.contains("OWNER"), "an owner token survived rendering");
1875 assert!(text.contains("'acme'"));
1876 assert!(!text.contains("TODO(release-kit)"));
1877 let title = entries
1878 .iter()
1879 .find(|entry| entry.destination.ends_with("pr-title.yml"))
1880 .expect("the title check projects");
1881 let text = String::from_utf8_lossy(&title.rendered);
1882 assert!(text.contains(SCOPE_SHAPE), "{text}");
1883 assert!(
1884 !text.contains("RK_SCOPE_SHAPE"),
1885 "a scope token survived: {text}"
1886 );
1887 let seeded = entries
1888 .iter()
1889 .find(|entry| entry.destination == "release-plz.toml")
1890 .expect("the seeded file projects");
1891 assert_eq!(seeded.kind, Kind::Seeded);
1892 assert_eq!(seeded.rendered, seeded.baseline);
1893 assert!(String::from_utf8_lossy(&seeded.rendered).contains("TODO(release-kit)"));
1894 for block in BLOCK_DESTINATIONS {
1895 let entry = entries
1896 .iter()
1897 .find(|entry| entry.destination == block)
1898 .expect("every block is part of the projection");
1899 let text = String::from_utf8_lossy(&entry.rendered);
1900 assert!(
1901 !text.contains("RK_SCOPE_SHAPE"),
1902 "{block} kept a token: {text}"
1903 );
1904 }
1905 }
1906
1907 #[test]
1912 fn the_nix_destinations_project_only_under_the_opt_in() {
1913 use super::NIX_DESTINATIONS;
1914 let paths = |nix: bool, forge: &str| -> Vec<String> {
1915 projection(
1916 &resolved_test_params(
1917 "rust",
1918 &super::Resolved {
1919 forge: forge.to_owned(),
1920 repo: Some("acme/widget".to_owned()),
1921 },
1922 Workflow::Worktree,
1923 Some(Style::Trunk),
1924 nix,
1925 )
1926 .expect("the parameters resolve"),
1927 )
1928 .expect("the pair projects")
1929 .into_iter()
1930 .map(|entry| entry.destination)
1931 .collect()
1932 };
1933 let off = paths(false, "github");
1934 for destination in NIX_DESTINATIONS {
1935 assert!(!off.contains(&destination.to_owned()), "{destination}");
1936 }
1937 let on = paths(true, "github");
1938 for destination in ["nix/package.nix", "flake.nix", "flake.lock"] {
1939 assert!(on.contains(&destination.to_owned()), "{destination}");
1940 }
1941 let gitlab = paths(true, "gitlab");
1946 assert!(gitlab.contains(&"nix/package.nix".to_owned()));
1947 assert!(
1948 !on.iter()
1949 .chain(gitlab.iter())
1950 .any(|destination| destination.contains("nix.yml"))
1951 );
1952 let bash = projection(
1953 &resolved_test_params(
1954 "bash",
1955 &super::Resolved {
1956 forge: "github".to_owned(),
1957 repo: Some("acme/widget".to_owned()),
1958 },
1959 Workflow::Worktree,
1960 Some(Style::Trunk),
1961 true,
1962 )
1963 .expect("the parameters resolve"),
1964 )
1965 .expect("an out-of-matrix pair projects the smaller product");
1966 assert!(
1967 bash.iter()
1968 .all(|entry| !NIX_DESTINATIONS.contains(&entry.destination.as_str()))
1969 );
1970 }
1971
1972 #[test]
1977 fn the_nix_seeds_are_identical_across_forge_pairs() {
1978 for name in ["nix/package.nix", "flake.nix", "flake.lock"] {
1979 let github = embedded::SNIPPETS
1980 .get_file(format!("rust/github/{name}"))
1981 .expect("the github copy ships")
1982 .contents();
1983 let gitlab = embedded::SNIPPETS
1984 .get_file(format!("rust/gitlab/{name}"))
1985 .expect("the gitlab copy ships")
1986 .contents();
1987 assert_eq!(github, gitlab, "{name} diverged between the pairs");
1988 }
1989 }
1990
1991 #[test]
1996 fn the_nix_withhold_judgment_covers_the_three_shapes() {
1997 use super::{NIX_DESTINATIONS, withhold_nix};
1998 let dir = tempfile::tempdir().expect("a scratch target exists");
1999 let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
2000 let entries = || {
2001 projection(
2002 &resolved_test_params(
2003 "rust",
2004 &super::Resolved {
2005 forge: "github".to_owned(),
2006 repo: Some("acme/widget".to_owned()),
2007 },
2008 Workflow::Worktree,
2009 Some(Style::Trunk),
2010 true,
2011 )
2012 .expect("the parameters resolve"),
2013 )
2014 .expect("the pair projects")
2015 };
2016
2017 let mut all = entries();
2019 let withheld = withhold_nix(target, true, None, &mut all).expect("the judgment runs");
2020 let paths: Vec<&str> = withheld.iter().map(|w| w.path.as_str()).collect();
2021 assert_eq!(paths, ["flake.lock", "flake.nix", "nix/package.nix"]);
2022 assert!(
2023 all.iter()
2024 .all(|entry| !NIX_DESTINATIONS.contains(&entry.destination.as_str()))
2025 );
2026
2027 std::fs::write(
2030 target.join("Cargo.toml"),
2031 "[package]\nname = \"widget\"\nversion = \"0.1.0\"\n",
2032 )
2033 .expect("the crate manifest writes");
2034 std::fs::write(target.join("Cargo.lock"), "version = 4\n").expect("the lock writes");
2035 std::fs::create_dir_all(target.join("src")).expect("the src dir exists");
2036 std::fs::write(target.join("src/main.rs"), "fn main() {}\n").expect("the main writes");
2037 std::fs::write(target.join("flake.nix"), "{ }\n").expect("the flake writes");
2038 let mut all = entries();
2039 let withheld = withhold_nix(target, true, None, &mut all).expect("the judgment runs");
2040 let paths: Vec<&str> = withheld.iter().map(|w| w.path.as_str()).collect();
2041 assert_eq!(paths, ["flake.lock", "flake.nix"]);
2042 assert!(
2043 all.iter()
2044 .any(|entry| entry.destination == "nix/package.nix")
2045 );
2046
2047 std::fs::remove_file(target.join("flake.nix")).expect("the flake removes");
2049 let mut all = entries();
2050 let withheld = withhold_nix(target, true, None, &mut all).expect("the judgment runs");
2051 assert!(withheld.is_empty());
2052 assert!(all.iter().any(|entry| entry.destination == "flake.nix"));
2053
2054 let mut all = entries();
2056 let withheld = withhold_nix(target, false, None, &mut all).expect("the judgment runs");
2057 assert!(withheld.is_empty());
2058 }
2059
2060 #[test]
2064 fn the_glossary_splices_into_every_shape() {
2065 let owned = glossary_block();
2066 let block = owned.as_str();
2067
2068 let fresh = spliced(None, block);
2069 assert_eq!(fresh, format!("{block}\n"));
2070 assert_eq!(extract_block(&fresh, BLOCK_BEGIN, BLOCK_END), Some(block));
2071
2072 let own = "# Glossary\n\n- `spike` — a throwaway branch.\n";
2073 let appended = spliced(Some(own), block);
2074 assert!(appended.starts_with(own));
2075 assert_eq!(
2076 extract_block(&appended, BLOCK_BEGIN, BLOCK_END),
2077 Some(block)
2078 );
2079
2080 let stale = appended.replace("full-implement", "do-everything");
2081 let refreshed = spliced(Some(&stale), block);
2082 assert_eq!(
2083 extract_block(&refreshed, BLOCK_BEGIN, BLOCK_END),
2084 Some(block)
2085 );
2086 assert_eq!(
2087 refreshed.matches("BEGIN release-kit").count(),
2088 1,
2089 "a re-splice must replace, not accumulate"
2090 );
2091 }
2092
2093 #[test]
2097 fn the_glossary_leaves_the_targets_region_alone() {
2098 let owned = glossary_block();
2099 let block = owned.as_str();
2100 let below = "\n## Our own terms\n\n- `spike` — a throwaway branch, never merged.\n";
2101 let landed = format!("{block}\n{below}");
2102
2103 let refreshed = spliced(Some(&landed), block);
2104 assert!(
2105 refreshed.ends_with(below),
2106 "the target's own region changed: {refreshed}"
2107 );
2108 assert_eq!(
2109 extract_block(&refreshed, BLOCK_BEGIN, BLOCK_END),
2110 Some(block)
2111 );
2112 }
2113
2114 #[test]
2118 fn an_append_rewrites_no_byte_the_target_wrote() {
2119 let owned = glossary_block();
2120 let block = owned.as_str();
2121 for own in [
2122 "# Glossary\n\n- `spike` — throwaway. \n\n\n",
2123 "# Glossary\n\n- `spike` — throwaway.",
2124 "# Glossary\r\n\r\n- `spike` — throwaway.\r\n",
2125 ] {
2126 let appended = spliced(Some(own), block);
2127 assert!(
2128 appended.starts_with(own),
2129 "the target's bytes changed: {appended:?}"
2130 );
2131 assert_eq!(
2132 extract_block(&appended, BLOCK_BEGIN, BLOCK_END),
2133 Some(block),
2134 "{appended:?}"
2135 );
2136 let marker = appended.find(BLOCK_BEGIN).expect("the block landed");
2137 assert!(
2138 appended[..marker].ends_with('\n'),
2139 "the block must open its own line: {appended:?}"
2140 );
2141 }
2142 }
2143
2144 #[test]
2148 fn a_splice_decodes_no_byte_the_target_wrote() {
2149 let owned = glossary_block();
2150 let block = owned.as_str();
2151
2152 let own = b"# Glossary\n\ncaf\xe9\n";
2154 let appended = splice_marked_block(Some(own), block);
2155 assert!(
2156 appended.starts_with(own),
2157 "the target's bytes changed: {appended:?}"
2158 );
2159 assert!(!appended.contains(&0xEF), "a replacement character landed");
2160
2161 let mut landed = Vec::new();
2163 landed.extend_from_slice(block.replace("full-implement", "do-everything").as_bytes());
2164 landed.extend_from_slice(b"\n\ncaf\xe9\n");
2165 let refreshed = splice_marked_block(Some(&landed), block);
2166 assert!(
2167 refreshed.ends_with(b"\n\ncaf\xe9\n"),
2168 "the target's region below the markers changed: {refreshed:?}"
2169 );
2170 assert!(refreshed.starts_with(block.as_bytes()), "{refreshed:?}");
2171 }
2172
2173 #[test]
2176 fn the_glossary_block_carries_no_parameter() {
2177 let block = glossary_block();
2178 assert!(block.starts_with(BLOCK_BEGIN), "{block}");
2179 assert!(block.ends_with(BLOCK_END), "{block}");
2180 assert!(!block.contains("RK_"), "a token survived: {block}");
2181 assert!(!block.contains("OWNER"), "an owner token survived: {block}");
2182 for term in [
2183 "implement-and-request",
2184 "implement-and-merge",
2185 "full-implement",
2186 ] {
2187 assert!(block.contains(term), "{term} is missing from {block}");
2188 }
2189 assert!(
2190 routing_block(Workflow::Worktree).contains(GLOSSARY_DESTINATION),
2191 "the routing block must name the destination it indexes"
2192 );
2193 }
2194
2195 #[test]
2196 fn the_block_splices_into_every_agents_shape() {
2197 let owned = routing_block(Workflow::Branches);
2198 let block = owned.as_str();
2199 let fresh = spliced(None, block);
2200 assert_eq!(fresh, format!("{block}\n"));
2201 assert_eq!(extract_block(&fresh, BLOCK_BEGIN, BLOCK_END), Some(block));
2202
2203 let appended = spliced(Some("# My project\n\nOwn rules.\n"), block);
2204 assert!(appended.starts_with("# My project\n\nOwn rules.\n\n<!-- BEGIN release-kit -->"));
2205 assert_eq!(
2206 extract_block(&appended, BLOCK_BEGIN, BLOCK_END),
2207 Some(block)
2208 );
2209
2210 let stale = appended.replace("Never author a tag", "Do author a tag");
2211 let refreshed = spliced(Some(&stale), block);
2212 assert_eq!(
2213 extract_block(&refreshed, BLOCK_BEGIN, BLOCK_END),
2214 Some(block)
2215 );
2216 assert!(refreshed.starts_with("# My project"));
2217 assert_eq!(
2218 refreshed.matches("BEGIN release-kit").count(),
2219 1,
2220 "a re-splice must replace, not accumulate"
2221 );
2222 }
2223
2224 #[test]
2227 fn the_hook_block_splices_under_repos() {
2228 let owned = hooks_block(Workflow::Branches);
2229 let block = owned.as_str();
2230 let fresh = splice_hooks_block(None, block).expect("a fresh file splices");
2231 assert!(fresh.starts_with(HOOK_TYPES_LINE));
2232 assert!(fresh.contains("\nrepos:\n# BEGIN release-kit\n"));
2233 assert_eq!(extract_block(&fresh, HOOKS_BEGIN, HOOKS_END), Some(block));
2234
2235 let own =
2236 "repos:\n - repo: https://example.com/own\n rev: v1\n hooks:\n - id: own\n";
2237 let spliced = splice_hooks_block(Some(own), block).expect("an unmarked file splices");
2238 assert!(spliced.starts_with("repos:\n# BEGIN release-kit\n"));
2239 assert!(spliced.contains("- id: own"), "the target's hooks survive");
2240 assert!(
2241 !spliced.contains(HOOK_TYPES_LINE),
2242 "an existing file's top level is the skills' duty, not the splice's"
2243 );
2244
2245 let stale = spliced.replace("--force-scope", "--no-scope");
2246 let refreshed = splice_hooks_block(Some(&stale), block).expect("a marked file re-splices");
2247 assert_eq!(
2248 extract_block(&refreshed, HOOKS_BEGIN, HOOKS_END),
2249 Some(block)
2250 );
2251 assert_eq!(refreshed.matches(HOOKS_BEGIN).count(), 1);
2252
2253 let err = splice_hooks_block(Some("minimum_pre_commit_version: '3.2.0'\n"), block)
2254 .expect_err("no repos: line refuses");
2255 assert!(err.contains("repos:"), "{err}");
2256
2257 let doubled = format!("repos:\n{block}\n{block}\n");
2261 let err = splice_hooks_block(Some(&doubled), block).expect_err("a second block refuses");
2262 assert!(err.contains("one block"), "{err}");
2263 let unmatched = "repos:\n# BEGIN release-kit\n - repo: local\n";
2264 let err =
2265 splice_hooks_block(Some(unmatched), block).expect_err("an unmatched marker refuses");
2266 assert!(err.contains("unmatched"), "{err}");
2267 }
2268
2269 #[test]
2275 fn the_blocks_render_per_mode_and_carry_the_one_grammar() {
2276 let worktree_hooks = hooks_block(Workflow::Worktree);
2277 let branches_hooks = hooks_block(Workflow::Branches);
2278 assert!(worktree_hooks.contains("- id: rk-worktree-location"));
2279 assert!(
2280 worktree_hooks.contains("SKIP=no-commit-to-branch,rk-worktree-location"),
2281 "{worktree_hooks}"
2282 );
2283 assert!(!branches_hooks.contains("rk-worktree-location"));
2284 assert!(branches_hooks.contains("SKIP=no-commit-to-branch in"));
2285 for block in [&worktree_hooks, &branches_hooks] {
2286 assert!(block.contains(BRANCH_GRAMMAR), "the grammar has one owner");
2287 for token in ["RK_BRANCH_GRAMMAR", "RK_SWEEP_SKIP", "RK_WORKTREE_GUARD"] {
2288 assert!(!block.contains(token), "{token} survived: {block}");
2289 }
2290 }
2291 for block in [&worktree_hooks, &branches_hooks] {
2296 for line in block.lines() {
2297 if let Some(value) = line.trim_start().strip_prefix("entry: ") {
2298 assert!(
2299 !value.contains(": "),
2300 "an entry value breaks the YAML plain scalar: {line}"
2301 );
2302 }
2303 }
2304 }
2305 let guard_line = worktree_hooks
2306 .lines()
2307 .position(|line| line.contains("id: rk-worktree-location"))
2308 .expect("the guard entry exists");
2309 let name_line = worktree_hooks
2310 .lines()
2311 .position(|line| line.contains("id: rk-branch-name"))
2312 .expect("the name hook exists");
2313 assert!(
2314 guard_line > name_line,
2315 "the guard lands directly after rk-branch-name"
2316 );
2317
2318 let worktree_routing = routing_block(Workflow::Worktree);
2319 let branches_routing = routing_block(Workflow::Branches);
2320 assert!(worktree_routing.contains("This project works in worktrees"));
2321 assert!(branches_routing.contains("Branches are worked in the main checkout"));
2322 for block in [&worktree_routing, &branches_routing] {
2323 assert!(block.contains("Create or remove a worktree"));
2324 assert!(block.contains("`rk worktree add <branch>`"));
2325 assert!(!block.contains("RK_WORKFLOW_LINE"), "{block}");
2326 }
2327 let differing: Vec<(&str, &str)> = worktree_routing
2328 .lines()
2329 .zip(branches_routing.lines())
2330 .filter(|(a, b)| a != b)
2331 .collect();
2332 assert_eq!(
2333 differing.len(),
2334 1,
2335 "exactly one routing line differs per mode: {differing:?}"
2336 );
2337 }
2338
2339 #[test]
2342 fn the_hook_marker_defects_are_named() {
2343 use super::hooks_marker_defect;
2344 let owned = hooks_block(Workflow::Branches);
2345 let block = owned.as_str();
2346 assert_eq!(hooks_marker_defect(""), None);
2347 assert_eq!(hooks_marker_defect(&format!("repos:\n{block}\n")), None);
2348 for (case, text) in [
2349 (
2350 "a second begin",
2351 format!("repos:\n{block}\n# BEGIN release-kit\n"),
2352 ),
2353 (
2354 "a second end",
2355 format!("repos:\n{block}\n# END release-kit\n"),
2356 ),
2357 (
2358 "an unpaired begin",
2359 "repos:\n# BEGIN release-kit\n".to_owned(),
2360 ),
2361 ("an unpaired end", "repos:\n# END release-kit\n".to_owned()),
2362 (
2363 "an end before its begin",
2364 "repos:\n# END release-kit\n# BEGIN release-kit\n".to_owned(),
2365 ),
2366 ] {
2367 assert!(
2368 hooks_marker_defect(&text).is_some(),
2369 "{case} must be a defect"
2370 );
2371 }
2372 }
2373}