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