1pub mod apply;
20pub mod gate;
21pub mod invariants;
22pub mod lock;
23pub mod manifest;
24
25use camino::Utf8Path;
26
27pub use crate::projection::{
28 AGENTS_DESTINATION, BLOCK_BEGIN, BLOCK_DESTINATIONS, BLOCK_END, BRANCH_GRAMMAR,
29 CODE_SCANNING_DESTINATIONS, CODE_SCANNING_TECHS, GLOSSARY_DESTINATION, HOOK_TYPES_LINE,
30 HOOKS_BEGIN, HOOKS_DESTINATION, HOOKS_END, Kind, LINE_PREFIX_RE_TOKEN, LINE_PREFIX_TOKEN,
31 LOCAL_HOOK_TYPES_LINE, NIX_DESTINATIONS, NIX_WITHHOLDABLE, OWNER_TOKEN, REPO_PLACEHOLDER,
32 REPO_TOKEN, SCOPE_SHAPE, SCOPE_SHAPE_TOKEN, SCORECARD_DESTINATIONS, SECURITY_SPANS,
33 STYLE_TOKEN, TRUNK_BRANCH_TOKEN, authored, block_markers, destinations, extract_block,
34 hooks_marker_defect, kind_of, marker_defect, render, scope_is_shaped, splice_hooks_block,
35 splice_marked_block, substitute,
36};
37pub use manifest::{CheckoutMode, Integration, MergeForm, Provider, Style};
38use serde::Serialize;
39
40use crate::diagnostic::{Diagnostic, Reason};
41use crate::error::RkError;
42
43pub use crate::profile::{Inputs, Params, Purpose};
44
45#[derive(Debug, Clone, Serialize)]
47pub struct Withheld {
48 pub path: String,
50 pub reason: String,
53}
54
55pub fn read_recorded(target: &Utf8Path, destination: &str) -> std::io::Result<Option<Vec<u8>>> {
66 let path = target.join(destination);
67 let bytes = match std::fs::read(&path) {
68 Ok(bytes) => bytes,
69 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
70 Err(e) => return Err(e),
71 };
72 if let Some((begin, end)) = block_markers(destination) {
73 let text = String::from_utf8_lossy(&bytes);
74 Ok(extract_block(&text, begin, end).map(|block| block.as_bytes().to_vec()))
75 } else {
76 Ok(Some(bytes))
77 }
78}
79
80#[derive(Debug)]
83pub struct Resolved {
84 pub forge: String,
86 pub repo: Option<String>,
88}
89
90pub fn resolve(
101 target: &Utf8Path,
102 forge_flag: Option<&str>,
103 repo_flag: Option<&str>,
104) -> Result<Resolved, RkError> {
105 let forge_flag = forge_flag
106 .map(|name| {
107 crate::detect::Forge::parse(name).ok_or_else(|| {
108 RkError::Usage(format!(
109 "unknown forge '{name}'; the forges are: github, gitlab"
110 ))
111 })
112 })
113 .transpose()?;
114 let detected = crate::detect::detect(target.as_std_path());
115 let forge = forge_flag
116 .or(detected.forge)
117 .map(|forge| forge.as_str().to_owned())
118 .ok_or_else(|| {
119 let message = detected.host.map_or_else(
120 || "no forge detected: the target has no origin remote".to_owned(),
121 |host| format!("no forge detected: the host {host} is not recognized"),
122 );
123 RkError::refusal(
124 Diagnostic::new(Reason::ForgeUndetected, message)
125 .expected("a github.com or gitlab remote, or --forge")
126 .action("pass --forge <github|gitlab>"),
127 )
128 })?;
129 Ok(Resolved {
130 forge,
131 repo: repo_flag.map(str::to_owned).or(detected.repo),
132 })
133}
134
135#[must_use]
138pub fn repo_unresolved() -> RkError {
139 RkError::missing(
140 Diagnostic::new(
141 Reason::ForgeUndetected,
142 "no repository detected: the target has no origin remote",
143 )
144 .expected("an origin remote naming the project")
145 .action("pass --repo <path>"),
146 )
147}
148
149pub fn accepted_drift_faults(
161 projection: &crate::projection::Projection,
162 recorded: Option<&manifest::Manifest>,
163 config: Option<&crate::config::Config>,
164) -> Result<(), crate::error::RkError> {
165 let Some(config) = config else {
166 return Ok(());
167 };
168 for destination in config.landing.accepted_drift.keys() {
169 if let Some(fault) = projection.accepted_drift_fault(destination) {
170 return Err(crate::config::invalid(fault));
171 }
172 let recorded_whole = recorded
173 .and_then(|record| record.file(destination))
174 .is_some_and(|file| {
175 file.kind == Kind::Rendered && file.placement == manifest::Placement::Whole
176 });
177 if !recorded_whole {
178 return Err(crate::config::invalid(format!(
179 "landing.accepted_drift names {destination}, which no receipt records as a whole rendered file; an entry accepts a deviation from bytes a landing wrote"
180 )));
181 }
182 }
183 Ok(())
184}
185
186#[cfg(test)]
187mod tests {
188 use super::{
189 AGENTS_DESTINATION, BLOCK_BEGIN, BLOCK_DESTINATIONS, BLOCK_END, BRANCH_GRAMMAR,
190 CheckoutMode, GLOSSARY_DESTINATION, HOOK_TYPES_LINE, HOOKS_BEGIN, HOOKS_DESTINATION,
191 HOOKS_END, Integration, Kind, LOCAL_HOOK_TYPES_LINE, Provider, SCOPE_SHAPE, Style,
192 extract_block, kind_of, render, splice_hooks_block, splice_marked_block,
193 };
194 use crate::embedded;
195 use crate::profile::{
196 CapabilityRequests, GitWorkflow, ProfileSnapshot, ReleaseIntent, ReleaseMode,
197 };
198 use crate::projection::{self, Projection, ProjectionInput, TargetEvidence};
199
200 const MODE_PAIRS: [(CheckoutMode, Integration); 4] = [
202 (CheckoutMode::MainWorktree, Integration::Forge),
203 (CheckoutMode::LinkedWorktree, Integration::Forge),
204 (CheckoutMode::MainWorktree, Integration::Local),
205 (CheckoutMode::LinkedWorktree, Integration::Local),
206 ];
207
208 fn destinations(params: &super::Params) -> Vec<String> {
211 Projection::compute(&ProjectionInput {
212 params: params.clone(),
213 evidence: TargetEvidence {
214 crate_shape: projection::CrateShape {
215 cargo_toml: Some(
216 "[package]\nname = \"widget\"\nversion = \"0.1.0\"\n".to_owned(),
217 ),
218 cargo_lock: true,
219 main_rs: true,
220 },
221 ..TargetEvidence::default()
222 },
223 })
224 .expect("the pair projects")
225 .candidates
226 .into_iter()
227 .map(|candidate| candidate.destination)
228 .collect()
229 }
230
231 fn routing_block(workflow_guards: bool) -> String {
232 projection::routing_block(workflow_guards).expect("the binary embeds the block")
233 }
234
235 fn hooks_block(mode: CheckoutMode) -> String {
236 projection::hooks_block(mode, Integration::Forge, true)
237 .expect("the binary embeds the block")
238 }
239
240 fn hooks_block_for(mode: CheckoutMode, integration: Integration) -> String {
241 projection::hooks_block(mode, integration, true).expect("the binary embeds the block")
242 }
243
244 fn unguarded_hooks_block(mode: CheckoutMode, integration: Integration) -> String {
245 projection::hooks_block(mode, integration, false).expect("the binary embeds the block")
246 }
247
248 fn glossary_block() -> String {
249 projection::glossary_block().expect("the binary embeds the block")
250 }
251
252 fn spliced(existing: Option<&str>, block: &str) -> String {
255 String::from_utf8(splice_marked_block(existing.map(str::as_bytes), block))
256 .expect("the fixtures are text")
257 }
258
259 #[test]
260 fn private_reporting_path_tokens_are_reproducible() {
261 for repo in [
262 "acme/widget",
263 "acme/group/widget",
264 "acme/OWNER-RK_STYLE-RK_SCOPE_SHAPE",
265 ] {
266 assert_eq!(
267 super::render(
268 b"RK_REPO RK_REPO OWNER RK_STYLE RK_SCOPE_SHAPE",
269 &super::Params::for_test(repo, Some(super::Style::Trunk))
270 ),
271 format!("{repo} {repo} acme trunk {}", super::SCOPE_SHAPE).as_bytes()
272 );
273 }
274 assert_eq!(super::kind_of("SECURITY.md"), Some(super::Kind::Rendered));
275 }
276
277 #[test]
282 fn each_forge_policy_carries_one_ordered_pair_of_every_span() {
283 for forge in ["github", "gitlab"] {
284 let bytes = embedded::SNIPPETS
285 .get_file(format!("_shared/{forge}/SECURITY.md"))
286 .expect("the policy ships")
287 .contents();
288 let text = String::from_utf8_lossy(bytes);
289 for (begin, end) in super::SECURITY_SPANS {
290 let begin = String::from_utf8_lossy(begin);
291 let end = String::from_utf8_lossy(end);
292 assert_eq!(text.matches(begin.as_ref()).count(), 1, "{forge} {begin}");
293 assert_eq!(text.matches(end.as_ref()).count(), 1, "{forge} {end}");
294 assert!(
295 text.find(begin.as_ref()) < text.find(end.as_ref()),
296 "{forge}: {begin} must precede {end}"
297 );
298 }
299 let (begin, end) = super::SECURITY_SPANS[3];
303 let span = format!(
304 "{}{}\n\n",
305 String::from_utf8_lossy(begin),
306 String::from_utf8_lossy(end)
307 );
308 let at = text
309 .find(&span)
310 .expect("the scope span is empty and ends a paragraph");
311 let channel = text[..at]
312 .rfind("\n\n")
313 .expect("a paragraph opens before it");
314 assert!(
315 text[channel..at].contains("RK_SECURITY_CONTACT_END"),
316 "{forge}: the scope span closes the channel paragraph"
317 );
318 }
319 }
320
321 #[test]
326 fn the_security_spans_render_per_answer() {
327 for forge in ["github", "gitlab"] {
328 let bytes = embedded::SNIPPETS
329 .get_file(format!("_shared/{forge}/SECURITY.md"))
330 .expect("the policy ships")
331 .contents();
332 let authored = String::from_utf8_lossy(bytes);
333 let stripped = {
334 let mut text = authored.clone().into_owned();
335 for (begin, end) in super::SECURITY_SPANS {
336 text = text.replace(&String::from_utf8_lossy(begin).into_owned(), "");
337 text = text.replace(&String::from_utf8_lossy(end).into_owned(), "");
338 }
339 text
340 };
341 let mut default =
342 super::Params::for_test_security("", crate::config::RESPONSE_DEFAULT, "");
343 default.set_pair_for_test("rust", forge);
344 let rendered = String::from_utf8(render(bytes, &default)).expect("text");
345 assert_eq!(
346 rendered,
347 stripped.replace("RK_REPO", "acme/widget"),
348 "{forge}: the default answers must reproduce the authored policy"
349 );
350 assert!(!rendered.contains("RK_SECURITY"), "{forge}: {rendered}");
351
352 let scope = "This wraps the RK_REPO CLI. Report a flaw in it upstream.\nThis policy covers the wrapper.";
353 let mut answered = super::Params::for_test_security(
354 "OWNER RK_REPO <team@acme.example>",
355 "14 days",
356 scope,
357 );
358 answered.set_pair_for_test("rust", forge);
359 let rendered = String::from_utf8(render(bytes, &answered)).expect("text");
360 assert!(
361 rendered.contains("OWNER RK_REPO <team@acme.example>"),
362 "{forge}: a contact spelling a token name lands literally: {rendered}"
363 );
364 assert!(
365 rendered.contains("Maintainers acknowledge a report within 14 days."),
366 "{forge}: {rendered}"
367 );
368 assert!(
369 rendered.contains("This policy commits to no disclosure deadline."),
370 "{forge}: {rendered}"
371 );
372 assert!(
373 !rendered.contains("best-effort basis"),
374 "{forge}: a stated window replaces the best-effort sentence: {rendered}"
375 );
376 assert!(
377 !rendered.contains("no response or disclosure deadline"),
378 "{forge}: a stated window contradicts the response disclaimer: {rendered}"
379 );
380 let paragraphs: Vec<&str> = rendered.split("\n\n").collect();
384 let at = paragraphs
385 .iter()
386 .position(|paragraph| *paragraph == scope)
387 .unwrap_or_else(|| panic!("{forge}: the scope is one paragraph: {rendered}"));
388 assert!(
389 paragraphs[at - 1].contains("team@acme.example"),
390 "{forge}: the scope follows the channel paragraph: {rendered}"
391 );
392 assert!(!rendered.contains("RK_SECURITY"), "{forge}: {rendered}");
393 }
394 }
395
396 #[test]
399 fn a_defective_span_renders_unchanged() {
400 let (begin, end) = super::SECURITY_SPANS[0];
401 let begin = String::from_utf8_lossy(begin).into_owned();
402 let end = String::from_utf8_lossy(end).into_owned();
403 let params = super::Params::for_test_security("team@acme.example", "1 day", "");
404 for baseline in [
405 format!("contact {begin}a maintainer\n"),
406 format!("contact a maintainer{end}\n"),
407 format!("contact {end}a maintainer{begin}\n"),
408 "contact a maintainer\n".to_owned(),
409 ] {
410 assert_eq!(
411 render(baseline.as_bytes(), ¶ms),
412 baseline.as_bytes(),
413 "{baseline}"
414 );
415 }
416 }
417
418 #[test]
422 fn the_kind_table_closes_over_every_snippet() {
423 for tech_dir in embedded::SNIPPETS.dirs() {
424 for pair_dir in tech_dir.dirs() {
425 let prefix = format!("{}/", pair_dir.path().to_string_lossy());
426 for (path, _) in embedded::walk(pair_dir) {
427 let destination = path.strip_prefix(&prefix).unwrap_or(&path);
428 assert!(
429 kind_of(destination).is_some(),
430 "{destination}: no declared kind"
431 );
432 }
433 }
434 }
435 for block in BLOCK_DESTINATIONS {
436 assert_eq!(kind_of(block), Some(Kind::Rendered), "{block}");
437 }
438 assert_eq!(kind_of("something-else.txt"), None);
439 }
440
441 #[test]
446 fn rendering_substitutes_every_owner_occurrence() {
447 let baseline = b"if: repository_owner == 'OWNER'\n# OWNER again: OWNER\n";
448 let rendered = render(baseline, &super::Params::for_test("acme/sub/widget", None));
449 let text = String::from_utf8(rendered).expect("rendered bytes stay text");
450 assert_eq!(text, "if: repository_owner == 'acme'\n# acme again: acme\n");
451
452 let baseline = b"match (RK_SCOPE_SHAPE)\n";
453 let rendered = render(baseline, &super::Params::for_test("acme/widget", None));
454 let text = String::from_utf8(rendered).expect("rendered bytes stay text");
455 assert_eq!(text, format!("match ({SCOPE_SHAPE})\n"));
456 }
457
458 #[test]
462 fn the_scope_shape_drops_into_the_title_check() {
463 assert_eq!(SCOPE_SHAPE, "[a-z0-9._/-]+");
464 assert!(
465 !SCOPE_SHAPE.contains('\''),
466 "the title checks single-quote it"
467 );
468 }
469
470 #[test]
475 fn the_scope_predicate_and_the_rendered_pattern_agree() {
476 let body = SCOPE_SHAPE
477 .strip_prefix('[')
478 .and_then(|rest| rest.strip_suffix("]+"))
479 .expect("the shape is one bracket expression, repeated");
480 let chars: Vec<char> = body.chars().collect();
481 let mut admitted = std::collections::BTreeSet::new();
482 let mut at = 0;
483 while at < chars.len() {
484 if at + 2 < chars.len() && chars[at + 1] == '-' {
487 for c in chars[at]..=chars[at + 2] {
488 admitted.insert(c);
489 }
490 at += 3;
491 } else {
492 admitted.insert(chars[at]);
493 at += 1;
494 }
495 }
496 for byte in 0..=127u8 {
497 let c = char::from(byte);
498 assert_eq!(
499 super::scope_is_shaped(&c.to_string()),
500 admitted.contains(&c),
501 "the predicate and {SCOPE_SHAPE} disagree on {c:?}"
502 );
503 }
504 assert!(super::scope_is_shaped("guides/release"));
505 assert!(!super::scope_is_shaped(""), "a scope is never empty");
506 assert!(!super::scope_is_shaped("Specs Ugly"));
507 }
508
509 #[test]
513 fn the_shared_zone_composes_into_the_pair() {
514 let mut github = super::Params::for_test("acme/widget", Some(Style::Trunk));
515 github.set_pair_for_test("rust", "github");
516 let github = destinations(&github);
517 assert!(
518 github.contains(&".github/workflows/pr-title.yml".to_owned()),
519 "the shared title check lands with the pair"
520 );
521 assert!(github.contains(&"SECURITY.md".to_owned()));
522 let mut gitlab = super::Params::for_test("acme/widget", Some(Style::Trunk));
523 gitlab.set_pair_for_test("rust", "gitlab");
524 let gitlab = destinations(&gitlab);
525 assert!(
526 gitlab.contains(&".gitlab/ci/mr-title.yml".to_owned()),
527 "the shared title job lands with the pair"
528 );
529 assert!(
530 !crate::profile::catalog::known_drivers()
531 .iter()
532 .any(|driver| driver.starts_with('_')),
533 "the shared zone is no driver"
534 );
535 }
536
537 #[test]
540 #[allow(
541 clippy::too_many_lines,
542 reason = "one sweep builds a whole record for every combination it round-trips"
543 )]
544 fn params_from_a_record_round_trips() {
545 use super::{Params, manifest};
546 let dir = tempfile::tempdir().expect("a scratch target exists");
547 let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
548 let prefix = Some(crate::config::LINE_PREFIX_DEFAULT.to_owned());
549 for tech in ["rust", "bash"] {
550 for forge in ["github", "gitlab"] {
551 for (checkout_mode, integration) in MODE_PAIRS {
552 for style in [None, Some(Style::Trunk), Some(Style::Lines)] {
553 for ((nix, scorecard), code_scanning) in [
554 ((false, false), None),
555 ((false, true), Some(Provider::Semgrep)),
556 ((true, false), Some(Provider::CodeQl)),
557 ((true, true), None),
558 ] {
559 let record = manifest::Manifest {
560 schema_version: manifest::SCHEMA_VERSION,
561 rk_version: "0.1.0".to_owned(),
562 origin: "init".to_owned(),
563 landed_at: "2026-08-29T00:00:00Z".to_owned(),
564 profile: ProfileSnapshot {
565 technologies: vec![tech.to_owned()],
566 forge: Some(forge.to_owned()),
567 release: ReleaseIntent {
568 mode: ReleaseMode::Automatic,
569 driver: Some(tech.to_owned()),
570 style,
571 line_prefix: prefix.clone(),
572 registry: None,
573 build: None,
574 },
575 },
576 git: GitWorkflow {
577 trunk: crate::config::TRUNK_DEFAULT.to_owned(),
578 checkout_mode,
579 integration,
580 merge_form: crate::landing::MergeForm::Squash,
581 },
582 capabilities: CapabilityRequests {
583 nix_packaging: nix,
584 reporting_policy: true,
585 scorecard,
586 code_scanning,
587 workflow_guards: Some(true),
588 },
589 parameters: manifest::Parameters {
590 repo: "acme/team/widget".to_owned(),
591 security_contact: String::new(),
592 security_response: crate::config::RESPONSE_DEFAULT.to_owned(),
593 security_scope: String::new(),
594 required_check: "gate".to_owned(),
595 dev_environment: None,
596 },
597 files: Vec::new(),
598 pins: std::collections::BTreeMap::new(),
599 };
600 manifest::write(target, &record).expect("the record writes");
601 let loaded = manifest::load(target)
602 .expect("the record loads")
603 .expect("the record exists");
604 let params = Params::from_record(&loaded);
605 assert_eq!(params.driver(), Some(tech));
606 assert_eq!(params.forge(), Some(forge));
607 assert_eq!(params.repo(), "acme/team/widget");
608 assert_eq!(params.integration(), integration);
609 assert_eq!(params.style(), style);
610 assert_eq!(params.nix_packaging(), nix);
611 assert_eq!(params.scorecard(), scorecard);
612 assert_eq!(params.code_scanning(), code_scanning);
613 let mut direct = super::Params::for_test("acme/team/widget", style);
616 direct.set_pair_for_test(tech, forge);
617 direct.set_checkout_mode_for_test(checkout_mode);
618 direct.set_integration_for_test(integration);
619 direct.set_nix_for_test(nix);
620 direct.set_scorecard_for_test(scorecard);
621 direct.set_code_scanning_for_test(code_scanning);
622 assert_eq!(params, direct);
623 let projected = destinations(¶ms);
624 for block in
625 [AGENTS_DESTINATION, GLOSSARY_DESTINATION, HOOKS_DESTINATION]
626 {
627 assert!(projected.contains(&block.to_owned()), "{block}");
628 }
629 for destination in super::NIX_DESTINATIONS {
630 assert_eq!(
631 projected.contains(&destination.to_owned()),
632 nix && tech == "rust",
633 "{tech} {forge} nix={nix}: {destination}"
634 );
635 }
636 for destination in super::SCORECARD_DESTINATIONS {
640 assert_eq!(
641 projected.contains(&destination.to_owned()),
642 scorecard && forge == "github",
643 "{tech} {forge} scorecard={scorecard}: {destination}"
644 );
645 }
646 }
647 }
648 }
649 }
650 }
651 }
652
653 fn resolved_test_params(
654 tech: &str,
655 resolved: &super::Resolved,
656 checkout_mode: CheckoutMode,
657 style: Option<Style>,
658 nix: bool,
659 scorecard: bool,
660 code_scanning: Option<Provider>,
661 ) -> Result<super::Params, crate::error::RkError> {
662 let technologies = vec![tech.to_owned()];
663 super::Params::resolve(
664 camino::Utf8Path::new("."),
665 &super::Inputs {
666 technologies: &technologies,
667 forge: Some(&resolved.forge),
668 repo: resolved.repo.as_deref(),
669 release_mode: Some(ReleaseMode::Automatic),
670 release_driver: Some(tech),
671 style,
672 required_check: (resolved.forge == "github").then_some("gate"),
674 build_packages: None,
675 trunk: None,
676 checkout_mode: Some(checkout_mode),
677 integration: None,
678 merge_form: None,
679 nix: Some(nix),
680 reporting_policy: None,
681 scorecard: Some(scorecard),
682 code_scanning: Some(code_scanning),
683 workflow_guards: Some(true),
684 },
685 None,
686 None,
687 super::Purpose::Init,
688 )
689 }
690
691 #[test]
695 fn a_projection_renders_owned_files_and_keeps_seeded_judgment() {
696 let params = resolved_test_params(
697 "rust",
698 &super::Resolved {
699 forge: "github".to_owned(),
700 repo: Some("acme/widget".to_owned()),
701 },
702 CheckoutMode::MainWorktree,
703 Some(Style::Trunk),
704 false,
705 false,
706 None,
707 )
708 .expect("the parameters resolve");
709 let entries = Projection::compute(&ProjectionInput {
710 params: params.clone(),
711 evidence: TargetEvidence::default(),
712 })
713 .expect("the pair projects")
714 .candidates;
715 let workflow = entries
716 .iter()
717 .find(|entry| entry.destination.ends_with("release-plz.yml"))
718 .expect("the workflow projects");
719 assert_eq!(workflow.kind, Kind::Rendered);
720 let text = String::from_utf8_lossy(&workflow.bytes);
721 assert!(!text.contains("OWNER"), "an owner token survived rendering");
722 assert!(text.contains("'acme'"));
723 assert!(!text.contains("TODO(release-kit)"));
724 let title = entries
725 .iter()
726 .find(|entry| entry.destination.ends_with("pr-title.yml"))
727 .expect("the title check projects");
728 let text = String::from_utf8_lossy(&title.bytes);
729 assert!(text.contains(SCOPE_SHAPE), "{text}");
730 assert!(
731 !text.contains("RK_SCOPE_SHAPE"),
732 "a scope token survived: {text}"
733 );
734 let seeded = entries
735 .iter()
736 .find(|entry| entry.destination == "release-plz.toml")
737 .expect("the seeded file projects");
738 assert_eq!(seeded.kind, Kind::Seeded);
739 let authored = embedded::SNIPPETS
740 .get_file("rust/github/release-plz.toml")
741 .expect("the seed ships")
742 .contents();
743 assert_eq!(
744 seeded.bytes,
745 crate::projection::registry_lines(authored, ¶ms),
746 "a seeded file lands as authored, with the lines of the unanswered registry"
747 );
748 assert!(
749 !String::from_utf8_lossy(&seeded.bytes).contains("RK_IF_"),
750 "no registry marker survives in a seed"
751 );
752 assert!(String::from_utf8_lossy(&seeded.bytes).contains("TODO(release-kit)"));
753 for block in BLOCK_DESTINATIONS {
754 let entry = entries
755 .iter()
756 .find(|entry| entry.destination == block)
757 .expect("every block is part of the projection");
758 let text = String::from_utf8_lossy(&entry.bytes);
759 assert!(
760 !text.contains("RK_SCOPE_SHAPE"),
761 "{block} kept a token: {text}"
762 );
763 }
764 }
765
766 #[test]
771 fn the_nix_destinations_project_only_under_the_opt_in() {
772 use super::NIX_DESTINATIONS;
773 let paths = |nix: bool, forge: &str| -> Vec<String> {
774 destinations(
775 &resolved_test_params(
776 "rust",
777 &super::Resolved {
778 forge: forge.to_owned(),
779 repo: Some("acme/widget".to_owned()),
780 },
781 CheckoutMode::LinkedWorktree,
782 Some(Style::Trunk),
783 nix,
784 false,
785 None,
786 )
787 .expect("the parameters resolve"),
788 )
789 };
790 let off = paths(false, "github");
791 for destination in NIX_DESTINATIONS {
792 assert!(!off.contains(&destination.to_owned()), "{destination}");
793 }
794 let on = paths(true, "github");
795 for destination in ["nix/package.nix", "flake.nix", "flake.lock"] {
796 assert!(on.contains(&destination.to_owned()), "{destination}");
797 }
798 let gitlab = paths(true, "gitlab");
803 assert!(gitlab.contains(&"nix/package.nix".to_owned()));
804 assert!(
805 !on.iter()
806 .chain(gitlab.iter())
807 .any(|destination| destination.contains("nix.yml"))
808 );
809 let bash = destinations(
810 &resolved_test_params(
811 "bash",
812 &super::Resolved {
813 forge: "github".to_owned(),
814 repo: Some("acme/widget".to_owned()),
815 },
816 CheckoutMode::LinkedWorktree,
817 Some(Style::Trunk),
818 true,
819 false,
820 None,
821 )
822 .expect("the parameters resolve"),
823 );
824 assert!(
825 bash.iter()
826 .all(|destination| !NIX_DESTINATIONS.contains(&destination.as_str()))
827 );
828 }
829
830 #[test]
835 fn the_nix_seeds_are_identical_across_forge_pairs() {
836 for name in ["nix/package.nix", "flake.nix", "flake.lock"] {
837 let github = embedded::SNIPPETS
838 .get_file(format!("rust/github/{name}"))
839 .expect("the github copy ships")
840 .contents();
841 let gitlab = embedded::SNIPPETS
842 .get_file(format!("rust/gitlab/{name}"))
843 .expect("the gitlab copy ships")
844 .contents();
845 assert_eq!(github, gitlab, "{name} diverged between the pairs");
846 }
847 }
848
849 #[test]
854 fn the_nix_withhold_judgment_covers_the_three_shapes() {
855 use super::NIX_DESTINATIONS;
856 let dir = tempfile::tempdir().expect("a scratch target exists");
857 let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
858 let project = |nix: bool| {
859 let params = resolved_test_params(
860 "rust",
861 &super::Resolved {
862 forge: "github".to_owned(),
863 repo: Some("acme/widget".to_owned()),
864 },
865 CheckoutMode::LinkedWorktree,
866 Some(Style::Trunk),
867 nix,
868 false,
869 None,
870 )
871 .expect("the parameters resolve");
872 let evidence = TargetEvidence::gather(target, None).expect("the evidence reads");
873 Projection::compute(&ProjectionInput { params, evidence }).expect("the pair projects")
874 };
875 let withheld = |projection: &Projection| -> Vec<String> {
876 projection
877 .omissions
878 .iter()
879 .map(|omission| omission.destination.clone())
880 .collect()
881 };
882 let landed = |projection: &Projection, destination: &str| {
883 projection
884 .candidates
885 .iter()
886 .any(|candidate| candidate.destination == destination)
887 };
888
889 let all = project(true);
891 assert_eq!(
892 withheld(&all),
893 ["flake.lock", "flake.nix", "nix/package.nix"]
894 );
895 assert!(
896 all.candidates
897 .iter()
898 .all(|entry| !NIX_DESTINATIONS.contains(&entry.destination.as_str()))
899 );
900
901 std::fs::write(
904 target.join("Cargo.toml"),
905 "[package]\nname = \"widget\"\nversion = \"0.1.0\"\n",
906 )
907 .expect("the crate manifest writes");
908 std::fs::write(target.join("Cargo.lock"), "version = 4\n").expect("the lock writes");
909 std::fs::create_dir_all(target.join("src")).expect("the src dir exists");
910 std::fs::write(target.join("src/main.rs"), "fn main() {}\n").expect("the main writes");
911 std::fs::write(target.join("flake.nix"), "{ }\n").expect("the flake writes");
912 let all = project(true);
913 assert_eq!(withheld(&all), ["flake.lock", "flake.nix"]);
914 assert!(landed(&all, "nix/package.nix"));
915
916 std::fs::remove_file(target.join("flake.nix")).expect("the flake removes");
918 let all = project(true);
919 assert!(all.omissions.is_empty());
920 assert!(landed(&all, "flake.nix"));
921
922 let all = project(false);
924 assert!(all.omissions.is_empty());
925 assert!(!landed(&all, "flake.nix"));
926 }
927
928 #[test]
932 fn the_glossary_splices_into_every_shape() {
933 let owned = glossary_block();
934 let block = owned.as_str();
935
936 let fresh = spliced(None, block);
937 assert_eq!(fresh, format!("{block}\n"));
938 assert_eq!(extract_block(&fresh, BLOCK_BEGIN, BLOCK_END), Some(block));
939
940 let own = "# Glossary\n\n- `spike` — a throwaway branch.\n";
941 let appended = spliced(Some(own), block);
942 assert!(appended.starts_with(own));
943 assert_eq!(
944 extract_block(&appended, BLOCK_BEGIN, BLOCK_END),
945 Some(block)
946 );
947
948 let stale = appended.replace("full-implement", "do-everything");
949 let refreshed = spliced(Some(&stale), block);
950 assert_eq!(
951 extract_block(&refreshed, BLOCK_BEGIN, BLOCK_END),
952 Some(block)
953 );
954 assert_eq!(
955 refreshed.matches("BEGIN release-kit").count(),
956 1,
957 "a re-splice must replace, not accumulate"
958 );
959 }
960
961 #[test]
965 fn the_glossary_leaves_the_targets_region_alone() {
966 let owned = glossary_block();
967 let block = owned.as_str();
968 let below = "\n## Our own terms\n\n- `spike` — a throwaway branch, never merged.\n";
969 let landed = format!("{block}\n{below}");
970
971 let refreshed = spliced(Some(&landed), block);
972 assert!(
973 refreshed.ends_with(below),
974 "the target's own region changed: {refreshed}"
975 );
976 assert_eq!(
977 extract_block(&refreshed, BLOCK_BEGIN, BLOCK_END),
978 Some(block)
979 );
980 }
981
982 #[test]
986 fn an_append_rewrites_no_byte_the_target_wrote() {
987 let owned = glossary_block();
988 let block = owned.as_str();
989 for own in [
990 "# Glossary\n\n- `spike` — throwaway. \n\n\n",
991 "# Glossary\n\n- `spike` — throwaway.",
992 "# Glossary\r\n\r\n- `spike` — throwaway.\r\n",
993 ] {
994 let appended = spliced(Some(own), block);
995 assert!(
996 appended.starts_with(own),
997 "the target's bytes changed: {appended:?}"
998 );
999 assert_eq!(
1000 extract_block(&appended, BLOCK_BEGIN, BLOCK_END),
1001 Some(block),
1002 "{appended:?}"
1003 );
1004 let marker = appended.find(BLOCK_BEGIN).expect("the block landed");
1005 assert!(
1006 appended[..marker].ends_with('\n'),
1007 "the block must open its own line: {appended:?}"
1008 );
1009 }
1010 }
1011
1012 #[test]
1016 fn a_splice_decodes_no_byte_the_target_wrote() {
1017 let owned = glossary_block();
1018 let block = owned.as_str();
1019
1020 let own = b"# Glossary\n\ncaf\xe9\n";
1022 let appended = splice_marked_block(Some(own), block);
1023 assert!(
1024 appended.starts_with(own),
1025 "the target's bytes changed: {appended:?}"
1026 );
1027 assert!(!appended.contains(&0xEF), "a replacement character landed");
1028
1029 let mut landed = Vec::new();
1031 landed.extend_from_slice(block.replace("full-implement", "do-everything").as_bytes());
1032 landed.extend_from_slice(b"\n\ncaf\xe9\n");
1033 let refreshed = splice_marked_block(Some(&landed), block);
1034 assert!(
1035 refreshed.ends_with(b"\n\ncaf\xe9\n"),
1036 "the target's region below the markers changed: {refreshed:?}"
1037 );
1038 assert!(refreshed.starts_with(block.as_bytes()), "{refreshed:?}");
1039 }
1040
1041 #[test]
1044 fn the_glossary_block_carries_no_parameter() {
1045 let block = glossary_block();
1046 assert!(block.starts_with(BLOCK_BEGIN), "{block}");
1047 assert!(block.ends_with(BLOCK_END), "{block}");
1048 assert!(!block.contains("RK_"), "a token survived: {block}");
1049 assert!(!block.contains("OWNER"), "an owner token survived: {block}");
1050 for term in [
1051 "implement-and-request",
1052 "implement-and-merge",
1053 "full-implement",
1054 ] {
1055 assert!(block.contains(term), "{term} is missing from {block}");
1056 }
1057 assert!(
1058 routing_block(true).contains(GLOSSARY_DESTINATION),
1059 "the routing block must name the destination it indexes"
1060 );
1061 assert!(
1062 !routing_block(false).contains(GLOSSARY_DESTINATION),
1063 "a target without the workflow guards lands no glossary to route to"
1064 );
1065 }
1066
1067 #[test]
1068 fn the_block_splices_into_every_agents_shape() {
1069 let owned = routing_block(false);
1070 let block = owned.as_str();
1071 let fresh = spliced(None, block);
1072 assert_eq!(fresh, format!("{block}\n"));
1073 assert_eq!(extract_block(&fresh, BLOCK_BEGIN, BLOCK_END), Some(block));
1074
1075 let appended = spliced(Some("# My project\n\nOwn rules.\n"), block);
1076 assert!(appended.starts_with("# My project\n\nOwn rules.\n\n<!-- BEGIN release-kit -->"));
1077 assert_eq!(
1078 extract_block(&appended, BLOCK_BEGIN, BLOCK_END),
1079 Some(block)
1080 );
1081
1082 let stale = appended.replace("Never author a tag", "Do author a tag");
1083 let refreshed = spliced(Some(&stale), block);
1084 assert_eq!(
1085 extract_block(&refreshed, BLOCK_BEGIN, BLOCK_END),
1086 Some(block)
1087 );
1088 assert!(refreshed.starts_with("# My project"));
1089 assert_eq!(
1090 refreshed.matches("BEGIN release-kit").count(),
1091 1,
1092 "a re-splice must replace, not accumulate"
1093 );
1094 }
1095
1096 #[test]
1099 fn the_hook_block_splices_under_repos() {
1100 let owned = hooks_block(CheckoutMode::MainWorktree);
1101 let block = owned.as_str();
1102 let fresh =
1103 splice_hooks_block(None, block, Integration::Forge).expect("a fresh file splices");
1104 assert!(fresh.starts_with(&format!("{HOOK_TYPES_LINE}\n")));
1105 let local =
1109 splice_hooks_block(None, block, Integration::Local).expect("a fresh file splices");
1110 assert!(local.starts_with(&format!("{LOCAL_HOOK_TYPES_LINE}\n")));
1111 assert!(LOCAL_HOOK_TYPES_LINE.ends_with("pre-push, pre-merge-commit]"));
1112 assert!(!HOOK_TYPES_LINE.contains("pre-merge-commit"));
1113 assert!(fresh.contains("\nrepos:\n# BEGIN release-kit\n"));
1114 assert_eq!(extract_block(&fresh, HOOKS_BEGIN, HOOKS_END), Some(block));
1115
1116 let own =
1117 "repos:\n - repo: https://example.com/own\n rev: v1\n hooks:\n - id: own\n";
1118 let spliced = splice_hooks_block(Some(own), block, Integration::Forge)
1119 .expect("an unmarked file splices");
1120 assert!(spliced.starts_with("repos:\n# BEGIN release-kit\n"));
1121 assert!(spliced.contains("- id: own"), "the target's hooks survive");
1122 assert!(
1123 !spliced.contains(HOOK_TYPES_LINE),
1124 "an existing file's top level is the skills' duty, not the splice's"
1125 );
1126
1127 let stale = spliced.replace("--force-scope", "--no-scope");
1128 let refreshed = splice_hooks_block(Some(&stale), block, Integration::Forge)
1129 .expect("a marked file re-splices");
1130 assert_eq!(
1131 extract_block(&refreshed, HOOKS_BEGIN, HOOKS_END),
1132 Some(block)
1133 );
1134 assert_eq!(refreshed.matches(HOOKS_BEGIN).count(), 1);
1135
1136 let err = splice_hooks_block(
1137 Some("minimum_pre_commit_version: '3.2.0'\n"),
1138 block,
1139 Integration::Forge,
1140 )
1141 .expect_err("no repos: line refuses");
1142 assert!(err.contains("repos:"), "{err}");
1143
1144 let doubled = format!("repos:\n{block}\n{block}\n");
1148 let err = splice_hooks_block(Some(&doubled), block, Integration::Forge)
1149 .expect_err("a second block refuses");
1150 assert!(err.contains("one block"), "{err}");
1151 let unmatched = "repos:\n# BEGIN release-kit\n - repo: local\n";
1152 let err = splice_hooks_block(Some(unmatched), block, Integration::Forge)
1153 .expect_err("an unmatched marker refuses");
1154 assert!(err.contains("unmatched"), "{err}");
1155 }
1156
1157 #[test]
1163 fn the_blocks_render_per_mode_and_carry_the_one_grammar() {
1164 let worktree_hooks = hooks_block(CheckoutMode::LinkedWorktree);
1165 let branches_hooks = hooks_block(CheckoutMode::MainWorktree);
1166 assert!(worktree_hooks.contains("- id: rk-worktree-location"));
1167 assert!(
1168 worktree_hooks.contains("SKIP=no-commit-to-branch,rk-worktree-location"),
1169 "{worktree_hooks}"
1170 );
1171 assert!(!branches_hooks.contains("rk-worktree-location"));
1172 assert!(branches_hooks.contains("SKIP=no-commit-to-branch in"));
1173 for block in [&worktree_hooks, &branches_hooks] {
1174 assert!(block.contains(BRANCH_GRAMMAR), "the grammar has one owner");
1175 for token in [
1176 "RK_BRANCH_GRAMMAR",
1177 "RK_SWEEP_SKIP",
1178 "RK_SWEEP_NOTE",
1179 "RK_WORKTREE_GUARD",
1180 "RK_TRUNK_COMMIT_GUARD",
1181 "RK_TRUNK_PUSH_GUARD",
1182 ] {
1183 assert!(!block.contains(token), "{token} survived: {block}");
1184 }
1185 }
1186 for block in [&worktree_hooks, &branches_hooks] {
1191 for line in block.lines() {
1192 if let Some(value) = line.trim_start().strip_prefix("entry: ") {
1193 assert!(
1194 !value.contains(": "),
1195 "an entry value breaks the YAML plain scalar: {line}"
1196 );
1197 }
1198 }
1199 }
1200 let guard_line = worktree_hooks
1201 .lines()
1202 .position(|line| line.contains("id: rk-worktree-location"))
1203 .expect("the guard entry exists");
1204 let name_line = worktree_hooks
1205 .lines()
1206 .position(|line| line.contains("id: rk-branch-name"))
1207 .expect("the name hook exists");
1208 assert!(
1209 guard_line > name_line,
1210 "the guard lands directly after rk-branch-name"
1211 );
1212 }
1213
1214 #[test]
1219 fn the_routing_block_adds_only_the_policy_lines_under_the_guards() {
1220 let guarded = routing_block(true);
1221 let plain = routing_block(false);
1222 for block in [&guarded, &plain] {
1223 for token in ["RK_AGENT_POLICY", "RK_COMMIT_POLICY"] {
1224 assert!(!block.contains(token), "{token} survived: {block}");
1225 }
1226 for kept in [
1227 "runs the release-kit convention",
1228 "MUST be a scoped Conventional Commit",
1229 "Never author a tag",
1230 "Run `rk status` before changing",
1231 "`rk method --list`",
1232 ] {
1233 assert!(block.contains(kept), "{kept}: {block}");
1234 }
1235 for gone in [
1236 "`rk integrate <branch>`",
1237 "squash-merged pull request",
1238 "`rk issue start <issue>`",
1239 "`rk worktree add <branch>`",
1240 "Branches are worked in the main checkout",
1241 ] {
1242 assert!(!block.contains(gone), "{gone}: {block}");
1243 }
1244 }
1245 assert!(guarded.contains("guides and never drives"), "{guarded}");
1246 assert!(guarded.contains("`GLOSSARY.md`"), "{guarded}");
1247 assert!(guarded.contains("Every commit follows the same scoped convention"));
1248 assert!(!plain.contains("guides and never drives"), "{plain}");
1249 assert!(!plain.contains("Every commit follows"), "{plain}");
1250 let mut guarded_lines = guarded.lines();
1253 for line in plain.lines() {
1254 assert!(
1255 guarded_lines.any(|other| other == line),
1256 "the guards changed a line the plain block carries: {line}"
1257 );
1258 }
1259 }
1260
1261 fn release_words(text: &str) -> Vec<String> {
1265 text.split(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '-'))
1266 .filter(|word| {
1267 matches!(
1268 *word,
1269 "version" | "versions" | "changelog" | "changelogs" | "tag" | "tags"
1270 )
1271 })
1272 .map(str::to_owned)
1273 .collect()
1274 }
1275
1276 fn shape(
1278 workflow_guards: bool,
1279 release: bool,
1280 merge_form: crate::landing::MergeForm,
1281 ) -> projection::BlockShape {
1282 projection::BlockShape {
1283 checkout_mode: CheckoutMode::LinkedWorktree,
1284 integration: Integration::Local,
1285 merge_form,
1286 workflow_guards,
1287 release,
1288 }
1289 }
1290
1291 #[test]
1300 fn workflow_only_routing_block_carries_no_release_text() {
1301 use crate::landing::MergeForm;
1302 for form in [MergeForm::Squash, MergeForm::Rebase] {
1303 for guards in [true, false] {
1304 let workflow = projection::routing_block_of(&shape(guards, false, form))
1305 .expect("the binary embeds the block");
1306 let at = format!("{form:?} guards {guards}");
1307 assert!(!workflow.contains("## Releases"), "{at}: {workflow}");
1308 assert!(workflow.contains("## Workflow"), "{at}: {workflow}");
1309 assert_eq!(release_words(&workflow), Vec::<String>::new(), "{at}");
1310 assert!(!workflow.contains("rk method recovery"), "{at}");
1311 for token in [
1312 "RK_AGENT_POLICY",
1313 "RK_COMMIT_POLICY",
1314 "RK_COMMIT_SOURCE",
1315 "RK_COMMIT_SCOPE",
1316 ] {
1317 assert!(!workflow.contains(token), "{at}: {token} survived");
1318 }
1319 assert!(
1320 workflow.contains("MUST be a Conventional Commit"),
1321 "{at}: {workflow}"
1322 );
1323 if guards {
1324 for kept in [
1325 "guides and never drives",
1326 "`implement-and-request` and `implement-and-merge`",
1327 "A scope is preferred and not required.",
1328 "names no internal planning artifact and carries no agent attribution",
1329 ] {
1330 assert!(workflow.contains(kept), "{at}: {kept}: {workflow}");
1331 }
1332 assert!(!workflow.contains("full-implement"), "{at}: {workflow}");
1333 assert!(!workflow.contains("requires a scope"), "{at}: {workflow}");
1334 }
1335 let releasing = projection::routing_block_of(&shape(guards, true, form))
1336 .expect("the binary embeds the block");
1337 assert!(releasing.contains("## Releases"), "{at}");
1338 assert!(releasing.contains("Never author a tag"), "{at}");
1339 if guards {
1340 assert!(releasing.contains("requires a scope on every one"), "{at}");
1341 assert_eq!(
1342 releasing.contains("The request's title becomes that commit's message"),
1343 form == MergeForm::Squash,
1344 "{at}: the title is the message under squash alone"
1345 );
1346 assert_eq!(
1347 releasing.contains(
1348 "Every commit of a branch reaches the trunk with its own message"
1349 ),
1350 form == MergeForm::Rebase,
1351 "{at}"
1352 );
1353 }
1354 }
1355 }
1356 assert_eq!(
1359 projection::routing_block_of(&shape(true, true, MergeForm::Squash))
1360 .expect("the binary embeds the block"),
1361 routing_block(true)
1362 );
1363 }
1364
1365 #[test]
1371 fn workflow_only_glossary_carries_no_release_term() {
1372 let workflow =
1373 projection::glossary_block_of(&shape(true, false, crate::landing::MergeForm::Squash))
1374 .expect("the binary embeds the block");
1375 assert!(workflow.contains("`implement-and-request`"), "{workflow}");
1376 assert!(workflow.contains("`implement-and-merge`"), "{workflow}");
1377 assert!(!workflow.contains("full-implement"), "{workflow}");
1378 assert!(!workflow.contains("release,"), "{workflow}");
1379 assert!(!workflow.contains("before the release"), "{workflow}");
1380 assert_eq!(release_words(&workflow), Vec::<String>::new());
1381 assert!(workflow.starts_with(BLOCK_BEGIN), "{workflow}");
1382 assert!(workflow.ends_with(BLOCK_END), "{workflow}");
1383 assert_eq!(
1384 projection::glossary_block_of(&shape(true, true, crate::landing::MergeForm::Squash))
1385 .expect("the binary embeds the block"),
1386 glossary_block()
1387 );
1388 }
1389
1390 #[test]
1396 fn workflow_only_hook_block_drops_the_tag_guard_and_the_forced_scope() {
1397 let authored = projection::embedded_block(projection::PRE_COMMIT_MESSAGE_GUARDS)
1398 .expect("the binary embeds the block");
1399 assert_eq!(
1400 authored.matches(projection::FORCE_SCOPE_ARG).count(),
1401 1,
1402 "the authored commit hook carries the argument the renderer drops: {authored}"
1403 );
1404 for (mode, integration) in MODE_PAIRS {
1405 for guards in [true, false] {
1406 let at = format!("{mode:?} {integration:?} guards {guards}");
1407 let release_less = projection::hooks_block_of(&projection::BlockShape {
1408 checkout_mode: mode,
1409 integration,
1410 merge_form: crate::landing::MergeForm::Squash,
1411 workflow_guards: guards,
1412 release: false,
1413 })
1414 .expect("the binary embeds the block");
1415 assert!(!release_less.contains("rk-no-hand-authored-tag"), "{at}");
1416 assert!(!release_less.contains("--force-scope"), "{at}");
1417 assert!(release_less.contains("- id: rk-status-check"), "{at}");
1418 assert!(!release_less.contains("RK_TAG_GUARD"), "{at}");
1419 assert!(!release_less.contains("RK_STATUS_RELEASE_FILES"), "{at}");
1420 assert!(!release_less.contains("release-plz"), "{at}");
1421 assert!(
1422 release_less.contains("\\.pre-commit-config\\.yaml$)'"),
1423 "{at}: {release_less}"
1424 );
1425 assert_eq!(release_words(&release_less), Vec::<String>::new(), "{at}");
1426 if guards {
1427 assert!(release_less.contains("args: [--strict]\n"), "{at}");
1428 }
1429 let releasing = if guards {
1430 hooks_block_for(mode, integration)
1431 } else {
1432 unguarded_hooks_block(mode, integration)
1433 };
1434 assert!(releasing.contains("- id: rk-no-hand-authored-tag"), "{at}");
1435 assert!(
1436 releasing.contains(r"\.pre-commit-config\.yaml$|release-plz\.toml$|dist-workspace\.toml$|cliff\.toml$|VERSION$)'"),
1437 "{at}: the release-bearing pattern names the release files a landing writes: {releasing}"
1438 );
1439 assert_eq!(releasing.contains("--force-scope"), guards, "{at}");
1440 }
1441 }
1442 }
1443
1444 #[test]
1448 fn the_hook_block_without_the_guards_keeps_the_release_hooks_alone() {
1449 for mode in [CheckoutMode::LinkedWorktree, CheckoutMode::MainWorktree] {
1450 for integration in [Integration::Forge, Integration::Local] {
1451 let block = unguarded_hooks_block(mode, integration);
1452 for kept in ["- id: rk-no-hand-authored-tag", "- id: rk-status-check"] {
1453 assert!(block.contains(kept), "{kept}: {block}");
1454 }
1455 for gone in [
1456 "conventional-pre-commit",
1457 "rk-message",
1458 "rk-branch-name",
1459 "rk-worktree-location",
1460 "rk-no-push-to-trunk",
1461 "no-commit-to-branch",
1462 ] {
1463 assert!(!block.contains(gone), "{gone}: {block}");
1464 }
1465 assert!(block.contains("A CI sweep needs no SKIP here"), "{block}");
1466 for token in [
1467 "RK_MESSAGE_GUARDS",
1468 "RK_BRANCH_NAME_GUARD",
1469 "RK_WORKTREE_GUARD",
1470 "RK_TRUNK_COMMIT_GUARD",
1471 "RK_TRUNK_PUSH_GUARD",
1472 "RK_SWEEP_NOTE",
1473 ] {
1474 assert!(!block.contains(token), "{token} survived: {block}");
1475 }
1476 assert_eq!(
1477 block.contains("pre-merge-commit hook through git"),
1478 integration == Integration::Local,
1479 "{block}"
1480 );
1481 for gone in [
1482 "--hook-stage manual",
1483 "prek or pre-commit",
1484 "where prek is not",
1485 ] {
1486 assert!(!block.contains(gone), "{gone}: {block}");
1487 }
1488 }
1489 }
1490 }
1491
1492 #[test]
1506 fn the_integration_mode_decides_which_trunk_guards_render() {
1507 for mode in [CheckoutMode::LinkedWorktree, CheckoutMode::MainWorktree] {
1508 let forge = hooks_block_for(mode, Integration::Forge);
1509 let local = hooks_block_for(mode, Integration::Local);
1510 assert!(forge.contains("- id: no-commit-to-branch"), "{forge}");
1511 assert!(forge.contains("- id: rk-no-push-to-trunk"), "{forge}");
1512 assert!(!forge.contains("rk-no-force-push-to-trunk"), "{forge}");
1513 assert!(!forge.contains("rk-no-merge-commit-to-trunk"), "{forge}");
1514 assert!(local.contains("- id: no-commit-to-branch"), "{local}");
1515 assert!(
1516 local.contains("args: [--branch, RK_TRUNK_BRANCH]\n stages: [pre-commit]"),
1517 "the local trunk guard runs at the pre-commit stage alone: {local}"
1518 );
1519 assert!(local.contains("- id: rk-no-force-push-to-trunk"), "{local}");
1520 assert!(
1521 local.contains("- id: rk-no-merge-commit-to-trunk"),
1522 "{local}"
1523 );
1524 assert!(!local.contains("rk-no-push-to-trunk"), "{local}");
1525 for kept in [
1527 "- id: conventional-pre-commit",
1528 "- id: rk-message",
1529 "- id: rk-branch-name",
1530 "- id: rk-no-hand-authored-tag",
1531 "- id: rk-status-check",
1532 ] {
1533 assert!(forge.contains(kept), "{kept}: {forge}");
1534 assert!(local.contains(kept), "{kept}: {local}");
1535 }
1536 let located = mode == CheckoutMode::LinkedWorktree;
1539 assert_eq!(forge.contains("- id: rk-worktree-location"), located);
1540 assert_eq!(local.contains("- id: rk-worktree-location"), located);
1541 for block in [&forge, &local] {
1545 assert!(
1546 block.contains(
1547 "# prek install --hook-type pre-commit --hook-type commit-msg --hook-type pre-push --hook-type pre-merge-commit"
1548 ),
1549 "{block}"
1550 );
1551 assert!(
1552 block.contains("with\n# the hook manager this project uses, for example:"),
1553 "{block}"
1554 );
1555 }
1556 assert!(
1559 local.contains("rk integrate runs this clone's pre-merge-commit hook through git"),
1560 "{local}"
1561 );
1562 assert!(local.contains("a root .prekignore"), "{local}");
1563 assert!(
1564 !forge.contains("pre-merge-commit hook through git"),
1565 "{forge}"
1566 );
1567 for block in [&forge, &local] {
1568 for gone in [
1569 "--hook-stage manual",
1570 "prek or pre-commit",
1571 "pre-commit run",
1572 ] {
1573 assert!(!block.contains(gone), "{gone}: {block}");
1574 }
1575 }
1576 for token in [
1577 "RK_TRUNK_COMMIT_GUARD",
1578 "RK_TRUNK_PUSH_GUARD",
1579 "RK_SWEEP_NOTE",
1580 ] {
1581 assert!(!local.contains(token), "{token} survived: {local}");
1582 assert!(!forge.contains(token), "{token} survived: {forge}");
1583 }
1584 }
1585 assert!(
1589 hooks_block_for(CheckoutMode::LinkedWorktree, Integration::Local)
1590 .contains("SKIP=no-commit-to-branch,rk-worktree-location in")
1591 );
1592 assert!(
1593 hooks_block_for(CheckoutMode::MainWorktree, Integration::Local)
1594 .contains("SKIP=no-commit-to-branch in")
1595 );
1596 assert!(
1597 unguarded_hooks_block(CheckoutMode::MainWorktree, Integration::Local)
1598 .contains("A CI sweep needs no SKIP here")
1599 );
1600 }
1601
1602 #[test]
1608 fn a_landed_sweep_note_invites_the_projects_own_skips() {
1609 let with_skip = hooks_block_for(CheckoutMode::LinkedWorktree, Integration::Local);
1610 assert!(
1611 with_skip.contains("SKIP=no-commit-to-branch,rk-worktree-location in"),
1612 "{with_skip}"
1613 );
1614 assert!(with_skip.contains("local-only"), "{with_skip}");
1615 assert!(
1616 with_skip
1617 .contains("a job running the project's hook manager\n# against a trunk checkout"),
1618 "{with_skip}"
1619 );
1620 assert!(
1621 with_skip.contains("value is a list the project owns"),
1622 "{with_skip}"
1623 );
1624
1625 let without = unguarded_hooks_block(CheckoutMode::MainWorktree, Integration::Local);
1626 assert!(
1627 without.contains("A CI sweep needs no SKIP here"),
1628 "{without}"
1629 );
1630 assert!(without.contains("local-only"), "{without}");
1631 }
1632
1633 #[test]
1639 fn a_landed_integrate_note_names_both_axes() {
1640 for mode in [CheckoutMode::LinkedWorktree, CheckoutMode::MainWorktree] {
1641 let local = hooks_block_for(mode, Integration::Local);
1642 assert!(
1643 local.contains("Tag pre-merge-commit on every check that must pass"),
1644 "{local}"
1645 );
1646 assert!(
1647 local.contains("continuous integration invoke the same checks"),
1648 "{local}"
1649 );
1650 assert!(
1651 local.contains("guards one side alone"),
1652 "the note states the consequence: {local}"
1653 );
1654 let forge = hooks_block_for(mode, Integration::Forge);
1656 assert!(!forge.contains("Tag pre-merge-commit"), "{forge}");
1657 }
1658 }
1659
1660 #[test]
1664 fn the_hook_marker_defects_are_named() {
1665 use super::hooks_marker_defect;
1666 let owned = hooks_block(CheckoutMode::MainWorktree);
1667 let block = owned.as_str();
1668 assert_eq!(hooks_marker_defect(""), None);
1669 assert_eq!(hooks_marker_defect(&format!("repos:\n{block}\n")), None);
1670 for (case, text) in [
1671 (
1672 "a second begin",
1673 format!("repos:\n{block}\n# BEGIN release-kit\n"),
1674 ),
1675 (
1676 "a second end",
1677 format!("repos:\n{block}\n# END release-kit\n"),
1678 ),
1679 (
1680 "an unpaired begin",
1681 "repos:\n# BEGIN release-kit\n".to_owned(),
1682 ),
1683 ("an unpaired end", "repos:\n# END release-kit\n".to_owned()),
1684 (
1685 "an end before its begin",
1686 "repos:\n# END release-kit\n# BEGIN release-kit\n".to_owned(),
1687 ),
1688 ] {
1689 assert!(
1690 hooks_marker_defect(&text).is_some(),
1691 "{case} must be a defect"
1692 );
1693 }
1694 }
1695}