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