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 || 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_PLACEHOLDER: &str = "OWNER";
399
400pub const REPO_TOKEN: &[u8] = b"RK_REPO";
402
403pub const SCOPE_SHAPE_TOKEN: &[u8] = b"RK_SCOPE_SHAPE";
405
406pub const STYLE_TOKEN: &[u8] = b"RK_STYLE";
409
410pub const TRUNK_BRANCH_TOKEN: &[u8] = b"RK_TRUNK_BRANCH";
414
415pub const LINE_PREFIX_TOKEN: &[u8] = b"RK_LINE_PREFIX";
418
419pub const LINE_PREFIX_RE_TOKEN: &[u8] = b"RK_LINE_PREFIX_RE";
425
426pub const SECURITY_SPANS: [(&[u8], &[u8]); 3] = [
437 (
438 b"<!--RK_SECURITY_CONTACT_BEGIN-->",
439 b"<!--RK_SECURITY_CONTACT_END-->",
440 ),
441 (
442 b"<!--RK_SECURITY_RESPONSE_BEGIN-->",
443 b"<!--RK_SECURITY_RESPONSE_END-->",
444 ),
445 (
446 b"<!--RK_SECURITY_DEADLINE_BEGIN-->",
447 b"<!--RK_SECURITY_DEADLINE_END-->",
448 ),
449];
450
451fn acknowledgment(response: &str) -> String {
454 format!("Maintainers acknowledge a report within {response}.")
455}
456
457const DISCLOSURE_ONLY: &[u8] = b"This policy commits to no disclosure deadline.";
461
462fn security_replacements(params: &Params) -> [Option<Vec<u8>>; 3] {
465 let contact = (!params.security_contact().is_empty())
466 .then(|| params.security_contact().as_bytes().to_vec());
467 let promised = params.security_response() != crate::config::RESPONSE_DEFAULT;
468 [
469 contact,
470 promised.then(|| acknowledgment(params.security_response()).into_bytes()),
471 promised.then(|| DISCLOSURE_ONLY.to_vec()),
472 ]
473}
474
475fn replace_span(baseline: &[u8], begin: &[u8], end: &[u8], value: Option<&[u8]>) -> Vec<u8> {
481 let ordered = find(baseline, begin)
482 .zip(find(baseline, end))
483 .filter(|(start, stop)| stop > start);
484 let Some((start, stop)) = ordered else {
485 return baseline.to_vec();
486 };
487 let mut out = Vec::with_capacity(baseline.len());
488 out.extend_from_slice(&baseline[..start]);
489 out.extend_from_slice(value.unwrap_or_else(|| &baseline[start + begin.len()..stop]));
490 out.extend_from_slice(&baseline[stop + end.len()..]);
491 out
492}
493
494#[must_use]
512pub fn render(baseline: &[u8], params: &Params) -> Vec<u8> {
513 let repo = params.repo();
514 let owner = repo.split('/').next().unwrap_or(repo);
515 let mut out = substitute(baseline, OWNER_TOKEN, owner.as_bytes());
516 if let Some(style) = params.style() {
517 out = substitute(&out, STYLE_TOKEN, style.as_str().as_bytes());
518 }
519 out = substitute(&out, SCOPE_SHAPE_TOKEN, SCOPE_SHAPE.as_bytes());
520 out = substitute(&out, TRUNK_BRANCH_TOKEN, params.trunk().as_bytes());
521 let escaped = params.line_prefix().replace('/', "\\/");
522 out = substitute(&out, LINE_PREFIX_RE_TOKEN, escaped.as_bytes());
523 out = substitute(&out, LINE_PREFIX_TOKEN, params.line_prefix().as_bytes());
524 out = substitute(&out, REPO_TOKEN, repo.as_bytes());
525 for ((begin, end), value) in SECURITY_SPANS.iter().zip(security_replacements(params)) {
526 out = replace_span(&out, begin, end, value.as_deref());
527 }
528 out
529}
530
531pub(crate) fn substitute(baseline: &[u8], token: &[u8], value: &[u8]) -> Vec<u8> {
533 let mut out = Vec::with_capacity(baseline.len());
534 let mut rest = baseline;
535 while let Some(at) = find(rest, token) {
536 out.extend_from_slice(&rest[..at]);
537 out.extend_from_slice(value);
538 rest = &rest[at + token.len()..];
539 }
540 out.extend_from_slice(rest);
541 out
542}
543
544fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
546 haystack
547 .windows(needle.len())
548 .position(|window| window == needle)
549}
550
551pub const AGENTS_DESTINATION: &str = "AGENTS.md";
553
554pub const BLOCK_BEGIN: &str = "<!-- BEGIN release-kit -->";
556
557pub const BLOCK_END: &str = "<!-- END release-kit -->";
559
560pub const HOOKS_DESTINATION: &str = ".pre-commit-config.yaml";
562
563pub const HOOKS_BEGIN: &str = "# BEGIN release-kit";
565
566pub const HOOKS_END: &str = "# END release-kit";
568
569pub const HOOK_TYPES_LINE: &str = "default_install_hook_types: [pre-commit, commit-msg, pre-push]";
573
574const AGENTS_BLOCK: &str = "blocks/agents-block.md.in";
576
577const AGENTS_LINE_WORKTREE: &str = "blocks/agents-line-worktree.md.in";
579
580const AGENTS_LINE_BRANCHES: &str = "blocks/agents-line-branches.md.in";
582
583const PRE_COMMIT_BLOCK: &str = "blocks/pre-commit-block.yaml.in";
585
586const PRE_COMMIT_WORKTREE_GUARD: &str = "blocks/pre-commit-worktree-guard.yaml.in";
588
589fn block(
591 source: &dyn ReleaseSource,
592 manifest: &ReleaseManifest,
593 path: &str,
594) -> Result<String, RkError> {
595 let bytes = release::read(source, manifest, path)?;
596 String::from_utf8(bytes).map_err(|_| anyhow::anyhow!("{path}: a block is UTF-8").into())
597}
598
599fn authored(text: &str) -> &str {
603 text.strip_suffix('\n').unwrap_or(text)
604}
605
606pub 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[-/].+)$";
614
615pub const SCOPE_SHAPE: &str = "[a-z0-9._/-]+";
625
626#[must_use]
633pub fn scope_is_shaped(scope: &str) -> bool {
634 !scope.is_empty()
635 && scope.chars().all(|c| {
636 c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '/' | '-')
637 })
638}
639
640pub fn routing_block(source: &dyn ReleaseSource, workflow: Workflow) -> Result<String, RkError> {
654 let manifest = source.manifest()?;
655 let line = block(
656 source,
657 &manifest,
658 match workflow {
659 Workflow::Worktree => AGENTS_LINE_WORKTREE,
660 Workflow::Branches => AGENTS_LINE_BRANCHES,
661 },
662 )?;
663 let template = block(source, &manifest, AGENTS_BLOCK)?;
664 Ok(authored(&template).replacen("RK_WORKFLOW_LINE", authored(&line), 1))
665}
666
667pub fn hooks_block(source: &dyn ReleaseSource, workflow: Workflow) -> Result<String, RkError> {
685 let manifest = source.manifest()?;
686 let (guard, skip) = match workflow {
687 Workflow::Worktree => (
688 format!(
689 "{}\n",
690 authored(&block(source, &manifest, PRE_COMMIT_WORKTREE_GUARD)?)
691 ),
692 "no-commit-to-branch,rk-worktree-location",
693 ),
694 Workflow::Branches => (String::new(), "no-commit-to-branch"),
695 };
696 let template = block(source, &manifest, PRE_COMMIT_BLOCK)?;
697 Ok(authored(&template)
698 .replacen("RK_BRANCH_GRAMMAR", BRANCH_GRAMMAR, 1)
699 .replacen("RK_SWEEP_SKIP", skip, 1)
700 .replacen("RK_WORKTREE_GUARD", &guard, 1))
701}
702
703#[must_use]
705pub fn block_markers(destination: &str) -> Option<(&'static str, &'static str)> {
706 match destination {
707 AGENTS_DESTINATION => Some((BLOCK_BEGIN, BLOCK_END)),
708 HOOKS_DESTINATION => Some((HOOKS_BEGIN, HOOKS_END)),
709 _ => None,
710 }
711}
712
713#[must_use]
716pub fn extract_block<'a>(text: &'a str, begin: &str, end: &str) -> Option<&'a str> {
717 let start = text.find(begin)?;
718 let stop = text[start..].find(end)? + start + end.len();
719 Some(&text[start..stop])
720}
721
722#[must_use]
728pub fn splice_agents_block(existing: Option<&str>, block: &str) -> String {
729 existing.map_or_else(
730 || format!("{block}\n"),
731 |text| {
732 extract_block(text, BLOCK_BEGIN, BLOCK_END).map_or_else(
733 || format!("{}\n\n{block}\n", text.trim_end()),
734 |found| text.replacen(found, block, 1),
735 )
736 },
737 )
738}
739
740pub fn splice_hooks_block(existing: Option<&str>, block: &str) -> Result<String, String> {
753 let Some(text) = existing else {
754 return Ok(format!("{HOOK_TYPES_LINE}\n\nrepos:\n{block}\n"));
755 };
756 if let Some(defect) = hooks_marker_defect(text) {
757 return Err(defect);
758 }
759 if let Some(found) = extract_block(text, HOOKS_BEGIN, HOOKS_END) {
760 return Ok(text.replacen(found, block, 1));
761 }
762 let mut out = String::with_capacity(text.len() + block.len() + 1);
763 let mut placed = false;
764 for line in text.split_inclusive('\n') {
765 out.push_str(line);
766 if !placed && line.trim_end() == "repos:" {
767 if !out.ends_with('\n') {
768 out.push('\n');
769 }
770 out.push_str(block);
771 out.push('\n');
772 placed = true;
773 }
774 }
775 if placed {
776 Ok(out)
777 } else {
778 Err(format!(
779 "{HOOKS_DESTINATION} exists with no repos: line, so the hook block has nowhere to land"
780 ))
781 }
782}
783
784#[must_use]
793pub fn hooks_marker_defect(text: &str) -> Option<String> {
794 let begins = text.matches(HOOKS_BEGIN).count();
795 let ends = text.matches(HOOKS_END).count();
796 if begins > 1 || ends > 1 {
797 return Some(format!(
798 "{HOOKS_DESTINATION} carries more than one release-kit marker pair; release-kit owns exactly one block"
799 ));
800 }
801 match (text.find(HOOKS_BEGIN), text.find(HOOKS_END)) {
802 (Some(begin), Some(end)) if end > begin => None,
803 (None, None) => None,
804 _ => Some(format!(
805 "{HOOKS_DESTINATION} carries an unmatched or misordered release-kit marker, so the block's extent is ambiguous"
806 )),
807 }
808}
809
810#[derive(Debug, Clone, Copy, PartialEq, Eq)]
812pub enum Placement {
813 Whole,
815 Block,
817}
818
819#[derive(Debug)]
822pub struct Entry {
823 pub destination: String,
825 pub kind: Kind,
827 pub placement: Placement,
829 pub baseline: Vec<u8>,
832 pub rendered: Vec<u8>,
835}
836
837pub fn pair_files(
845 source: &dyn ReleaseSource,
846 tech: &str,
847 forge: &str,
848) -> Result<Vec<(String, Vec<u8>)>, RkError> {
849 let manifest = source.manifest()?;
850 let techs: Vec<String> = manifest
851 .dirs_under("snippets")
852 .into_iter()
853 .filter(|name| !name.starts_with('_'))
854 .collect();
855 if tech.starts_with('_') || !techs.iter().any(|known| known == tech) {
858 return Err(RkError::Usage(format!(
859 "unknown tech '{tech}'; the bindings are: {}",
860 techs.join(", ")
861 )));
862 }
863 let pair = format!("snippets/{tech}/{forge}");
864 if manifest.under(&pair).next().is_none() {
865 let known: Vec<String> = techs
866 .iter()
867 .flat_map(|tech| {
868 manifest
869 .dirs_under(&format!("snippets/{tech}"))
870 .into_iter()
871 .map(move |forge| format!("{tech}, {forge}"))
872 })
873 .collect();
874 return Err(RkError::Usage(format!(
875 "the pair ({tech}, {forge}) has no landable files; the supported pairs are: {}",
876 known.join("; ")
877 )));
878 }
879 let mut files: Vec<(String, Vec<u8>)> = Vec::new();
883 for (rel, artifact) in manifest.under(&format!("snippets/_shared/{forge}")) {
884 files.push((rel.to_owned(), source.blob(&artifact.sha256)?));
885 }
886 for (rel, artifact) in manifest.under(&pair) {
887 if files.iter().any(|(existing, _)| existing == rel) {
888 return Err(anyhow::anyhow!(
889 "the shared zone and the pair ({tech}, {forge}) both ship {rel}; the payload is defective"
890 )
891 .into());
892 }
893 files.push((rel.to_owned(), source.blob(&artifact.sha256)?));
894 }
895 Ok(files)
896}
897
898pub fn projection(source: &dyn ReleaseSource, params: &Params) -> Result<Vec<Entry>, RkError> {
914 let mut entries = Vec::new();
915 for (destination, baseline) in pair_files(source, ¶ms.tech, ¶ms.forge)? {
916 if !params.nix && NIX_DESTINATIONS.contains(&destination.as_str()) {
917 continue;
918 }
919 let kind = kind_of(&destination).ok_or_else(|| {
920 anyhow::anyhow!("the payload does not classify {destination}; the kind table is stale")
921 })?;
922 let rendered = match kind {
923 Kind::Rendered => render(&baseline, params),
924 Kind::Seeded | Kind::State => baseline.clone(),
925 };
926 entries.push(Entry {
927 destination,
928 kind,
929 placement: Placement::Whole,
930 baseline,
931 rendered,
932 });
933 }
934 for (destination, template) in [
935 (AGENTS_DESTINATION, routing_block(source, params.workflow)?),
936 (HOOKS_DESTINATION, hooks_block(source, params.workflow)?),
937 ] {
938 entries.push(Entry {
939 destination: destination.to_owned(),
940 kind: Kind::Rendered,
941 placement: Placement::Block,
942 baseline: template.as_bytes().to_vec(),
943 rendered: render(template.as_bytes(), params),
944 });
945 }
946 entries.sort_by(|a, b| a.destination.cmp(&b.destination));
947 Ok(entries)
948}
949
950#[must_use]
962pub fn nix_unsupported_shape(target: &Utf8Path) -> Option<String> {
963 let Ok(text) = std::fs::read_to_string(target.join("Cargo.toml")) else {
964 return Some(
965 "the target has no readable Cargo.toml, which the seeded package expression reads; no Nix file lands".to_owned(),
966 );
967 };
968 let Ok(table) = text.parse::<toml::Table>() else {
969 return Some(
970 "the target's Cargo.toml does not parse, and the seeded package expression reads it; no Nix file lands".to_owned(),
971 );
972 };
973 if !table.contains_key("package") {
974 return Some(
975 "the target's Cargo.toml has no [package] table; the seed supports a single crate, so no Nix file lands".to_owned(),
976 );
977 }
978 if !target.join("Cargo.lock").is_file() {
979 return Some(
980 "the target has no Cargo.lock, which the seeded package expression builds from; commit one, then opt in".to_owned(),
981 );
982 }
983 let implicit_bin = target.join("src/main.rs").is_file()
984 && table
985 .get("package")
986 .and_then(toml::Value::as_table)
987 .and_then(|package| package.get("autobins"))
988 .and_then(toml::Value::as_bool)
989 != Some(false);
990 let explicit_bins = table.get("bin").and_then(toml::Value::as_array);
991 if explicit_bins.is_none() && !implicit_bin {
992 return Some(
993 "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(),
994 );
995 }
996 if let Some(bins) = explicit_bins {
1002 let required = bins
1003 .first()
1004 .and_then(toml::Value::as_table)
1005 .and_then(|bin| bin.get("required-features"))
1006 .and_then(toml::Value::as_array);
1007 if let Some(required) = required {
1008 let enabled = default_features(&table);
1009 let missing = required
1010 .iter()
1011 .filter_map(toml::Value::as_str)
1012 .any(|feature| !enabled.contains(feature));
1013 if missing {
1014 return Some(
1015 "the target's first [[bin]] entry requires features a default build does not enable; no Nix file lands".to_owned(),
1016 );
1017 }
1018 }
1019 }
1020 None
1021}
1022
1023fn dep_edge_suppresses(features: &toml::Table, name: &str) -> bool {
1026 let edge = format!("dep:{name}");
1027 features.values().any(|list| {
1028 list.as_array().is_some_and(|entries| {
1029 entries
1030 .iter()
1031 .filter_map(toml::Value::as_str)
1032 .any(|entry| entry == edge)
1033 })
1034 })
1035}
1036
1037fn is_optional_dependency(table: &toml::Table, name: &str) -> bool {
1040 ["dependencies", "build-dependencies"]
1041 .iter()
1042 .any(|section| {
1043 table
1044 .get(*section)
1045 .and_then(toml::Value::as_table)
1046 .and_then(|dependencies| dependencies.get(name))
1047 .and_then(toml::Value::as_table)
1048 .and_then(|dependency| dependency.get("optional"))
1049 .and_then(toml::Value::as_bool)
1050 == Some(true)
1051 })
1052}
1053
1054fn default_features(table: &toml::Table) -> std::collections::BTreeSet<String> {
1061 let Some(features) = table.get("features").and_then(toml::Value::as_table) else {
1062 return std::collections::BTreeSet::new();
1063 };
1064 let mut enabled = std::collections::BTreeSet::new();
1065 let mut queue = vec!["default".to_owned()];
1066 while let Some(name) = queue.pop() {
1067 if !enabled.insert(name.clone()) {
1068 continue;
1069 }
1070 if let Some(implies) = features.get(&name).and_then(toml::Value::as_array) {
1071 for implied in implies.iter().filter_map(toml::Value::as_str) {
1072 if implied.starts_with("dep:") || implied.contains("?/") {
1073 continue;
1077 }
1078 if let Some((package, _)) = implied.split_once('/') {
1079 let feature_exists =
1087 features.contains_key(package) || !dep_edge_suppresses(features, package);
1088 if is_optional_dependency(table, package) && feature_exists {
1089 queue.push(package.to_owned());
1090 }
1091 } else {
1092 queue.push(implied.to_owned());
1093 }
1094 }
1095 }
1096 }
1097 enabled
1098}
1099
1100pub fn nix_withheld(
1112 target: &Utf8Path,
1113 recorded: Option<&manifest::Manifest>,
1114) -> std::io::Result<Option<String>> {
1115 if recorded.is_some_and(|record| record.file("flake.nix").is_some()) {
1116 return Ok(None);
1117 }
1118 let mut present = Vec::new();
1119 for name in ["flake.nix", "flake.lock"] {
1120 match std::fs::symlink_metadata(target.join(name).as_std_path()) {
1121 Ok(_) => present.push(name),
1122 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
1123 Err(e) => return Err(e),
1124 }
1125 }
1126 if present.is_empty() {
1127 return Ok(None);
1128 }
1129 Ok(Some(format!(
1130 "the target already carries {}; its flake pair stays its own",
1131 present.join(" and ")
1132 )))
1133}
1134
1135#[derive(Debug, Clone, Serialize)]
1137pub struct Withheld {
1138 pub path: String,
1140 pub reason: String,
1143}
1144
1145pub fn nix_withholding(
1157 target: &Utf8Path,
1158 recorded: Option<&manifest::Manifest>,
1159) -> Result<Option<(&'static [&'static str], String)>, RkError> {
1160 if let Some(reason) = nix_unsupported_shape(target) {
1161 return Ok(Some((&NIX_DESTINATIONS[..], reason)));
1162 }
1163 if let Some(reason) = nix_withheld(target, recorded)? {
1164 return Ok(Some((&NIX_WITHHOLDABLE[..], reason)));
1165 }
1166 Ok(None)
1167}
1168
1169pub fn withhold_nix(
1182 target: &Utf8Path,
1183 nix: bool,
1184 recorded: Option<&manifest::Manifest>,
1185 entries: &mut Vec<Entry>,
1186) -> Result<Vec<Withheld>, RkError> {
1187 if !nix {
1188 return Ok(Vec::new());
1189 }
1190 let Some((set, reason)) = nix_withholding(target, recorded)? else {
1191 return Ok(Vec::new());
1192 };
1193 let mut withheld = Vec::new();
1194 entries.retain(|entry| {
1195 if set.contains(&entry.destination.as_str()) {
1196 withheld.push(Withheld {
1197 path: entry.destination.clone(),
1198 reason: reason.clone(),
1199 });
1200 false
1201 } else {
1202 true
1203 }
1204 });
1205 Ok(withheld)
1206}
1207
1208pub fn read_destination(target: &Utf8Path, entry: &Entry) -> std::io::Result<Option<Vec<u8>>> {
1216 read_recorded(target, &entry.destination)
1217}
1218
1219pub fn read_recorded(target: &Utf8Path, destination: &str) -> std::io::Result<Option<Vec<u8>>> {
1230 let path = target.join(destination);
1231 let bytes = match std::fs::read(&path) {
1232 Ok(bytes) => bytes,
1233 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1234 Err(e) => return Err(e),
1235 };
1236 if let Some((begin, end)) = block_markers(destination) {
1237 let text = String::from_utf8_lossy(&bytes);
1238 Ok(extract_block(&text, begin, end).map(|block| block.as_bytes().to_vec()))
1239 } else {
1240 Ok(Some(bytes))
1241 }
1242}
1243
1244#[derive(Debug)]
1247pub struct Resolved {
1248 pub forge: String,
1250 pub repo: Option<String>,
1252}
1253
1254pub fn resolve(
1266 target: &Utf8Path,
1267 forge_flag: Option<&str>,
1268 repo_flag: Option<&str>,
1269) -> Result<Resolved, RkError> {
1270 let forge_flag = forge_flag
1271 .map(|name| {
1272 crate::detect::Forge::parse(name).ok_or_else(|| {
1273 RkError::Usage(format!(
1274 "unknown forge '{name}'; the forges are: github, gitlab"
1275 ))
1276 })
1277 })
1278 .transpose()?;
1279 let detected = crate::detect::detect(target.as_std_path());
1280 let forge = forge_flag
1281 .or(detected.forge)
1282 .map(|forge| forge.as_str().to_owned())
1283 .ok_or_else(|| {
1284 let message = detected.host.map_or_else(
1285 || "no forge detected: the target has no origin remote".to_owned(),
1286 |host| format!("no forge detected: the host {host} is not recognized"),
1287 );
1288 RkError::refusal(
1289 Diagnostic::new(Reason::ForgeUndetected, message)
1290 .expected("a github.com or gitlab remote, or --forge")
1291 .action("pass --forge <github|gitlab>"),
1292 )
1293 })?;
1294 Ok(Resolved {
1295 forge,
1296 repo: repo_flag.map(str::to_owned).or(detected.repo),
1297 })
1298}
1299
1300#[must_use]
1303pub fn repo_unresolved() -> RkError {
1304 RkError::missing(
1305 Diagnostic::new(
1306 Reason::ForgeUndetected,
1307 "no repository detected: the target has no origin remote",
1308 )
1309 .expected("an origin remote naming the project")
1310 .action("pass --repo <path>"),
1311 )
1312}
1313
1314pub fn write_destination(target: &Utf8Path, entry: &Entry) -> std::io::Result<()> {
1324 let path = target.join(&entry.destination);
1325 match entry.placement {
1326 Placement::Whole => atomic::write(path.as_std_path(), &entry.rendered),
1327 Placement::Block => {
1328 let existing = match std::fs::read(&path) {
1329 Ok(bytes) => Some(String::from_utf8_lossy(&bytes).into_owned()),
1330 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
1331 Err(e) => return Err(e),
1332 };
1333 let block = String::from_utf8_lossy(&entry.rendered).into_owned();
1334 let spliced = if entry.destination == HOOKS_DESTINATION {
1335 splice_hooks_block(existing.as_deref(), &block).map_err(std::io::Error::other)?
1336 } else {
1337 splice_agents_block(existing.as_deref(), &block)
1338 };
1339 atomic::write(path.as_std_path(), spliced.as_bytes())
1340 }
1341 }
1342}
1343
1344pub fn hooks_file_defect(
1357 source: &dyn ReleaseSource,
1358 target: &Utf8Path,
1359) -> Result<Option<String>, RkError> {
1360 let path = target.join(HOOKS_DESTINATION);
1361 match std::fs::read(&path) {
1362 Ok(bytes) => {
1363 let text = String::from_utf8_lossy(&bytes);
1364 let manifest = source.manifest()?;
1365 let template = block(source, &manifest, PRE_COMMIT_BLOCK)?;
1366 Ok(splice_hooks_block(Some(&text), authored(&template)).err())
1367 }
1368 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
1369 Err(e) => Err(e.into()),
1370 }
1371}
1372
1373pub fn hooks_splice_refusal(source: &dyn ReleaseSource, target: &Utf8Path) -> Result<(), RkError> {
1383 hooks_file_defect(source, target)?.map_or(Ok(()), |reason| {
1384 Err(RkError::refusal(
1385 Diagnostic::new(
1386 Reason::StateDrift,
1387 format!("{reason}, and nothing was written"),
1388 )
1389 .expected("a .pre-commit-config.yaml the block can land in, or none")
1390 .action(format!(
1391 "resolve it in {}, then re-run",
1392 target.join(HOOKS_DESTINATION)
1393 ))
1394 .target_state("unchanged"),
1395 ))
1396 })
1397}
1398
1399#[cfg(test)]
1400mod tests {
1401 use super::{
1402 AGENTS_DESTINATION, BLOCK_BEGIN, BLOCK_END, BRANCH_GRAMMAR, HOOK_TYPES_LINE, HOOKS_BEGIN,
1403 HOOKS_DESTINATION, HOOKS_END, Kind, SCOPE_SHAPE, Style, Workflow, extract_block, kind_of,
1404 render, splice_agents_block, splice_hooks_block,
1405 };
1406 use crate::embedded;
1407 use crate::release::EmbeddedReleaseSource;
1408
1409 const SOURCE: EmbeddedReleaseSource = EmbeddedReleaseSource;
1411
1412 fn pair_files(
1413 tech: &str,
1414 forge: &str,
1415 ) -> Result<Vec<(String, Vec<u8>)>, crate::error::RkError> {
1416 super::pair_files(&SOURCE, tech, forge)
1417 }
1418
1419 fn projection(params: &super::Params) -> Result<Vec<super::Entry>, crate::error::RkError> {
1420 super::projection(&SOURCE, params)
1421 }
1422
1423 fn routing_block(workflow: Workflow) -> String {
1424 super::routing_block(&SOURCE, workflow).expect("the embedded bundle carries the block")
1425 }
1426
1427 fn hooks_block(workflow: Workflow) -> String {
1428 super::hooks_block(&SOURCE, workflow).expect("the embedded bundle carries the block")
1429 }
1430
1431 #[test]
1432 fn private_reporting_path_tokens_are_reproducible() {
1433 for repo in [
1434 "acme/widget",
1435 "acme/group/widget",
1436 "acme/OWNER-RK_STYLE-RK_SCOPE_SHAPE",
1437 ] {
1438 assert_eq!(
1439 super::render(
1440 b"RK_REPO RK_REPO OWNER RK_STYLE RK_SCOPE_SHAPE",
1441 &super::Params::for_test(repo, Some(super::Style::Trunk))
1442 ),
1443 format!("{repo} {repo} acme trunk {}", super::SCOPE_SHAPE).as_bytes()
1444 );
1445 }
1446 assert_eq!(super::kind_of("SECURITY.md"), Some(super::Kind::Rendered));
1447 }
1448
1449 #[test]
1454 fn each_forge_policy_carries_one_ordered_pair_of_every_span() {
1455 for forge in ["github", "gitlab"] {
1456 let bytes = embedded::SNIPPETS
1457 .get_file(format!("_shared/{forge}/SECURITY.md"))
1458 .expect("the policy ships")
1459 .contents();
1460 let text = String::from_utf8_lossy(bytes);
1461 for (begin, end) in super::SECURITY_SPANS {
1462 let begin = String::from_utf8_lossy(begin);
1463 let end = String::from_utf8_lossy(end);
1464 assert_eq!(text.matches(begin.as_ref()).count(), 1, "{forge} {begin}");
1465 assert_eq!(text.matches(end.as_ref()).count(), 1, "{forge} {end}");
1466 assert!(
1467 text.find(begin.as_ref()) < text.find(end.as_ref()),
1468 "{forge}: {begin} must precede {end}"
1469 );
1470 }
1471 }
1472 }
1473
1474 #[test]
1479 fn the_security_spans_render_per_answer() {
1480 for forge in ["github", "gitlab"] {
1481 let bytes = embedded::SNIPPETS
1482 .get_file(format!("_shared/{forge}/SECURITY.md"))
1483 .expect("the policy ships")
1484 .contents();
1485 let authored = String::from_utf8_lossy(bytes);
1486 let stripped = {
1487 let mut text = authored.clone().into_owned();
1488 for (begin, end) in super::SECURITY_SPANS {
1489 text = text.replace(&String::from_utf8_lossy(begin).into_owned(), "");
1490 text = text.replace(&String::from_utf8_lossy(end).into_owned(), "");
1491 }
1492 text
1493 };
1494 let default = super::Params {
1495 forge: forge.to_owned(),
1496 ..super::Params::for_test_security("", crate::config::RESPONSE_DEFAULT)
1497 };
1498 let rendered = String::from_utf8(render(bytes, &default)).expect("text");
1499 assert_eq!(
1500 rendered,
1501 stripped.replace("RK_REPO", "acme/widget"),
1502 "{forge}: the default answers must reproduce the authored policy"
1503 );
1504 assert!(!rendered.contains("RK_SECURITY"), "{forge}: {rendered}");
1505
1506 let answered = super::Params {
1507 forge: forge.to_owned(),
1508 ..super::Params::for_test_security("OWNER RK_REPO <team@acme.example>", "14 days")
1509 };
1510 let rendered = String::from_utf8(render(bytes, &answered)).expect("text");
1511 assert!(
1512 rendered.contains("OWNER RK_REPO <team@acme.example>"),
1513 "{forge}: a contact spelling a token name lands literally: {rendered}"
1514 );
1515 assert!(
1516 rendered.contains("Maintainers acknowledge a report within 14 days."),
1517 "{forge}: {rendered}"
1518 );
1519 assert!(
1520 rendered.contains("This policy commits to no disclosure deadline."),
1521 "{forge}: {rendered}"
1522 );
1523 assert!(
1524 !rendered.contains("best-effort basis"),
1525 "{forge}: a stated window replaces the best-effort sentence: {rendered}"
1526 );
1527 assert!(
1528 !rendered.contains("no response or disclosure deadline"),
1529 "{forge}: a stated window contradicts the response disclaimer: {rendered}"
1530 );
1531 }
1532 }
1533
1534 #[test]
1537 fn a_defective_span_renders_unchanged() {
1538 let (begin, end) = super::SECURITY_SPANS[0];
1539 let begin = String::from_utf8_lossy(begin).into_owned();
1540 let end = String::from_utf8_lossy(end).into_owned();
1541 let params = super::Params::for_test_security("team@acme.example", "1 day");
1542 for baseline in [
1543 format!("contact {begin}a maintainer\n"),
1544 format!("contact a maintainer{end}\n"),
1545 format!("contact {end}a maintainer{begin}\n"),
1546 "contact a maintainer\n".to_owned(),
1547 ] {
1548 assert_eq!(
1549 render(baseline.as_bytes(), ¶ms),
1550 baseline.as_bytes(),
1551 "{baseline}"
1552 );
1553 }
1554 }
1555
1556 #[test]
1560 fn the_kind_table_closes_over_every_snippet() {
1561 for tech_dir in embedded::SNIPPETS.dirs() {
1562 for pair_dir in tech_dir.dirs() {
1563 let prefix = format!("{}/", pair_dir.path().to_string_lossy());
1564 for (path, _) in embedded::walk(pair_dir) {
1565 let destination = path.strip_prefix(&prefix).unwrap_or(&path);
1566 assert!(
1567 kind_of(destination).is_some(),
1568 "{destination}: no declared kind"
1569 );
1570 }
1571 }
1572 }
1573 assert_eq!(kind_of(AGENTS_DESTINATION), Some(Kind::Rendered));
1574 assert_eq!(kind_of(HOOKS_DESTINATION), Some(Kind::Rendered));
1575 assert_eq!(kind_of("something-else.txt"), None);
1576 }
1577
1578 #[test]
1583 fn rendering_substitutes_every_owner_occurrence() {
1584 let baseline = b"if: repository_owner == 'OWNER'\n# OWNER again: OWNER\n";
1585 let rendered = render(baseline, &super::Params::for_test("acme/sub/widget", None));
1586 let text = String::from_utf8(rendered).expect("rendered bytes stay text");
1587 assert_eq!(text, "if: repository_owner == 'acme'\n# acme again: acme\n");
1588
1589 let baseline = b"match (RK_SCOPE_SHAPE)\n";
1590 let rendered = render(baseline, &super::Params::for_test("acme/widget", None));
1591 let text = String::from_utf8(rendered).expect("rendered bytes stay text");
1592 assert_eq!(text, format!("match ({SCOPE_SHAPE})\n"));
1593 }
1594
1595 #[test]
1599 fn the_scope_shape_drops_into_the_title_check() {
1600 assert_eq!(SCOPE_SHAPE, "[a-z0-9._/-]+");
1601 assert!(
1602 !SCOPE_SHAPE.contains('\''),
1603 "the title checks single-quote it"
1604 );
1605 }
1606
1607 #[test]
1612 fn the_scope_predicate_and_the_rendered_pattern_agree() {
1613 let body = SCOPE_SHAPE
1614 .strip_prefix('[')
1615 .and_then(|rest| rest.strip_suffix("]+"))
1616 .expect("the shape is one bracket expression, repeated");
1617 let chars: Vec<char> = body.chars().collect();
1618 let mut admitted = std::collections::BTreeSet::new();
1619 let mut at = 0;
1620 while at < chars.len() {
1621 if at + 2 < chars.len() && chars[at + 1] == '-' {
1624 for c in chars[at]..=chars[at + 2] {
1625 admitted.insert(c);
1626 }
1627 at += 3;
1628 } else {
1629 admitted.insert(chars[at]);
1630 at += 1;
1631 }
1632 }
1633 for byte in 0..=127u8 {
1634 let c = char::from(byte);
1635 assert_eq!(
1636 super::scope_is_shaped(&c.to_string()),
1637 admitted.contains(&c),
1638 "the predicate and {SCOPE_SHAPE} disagree on {c:?}"
1639 );
1640 }
1641 assert!(super::scope_is_shaped("guides/release"));
1642 assert!(!super::scope_is_shaped(""), "a scope is never empty");
1643 assert!(!super::scope_is_shaped("Specs Ugly"));
1644 }
1645
1646 #[test]
1649 fn the_shared_zone_composes_into_the_pair() {
1650 let files = pair_files("rust", "github").expect("the pair lists");
1651 assert!(
1652 files
1653 .iter()
1654 .any(|(dest, _)| dest == ".github/workflows/pr-title.yml"),
1655 "the shared title check lands with the pair"
1656 );
1657 let files = pair_files("rust", "gitlab").expect("the pair lists");
1658 assert!(
1659 files
1660 .iter()
1661 .any(|(dest, _)| dest == ".gitlab/ci/mr-title.yml"),
1662 "the shared title job lands with the pair"
1663 );
1664 let err = pair_files("_shared", "github").expect_err("the shared zone is no tech");
1665 let listing = err.to_string();
1666 let bindings = listing
1667 .split("the bindings are:")
1668 .nth(1)
1669 .expect("the refusal lists the bindings");
1670 assert!(!bindings.contains("_shared"), "{listing}");
1671 }
1672
1673 #[test]
1676 fn params_from_a_record_round_trips() {
1677 use super::{Params, manifest};
1678 let dir = tempfile::tempdir().expect("a scratch target exists");
1679 let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
1680 for tech in ["rust", "bash"] {
1681 for forge in ["github", "gitlab"] {
1682 for workflow in [Workflow::Branches, Workflow::Worktree] {
1683 for style in [None, Some(Style::Trunk), Some(Style::Lines)] {
1684 for nix in [false, true] {
1685 let record = manifest::Manifest {
1686 schema_version: manifest::SCHEMA_VERSION,
1687 rk_version: "0.1.0".to_owned(),
1688 payload_sha256: crate::digest::Digest::of(b""),
1689 origin: "init".to_owned(),
1690 tech: tech.to_owned(),
1691 forge: forge.to_owned(),
1692 landed_at: "2026-08-29T00:00:00Z".to_owned(),
1693 parameters: manifest::Parameters {
1694 repo: "acme/team/widget".to_owned(),
1695 workflow,
1696 style,
1697 nix,
1698 trunk: crate::config::TRUNK_DEFAULT.to_owned(),
1699 line_prefix: crate::config::LINE_PREFIX_DEFAULT.to_owned(),
1700 security_contact: String::new(),
1701 security_response: crate::config::RESPONSE_DEFAULT.to_owned(),
1702 },
1703 files: Vec::new(),
1704 pins: std::collections::BTreeMap::new(),
1705 };
1706 manifest::write(target, &record).expect("the record writes");
1707 let loaded = manifest::load(target)
1708 .expect("the record loads")
1709 .expect("the record exists");
1710 let params = Params::from_record(&loaded);
1711 assert_eq!(params.tech, tech);
1712 assert_eq!(params.forge, forge);
1713 assert_eq!(params.repo(), "acme/team/widget");
1714 assert_eq!(params.workflow(), workflow);
1715 assert_eq!(params.style(), style);
1716 assert_eq!(params.nix, nix);
1717 let entries = projection(¶ms).expect("the record projects");
1718 let mut expected: Vec<_> = pair_files(tech, forge)
1719 .expect("the pair lists")
1720 .into_iter()
1721 .filter(|(path, _)| {
1722 nix || !super::NIX_DESTINATIONS.contains(&path.as_str())
1723 })
1724 .collect();
1725 let routing = routing_block(workflow);
1726 let hooks = hooks_block(workflow);
1727 expected.push((AGENTS_DESTINATION.to_owned(), routing.into_bytes()));
1728 expected.push((HOOKS_DESTINATION.to_owned(), hooks.into_bytes()));
1729 expected.sort_by(|a, b| a.0.cmp(&b.0));
1730 assert_eq!(entries.len(), expected.len());
1731 for (entry, (destination, baseline)) in entries.iter().zip(expected) {
1732 assert_eq!(entry.destination, destination);
1733 assert_eq!(entry.baseline, baseline);
1734 let rendered = match entry.kind {
1735 Kind::Rendered => super::render(
1736 &baseline,
1737 &super::Params::for_test("acme/team/widget", style),
1738 ),
1739 Kind::Seeded | Kind::State => baseline.clone(),
1740 };
1741 assert_eq!(entry.rendered, rendered, "{destination}");
1742 }
1743 }
1744 }
1745 }
1746 }
1747 }
1748 }
1749
1750 fn resolved_test_params(
1751 tech: &str,
1752 resolved: &super::Resolved,
1753 workflow: Workflow,
1754 style: Option<Style>,
1755 nix: bool,
1756 ) -> Result<super::Params, crate::error::RkError> {
1757 super::Params::resolve(
1758 &SOURCE,
1759 camino::Utf8Path::new("."),
1760 &super::Inputs {
1761 tech: Some(tech),
1762 forge: Some(&resolved.forge),
1763 repo: resolved.repo.as_deref(),
1764 workflow: Some(workflow),
1765 style,
1766 nix: Some(nix),
1767 },
1768 None,
1769 None,
1770 super::Purpose::Init,
1771 )
1772 }
1773
1774 #[test]
1778 fn a_projection_renders_owned_files_and_keeps_seeded_judgment() {
1779 let entries = projection(
1780 &resolved_test_params(
1781 "rust",
1782 &super::Resolved {
1783 forge: "github".to_owned(),
1784 repo: Some("acme/widget".to_owned()),
1785 },
1786 Workflow::Branches,
1787 Some(Style::Trunk),
1788 false,
1789 )
1790 .expect("the parameters resolve"),
1791 )
1792 .expect("the pair projects");
1793 let workflow = entries
1794 .iter()
1795 .find(|entry| entry.destination.ends_with("release-plz.yml"))
1796 .expect("the workflow projects");
1797 assert_eq!(workflow.kind, Kind::Rendered);
1798 let text = String::from_utf8_lossy(&workflow.rendered);
1799 assert!(!text.contains("OWNER"), "an owner token survived rendering");
1800 assert!(text.contains("'acme'"));
1801 assert!(!text.contains("TODO(release-kit)"));
1802 let title = entries
1803 .iter()
1804 .find(|entry| entry.destination.ends_with("pr-title.yml"))
1805 .expect("the title check projects");
1806 let text = String::from_utf8_lossy(&title.rendered);
1807 assert!(text.contains(SCOPE_SHAPE), "{text}");
1808 assert!(
1809 !text.contains("RK_SCOPE_SHAPE"),
1810 "a scope token survived: {text}"
1811 );
1812 let seeded = entries
1813 .iter()
1814 .find(|entry| entry.destination == "release-plz.toml")
1815 .expect("the seeded file projects");
1816 assert_eq!(seeded.kind, Kind::Seeded);
1817 assert_eq!(seeded.rendered, seeded.baseline);
1818 assert!(String::from_utf8_lossy(&seeded.rendered).contains("TODO(release-kit)"));
1819 for block in [AGENTS_DESTINATION, HOOKS_DESTINATION] {
1820 let entry = entries
1821 .iter()
1822 .find(|entry| entry.destination == block)
1823 .expect("both blocks are part of the projection");
1824 let text = String::from_utf8_lossy(&entry.rendered);
1825 assert!(
1826 !text.contains("RK_SCOPE_SHAPE"),
1827 "{block} kept a token: {text}"
1828 );
1829 }
1830 }
1831
1832 #[test]
1837 fn the_nix_destinations_project_only_under_the_opt_in() {
1838 use super::NIX_DESTINATIONS;
1839 let paths = |nix: bool, forge: &str| -> Vec<String> {
1840 projection(
1841 &resolved_test_params(
1842 "rust",
1843 &super::Resolved {
1844 forge: forge.to_owned(),
1845 repo: Some("acme/widget".to_owned()),
1846 },
1847 Workflow::Worktree,
1848 Some(Style::Trunk),
1849 nix,
1850 )
1851 .expect("the parameters resolve"),
1852 )
1853 .expect("the pair projects")
1854 .into_iter()
1855 .map(|entry| entry.destination)
1856 .collect()
1857 };
1858 let off = paths(false, "github");
1859 for destination in NIX_DESTINATIONS {
1860 assert!(!off.contains(&destination.to_owned()), "{destination}");
1861 }
1862 let on = paths(true, "github");
1863 for destination in ["nix/package.nix", "flake.nix", "flake.lock"] {
1864 assert!(on.contains(&destination.to_owned()), "{destination}");
1865 }
1866 let gitlab = paths(true, "gitlab");
1871 assert!(gitlab.contains(&"nix/package.nix".to_owned()));
1872 assert!(
1873 !on.iter()
1874 .chain(gitlab.iter())
1875 .any(|destination| destination.contains("nix.yml"))
1876 );
1877 let bash = projection(
1878 &resolved_test_params(
1879 "bash",
1880 &super::Resolved {
1881 forge: "github".to_owned(),
1882 repo: Some("acme/widget".to_owned()),
1883 },
1884 Workflow::Worktree,
1885 Some(Style::Trunk),
1886 true,
1887 )
1888 .expect("the parameters resolve"),
1889 )
1890 .expect("an out-of-matrix pair projects the smaller product");
1891 assert!(
1892 bash.iter()
1893 .all(|entry| !NIX_DESTINATIONS.contains(&entry.destination.as_str()))
1894 );
1895 }
1896
1897 #[test]
1902 fn the_nix_seeds_are_identical_across_forge_pairs() {
1903 for name in ["nix/package.nix", "flake.nix", "flake.lock"] {
1904 let github = embedded::SNIPPETS
1905 .get_file(format!("rust/github/{name}"))
1906 .expect("the github copy ships")
1907 .contents();
1908 let gitlab = embedded::SNIPPETS
1909 .get_file(format!("rust/gitlab/{name}"))
1910 .expect("the gitlab copy ships")
1911 .contents();
1912 assert_eq!(github, gitlab, "{name} diverged between the pairs");
1913 }
1914 }
1915
1916 #[test]
1921 fn the_nix_withhold_judgment_covers_the_three_shapes() {
1922 use super::{NIX_DESTINATIONS, withhold_nix};
1923 let dir = tempfile::tempdir().expect("a scratch target exists");
1924 let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
1925 let entries = || {
1926 projection(
1927 &resolved_test_params(
1928 "rust",
1929 &super::Resolved {
1930 forge: "github".to_owned(),
1931 repo: Some("acme/widget".to_owned()),
1932 },
1933 Workflow::Worktree,
1934 Some(Style::Trunk),
1935 true,
1936 )
1937 .expect("the parameters resolve"),
1938 )
1939 .expect("the pair projects")
1940 };
1941
1942 let mut all = entries();
1944 let withheld = withhold_nix(target, true, None, &mut all).expect("the judgment runs");
1945 let paths: Vec<&str> = withheld.iter().map(|w| w.path.as_str()).collect();
1946 assert_eq!(paths, ["flake.lock", "flake.nix", "nix/package.nix"]);
1947 assert!(
1948 all.iter()
1949 .all(|entry| !NIX_DESTINATIONS.contains(&entry.destination.as_str()))
1950 );
1951
1952 std::fs::write(
1955 target.join("Cargo.toml"),
1956 "[package]\nname = \"widget\"\nversion = \"0.1.0\"\n",
1957 )
1958 .expect("the crate manifest writes");
1959 std::fs::write(target.join("Cargo.lock"), "version = 4\n").expect("the lock writes");
1960 std::fs::create_dir_all(target.join("src")).expect("the src dir exists");
1961 std::fs::write(target.join("src/main.rs"), "fn main() {}\n").expect("the main writes");
1962 std::fs::write(target.join("flake.nix"), "{ }\n").expect("the flake writes");
1963 let mut all = entries();
1964 let withheld = withhold_nix(target, true, None, &mut all).expect("the judgment runs");
1965 let paths: Vec<&str> = withheld.iter().map(|w| w.path.as_str()).collect();
1966 assert_eq!(paths, ["flake.lock", "flake.nix"]);
1967 assert!(
1968 all.iter()
1969 .any(|entry| entry.destination == "nix/package.nix")
1970 );
1971
1972 std::fs::remove_file(target.join("flake.nix")).expect("the flake removes");
1974 let mut all = entries();
1975 let withheld = withhold_nix(target, true, None, &mut all).expect("the judgment runs");
1976 assert!(withheld.is_empty());
1977 assert!(all.iter().any(|entry| entry.destination == "flake.nix"));
1978
1979 let mut all = entries();
1981 let withheld = withhold_nix(target, false, None, &mut all).expect("the judgment runs");
1982 assert!(withheld.is_empty());
1983 }
1984
1985 #[test]
1986 fn the_block_splices_into_every_agents_shape() {
1987 let owned = routing_block(Workflow::Branches);
1988 let block = owned.as_str();
1989 let fresh = splice_agents_block(None, block);
1990 assert_eq!(fresh, format!("{block}\n"));
1991 assert_eq!(extract_block(&fresh, BLOCK_BEGIN, BLOCK_END), Some(block));
1992
1993 let appended = splice_agents_block(Some("# My project\n\nOwn rules.\n"), block);
1994 assert!(appended.starts_with("# My project\n\nOwn rules.\n\n<!-- BEGIN release-kit -->"));
1995 assert_eq!(
1996 extract_block(&appended, BLOCK_BEGIN, BLOCK_END),
1997 Some(block)
1998 );
1999
2000 let stale = appended.replace("Never author a tag", "Do author a tag");
2001 let refreshed = splice_agents_block(Some(&stale), block);
2002 assert_eq!(
2003 extract_block(&refreshed, BLOCK_BEGIN, BLOCK_END),
2004 Some(block)
2005 );
2006 assert!(refreshed.starts_with("# My project"));
2007 assert_eq!(
2008 refreshed.matches("BEGIN release-kit").count(),
2009 1,
2010 "a re-splice must replace, not accumulate"
2011 );
2012 }
2013
2014 #[test]
2017 fn the_hook_block_splices_under_repos() {
2018 let owned = hooks_block(Workflow::Branches);
2019 let block = owned.as_str();
2020 let fresh = splice_hooks_block(None, block).expect("a fresh file splices");
2021 assert!(fresh.starts_with(HOOK_TYPES_LINE));
2022 assert!(fresh.contains("\nrepos:\n# BEGIN release-kit\n"));
2023 assert_eq!(extract_block(&fresh, HOOKS_BEGIN, HOOKS_END), Some(block));
2024
2025 let own =
2026 "repos:\n - repo: https://example.com/own\n rev: v1\n hooks:\n - id: own\n";
2027 let spliced = splice_hooks_block(Some(own), block).expect("an unmarked file splices");
2028 assert!(spliced.starts_with("repos:\n# BEGIN release-kit\n"));
2029 assert!(spliced.contains("- id: own"), "the target's hooks survive");
2030 assert!(
2031 !spliced.contains(HOOK_TYPES_LINE),
2032 "an existing file's top level is the skills' duty, not the splice's"
2033 );
2034
2035 let stale = spliced.replace("--force-scope", "--no-scope");
2036 let refreshed = splice_hooks_block(Some(&stale), block).expect("a marked file re-splices");
2037 assert_eq!(
2038 extract_block(&refreshed, HOOKS_BEGIN, HOOKS_END),
2039 Some(block)
2040 );
2041 assert_eq!(refreshed.matches(HOOKS_BEGIN).count(), 1);
2042
2043 let err = splice_hooks_block(Some("minimum_pre_commit_version: '3.2.0'\n"), block)
2044 .expect_err("no repos: line refuses");
2045 assert!(err.contains("repos:"), "{err}");
2046
2047 let doubled = format!("repos:\n{block}\n{block}\n");
2051 let err = splice_hooks_block(Some(&doubled), block).expect_err("a second block refuses");
2052 assert!(err.contains("one block"), "{err}");
2053 let unmatched = "repos:\n# BEGIN release-kit\n - repo: local\n";
2054 let err =
2055 splice_hooks_block(Some(unmatched), block).expect_err("an unmatched marker refuses");
2056 assert!(err.contains("unmatched"), "{err}");
2057 }
2058
2059 #[test]
2065 fn the_blocks_render_per_mode_and_carry_the_one_grammar() {
2066 let worktree_hooks = hooks_block(Workflow::Worktree);
2067 let branches_hooks = hooks_block(Workflow::Branches);
2068 assert!(worktree_hooks.contains("- id: rk-worktree-location"));
2069 assert!(
2070 worktree_hooks.contains("SKIP=no-commit-to-branch,rk-worktree-location"),
2071 "{worktree_hooks}"
2072 );
2073 assert!(!branches_hooks.contains("rk-worktree-location"));
2074 assert!(branches_hooks.contains("SKIP=no-commit-to-branch in"));
2075 for block in [&worktree_hooks, &branches_hooks] {
2076 assert!(block.contains(BRANCH_GRAMMAR), "the grammar has one owner");
2077 for token in ["RK_BRANCH_GRAMMAR", "RK_SWEEP_SKIP", "RK_WORKTREE_GUARD"] {
2078 assert!(!block.contains(token), "{token} survived: {block}");
2079 }
2080 }
2081 for block in [&worktree_hooks, &branches_hooks] {
2086 for line in block.lines() {
2087 if let Some(value) = line.trim_start().strip_prefix("entry: ") {
2088 assert!(
2089 !value.contains(": "),
2090 "an entry value breaks the YAML plain scalar: {line}"
2091 );
2092 }
2093 }
2094 }
2095 let guard_line = worktree_hooks
2096 .lines()
2097 .position(|line| line.contains("id: rk-worktree-location"))
2098 .expect("the guard entry exists");
2099 let name_line = worktree_hooks
2100 .lines()
2101 .position(|line| line.contains("id: rk-branch-name"))
2102 .expect("the name hook exists");
2103 assert!(
2104 guard_line > name_line,
2105 "the guard lands directly after rk-branch-name"
2106 );
2107
2108 let worktree_routing = routing_block(Workflow::Worktree);
2109 let branches_routing = routing_block(Workflow::Branches);
2110 assert!(worktree_routing.contains("This project works in worktrees"));
2111 assert!(branches_routing.contains("Branches are worked in the main checkout"));
2112 for block in [&worktree_routing, &branches_routing] {
2113 assert!(block.contains("Create or remove a worktree"));
2114 assert!(block.contains("`rk worktree add <branch>`"));
2115 assert!(!block.contains("RK_WORKFLOW_LINE"), "{block}");
2116 }
2117 let differing: Vec<(&str, &str)> = worktree_routing
2118 .lines()
2119 .zip(branches_routing.lines())
2120 .filter(|(a, b)| a != b)
2121 .collect();
2122 assert_eq!(
2123 differing.len(),
2124 1,
2125 "exactly one routing line differs per mode: {differing:?}"
2126 );
2127 }
2128
2129 #[test]
2132 fn the_hook_marker_defects_are_named() {
2133 use super::hooks_marker_defect;
2134 let owned = hooks_block(Workflow::Branches);
2135 let block = owned.as_str();
2136 assert_eq!(hooks_marker_defect(""), None);
2137 assert_eq!(hooks_marker_defect(&format!("repos:\n{block}\n")), None);
2138 for (case, text) in [
2139 (
2140 "a second begin",
2141 format!("repos:\n{block}\n# BEGIN release-kit\n"),
2142 ),
2143 (
2144 "a second end",
2145 format!("repos:\n{block}\n# END release-kit\n"),
2146 ),
2147 (
2148 "an unpaired begin",
2149 "repos:\n# BEGIN release-kit\n".to_owned(),
2150 ),
2151 ("an unpaired end", "repos:\n# END release-kit\n".to_owned()),
2152 (
2153 "an end before its begin",
2154 "repos:\n# END release-kit\n# BEGIN release-kit\n".to_owned(),
2155 ),
2156 ] {
2157 assert!(
2158 hooks_marker_defect(&text).is_some(),
2159 "{case} must be a defect"
2160 );
2161 }
2162 }
2163}