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 }
261 }
262
263 #[test]
268 fn the_security_spans_render_per_answer() {
269 for forge in ["github", "gitlab"] {
270 let bytes = embedded::SNIPPETS
271 .get_file(format!("_shared/{forge}/SECURITY.md"))
272 .expect("the policy ships")
273 .contents();
274 let authored = String::from_utf8_lossy(bytes);
275 let stripped = {
276 let mut text = authored.clone().into_owned();
277 for (begin, end) in super::SECURITY_SPANS {
278 text = text.replace(&String::from_utf8_lossy(begin).into_owned(), "");
279 text = text.replace(&String::from_utf8_lossy(end).into_owned(), "");
280 }
281 text
282 };
283 let mut default = super::Params::for_test_security("", crate::config::RESPONSE_DEFAULT);
284 default.set_pair_for_test("rust", forge);
285 let rendered = String::from_utf8(render(bytes, &default)).expect("text");
286 assert_eq!(
287 rendered,
288 stripped.replace("RK_REPO", "acme/widget"),
289 "{forge}: the default answers must reproduce the authored policy"
290 );
291 assert!(!rendered.contains("RK_SECURITY"), "{forge}: {rendered}");
292
293 let mut answered =
294 super::Params::for_test_security("OWNER RK_REPO <team@acme.example>", "14 days");
295 answered.set_pair_for_test("rust", forge);
296 let rendered = String::from_utf8(render(bytes, &answered)).expect("text");
297 assert!(
298 rendered.contains("OWNER RK_REPO <team@acme.example>"),
299 "{forge}: a contact spelling a token name lands literally: {rendered}"
300 );
301 assert!(
302 rendered.contains("Maintainers acknowledge a report within 14 days."),
303 "{forge}: {rendered}"
304 );
305 assert!(
306 rendered.contains("This policy commits to no disclosure deadline."),
307 "{forge}: {rendered}"
308 );
309 assert!(
310 !rendered.contains("best-effort basis"),
311 "{forge}: a stated window replaces the best-effort sentence: {rendered}"
312 );
313 assert!(
314 !rendered.contains("no response or disclosure deadline"),
315 "{forge}: a stated window contradicts the response disclaimer: {rendered}"
316 );
317 }
318 }
319
320 #[test]
323 fn a_defective_span_renders_unchanged() {
324 let (begin, end) = super::SECURITY_SPANS[0];
325 let begin = String::from_utf8_lossy(begin).into_owned();
326 let end = String::from_utf8_lossy(end).into_owned();
327 let params = super::Params::for_test_security("team@acme.example", "1 day");
328 for baseline in [
329 format!("contact {begin}a maintainer\n"),
330 format!("contact a maintainer{end}\n"),
331 format!("contact {end}a maintainer{begin}\n"),
332 "contact a maintainer\n".to_owned(),
333 ] {
334 assert_eq!(
335 render(baseline.as_bytes(), ¶ms),
336 baseline.as_bytes(),
337 "{baseline}"
338 );
339 }
340 }
341
342 #[test]
346 fn the_kind_table_closes_over_every_snippet() {
347 for tech_dir in embedded::SNIPPETS.dirs() {
348 for pair_dir in tech_dir.dirs() {
349 let prefix = format!("{}/", pair_dir.path().to_string_lossy());
350 for (path, _) in embedded::walk(pair_dir) {
351 let destination = path.strip_prefix(&prefix).unwrap_or(&path);
352 assert!(
353 kind_of(destination).is_some(),
354 "{destination}: no declared kind"
355 );
356 }
357 }
358 }
359 for block in BLOCK_DESTINATIONS {
360 assert_eq!(kind_of(block), Some(Kind::Rendered), "{block}");
361 }
362 assert_eq!(kind_of("something-else.txt"), None);
363 }
364
365 #[test]
370 fn rendering_substitutes_every_owner_occurrence() {
371 let baseline = b"if: repository_owner == 'OWNER'\n# OWNER again: OWNER\n";
372 let rendered = render(baseline, &super::Params::for_test("acme/sub/widget", None));
373 let text = String::from_utf8(rendered).expect("rendered bytes stay text");
374 assert_eq!(text, "if: repository_owner == 'acme'\n# acme again: acme\n");
375
376 let baseline = b"match (RK_SCOPE_SHAPE)\n";
377 let rendered = render(baseline, &super::Params::for_test("acme/widget", None));
378 let text = String::from_utf8(rendered).expect("rendered bytes stay text");
379 assert_eq!(text, format!("match ({SCOPE_SHAPE})\n"));
380 }
381
382 #[test]
386 fn the_scope_shape_drops_into_the_title_check() {
387 assert_eq!(SCOPE_SHAPE, "[a-z0-9._/-]+");
388 assert!(
389 !SCOPE_SHAPE.contains('\''),
390 "the title checks single-quote it"
391 );
392 }
393
394 #[test]
399 fn the_scope_predicate_and_the_rendered_pattern_agree() {
400 let body = SCOPE_SHAPE
401 .strip_prefix('[')
402 .and_then(|rest| rest.strip_suffix("]+"))
403 .expect("the shape is one bracket expression, repeated");
404 let chars: Vec<char> = body.chars().collect();
405 let mut admitted = std::collections::BTreeSet::new();
406 let mut at = 0;
407 while at < chars.len() {
408 if at + 2 < chars.len() && chars[at + 1] == '-' {
411 for c in chars[at]..=chars[at + 2] {
412 admitted.insert(c);
413 }
414 at += 3;
415 } else {
416 admitted.insert(chars[at]);
417 at += 1;
418 }
419 }
420 for byte in 0..=127u8 {
421 let c = char::from(byte);
422 assert_eq!(
423 super::scope_is_shaped(&c.to_string()),
424 admitted.contains(&c),
425 "the predicate and {SCOPE_SHAPE} disagree on {c:?}"
426 );
427 }
428 assert!(super::scope_is_shaped("guides/release"));
429 assert!(!super::scope_is_shaped(""), "a scope is never empty");
430 assert!(!super::scope_is_shaped("Specs Ugly"));
431 }
432
433 #[test]
437 fn the_shared_zone_composes_into_the_pair() {
438 let mut github = super::Params::for_test("acme/widget", Some(Style::Trunk));
439 github.set_pair_for_test("rust", "github");
440 let github = destinations(&github);
441 assert!(
442 github.contains(&".github/workflows/pr-title.yml".to_owned()),
443 "the shared title check lands with the pair"
444 );
445 assert!(github.contains(&"SECURITY.md".to_owned()));
446 let mut gitlab = super::Params::for_test("acme/widget", Some(Style::Trunk));
447 gitlab.set_pair_for_test("rust", "gitlab");
448 let gitlab = destinations(&gitlab);
449 assert!(
450 gitlab.contains(&".gitlab/ci/mr-title.yml".to_owned()),
451 "the shared title job lands with the pair"
452 );
453 assert!(
454 !crate::profile::catalog::known_drivers()
455 .iter()
456 .any(|driver| driver.starts_with('_')),
457 "the shared zone is no driver"
458 );
459 }
460
461 #[test]
464 fn params_from_a_record_round_trips() {
465 use super::{Params, manifest};
466 let dir = tempfile::tempdir().expect("a scratch target exists");
467 let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
468 let prefix = Some(crate::config::LINE_PREFIX_DEFAULT.to_owned());
469 for tech in ["rust", "bash"] {
470 for forge in ["github", "gitlab"] {
471 for (checkout_mode, integration) in MODE_PAIRS {
472 for style in [None, Some(Style::Trunk), Some(Style::Lines)] {
473 for ((nix, scorecard), code_scanning) in [
474 ((false, false), None),
475 ((false, true), Some(Provider::Semgrep)),
476 ((true, false), Some(Provider::CodeQl)),
477 ((true, true), None),
478 ] {
479 let record = manifest::Manifest {
480 schema_version: manifest::SCHEMA_VERSION,
481 rk_version: "0.1.0".to_owned(),
482 origin: "init".to_owned(),
483 landed_at: "2026-08-29T00:00:00Z".to_owned(),
484 profile: ProfileSnapshot {
485 technologies: vec![tech.to_owned()],
486 forge: Some(forge.to_owned()),
487 release: ReleaseIntent {
488 mode: ReleaseMode::Automatic,
489 driver: Some(tech.to_owned()),
490 style,
491 line_prefix: prefix.clone(),
492 registry: None,
493 },
494 },
495 git: GitWorkflow {
496 trunk: crate::config::TRUNK_DEFAULT.to_owned(),
497 checkout_mode,
498 integration,
499 merge_form: crate::landing::MergeForm::Squash,
500 },
501 capabilities: CapabilityRequests {
502 nix_packaging: nix,
503 reporting_policy: true,
504 scorecard,
505 code_scanning,
506 workflow_guards: Some(true),
507 },
508 parameters: manifest::Parameters {
509 repo: "acme/team/widget".to_owned(),
510 security_contact: String::new(),
511 security_response: crate::config::RESPONSE_DEFAULT.to_owned(),
512 required_check: "gate".to_owned(),
513 required_workflow: "ci".to_owned(),
514 dev_environment: None,
515 },
516 files: Vec::new(),
517 pins: std::collections::BTreeMap::new(),
518 };
519 manifest::write(target, &record).expect("the record writes");
520 let loaded = manifest::load(target)
521 .expect("the record loads")
522 .expect("the record exists");
523 let params = Params::from_record(&loaded);
524 assert_eq!(params.driver(), Some(tech));
525 assert_eq!(params.forge(), Some(forge));
526 assert_eq!(params.repo(), "acme/team/widget");
527 assert_eq!(params.integration(), integration);
528 assert_eq!(params.style(), style);
529 assert_eq!(params.nix_packaging(), nix);
530 assert_eq!(params.scorecard(), scorecard);
531 assert_eq!(params.code_scanning(), code_scanning);
532 let mut direct = super::Params::for_test("acme/team/widget", style);
535 direct.set_pair_for_test(tech, forge);
536 direct.set_checkout_mode_for_test(checkout_mode);
537 direct.set_integration_for_test(integration);
538 direct.set_nix_for_test(nix);
539 direct.set_scorecard_for_test(scorecard);
540 direct.set_code_scanning_for_test(code_scanning);
541 assert_eq!(params, direct);
542 let projected = destinations(¶ms);
543 for block in
544 [AGENTS_DESTINATION, GLOSSARY_DESTINATION, HOOKS_DESTINATION]
545 {
546 assert!(projected.contains(&block.to_owned()), "{block}");
547 }
548 for destination in super::NIX_DESTINATIONS {
549 assert_eq!(
550 projected.contains(&destination.to_owned()),
551 nix && tech == "rust",
552 "{tech} {forge} nix={nix}: {destination}"
553 );
554 }
555 for destination in super::SCORECARD_DESTINATIONS {
559 assert_eq!(
560 projected.contains(&destination.to_owned()),
561 scorecard && forge == "github",
562 "{tech} {forge} scorecard={scorecard}: {destination}"
563 );
564 }
565 }
566 }
567 }
568 }
569 }
570 }
571
572 fn resolved_test_params(
573 tech: &str,
574 resolved: &super::Resolved,
575 checkout_mode: CheckoutMode,
576 style: Option<Style>,
577 nix: bool,
578 scorecard: bool,
579 code_scanning: Option<Provider>,
580 ) -> Result<super::Params, crate::error::RkError> {
581 let technologies = vec![tech.to_owned()];
582 super::Params::resolve(
583 camino::Utf8Path::new("."),
584 &super::Inputs {
585 technologies: &technologies,
586 forge: Some(&resolved.forge),
587 repo: resolved.repo.as_deref(),
588 release_mode: Some(ReleaseMode::Automatic),
589 release_driver: Some(tech),
590 style,
591 required_check: (resolved.forge == "github").then_some("gate"),
593 required_workflow: (resolved.forge == "github").then_some("ci"),
594 trunk: None,
595 checkout_mode: Some(checkout_mode),
596 integration: None,
597 merge_form: None,
598 nix: Some(nix),
599 reporting_policy: None,
600 scorecard: Some(scorecard),
601 code_scanning: Some(code_scanning),
602 workflow_guards: Some(true),
603 },
604 None,
605 None,
606 super::Purpose::Init,
607 )
608 }
609
610 #[test]
614 fn a_projection_renders_owned_files_and_keeps_seeded_judgment() {
615 let params = resolved_test_params(
616 "rust",
617 &super::Resolved {
618 forge: "github".to_owned(),
619 repo: Some("acme/widget".to_owned()),
620 },
621 CheckoutMode::MainWorktree,
622 Some(Style::Trunk),
623 false,
624 false,
625 None,
626 )
627 .expect("the parameters resolve");
628 let entries = Projection::compute(&ProjectionInput {
629 params: params.clone(),
630 evidence: TargetEvidence::default(),
631 })
632 .expect("the pair projects")
633 .candidates;
634 let workflow = entries
635 .iter()
636 .find(|entry| entry.destination.ends_with("release-plz.yml"))
637 .expect("the workflow projects");
638 assert_eq!(workflow.kind, Kind::Rendered);
639 let text = String::from_utf8_lossy(&workflow.bytes);
640 assert!(!text.contains("OWNER"), "an owner token survived rendering");
641 assert!(text.contains("'acme'"));
642 assert!(!text.contains("TODO(release-kit)"));
643 let title = entries
644 .iter()
645 .find(|entry| entry.destination.ends_with("pr-title.yml"))
646 .expect("the title check projects");
647 let text = String::from_utf8_lossy(&title.bytes);
648 assert!(text.contains(SCOPE_SHAPE), "{text}");
649 assert!(
650 !text.contains("RK_SCOPE_SHAPE"),
651 "a scope token survived: {text}"
652 );
653 let seeded = entries
654 .iter()
655 .find(|entry| entry.destination == "release-plz.toml")
656 .expect("the seeded file projects");
657 assert_eq!(seeded.kind, Kind::Seeded);
658 let authored = embedded::SNIPPETS
659 .get_file("rust/github/release-plz.toml")
660 .expect("the seed ships")
661 .contents();
662 assert_eq!(
663 seeded.bytes,
664 crate::projection::registry_lines(authored, ¶ms),
665 "a seeded file lands as authored, with the lines of the unanswered registry"
666 );
667 assert!(
668 !String::from_utf8_lossy(&seeded.bytes).contains("RK_IF_"),
669 "no registry marker survives in a seed"
670 );
671 assert!(String::from_utf8_lossy(&seeded.bytes).contains("TODO(release-kit)"));
672 for block in BLOCK_DESTINATIONS {
673 let entry = entries
674 .iter()
675 .find(|entry| entry.destination == block)
676 .expect("every block is part of the projection");
677 let text = String::from_utf8_lossy(&entry.bytes);
678 assert!(
679 !text.contains("RK_SCOPE_SHAPE"),
680 "{block} kept a token: {text}"
681 );
682 }
683 }
684
685 #[test]
690 fn the_nix_destinations_project_only_under_the_opt_in() {
691 use super::NIX_DESTINATIONS;
692 let paths = |nix: bool, forge: &str| -> Vec<String> {
693 destinations(
694 &resolved_test_params(
695 "rust",
696 &super::Resolved {
697 forge: forge.to_owned(),
698 repo: Some("acme/widget".to_owned()),
699 },
700 CheckoutMode::LinkedWorktree,
701 Some(Style::Trunk),
702 nix,
703 false,
704 None,
705 )
706 .expect("the parameters resolve"),
707 )
708 };
709 let off = paths(false, "github");
710 for destination in NIX_DESTINATIONS {
711 assert!(!off.contains(&destination.to_owned()), "{destination}");
712 }
713 let on = paths(true, "github");
714 for destination in ["nix/package.nix", "flake.nix", "flake.lock"] {
715 assert!(on.contains(&destination.to_owned()), "{destination}");
716 }
717 let gitlab = paths(true, "gitlab");
722 assert!(gitlab.contains(&"nix/package.nix".to_owned()));
723 assert!(
724 !on.iter()
725 .chain(gitlab.iter())
726 .any(|destination| destination.contains("nix.yml"))
727 );
728 let bash = destinations(
729 &resolved_test_params(
730 "bash",
731 &super::Resolved {
732 forge: "github".to_owned(),
733 repo: Some("acme/widget".to_owned()),
734 },
735 CheckoutMode::LinkedWorktree,
736 Some(Style::Trunk),
737 true,
738 false,
739 None,
740 )
741 .expect("the parameters resolve"),
742 );
743 assert!(
744 bash.iter()
745 .all(|destination| !NIX_DESTINATIONS.contains(&destination.as_str()))
746 );
747 }
748
749 #[test]
754 fn the_nix_seeds_are_identical_across_forge_pairs() {
755 for name in ["nix/package.nix", "flake.nix", "flake.lock"] {
756 let github = embedded::SNIPPETS
757 .get_file(format!("rust/github/{name}"))
758 .expect("the github copy ships")
759 .contents();
760 let gitlab = embedded::SNIPPETS
761 .get_file(format!("rust/gitlab/{name}"))
762 .expect("the gitlab copy ships")
763 .contents();
764 assert_eq!(github, gitlab, "{name} diverged between the pairs");
765 }
766 }
767
768 #[test]
773 fn the_nix_withhold_judgment_covers_the_three_shapes() {
774 use super::NIX_DESTINATIONS;
775 let dir = tempfile::tempdir().expect("a scratch target exists");
776 let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
777 let project = |nix: bool| {
778 let params = resolved_test_params(
779 "rust",
780 &super::Resolved {
781 forge: "github".to_owned(),
782 repo: Some("acme/widget".to_owned()),
783 },
784 CheckoutMode::LinkedWorktree,
785 Some(Style::Trunk),
786 nix,
787 false,
788 None,
789 )
790 .expect("the parameters resolve");
791 let evidence = TargetEvidence::gather(target, None).expect("the evidence reads");
792 Projection::compute(&ProjectionInput { params, evidence }).expect("the pair projects")
793 };
794 let withheld = |projection: &Projection| -> Vec<String> {
795 projection
796 .omissions
797 .iter()
798 .map(|omission| omission.destination.clone())
799 .collect()
800 };
801 let landed = |projection: &Projection, destination: &str| {
802 projection
803 .candidates
804 .iter()
805 .any(|candidate| candidate.destination == destination)
806 };
807
808 let all = project(true);
810 assert_eq!(
811 withheld(&all),
812 ["flake.lock", "flake.nix", "nix/package.nix"]
813 );
814 assert!(
815 all.candidates
816 .iter()
817 .all(|entry| !NIX_DESTINATIONS.contains(&entry.destination.as_str()))
818 );
819
820 std::fs::write(
823 target.join("Cargo.toml"),
824 "[package]\nname = \"widget\"\nversion = \"0.1.0\"\n",
825 )
826 .expect("the crate manifest writes");
827 std::fs::write(target.join("Cargo.lock"), "version = 4\n").expect("the lock writes");
828 std::fs::create_dir_all(target.join("src")).expect("the src dir exists");
829 std::fs::write(target.join("src/main.rs"), "fn main() {}\n").expect("the main writes");
830 std::fs::write(target.join("flake.nix"), "{ }\n").expect("the flake writes");
831 let all = project(true);
832 assert_eq!(withheld(&all), ["flake.lock", "flake.nix"]);
833 assert!(landed(&all, "nix/package.nix"));
834
835 std::fs::remove_file(target.join("flake.nix")).expect("the flake removes");
837 let all = project(true);
838 assert!(all.omissions.is_empty());
839 assert!(landed(&all, "flake.nix"));
840
841 let all = project(false);
843 assert!(all.omissions.is_empty());
844 assert!(!landed(&all, "flake.nix"));
845 }
846
847 #[test]
851 fn the_glossary_splices_into_every_shape() {
852 let owned = glossary_block();
853 let block = owned.as_str();
854
855 let fresh = spliced(None, block);
856 assert_eq!(fresh, format!("{block}\n"));
857 assert_eq!(extract_block(&fresh, BLOCK_BEGIN, BLOCK_END), Some(block));
858
859 let own = "# Glossary\n\n- `spike` — a throwaway branch.\n";
860 let appended = spliced(Some(own), block);
861 assert!(appended.starts_with(own));
862 assert_eq!(
863 extract_block(&appended, BLOCK_BEGIN, BLOCK_END),
864 Some(block)
865 );
866
867 let stale = appended.replace("full-implement", "do-everything");
868 let refreshed = spliced(Some(&stale), block);
869 assert_eq!(
870 extract_block(&refreshed, BLOCK_BEGIN, BLOCK_END),
871 Some(block)
872 );
873 assert_eq!(
874 refreshed.matches("BEGIN release-kit").count(),
875 1,
876 "a re-splice must replace, not accumulate"
877 );
878 }
879
880 #[test]
884 fn the_glossary_leaves_the_targets_region_alone() {
885 let owned = glossary_block();
886 let block = owned.as_str();
887 let below = "\n## Our own terms\n\n- `spike` — a throwaway branch, never merged.\n";
888 let landed = format!("{block}\n{below}");
889
890 let refreshed = spliced(Some(&landed), block);
891 assert!(
892 refreshed.ends_with(below),
893 "the target's own region changed: {refreshed}"
894 );
895 assert_eq!(
896 extract_block(&refreshed, BLOCK_BEGIN, BLOCK_END),
897 Some(block)
898 );
899 }
900
901 #[test]
905 fn an_append_rewrites_no_byte_the_target_wrote() {
906 let owned = glossary_block();
907 let block = owned.as_str();
908 for own in [
909 "# Glossary\n\n- `spike` — throwaway. \n\n\n",
910 "# Glossary\n\n- `spike` — throwaway.",
911 "# Glossary\r\n\r\n- `spike` — throwaway.\r\n",
912 ] {
913 let appended = spliced(Some(own), block);
914 assert!(
915 appended.starts_with(own),
916 "the target's bytes changed: {appended:?}"
917 );
918 assert_eq!(
919 extract_block(&appended, BLOCK_BEGIN, BLOCK_END),
920 Some(block),
921 "{appended:?}"
922 );
923 let marker = appended.find(BLOCK_BEGIN).expect("the block landed");
924 assert!(
925 appended[..marker].ends_with('\n'),
926 "the block must open its own line: {appended:?}"
927 );
928 }
929 }
930
931 #[test]
935 fn a_splice_decodes_no_byte_the_target_wrote() {
936 let owned = glossary_block();
937 let block = owned.as_str();
938
939 let own = b"# Glossary\n\ncaf\xe9\n";
941 let appended = splice_marked_block(Some(own), block);
942 assert!(
943 appended.starts_with(own),
944 "the target's bytes changed: {appended:?}"
945 );
946 assert!(!appended.contains(&0xEF), "a replacement character landed");
947
948 let mut landed = Vec::new();
950 landed.extend_from_slice(block.replace("full-implement", "do-everything").as_bytes());
951 landed.extend_from_slice(b"\n\ncaf\xe9\n");
952 let refreshed = splice_marked_block(Some(&landed), block);
953 assert!(
954 refreshed.ends_with(b"\n\ncaf\xe9\n"),
955 "the target's region below the markers changed: {refreshed:?}"
956 );
957 assert!(refreshed.starts_with(block.as_bytes()), "{refreshed:?}");
958 }
959
960 #[test]
963 fn the_glossary_block_carries_no_parameter() {
964 let block = glossary_block();
965 assert!(block.starts_with(BLOCK_BEGIN), "{block}");
966 assert!(block.ends_with(BLOCK_END), "{block}");
967 assert!(!block.contains("RK_"), "a token survived: {block}");
968 assert!(!block.contains("OWNER"), "an owner token survived: {block}");
969 for term in [
970 "implement-and-request",
971 "implement-and-merge",
972 "full-implement",
973 ] {
974 assert!(block.contains(term), "{term} is missing from {block}");
975 }
976 assert!(
977 routing_block(true).contains(GLOSSARY_DESTINATION),
978 "the routing block must name the destination it indexes"
979 );
980 assert!(
981 !routing_block(false).contains(GLOSSARY_DESTINATION),
982 "a target without the workflow guards lands no glossary to route to"
983 );
984 }
985
986 #[test]
987 fn the_block_splices_into_every_agents_shape() {
988 let owned = routing_block(false);
989 let block = owned.as_str();
990 let fresh = spliced(None, block);
991 assert_eq!(fresh, format!("{block}\n"));
992 assert_eq!(extract_block(&fresh, BLOCK_BEGIN, BLOCK_END), Some(block));
993
994 let appended = spliced(Some("# My project\n\nOwn rules.\n"), block);
995 assert!(appended.starts_with("# My project\n\nOwn rules.\n\n<!-- BEGIN release-kit -->"));
996 assert_eq!(
997 extract_block(&appended, BLOCK_BEGIN, BLOCK_END),
998 Some(block)
999 );
1000
1001 let stale = appended.replace("Never author a tag", "Do author a tag");
1002 let refreshed = spliced(Some(&stale), block);
1003 assert_eq!(
1004 extract_block(&refreshed, BLOCK_BEGIN, BLOCK_END),
1005 Some(block)
1006 );
1007 assert!(refreshed.starts_with("# My project"));
1008 assert_eq!(
1009 refreshed.matches("BEGIN release-kit").count(),
1010 1,
1011 "a re-splice must replace, not accumulate"
1012 );
1013 }
1014
1015 #[test]
1018 fn the_hook_block_splices_under_repos() {
1019 let owned = hooks_block(CheckoutMode::MainWorktree);
1020 let block = owned.as_str();
1021 let fresh = splice_hooks_block(None, block).expect("a fresh file splices");
1022 assert!(fresh.starts_with(HOOK_TYPES_LINE));
1023 assert!(fresh.contains("\nrepos:\n# BEGIN release-kit\n"));
1024 assert_eq!(extract_block(&fresh, HOOKS_BEGIN, HOOKS_END), Some(block));
1025
1026 let own =
1027 "repos:\n - repo: https://example.com/own\n rev: v1\n hooks:\n - id: own\n";
1028 let spliced = splice_hooks_block(Some(own), block).expect("an unmarked file splices");
1029 assert!(spliced.starts_with("repos:\n# BEGIN release-kit\n"));
1030 assert!(spliced.contains("- id: own"), "the target's hooks survive");
1031 assert!(
1032 !spliced.contains(HOOK_TYPES_LINE),
1033 "an existing file's top level is the skills' duty, not the splice's"
1034 );
1035
1036 let stale = spliced.replace("--force-scope", "--no-scope");
1037 let refreshed = splice_hooks_block(Some(&stale), block).expect("a marked file re-splices");
1038 assert_eq!(
1039 extract_block(&refreshed, HOOKS_BEGIN, HOOKS_END),
1040 Some(block)
1041 );
1042 assert_eq!(refreshed.matches(HOOKS_BEGIN).count(), 1);
1043
1044 let err = splice_hooks_block(Some("minimum_pre_commit_version: '3.2.0'\n"), block)
1045 .expect_err("no repos: line refuses");
1046 assert!(err.contains("repos:"), "{err}");
1047
1048 let doubled = format!("repos:\n{block}\n{block}\n");
1052 let err = splice_hooks_block(Some(&doubled), block).expect_err("a second block refuses");
1053 assert!(err.contains("one block"), "{err}");
1054 let unmatched = "repos:\n# BEGIN release-kit\n - repo: local\n";
1055 let err =
1056 splice_hooks_block(Some(unmatched), block).expect_err("an unmatched marker refuses");
1057 assert!(err.contains("unmatched"), "{err}");
1058 }
1059
1060 #[test]
1066 fn the_blocks_render_per_mode_and_carry_the_one_grammar() {
1067 let worktree_hooks = hooks_block(CheckoutMode::LinkedWorktree);
1068 let branches_hooks = hooks_block(CheckoutMode::MainWorktree);
1069 assert!(worktree_hooks.contains("- id: rk-worktree-location"));
1070 assert!(
1071 worktree_hooks.contains("SKIP=no-commit-to-branch,rk-worktree-location"),
1072 "{worktree_hooks}"
1073 );
1074 assert!(!branches_hooks.contains("rk-worktree-location"));
1075 assert!(branches_hooks.contains("SKIP=no-commit-to-branch in"));
1076 for block in [&worktree_hooks, &branches_hooks] {
1077 assert!(block.contains(BRANCH_GRAMMAR), "the grammar has one owner");
1078 for token in [
1079 "RK_BRANCH_GRAMMAR",
1080 "RK_SWEEP_SKIP",
1081 "RK_SWEEP_NOTE",
1082 "RK_WORKTREE_GUARD",
1083 "RK_TRUNK_COMMIT_GUARD",
1084 "RK_TRUNK_PUSH_GUARD",
1085 ] {
1086 assert!(!block.contains(token), "{token} survived: {block}");
1087 }
1088 }
1089 for block in [&worktree_hooks, &branches_hooks] {
1094 for line in block.lines() {
1095 if let Some(value) = line.trim_start().strip_prefix("entry: ") {
1096 assert!(
1097 !value.contains(": "),
1098 "an entry value breaks the YAML plain scalar: {line}"
1099 );
1100 }
1101 }
1102 }
1103 let guard_line = worktree_hooks
1104 .lines()
1105 .position(|line| line.contains("id: rk-worktree-location"))
1106 .expect("the guard entry exists");
1107 let name_line = worktree_hooks
1108 .lines()
1109 .position(|line| line.contains("id: rk-branch-name"))
1110 .expect("the name hook exists");
1111 assert!(
1112 guard_line > name_line,
1113 "the guard lands directly after rk-branch-name"
1114 );
1115 }
1116
1117 #[test]
1122 fn the_routing_block_adds_only_the_policy_lines_under_the_guards() {
1123 let guarded = routing_block(true);
1124 let plain = routing_block(false);
1125 for block in [&guarded, &plain] {
1126 for token in ["RK_AGENT_POLICY", "RK_COMMIT_POLICY"] {
1127 assert!(!block.contains(token), "{token} survived: {block}");
1128 }
1129 for kept in [
1130 "runs the release-kit convention",
1131 "MUST be a scoped Conventional Commit",
1132 "Never author a tag",
1133 "Run `rk status` before changing",
1134 "`rk method --list`",
1135 ] {
1136 assert!(block.contains(kept), "{kept}: {block}");
1137 }
1138 for gone in [
1139 "`rk integrate <branch>`",
1140 "squash-merged pull request",
1141 "`rk issue start <issue>`",
1142 "`rk worktree add <branch>`",
1143 "Branches are worked in the main checkout",
1144 ] {
1145 assert!(!block.contains(gone), "{gone}: {block}");
1146 }
1147 }
1148 assert!(guarded.contains("guides and never drives"), "{guarded}");
1149 assert!(guarded.contains("`GLOSSARY.md`"), "{guarded}");
1150 assert!(guarded.contains("Every commit follows the same scoped convention"));
1151 assert!(!plain.contains("guides and never drives"), "{plain}");
1152 assert!(!plain.contains("Every commit follows"), "{plain}");
1153 let mut guarded_lines = guarded.lines();
1156 for line in plain.lines() {
1157 assert!(
1158 guarded_lines.any(|other| other == line),
1159 "the guards changed a line the plain block carries: {line}"
1160 );
1161 }
1162 }
1163
1164 fn release_words(text: &str) -> Vec<String> {
1168 text.split(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '-'))
1169 .filter(|word| {
1170 matches!(
1171 *word,
1172 "version" | "versions" | "changelog" | "changelogs" | "tag" | "tags"
1173 )
1174 })
1175 .map(str::to_owned)
1176 .collect()
1177 }
1178
1179 fn shape(
1181 workflow_guards: bool,
1182 release: bool,
1183 merge_form: crate::landing::MergeForm,
1184 ) -> projection::BlockShape {
1185 projection::BlockShape {
1186 checkout_mode: CheckoutMode::LinkedWorktree,
1187 integration: Integration::Local,
1188 merge_form,
1189 workflow_guards,
1190 release,
1191 }
1192 }
1193
1194 #[test]
1203 fn workflow_only_routing_block_carries_no_release_text() {
1204 use crate::landing::MergeForm;
1205 for form in [MergeForm::Squash, MergeForm::Rebase] {
1206 for guards in [true, false] {
1207 let workflow = projection::routing_block_of(&shape(guards, false, form))
1208 .expect("the binary embeds the block");
1209 let at = format!("{form:?} guards {guards}");
1210 assert!(!workflow.contains("## Releases"), "{at}: {workflow}");
1211 assert!(workflow.contains("## Workflow"), "{at}: {workflow}");
1212 assert_eq!(release_words(&workflow), Vec::<String>::new(), "{at}");
1213 assert!(!workflow.contains("rk method recovery"), "{at}");
1214 for token in [
1215 "RK_AGENT_POLICY",
1216 "RK_COMMIT_POLICY",
1217 "RK_COMMIT_SOURCE",
1218 "RK_COMMIT_SCOPE",
1219 ] {
1220 assert!(!workflow.contains(token), "{at}: {token} survived");
1221 }
1222 assert!(
1223 workflow.contains("MUST be a Conventional Commit"),
1224 "{at}: {workflow}"
1225 );
1226 if guards {
1227 for kept in [
1228 "guides and never drives",
1229 "`implement-and-request` and `implement-and-merge`",
1230 "A scope is preferred and not required.",
1231 "names no internal planning artifact and carries no agent attribution",
1232 ] {
1233 assert!(workflow.contains(kept), "{at}: {kept}: {workflow}");
1234 }
1235 assert!(!workflow.contains("full-implement"), "{at}: {workflow}");
1236 assert!(!workflow.contains("requires a scope"), "{at}: {workflow}");
1237 }
1238 let releasing = projection::routing_block_of(&shape(guards, true, form))
1239 .expect("the binary embeds the block");
1240 assert!(releasing.contains("## Releases"), "{at}");
1241 assert!(releasing.contains("Never author a tag"), "{at}");
1242 if guards {
1243 assert!(releasing.contains("requires a scope on every one"), "{at}");
1244 assert_eq!(
1245 releasing.contains("The request's title becomes that commit's message"),
1246 form == MergeForm::Squash,
1247 "{at}: the title is the message under squash alone"
1248 );
1249 assert_eq!(
1250 releasing.contains(
1251 "Every commit of a branch reaches the trunk with its own message"
1252 ),
1253 form == MergeForm::Rebase,
1254 "{at}"
1255 );
1256 }
1257 }
1258 }
1259 assert_eq!(
1262 projection::routing_block_of(&shape(true, true, MergeForm::Squash))
1263 .expect("the binary embeds the block"),
1264 routing_block(true)
1265 );
1266 }
1267
1268 #[test]
1274 fn workflow_only_glossary_carries_no_release_term() {
1275 let workflow =
1276 projection::glossary_block_of(&shape(true, false, crate::landing::MergeForm::Squash))
1277 .expect("the binary embeds the block");
1278 assert!(workflow.contains("`implement-and-request`"), "{workflow}");
1279 assert!(workflow.contains("`implement-and-merge`"), "{workflow}");
1280 assert!(!workflow.contains("full-implement"), "{workflow}");
1281 assert!(!workflow.contains("release,"), "{workflow}");
1282 assert!(!workflow.contains("before the release"), "{workflow}");
1283 assert_eq!(release_words(&workflow), Vec::<String>::new());
1284 assert!(workflow.starts_with(BLOCK_BEGIN), "{workflow}");
1285 assert!(workflow.ends_with(BLOCK_END), "{workflow}");
1286 assert_eq!(
1287 projection::glossary_block_of(&shape(true, true, crate::landing::MergeForm::Squash))
1288 .expect("the binary embeds the block"),
1289 glossary_block()
1290 );
1291 }
1292
1293 #[test]
1299 fn workflow_only_hook_block_drops_the_tag_guard_and_the_forced_scope() {
1300 let authored = projection::embedded_block(projection::PRE_COMMIT_MESSAGE_GUARDS)
1301 .expect("the binary embeds the block");
1302 assert_eq!(
1303 authored.matches(projection::FORCE_SCOPE_ARG).count(),
1304 1,
1305 "the authored commit hook carries the argument the renderer drops: {authored}"
1306 );
1307 for (mode, integration) in MODE_PAIRS {
1308 for guards in [true, false] {
1309 let at = format!("{mode:?} {integration:?} guards {guards}");
1310 let release_less = projection::hooks_block_of(&projection::BlockShape {
1311 checkout_mode: mode,
1312 integration,
1313 merge_form: crate::landing::MergeForm::Squash,
1314 workflow_guards: guards,
1315 release: false,
1316 })
1317 .expect("the binary embeds the block");
1318 assert!(!release_less.contains("rk-no-hand-authored-tag"), "{at}");
1319 assert!(!release_less.contains("--force-scope"), "{at}");
1320 assert!(release_less.contains("- id: rk-status-check"), "{at}");
1321 assert!(!release_less.contains("RK_TAG_GUARD"), "{at}");
1322 assert!(!release_less.contains("RK_STATUS_RELEASE_FILES"), "{at}");
1323 assert!(!release_less.contains("release-plz"), "{at}");
1324 assert!(
1325 release_less.contains("\\.pre-commit-config\\.yaml$)'"),
1326 "{at}: {release_less}"
1327 );
1328 assert_eq!(release_words(&release_less), Vec::<String>::new(), "{at}");
1329 if guards {
1330 assert!(release_less.contains("args: [--strict]\n"), "{at}");
1331 }
1332 let releasing = if guards {
1333 hooks_block_for(mode, integration)
1334 } else {
1335 unguarded_hooks_block(mode, integration)
1336 };
1337 assert!(releasing.contains("- id: rk-no-hand-authored-tag"), "{at}");
1338 assert!(
1339 releasing.contains(r"\.pre-commit-config\.yaml$|release-plz\.toml$|dist-workspace\.toml$|release-please-config\.json$|cliff\.toml$|\.release-please-manifest\.json$|VERSION$)'"),
1340 "{at}: the release-bearing pattern is unchanged: {releasing}"
1341 );
1342 assert_eq!(releasing.contains("--force-scope"), guards, "{at}");
1343 }
1344 }
1345 }
1346
1347 #[test]
1351 fn the_hook_block_without_the_guards_keeps_the_release_hooks_alone() {
1352 for mode in [CheckoutMode::LinkedWorktree, CheckoutMode::MainWorktree] {
1353 for integration in [Integration::Forge, Integration::Local] {
1354 let block = unguarded_hooks_block(mode, integration);
1355 for kept in ["- id: rk-no-hand-authored-tag", "- id: rk-status-check"] {
1356 assert!(block.contains(kept), "{kept}: {block}");
1357 }
1358 for gone in [
1359 "conventional-pre-commit",
1360 "rk-message",
1361 "rk-branch-name",
1362 "rk-worktree-location",
1363 "rk-no-push-to-trunk",
1364 "no-commit-to-branch",
1365 ] {
1366 assert!(!block.contains(gone), "{gone}: {block}");
1367 }
1368 assert!(block.contains("A CI sweep needs no SKIP here"), "{block}");
1369 for token in [
1370 "RK_MESSAGE_GUARDS",
1371 "RK_BRANCH_NAME_GUARD",
1372 "RK_WORKTREE_GUARD",
1373 "RK_TRUNK_COMMIT_GUARD",
1374 "RK_TRUNK_PUSH_GUARD",
1375 "RK_SWEEP_NOTE",
1376 ] {
1377 assert!(!block.contains(token), "{token} survived: {block}");
1378 }
1379 assert_eq!(
1380 block.contains("--hook-stage manual"),
1381 integration == Integration::Local,
1382 "{block}"
1383 );
1384 }
1385 }
1386 }
1387
1388 #[test]
1402 fn the_integration_mode_decides_which_trunk_guards_render() {
1403 for mode in [CheckoutMode::LinkedWorktree, CheckoutMode::MainWorktree] {
1404 let forge = hooks_block_for(mode, Integration::Forge);
1405 let local = hooks_block_for(mode, Integration::Local);
1406 assert!(forge.contains("- id: no-commit-to-branch"), "{forge}");
1407 assert!(forge.contains("- id: rk-no-push-to-trunk"), "{forge}");
1408 assert!(!forge.contains("rk-no-force-push-to-trunk"), "{forge}");
1409 assert!(!forge.contains("rk-no-merge-commit-to-trunk"), "{forge}");
1410 assert!(local.contains("- id: no-commit-to-branch"), "{local}");
1411 assert!(
1412 local.contains("args: [--branch, RK_TRUNK_BRANCH]\n stages: [pre-commit]"),
1413 "the local trunk guard runs at the pre-commit stage alone: {local}"
1414 );
1415 assert!(local.contains("- id: rk-no-force-push-to-trunk"), "{local}");
1416 assert!(
1417 local.contains("- id: rk-no-merge-commit-to-trunk"),
1418 "{local}"
1419 );
1420 assert!(!local.contains("rk-no-push-to-trunk"), "{local}");
1421 for kept in [
1423 "- id: conventional-pre-commit",
1424 "- id: rk-message",
1425 "- id: rk-branch-name",
1426 "- id: rk-no-hand-authored-tag",
1427 "- id: rk-status-check",
1428 ] {
1429 assert!(forge.contains(kept), "{kept}: {forge}");
1430 assert!(local.contains(kept), "{kept}: {local}");
1431 }
1432 let located = mode == CheckoutMode::LinkedWorktree;
1435 assert_eq!(forge.contains("- id: rk-worktree-location"), located);
1436 assert_eq!(local.contains("- id: rk-worktree-location"), located);
1437 assert!(
1439 local.contains("pre-commit run --hook-stage manual --all-files"),
1440 "{local}"
1441 );
1442 assert!(!forge.contains("--hook-stage manual"), "{forge}");
1443 for token in [
1444 "RK_TRUNK_COMMIT_GUARD",
1445 "RK_TRUNK_PUSH_GUARD",
1446 "RK_SWEEP_NOTE",
1447 ] {
1448 assert!(!local.contains(token), "{token} survived: {local}");
1449 assert!(!forge.contains(token), "{token} survived: {forge}");
1450 }
1451 }
1452 assert!(
1456 hooks_block_for(CheckoutMode::LinkedWorktree, Integration::Local)
1457 .contains("SKIP=no-commit-to-branch,rk-worktree-location in")
1458 );
1459 assert!(
1460 hooks_block_for(CheckoutMode::MainWorktree, Integration::Local)
1461 .contains("SKIP=no-commit-to-branch in")
1462 );
1463 assert!(
1464 unguarded_hooks_block(CheckoutMode::MainWorktree, Integration::Local)
1465 .contains("A CI sweep needs no SKIP here")
1466 );
1467 }
1468
1469 #[test]
1475 fn a_landed_sweep_note_invites_the_projects_own_skips() {
1476 let with_skip = hooks_block_for(CheckoutMode::LinkedWorktree, Integration::Local);
1477 assert!(
1478 with_skip.contains("SKIP=no-commit-to-branch,rk-worktree-location in"),
1479 "{with_skip}"
1480 );
1481 assert!(with_skip.contains("local-only"), "{with_skip}");
1482 assert!(with_skip.contains("the value is a list the"), "{with_skip}");
1483
1484 let without = unguarded_hooks_block(CheckoutMode::MainWorktree, Integration::Local);
1485 assert!(
1486 without.contains("A CI sweep needs no SKIP here"),
1487 "{without}"
1488 );
1489 assert!(without.contains("local-only"), "{without}");
1490 }
1491
1492 #[test]
1498 fn a_landed_integrate_note_names_both_axes() {
1499 for mode in [CheckoutMode::LinkedWorktree, CheckoutMode::MainWorktree] {
1500 let local = hooks_block_for(mode, Integration::Local);
1501 assert!(
1502 local.contains("pre-commit run --hook-stage manual --all-files"),
1503 "{local}"
1504 );
1505 assert!(
1506 local.contains("continuous integration invoke the same stages"),
1507 "{local}"
1508 );
1509 assert!(
1510 local.contains("guards one side alone"),
1511 "the note states the consequence: {local}"
1512 );
1513 let forge = hooks_block_for(mode, Integration::Forge);
1515 assert!(!forge.contains("--hook-stage manual"), "{forge}");
1516 }
1517 }
1518
1519 #[test]
1523 fn the_hook_marker_defects_are_named() {
1524 use super::hooks_marker_defect;
1525 let owned = hooks_block(CheckoutMode::MainWorktree);
1526 let block = owned.as_str();
1527 assert_eq!(hooks_marker_defect(""), None);
1528 assert_eq!(hooks_marker_defect(&format!("repos:\n{block}\n")), None);
1529 for (case, text) in [
1530 (
1531 "a second begin",
1532 format!("repos:\n{block}\n# BEGIN release-kit\n"),
1533 ),
1534 (
1535 "a second end",
1536 format!("repos:\n{block}\n# END release-kit\n"),
1537 ),
1538 (
1539 "an unpaired begin",
1540 "repos:\n# BEGIN release-kit\n".to_owned(),
1541 ),
1542 ("an unpaired end", "repos:\n# END release-kit\n".to_owned()),
1543 (
1544 "an end before its begin",
1545 "repos:\n# END release-kit\n# BEGIN release-kit\n".to_owned(),
1546 ),
1547 ] {
1548 assert!(
1549 hooks_marker_defect(&text).is_some(),
1550 "{case} must be a defect"
1551 );
1552 }
1553 }
1554}