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