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