1pub mod apply;
19pub mod invariants;
20pub mod lock;
21pub mod manifest;
22
23use camino::Utf8Path;
24
25pub use crate::projection::{
26 AGENTS_DESTINATION, BLOCK_BEGIN, BLOCK_DESTINATIONS, BLOCK_END, BRANCH_GRAMMAR,
27 CODE_SCANNING_DESTINATIONS, CODE_SCANNING_TECHS, GLOSSARY_DESTINATION, HOOK_TYPES_LINE,
28 HOOKS_BEGIN, HOOKS_DESTINATION, HOOKS_END, Kind, LINE_PREFIX_RE_TOKEN, LINE_PREFIX_TOKEN,
29 NIX_DESTINATIONS, NIX_WITHHOLDABLE, OWNER_TOKEN, REPO_PLACEHOLDER, REPO_TOKEN, SCOPE_SHAPE,
30 SCOPE_SHAPE_TOKEN, SCORECARD_DESTINATIONS, SECURITY_SPANS, STYLE_TOKEN, TRUNK_BRANCH_TOKEN,
31 authored, block_markers, destinations, extract_block, hooks_marker_defect, kind_of,
32 marker_defect, render, scope_is_shaped, splice_hooks_block, splice_marked_block, substitute,
33};
34pub use manifest::{Provider, Style, Workflow};
35use serde::Serialize;
36
37use crate::diagnostic::{Diagnostic, Reason};
38use crate::error::RkError;
39
40#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct Params {
44 tech: String,
45 forge: String,
46 repo: String,
47 workflow: Workflow,
48 style: Option<Style>,
49 nix: bool,
50 scorecard: bool,
51 code_scanning: Option<Provider>,
52 trunk: String,
53 line_prefix: String,
54 security_contact: String,
55 security_response: String,
56}
57
58#[derive(Default)]
60pub struct Inputs<'a> {
61 pub tech: Option<&'a str>,
63 pub forge: Option<&'a str>,
65 pub repo: Option<&'a str>,
67 pub workflow: Option<Workflow>,
69 pub style: Option<Style>,
71 pub nix: Option<bool>,
73 pub scorecard: Option<bool>,
75 pub code_scanning: Option<Option<Provider>>,
78}
79
80#[derive(Clone, Copy, PartialEq, Eq)]
82pub enum Purpose {
83 Init,
85 Preview,
87 Upgrade,
89 Adopt,
91}
92
93impl Params {
94 #[must_use]
97 pub fn from_record(record: &manifest::Manifest) -> Self {
98 Self {
99 tech: record.tech.clone(),
100 forge: record.forge.clone(),
101 repo: record.parameters.repo.clone(),
102 workflow: record.parameters.workflow,
103 style: record.parameters.style,
104 nix: record.parameters.nix,
105 scorecard: record.parameters.scorecard,
106 code_scanning: record.parameters.code_scanning,
107 trunk: record.parameters.trunk.clone(),
108 line_prefix: record.parameters.line_prefix.clone(),
109 security_contact: record.parameters.security_contact.clone(),
110 security_response: record.parameters.security_response.clone(),
111 }
112 }
113
114 pub fn resolve(
120 target: &Utf8Path,
121 flags: &Inputs<'_>,
122 config: Option<&crate::config::Config>,
123 record: Option<&manifest::Manifest>,
124 purpose: Purpose,
125 ) -> Result<Self, RkError> {
126 let answer = |flag: Option<&str>, configured: Option<&str>, recorded: Option<&str>| {
127 flag.or_else(|| configured.filter(|value| !value.is_empty()))
128 .or(recorded)
129 .map(str::to_owned)
130 };
131 let forge = answer(
132 flags.forge,
133 config.map(|c| c.project.forge.as_str()),
134 record.map(|r| r.forge.as_str()),
135 );
136 let repo = answer(
137 flags.repo,
138 config.map(|c| c.project.repo.as_str()),
139 record.map(|r| r.parameters.repo.as_str()),
140 );
141 let resolved = resolve(target, forge.as_deref(), repo.as_deref())?;
142 let tech = answer(
143 flags.tech,
144 config.map(|c| c.project.tech.as_str()),
145 record.map(|r| r.tech.as_str()),
146 )
147 .or_else(|| crate::detect::tech_of(target.as_std_path()).map(str::to_owned))
148 .ok_or_else(|| {
149 RkError::missing(
150 Diagnostic::new(
151 Reason::TargetNotFound,
152 "no technology detected: the target has no version file",
153 )
154 .action("pass --tech <rust|python|bash>"),
155 )
156 })?;
157 crate::projection::check_pair(&tech, &resolved.forge)?;
158 let workflow = flags
159 .workflow
160 .or_else(|| config.and_then(|c| c.landing.workflow))
161 .or_else(|| record.map(|r| r.parameters.workflow))
162 .unwrap_or(if purpose == Purpose::Adopt {
163 Workflow::Branches
164 } else {
165 Workflow::Worktree
166 });
167 let style = flags
168 .style
169 .or_else(|| config.and_then(|c| c.landing.style))
170 .or_else(|| record.and_then(|r| r.parameters.style));
171 let style = match (style, purpose) {
172 (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())),
173 (value, _) => Some(value.unwrap_or(Style::Trunk)),
174 };
175 let repo = resolved
176 .repo
177 .or_else(|| (purpose == Purpose::Preview).then(|| REPO_PLACEHOLDER.to_owned()))
178 .ok_or_else(repo_unresolved)?;
179 let trunk = config
180 .and_then(|c| c.project.trunk.clone())
181 .or_else(|| record.map(|r| r.parameters.trunk.clone()))
182 .unwrap_or_else(|| crate::config::TRUNK_DEFAULT.to_owned());
183 let line_prefix = config
184 .and_then(|c| c.setup.line_prefix.clone())
185 .or_else(|| record.map(|r| r.parameters.line_prefix.clone()))
186 .unwrap_or_else(|| crate::config::LINE_PREFIX_DEFAULT.to_owned());
187 let security_contact = config
192 .and_then(|c| c.security.contact.clone())
193 .or_else(|| record.map(|r| r.parameters.security_contact.clone()))
194 .unwrap_or_default();
195 let security_contact =
196 crate::config::canonical_contact(&security_contact).map_err(crate::config::invalid)?;
197 let security_response = config
198 .and_then(|c| c.security.response.clone())
199 .or_else(|| record.map(|r| r.parameters.security_response.clone()))
200 .unwrap_or_else(|| crate::config::RESPONSE_DEFAULT.to_owned());
201 let security_response = crate::config::canonical_response(&security_response)
202 .map_err(crate::config::invalid)?;
203 let code_scanning = resolve_code_scanning(flags, config, record, &tech, &resolved.forge)?;
204 Ok(Self {
205 tech,
206 forge: resolved.forge,
207 repo,
208 workflow,
209 style,
210 nix: flags
211 .nix
212 .or_else(|| config.and_then(|c| c.landing.nix))
213 .or_else(|| record.map(|r| r.parameters.nix))
214 .unwrap_or(false),
215 scorecard: flags
216 .scorecard
217 .or_else(|| config.and_then(|c| c.landing.scorecard))
218 .or_else(|| record.map(|r| r.parameters.scorecard))
219 .unwrap_or(false),
220 code_scanning,
221 trunk,
222 line_prefix,
223 security_contact,
224 security_response,
225 })
226 }
227
228 #[must_use]
230 pub fn tech(&self) -> &str {
231 &self.tech
232 }
233
234 #[must_use]
236 pub fn forge(&self) -> &str {
237 &self.forge
238 }
239
240 #[must_use]
242 pub const fn nix(&self) -> bool {
243 self.nix
244 }
245
246 #[must_use]
248 pub const fn scorecard(&self) -> bool {
249 self.scorecard
250 }
251
252 #[must_use]
254 pub const fn code_scanning(&self) -> Option<Provider> {
255 self.code_scanning
256 }
257
258 #[must_use]
264 pub fn capability_flags(&self) -> String {
265 let mut out = String::new();
266 if self.nix {
267 out.push_str(" --nix");
268 }
269 if self.scorecard {
270 out.push_str(" --scorecard");
271 }
272 out.push_str(" --code-scanning ");
278 out.push_str(self.code_scanning.map_or("off", Provider::as_str));
279 out
280 }
281
282 #[must_use]
289 pub fn capability_toggles(&self) -> String {
290 let word = |on: bool| if on { "on" } else { "off" };
291 format!(
292 " --nix {} --scorecard {} --code-scanning {}",
293 word(self.nix),
294 word(self.scorecard),
295 self.code_scanning.map_or("off", Provider::as_str)
296 )
297 }
298
299 #[must_use]
301 pub fn repo(&self) -> &str {
302 &self.repo
303 }
304
305 #[must_use]
307 pub const fn workflow(&self) -> Workflow {
308 self.workflow
309 }
310
311 #[must_use]
313 pub const fn style(&self) -> Option<Style> {
314 self.style
315 }
316
317 #[must_use]
319 pub fn trunk(&self) -> &str {
320 &self.trunk
321 }
322
323 #[must_use]
325 pub fn line_prefix(&self) -> &str {
326 &self.line_prefix
327 }
328
329 #[must_use]
332 pub fn security_contact(&self) -> &str {
333 &self.security_contact
334 }
335
336 #[must_use]
338 pub fn security_response(&self) -> &str {
339 &self.security_response
340 }
341}
342
343#[cfg(test)]
344impl Params {
345 pub(crate) fn for_test(repo: &str, style: Option<Style>) -> Self {
349 Self {
350 tech: "rust".to_owned(),
351 forge: "github".to_owned(),
352 repo: repo.to_owned(),
353 workflow: Workflow::Worktree,
354 style,
355 nix: false,
356 scorecard: false,
357 code_scanning: None,
358 trunk: crate::config::TRUNK_DEFAULT.to_owned(),
359 line_prefix: crate::config::LINE_PREFIX_DEFAULT.to_owned(),
360 security_contact: String::new(),
361 security_response: crate::config::RESPONSE_DEFAULT.to_owned(),
362 }
363 }
364
365 pub(crate) fn for_test_security(contact: &str, response: &str) -> Self {
367 Self {
368 security_contact: contact.to_owned(),
369 security_response: response.to_owned(),
370 ..Self::for_test("acme/widget", Some(Style::Trunk))
371 }
372 }
373
374 pub(crate) fn set_nix_for_test(&mut self, nix: bool) {
376 self.nix = nix;
377 }
378
379 pub(crate) fn set_scorecard_for_test(&mut self, scorecard: bool) {
381 self.scorecard = scorecard;
382 }
383
384 pub(crate) fn set_code_scanning_for_test(&mut self, provider: Option<Provider>) {
386 self.code_scanning = provider;
387 }
388}
389
390fn resolve_code_scanning(
409 flags: &Inputs<'_>,
410 config: Option<&crate::config::Config>,
411 record: Option<&manifest::Manifest>,
412 tech: &str,
413 forge: &str,
414) -> Result<Option<Provider>, RkError> {
415 let configured = config
416 .and_then(|c| c.landing.code_scanning.as_deref())
417 .map(Provider::parse)
418 .transpose()?;
419 let provider = flags
420 .code_scanning
421 .or(configured)
422 .or_else(|| record.map(|r| r.parameters.code_scanning))
423 .unwrap_or(None);
424 if let Some(reason) = crate::projection::code_scanning_incompatibility(provider, tech, forge) {
425 return Err(RkError::Usage(reason));
426 }
427 Ok(provider)
428}
429
430#[derive(Debug, Clone, Serialize)]
432pub struct Withheld {
433 pub path: String,
435 pub reason: String,
438}
439
440pub fn read_recorded(target: &Utf8Path, destination: &str) -> std::io::Result<Option<Vec<u8>>> {
451 let path = target.join(destination);
452 let bytes = match std::fs::read(&path) {
453 Ok(bytes) => bytes,
454 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
455 Err(e) => return Err(e),
456 };
457 if let Some((begin, end)) = block_markers(destination) {
458 let text = String::from_utf8_lossy(&bytes);
459 Ok(extract_block(&text, begin, end).map(|block| block.as_bytes().to_vec()))
460 } else {
461 Ok(Some(bytes))
462 }
463}
464
465#[derive(Debug)]
468pub struct Resolved {
469 pub forge: String,
471 pub repo: Option<String>,
473}
474
475pub fn resolve(
487 target: &Utf8Path,
488 forge_flag: Option<&str>,
489 repo_flag: Option<&str>,
490) -> Result<Resolved, RkError> {
491 let forge_flag = forge_flag
492 .map(|name| {
493 crate::detect::Forge::parse(name).ok_or_else(|| {
494 RkError::Usage(format!(
495 "unknown forge '{name}'; the forges are: github, gitlab"
496 ))
497 })
498 })
499 .transpose()?;
500 let detected = crate::detect::detect(target.as_std_path());
501 let forge = forge_flag
502 .or(detected.forge)
503 .map(|forge| forge.as_str().to_owned())
504 .ok_or_else(|| {
505 let message = detected.host.map_or_else(
506 || "no forge detected: the target has no origin remote".to_owned(),
507 |host| format!("no forge detected: the host {host} is not recognized"),
508 );
509 RkError::refusal(
510 Diagnostic::new(Reason::ForgeUndetected, message)
511 .expected("a github.com or gitlab remote, or --forge")
512 .action("pass --forge <github|gitlab>"),
513 )
514 })?;
515 Ok(Resolved {
516 forge,
517 repo: repo_flag.map(str::to_owned).or(detected.repo),
518 })
519}
520
521#[must_use]
524pub fn repo_unresolved() -> RkError {
525 RkError::missing(
526 Diagnostic::new(
527 Reason::ForgeUndetected,
528 "no repository detected: the target has no origin remote",
529 )
530 .expected("an origin remote naming the project")
531 .action("pass --repo <path>"),
532 )
533}
534
535#[cfg(test)]
536mod tests {
537 use super::{
538 AGENTS_DESTINATION, BLOCK_BEGIN, BLOCK_DESTINATIONS, BLOCK_END, BRANCH_GRAMMAR,
539 GLOSSARY_DESTINATION, HOOK_TYPES_LINE, HOOKS_BEGIN, HOOKS_DESTINATION, HOOKS_END, Kind,
540 Provider, SCOPE_SHAPE, Style, Workflow, extract_block, kind_of, render, splice_hooks_block,
541 splice_marked_block,
542 };
543 use crate::embedded;
544 use crate::projection::{self, Projection, ProjectionInput, TargetEvidence};
545
546 fn destinations(params: &super::Params) -> Vec<String> {
549 Projection::compute(&ProjectionInput {
550 params: params.clone(),
551 evidence: TargetEvidence {
552 crate_shape: projection::CrateShape {
553 cargo_toml: Some(
554 "[package]\nname = \"widget\"\nversion = \"0.1.0\"\n".to_owned(),
555 ),
556 cargo_lock: true,
557 main_rs: true,
558 },
559 ..TargetEvidence::default()
560 },
561 })
562 .expect("the pair projects")
563 .candidates
564 .into_iter()
565 .map(|candidate| candidate.destination)
566 .collect()
567 }
568
569 fn routing_block(workflow: Workflow) -> String {
570 projection::routing_block(workflow).expect("the binary embeds the block")
571 }
572
573 fn hooks_block(workflow: Workflow) -> String {
574 projection::hooks_block(workflow).expect("the binary embeds the block")
575 }
576
577 fn glossary_block() -> String {
578 projection::glossary_block().expect("the binary embeds the block")
579 }
580
581 fn spliced(existing: Option<&str>, block: &str) -> String {
584 String::from_utf8(splice_marked_block(existing.map(str::as_bytes), block))
585 .expect("the fixtures are text")
586 }
587
588 #[test]
589 fn private_reporting_path_tokens_are_reproducible() {
590 for repo in [
591 "acme/widget",
592 "acme/group/widget",
593 "acme/OWNER-RK_STYLE-RK_SCOPE_SHAPE",
594 ] {
595 assert_eq!(
596 super::render(
597 b"RK_REPO RK_REPO OWNER RK_STYLE RK_SCOPE_SHAPE",
598 &super::Params::for_test(repo, Some(super::Style::Trunk))
599 ),
600 format!("{repo} {repo} acme trunk {}", super::SCOPE_SHAPE).as_bytes()
601 );
602 }
603 assert_eq!(super::kind_of("SECURITY.md"), Some(super::Kind::Rendered));
604 }
605
606 #[test]
611 fn each_forge_policy_carries_one_ordered_pair_of_every_span() {
612 for forge in ["github", "gitlab"] {
613 let bytes = embedded::SNIPPETS
614 .get_file(format!("_shared/{forge}/SECURITY.md"))
615 .expect("the policy ships")
616 .contents();
617 let text = String::from_utf8_lossy(bytes);
618 for (begin, end) in super::SECURITY_SPANS {
619 let begin = String::from_utf8_lossy(begin);
620 let end = String::from_utf8_lossy(end);
621 assert_eq!(text.matches(begin.as_ref()).count(), 1, "{forge} {begin}");
622 assert_eq!(text.matches(end.as_ref()).count(), 1, "{forge} {end}");
623 assert!(
624 text.find(begin.as_ref()) < text.find(end.as_ref()),
625 "{forge}: {begin} must precede {end}"
626 );
627 }
628 }
629 }
630
631 #[test]
636 fn the_security_spans_render_per_answer() {
637 for forge in ["github", "gitlab"] {
638 let bytes = embedded::SNIPPETS
639 .get_file(format!("_shared/{forge}/SECURITY.md"))
640 .expect("the policy ships")
641 .contents();
642 let authored = String::from_utf8_lossy(bytes);
643 let stripped = {
644 let mut text = authored.clone().into_owned();
645 for (begin, end) in super::SECURITY_SPANS {
646 text = text.replace(&String::from_utf8_lossy(begin).into_owned(), "");
647 text = text.replace(&String::from_utf8_lossy(end).into_owned(), "");
648 }
649 text
650 };
651 let default = super::Params {
652 forge: forge.to_owned(),
653 ..super::Params::for_test_security("", crate::config::RESPONSE_DEFAULT)
654 };
655 let rendered = String::from_utf8(render(bytes, &default)).expect("text");
656 assert_eq!(
657 rendered,
658 stripped.replace("RK_REPO", "acme/widget"),
659 "{forge}: the default answers must reproduce the authored policy"
660 );
661 assert!(!rendered.contains("RK_SECURITY"), "{forge}: {rendered}");
662
663 let answered = super::Params {
664 forge: forge.to_owned(),
665 ..super::Params::for_test_security("OWNER RK_REPO <team@acme.example>", "14 days")
666 };
667 let rendered = String::from_utf8(render(bytes, &answered)).expect("text");
668 assert!(
669 rendered.contains("OWNER RK_REPO <team@acme.example>"),
670 "{forge}: a contact spelling a token name lands literally: {rendered}"
671 );
672 assert!(
673 rendered.contains("Maintainers acknowledge a report within 14 days."),
674 "{forge}: {rendered}"
675 );
676 assert!(
677 rendered.contains("This policy commits to no disclosure deadline."),
678 "{forge}: {rendered}"
679 );
680 assert!(
681 !rendered.contains("best-effort basis"),
682 "{forge}: a stated window replaces the best-effort sentence: {rendered}"
683 );
684 assert!(
685 !rendered.contains("no response or disclosure deadline"),
686 "{forge}: a stated window contradicts the response disclaimer: {rendered}"
687 );
688 }
689 }
690
691 #[test]
694 fn a_defective_span_renders_unchanged() {
695 let (begin, end) = super::SECURITY_SPANS[0];
696 let begin = String::from_utf8_lossy(begin).into_owned();
697 let end = String::from_utf8_lossy(end).into_owned();
698 let params = super::Params::for_test_security("team@acme.example", "1 day");
699 for baseline in [
700 format!("contact {begin}a maintainer\n"),
701 format!("contact a maintainer{end}\n"),
702 format!("contact {end}a maintainer{begin}\n"),
703 "contact a maintainer\n".to_owned(),
704 ] {
705 assert_eq!(
706 render(baseline.as_bytes(), ¶ms),
707 baseline.as_bytes(),
708 "{baseline}"
709 );
710 }
711 }
712
713 #[test]
717 fn the_kind_table_closes_over_every_snippet() {
718 for tech_dir in embedded::SNIPPETS.dirs() {
719 for pair_dir in tech_dir.dirs() {
720 let prefix = format!("{}/", pair_dir.path().to_string_lossy());
721 for (path, _) in embedded::walk(pair_dir) {
722 let destination = path.strip_prefix(&prefix).unwrap_or(&path);
723 assert!(
724 kind_of(destination).is_some(),
725 "{destination}: no declared kind"
726 );
727 }
728 }
729 }
730 for block in BLOCK_DESTINATIONS {
731 assert_eq!(kind_of(block), Some(Kind::Rendered), "{block}");
732 }
733 assert_eq!(kind_of("something-else.txt"), None);
734 }
735
736 #[test]
741 fn rendering_substitutes_every_owner_occurrence() {
742 let baseline = b"if: repository_owner == 'OWNER'\n# OWNER again: OWNER\n";
743 let rendered = render(baseline, &super::Params::for_test("acme/sub/widget", None));
744 let text = String::from_utf8(rendered).expect("rendered bytes stay text");
745 assert_eq!(text, "if: repository_owner == 'acme'\n# acme again: acme\n");
746
747 let baseline = b"match (RK_SCOPE_SHAPE)\n";
748 let rendered = render(baseline, &super::Params::for_test("acme/widget", None));
749 let text = String::from_utf8(rendered).expect("rendered bytes stay text");
750 assert_eq!(text, format!("match ({SCOPE_SHAPE})\n"));
751 }
752
753 #[test]
757 fn the_scope_shape_drops_into_the_title_check() {
758 assert_eq!(SCOPE_SHAPE, "[a-z0-9._/-]+");
759 assert!(
760 !SCOPE_SHAPE.contains('\''),
761 "the title checks single-quote it"
762 );
763 }
764
765 #[test]
770 fn the_scope_predicate_and_the_rendered_pattern_agree() {
771 let body = SCOPE_SHAPE
772 .strip_prefix('[')
773 .and_then(|rest| rest.strip_suffix("]+"))
774 .expect("the shape is one bracket expression, repeated");
775 let chars: Vec<char> = body.chars().collect();
776 let mut admitted = std::collections::BTreeSet::new();
777 let mut at = 0;
778 while at < chars.len() {
779 if at + 2 < chars.len() && chars[at + 1] == '-' {
782 for c in chars[at]..=chars[at + 2] {
783 admitted.insert(c);
784 }
785 at += 3;
786 } else {
787 admitted.insert(chars[at]);
788 at += 1;
789 }
790 }
791 for byte in 0..=127u8 {
792 let c = char::from(byte);
793 assert_eq!(
794 super::scope_is_shaped(&c.to_string()),
795 admitted.contains(&c),
796 "the predicate and {SCOPE_SHAPE} disagree on {c:?}"
797 );
798 }
799 assert!(super::scope_is_shaped("guides/release"));
800 assert!(!super::scope_is_shaped(""), "a scope is never empty");
801 assert!(!super::scope_is_shaped("Specs Ugly"));
802 }
803
804 #[test]
807 fn the_shared_zone_composes_into_the_pair() {
808 let github = destinations(&super::Params {
809 forge: "github".to_owned(),
810 ..super::Params::for_test("acme/widget", Some(Style::Trunk))
811 });
812 assert!(
813 github.contains(&".github/workflows/pr-title.yml".to_owned()),
814 "the shared title check lands with the pair"
815 );
816 let gitlab = destinations(&super::Params {
817 forge: "gitlab".to_owned(),
818 ..super::Params::for_test("acme/widget", Some(Style::Trunk))
819 });
820 assert!(
821 gitlab.contains(&".gitlab/ci/mr-title.yml".to_owned()),
822 "the shared title job lands with the pair"
823 );
824 let err =
825 projection::check_pair("_shared", "github").expect_err("the shared zone is no tech");
826 let listing = err.to_string();
827 let bindings = listing
828 .split("the bindings are:")
829 .nth(1)
830 .expect("the refusal lists the bindings");
831 assert!(!bindings.contains("_shared"), "{listing}");
832 }
833
834 #[test]
837 fn params_from_a_record_round_trips() {
838 use super::{Params, manifest};
839 let dir = tempfile::tempdir().expect("a scratch target exists");
840 let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
841 for tech in ["rust", "bash"] {
842 for forge in ["github", "gitlab"] {
843 for workflow in [Workflow::Branches, Workflow::Worktree] {
844 for style in [None, Some(Style::Trunk), Some(Style::Lines)] {
845 for ((nix, scorecard), code_scanning) in [
846 ((false, false), None),
847 ((false, true), Some(Provider::Semgrep)),
848 ((true, false), Some(Provider::CodeQl)),
849 ((true, true), None),
850 ] {
851 let record = manifest::Manifest {
852 schema_version: manifest::SCHEMA_VERSION,
853 rk_version: "0.1.0".to_owned(),
854 origin: "init".to_owned(),
855 tech: tech.to_owned(),
856 forge: forge.to_owned(),
857 landed_at: "2026-08-29T00:00:00Z".to_owned(),
858 parameters: manifest::Parameters {
859 repo: "acme/team/widget".to_owned(),
860 workflow,
861 style,
862 nix,
863 scorecard,
864 code_scanning,
865 trunk: crate::config::TRUNK_DEFAULT.to_owned(),
866 line_prefix: crate::config::LINE_PREFIX_DEFAULT.to_owned(),
867 security_contact: String::new(),
868 security_response: crate::config::RESPONSE_DEFAULT.to_owned(),
869 },
870 files: Vec::new(),
871 pins: std::collections::BTreeMap::new(),
872 };
873 manifest::write(target, &record).expect("the record writes");
874 let loaded = manifest::load(target)
875 .expect("the record loads")
876 .expect("the record exists");
877 let params = Params::from_record(&loaded);
878 assert_eq!(params.tech, tech);
879 assert_eq!(params.forge, forge);
880 assert_eq!(params.repo(), "acme/team/widget");
881 assert_eq!(params.workflow(), workflow);
882 assert_eq!(params.style(), style);
883 assert_eq!(params.nix, nix);
884 assert_eq!(params.scorecard, scorecard);
885 assert_eq!(params.code_scanning, code_scanning);
886 let mut direct = super::Params::for_test("acme/team/widget", style);
889 direct.tech = tech.to_owned();
890 direct.forge = forge.to_owned();
891 direct.workflow = workflow;
892 direct.nix = nix;
893 direct.scorecard = scorecard;
894 direct.code_scanning = code_scanning;
895 assert_eq!(params, direct);
896 let projected = destinations(¶ms);
897 for block in
898 [AGENTS_DESTINATION, GLOSSARY_DESTINATION, HOOKS_DESTINATION]
899 {
900 assert!(projected.contains(&block.to_owned()), "{block}");
901 }
902 for destination in super::NIX_DESTINATIONS {
903 assert_eq!(
904 projected.contains(&destination.to_owned()),
905 nix && tech == "rust",
906 "{tech} {forge} nix={nix}: {destination}"
907 );
908 }
909 for destination in super::SCORECARD_DESTINATIONS {
913 assert_eq!(
914 projected.contains(&destination.to_owned()),
915 scorecard && forge == "github",
916 "{tech} {forge} scorecard={scorecard}: {destination}"
917 );
918 }
919 }
920 }
921 }
922 }
923 }
924 }
925
926 fn resolved_test_params(
927 tech: &str,
928 resolved: &super::Resolved,
929 workflow: Workflow,
930 style: Option<Style>,
931 nix: bool,
932 scorecard: bool,
933 code_scanning: Option<Provider>,
934 ) -> Result<super::Params, crate::error::RkError> {
935 super::Params::resolve(
936 camino::Utf8Path::new("."),
937 &super::Inputs {
938 tech: Some(tech),
939 forge: Some(&resolved.forge),
940 repo: resolved.repo.as_deref(),
941 workflow: Some(workflow),
942 style,
943 nix: Some(nix),
944 scorecard: Some(scorecard),
945 code_scanning: Some(code_scanning),
946 },
947 None,
948 None,
949 super::Purpose::Init,
950 )
951 }
952
953 #[test]
957 fn a_projection_renders_owned_files_and_keeps_seeded_judgment() {
958 let params = resolved_test_params(
959 "rust",
960 &super::Resolved {
961 forge: "github".to_owned(),
962 repo: Some("acme/widget".to_owned()),
963 },
964 Workflow::Branches,
965 Some(Style::Trunk),
966 false,
967 false,
968 None,
969 )
970 .expect("the parameters resolve");
971 let entries = Projection::compute(&ProjectionInput {
972 params,
973 evidence: TargetEvidence::default(),
974 })
975 .expect("the pair projects")
976 .candidates;
977 let workflow = entries
978 .iter()
979 .find(|entry| entry.destination.ends_with("release-plz.yml"))
980 .expect("the workflow projects");
981 assert_eq!(workflow.kind, Kind::Rendered);
982 let text = String::from_utf8_lossy(&workflow.bytes);
983 assert!(!text.contains("OWNER"), "an owner token survived rendering");
984 assert!(text.contains("'acme'"));
985 assert!(!text.contains("TODO(release-kit)"));
986 let title = entries
987 .iter()
988 .find(|entry| entry.destination.ends_with("pr-title.yml"))
989 .expect("the title check projects");
990 let text = String::from_utf8_lossy(&title.bytes);
991 assert!(text.contains(SCOPE_SHAPE), "{text}");
992 assert!(
993 !text.contains("RK_SCOPE_SHAPE"),
994 "a scope token survived: {text}"
995 );
996 let seeded = entries
997 .iter()
998 .find(|entry| entry.destination == "release-plz.toml")
999 .expect("the seeded file projects");
1000 assert_eq!(seeded.kind, Kind::Seeded);
1001 let authored = embedded::SNIPPETS
1002 .get_file("rust/github/release-plz.toml")
1003 .expect("the seed ships")
1004 .contents();
1005 assert_eq!(seeded.bytes, authored, "a seeded file lands as authored");
1006 assert!(String::from_utf8_lossy(&seeded.bytes).contains("TODO(release-kit)"));
1007 for block in BLOCK_DESTINATIONS {
1008 let entry = entries
1009 .iter()
1010 .find(|entry| entry.destination == block)
1011 .expect("every block is part of the projection");
1012 let text = String::from_utf8_lossy(&entry.bytes);
1013 assert!(
1014 !text.contains("RK_SCOPE_SHAPE"),
1015 "{block} kept a token: {text}"
1016 );
1017 }
1018 }
1019
1020 #[test]
1025 fn the_nix_destinations_project_only_under_the_opt_in() {
1026 use super::NIX_DESTINATIONS;
1027 let paths = |nix: bool, forge: &str| -> Vec<String> {
1028 destinations(
1029 &resolved_test_params(
1030 "rust",
1031 &super::Resolved {
1032 forge: forge.to_owned(),
1033 repo: Some("acme/widget".to_owned()),
1034 },
1035 Workflow::Worktree,
1036 Some(Style::Trunk),
1037 nix,
1038 false,
1039 None,
1040 )
1041 .expect("the parameters resolve"),
1042 )
1043 };
1044 let off = paths(false, "github");
1045 for destination in NIX_DESTINATIONS {
1046 assert!(!off.contains(&destination.to_owned()), "{destination}");
1047 }
1048 let on = paths(true, "github");
1049 for destination in ["nix/package.nix", "flake.nix", "flake.lock"] {
1050 assert!(on.contains(&destination.to_owned()), "{destination}");
1051 }
1052 let gitlab = paths(true, "gitlab");
1057 assert!(gitlab.contains(&"nix/package.nix".to_owned()));
1058 assert!(
1059 !on.iter()
1060 .chain(gitlab.iter())
1061 .any(|destination| destination.contains("nix.yml"))
1062 );
1063 let bash = destinations(
1064 &resolved_test_params(
1065 "bash",
1066 &super::Resolved {
1067 forge: "github".to_owned(),
1068 repo: Some("acme/widget".to_owned()),
1069 },
1070 Workflow::Worktree,
1071 Some(Style::Trunk),
1072 true,
1073 false,
1074 None,
1075 )
1076 .expect("the parameters resolve"),
1077 );
1078 assert!(
1079 bash.iter()
1080 .all(|destination| !NIX_DESTINATIONS.contains(&destination.as_str()))
1081 );
1082 }
1083
1084 #[test]
1089 fn the_nix_seeds_are_identical_across_forge_pairs() {
1090 for name in ["nix/package.nix", "flake.nix", "flake.lock"] {
1091 let github = embedded::SNIPPETS
1092 .get_file(format!("rust/github/{name}"))
1093 .expect("the github copy ships")
1094 .contents();
1095 let gitlab = embedded::SNIPPETS
1096 .get_file(format!("rust/gitlab/{name}"))
1097 .expect("the gitlab copy ships")
1098 .contents();
1099 assert_eq!(github, gitlab, "{name} diverged between the pairs");
1100 }
1101 }
1102
1103 #[test]
1108 fn the_nix_withhold_judgment_covers_the_three_shapes() {
1109 use super::NIX_DESTINATIONS;
1110 let dir = tempfile::tempdir().expect("a scratch target exists");
1111 let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
1112 let project = |nix: bool| {
1113 let params = resolved_test_params(
1114 "rust",
1115 &super::Resolved {
1116 forge: "github".to_owned(),
1117 repo: Some("acme/widget".to_owned()),
1118 },
1119 Workflow::Worktree,
1120 Some(Style::Trunk),
1121 nix,
1122 false,
1123 None,
1124 )
1125 .expect("the parameters resolve");
1126 let evidence = TargetEvidence::gather(target, None).expect("the evidence reads");
1127 Projection::compute(&ProjectionInput { params, evidence }).expect("the pair projects")
1128 };
1129 let withheld = |projection: &Projection| -> Vec<String> {
1130 projection
1131 .omissions
1132 .iter()
1133 .map(|omission| omission.destination.clone())
1134 .collect()
1135 };
1136 let landed = |projection: &Projection, destination: &str| {
1137 projection
1138 .candidates
1139 .iter()
1140 .any(|candidate| candidate.destination == destination)
1141 };
1142
1143 let all = project(true);
1145 assert_eq!(
1146 withheld(&all),
1147 ["flake.lock", "flake.nix", "nix/package.nix"]
1148 );
1149 assert!(
1150 all.candidates
1151 .iter()
1152 .all(|entry| !NIX_DESTINATIONS.contains(&entry.destination.as_str()))
1153 );
1154
1155 std::fs::write(
1158 target.join("Cargo.toml"),
1159 "[package]\nname = \"widget\"\nversion = \"0.1.0\"\n",
1160 )
1161 .expect("the crate manifest writes");
1162 std::fs::write(target.join("Cargo.lock"), "version = 4\n").expect("the lock writes");
1163 std::fs::create_dir_all(target.join("src")).expect("the src dir exists");
1164 std::fs::write(target.join("src/main.rs"), "fn main() {}\n").expect("the main writes");
1165 std::fs::write(target.join("flake.nix"), "{ }\n").expect("the flake writes");
1166 let all = project(true);
1167 assert_eq!(withheld(&all), ["flake.lock", "flake.nix"]);
1168 assert!(landed(&all, "nix/package.nix"));
1169
1170 std::fs::remove_file(target.join("flake.nix")).expect("the flake removes");
1172 let all = project(true);
1173 assert!(all.omissions.is_empty());
1174 assert!(landed(&all, "flake.nix"));
1175
1176 let all = project(false);
1178 assert!(all.omissions.is_empty());
1179 assert!(!landed(&all, "flake.nix"));
1180 }
1181
1182 #[test]
1186 fn the_glossary_splices_into_every_shape() {
1187 let owned = glossary_block();
1188 let block = owned.as_str();
1189
1190 let fresh = spliced(None, block);
1191 assert_eq!(fresh, format!("{block}\n"));
1192 assert_eq!(extract_block(&fresh, BLOCK_BEGIN, BLOCK_END), Some(block));
1193
1194 let own = "# Glossary\n\n- `spike` — a throwaway branch.\n";
1195 let appended = spliced(Some(own), block);
1196 assert!(appended.starts_with(own));
1197 assert_eq!(
1198 extract_block(&appended, BLOCK_BEGIN, BLOCK_END),
1199 Some(block)
1200 );
1201
1202 let stale = appended.replace("full-implement", "do-everything");
1203 let refreshed = spliced(Some(&stale), block);
1204 assert_eq!(
1205 extract_block(&refreshed, BLOCK_BEGIN, BLOCK_END),
1206 Some(block)
1207 );
1208 assert_eq!(
1209 refreshed.matches("BEGIN release-kit").count(),
1210 1,
1211 "a re-splice must replace, not accumulate"
1212 );
1213 }
1214
1215 #[test]
1219 fn the_glossary_leaves_the_targets_region_alone() {
1220 let owned = glossary_block();
1221 let block = owned.as_str();
1222 let below = "\n## Our own terms\n\n- `spike` — a throwaway branch, never merged.\n";
1223 let landed = format!("{block}\n{below}");
1224
1225 let refreshed = spliced(Some(&landed), block);
1226 assert!(
1227 refreshed.ends_with(below),
1228 "the target's own region changed: {refreshed}"
1229 );
1230 assert_eq!(
1231 extract_block(&refreshed, BLOCK_BEGIN, BLOCK_END),
1232 Some(block)
1233 );
1234 }
1235
1236 #[test]
1240 fn an_append_rewrites_no_byte_the_target_wrote() {
1241 let owned = glossary_block();
1242 let block = owned.as_str();
1243 for own in [
1244 "# Glossary\n\n- `spike` — throwaway. \n\n\n",
1245 "# Glossary\n\n- `spike` — throwaway.",
1246 "# Glossary\r\n\r\n- `spike` — throwaway.\r\n",
1247 ] {
1248 let appended = spliced(Some(own), block);
1249 assert!(
1250 appended.starts_with(own),
1251 "the target's bytes changed: {appended:?}"
1252 );
1253 assert_eq!(
1254 extract_block(&appended, BLOCK_BEGIN, BLOCK_END),
1255 Some(block),
1256 "{appended:?}"
1257 );
1258 let marker = appended.find(BLOCK_BEGIN).expect("the block landed");
1259 assert!(
1260 appended[..marker].ends_with('\n'),
1261 "the block must open its own line: {appended:?}"
1262 );
1263 }
1264 }
1265
1266 #[test]
1270 fn a_splice_decodes_no_byte_the_target_wrote() {
1271 let owned = glossary_block();
1272 let block = owned.as_str();
1273
1274 let own = b"# Glossary\n\ncaf\xe9\n";
1276 let appended = splice_marked_block(Some(own), block);
1277 assert!(
1278 appended.starts_with(own),
1279 "the target's bytes changed: {appended:?}"
1280 );
1281 assert!(!appended.contains(&0xEF), "a replacement character landed");
1282
1283 let mut landed = Vec::new();
1285 landed.extend_from_slice(block.replace("full-implement", "do-everything").as_bytes());
1286 landed.extend_from_slice(b"\n\ncaf\xe9\n");
1287 let refreshed = splice_marked_block(Some(&landed), block);
1288 assert!(
1289 refreshed.ends_with(b"\n\ncaf\xe9\n"),
1290 "the target's region below the markers changed: {refreshed:?}"
1291 );
1292 assert!(refreshed.starts_with(block.as_bytes()), "{refreshed:?}");
1293 }
1294
1295 #[test]
1298 fn the_glossary_block_carries_no_parameter() {
1299 let block = glossary_block();
1300 assert!(block.starts_with(BLOCK_BEGIN), "{block}");
1301 assert!(block.ends_with(BLOCK_END), "{block}");
1302 assert!(!block.contains("RK_"), "a token survived: {block}");
1303 assert!(!block.contains("OWNER"), "an owner token survived: {block}");
1304 for term in [
1305 "implement-and-request",
1306 "implement-and-merge",
1307 "full-implement",
1308 ] {
1309 assert!(block.contains(term), "{term} is missing from {block}");
1310 }
1311 assert!(
1312 routing_block(Workflow::Worktree).contains(GLOSSARY_DESTINATION),
1313 "the routing block must name the destination it indexes"
1314 );
1315 }
1316
1317 #[test]
1318 fn the_block_splices_into_every_agents_shape() {
1319 let owned = routing_block(Workflow::Branches);
1320 let block = owned.as_str();
1321 let fresh = spliced(None, block);
1322 assert_eq!(fresh, format!("{block}\n"));
1323 assert_eq!(extract_block(&fresh, BLOCK_BEGIN, BLOCK_END), Some(block));
1324
1325 let appended = spliced(Some("# My project\n\nOwn rules.\n"), block);
1326 assert!(appended.starts_with("# My project\n\nOwn rules.\n\n<!-- BEGIN release-kit -->"));
1327 assert_eq!(
1328 extract_block(&appended, BLOCK_BEGIN, BLOCK_END),
1329 Some(block)
1330 );
1331
1332 let stale = appended.replace("Never author a tag", "Do author a tag");
1333 let refreshed = spliced(Some(&stale), block);
1334 assert_eq!(
1335 extract_block(&refreshed, BLOCK_BEGIN, BLOCK_END),
1336 Some(block)
1337 );
1338 assert!(refreshed.starts_with("# My project"));
1339 assert_eq!(
1340 refreshed.matches("BEGIN release-kit").count(),
1341 1,
1342 "a re-splice must replace, not accumulate"
1343 );
1344 }
1345
1346 #[test]
1349 fn the_hook_block_splices_under_repos() {
1350 let owned = hooks_block(Workflow::Branches);
1351 let block = owned.as_str();
1352 let fresh = splice_hooks_block(None, block).expect("a fresh file splices");
1353 assert!(fresh.starts_with(HOOK_TYPES_LINE));
1354 assert!(fresh.contains("\nrepos:\n# BEGIN release-kit\n"));
1355 assert_eq!(extract_block(&fresh, HOOKS_BEGIN, HOOKS_END), Some(block));
1356
1357 let own =
1358 "repos:\n - repo: https://example.com/own\n rev: v1\n hooks:\n - id: own\n";
1359 let spliced = splice_hooks_block(Some(own), block).expect("an unmarked file splices");
1360 assert!(spliced.starts_with("repos:\n# BEGIN release-kit\n"));
1361 assert!(spliced.contains("- id: own"), "the target's hooks survive");
1362 assert!(
1363 !spliced.contains(HOOK_TYPES_LINE),
1364 "an existing file's top level is the skills' duty, not the splice's"
1365 );
1366
1367 let stale = spliced.replace("--force-scope", "--no-scope");
1368 let refreshed = splice_hooks_block(Some(&stale), block).expect("a marked file re-splices");
1369 assert_eq!(
1370 extract_block(&refreshed, HOOKS_BEGIN, HOOKS_END),
1371 Some(block)
1372 );
1373 assert_eq!(refreshed.matches(HOOKS_BEGIN).count(), 1);
1374
1375 let err = splice_hooks_block(Some("minimum_pre_commit_version: '3.2.0'\n"), block)
1376 .expect_err("no repos: line refuses");
1377 assert!(err.contains("repos:"), "{err}");
1378
1379 let doubled = format!("repos:\n{block}\n{block}\n");
1383 let err = splice_hooks_block(Some(&doubled), block).expect_err("a second block refuses");
1384 assert!(err.contains("one block"), "{err}");
1385 let unmatched = "repos:\n# BEGIN release-kit\n - repo: local\n";
1386 let err =
1387 splice_hooks_block(Some(unmatched), block).expect_err("an unmatched marker refuses");
1388 assert!(err.contains("unmatched"), "{err}");
1389 }
1390
1391 #[test]
1397 fn the_blocks_render_per_mode_and_carry_the_one_grammar() {
1398 let worktree_hooks = hooks_block(Workflow::Worktree);
1399 let branches_hooks = hooks_block(Workflow::Branches);
1400 assert!(worktree_hooks.contains("- id: rk-worktree-location"));
1401 assert!(
1402 worktree_hooks.contains("SKIP=no-commit-to-branch,rk-worktree-location"),
1403 "{worktree_hooks}"
1404 );
1405 assert!(!branches_hooks.contains("rk-worktree-location"));
1406 assert!(branches_hooks.contains("SKIP=no-commit-to-branch in"));
1407 for block in [&worktree_hooks, &branches_hooks] {
1408 assert!(block.contains(BRANCH_GRAMMAR), "the grammar has one owner");
1409 for token in ["RK_BRANCH_GRAMMAR", "RK_SWEEP_SKIP", "RK_WORKTREE_GUARD"] {
1410 assert!(!block.contains(token), "{token} survived: {block}");
1411 }
1412 }
1413 for block in [&worktree_hooks, &branches_hooks] {
1418 for line in block.lines() {
1419 if let Some(value) = line.trim_start().strip_prefix("entry: ") {
1420 assert!(
1421 !value.contains(": "),
1422 "an entry value breaks the YAML plain scalar: {line}"
1423 );
1424 }
1425 }
1426 }
1427 let guard_line = worktree_hooks
1428 .lines()
1429 .position(|line| line.contains("id: rk-worktree-location"))
1430 .expect("the guard entry exists");
1431 let name_line = worktree_hooks
1432 .lines()
1433 .position(|line| line.contains("id: rk-branch-name"))
1434 .expect("the name hook exists");
1435 assert!(
1436 guard_line > name_line,
1437 "the guard lands directly after rk-branch-name"
1438 );
1439
1440 let worktree_routing = routing_block(Workflow::Worktree);
1441 let branches_routing = routing_block(Workflow::Branches);
1442 assert!(worktree_routing.contains("This project works in worktrees"));
1443 assert!(branches_routing.contains("Branches are worked in the main checkout"));
1444 for block in [&worktree_routing, &branches_routing] {
1445 assert!(block.contains("Create or remove a worktree"));
1446 assert!(block.contains("`rk worktree add <branch>`"));
1447 assert!(!block.contains("RK_WORKFLOW_LINE"), "{block}");
1448 }
1449 let differing: Vec<(&str, &str)> = worktree_routing
1450 .lines()
1451 .zip(branches_routing.lines())
1452 .filter(|(a, b)| a != b)
1453 .collect();
1454 assert_eq!(
1455 differing.len(),
1456 1,
1457 "exactly one routing line differs per mode: {differing:?}"
1458 );
1459 }
1460
1461 #[test]
1464 fn the_hook_marker_defects_are_named() {
1465 use super::hooks_marker_defect;
1466 let owned = hooks_block(Workflow::Branches);
1467 let block = owned.as_str();
1468 assert_eq!(hooks_marker_defect(""), None);
1469 assert_eq!(hooks_marker_defect(&format!("repos:\n{block}\n")), None);
1470 for (case, text) in [
1471 (
1472 "a second begin",
1473 format!("repos:\n{block}\n# BEGIN release-kit\n"),
1474 ),
1475 (
1476 "a second end",
1477 format!("repos:\n{block}\n# END release-kit\n"),
1478 ),
1479 (
1480 "an unpaired begin",
1481 "repos:\n# BEGIN release-kit\n".to_owned(),
1482 ),
1483 ("an unpaired end", "repos:\n# END release-kit\n".to_owned()),
1484 (
1485 "an end before its begin",
1486 "repos:\n# END release-kit\n# BEGIN release-kit\n".to_owned(),
1487 ),
1488 ] {
1489 assert!(
1490 hooks_marker_defect(&text).is_some(),
1491 "{case} must be a defect"
1492 );
1493 }
1494 }
1495}