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, 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(mode: CheckoutMode) -> String {
193 projection::routing_block(mode, Integration::Forge).expect("the binary embeds the block")
194 }
195
196 fn routing_block_for(mode: CheckoutMode, integration: Integration) -> String {
197 projection::routing_block(mode, integration).expect("the binary embeds the block")
198 }
199
200 fn hooks_block(mode: CheckoutMode) -> String {
201 projection::hooks_block(mode, Integration::Forge).expect("the binary embeds the block")
202 }
203
204 fn hooks_block_for(mode: CheckoutMode, integration: Integration) -> String {
205 projection::hooks_block(mode, integration).expect("the binary embeds the block")
206 }
207
208 fn glossary_block() -> String {
209 projection::glossary_block().expect("the binary embeds the block")
210 }
211
212 fn spliced(existing: Option<&str>, block: &str) -> String {
215 String::from_utf8(splice_marked_block(existing.map(str::as_bytes), block))
216 .expect("the fixtures are text")
217 }
218
219 #[test]
220 fn private_reporting_path_tokens_are_reproducible() {
221 for repo in [
222 "acme/widget",
223 "acme/group/widget",
224 "acme/OWNER-RK_STYLE-RK_SCOPE_SHAPE",
225 ] {
226 assert_eq!(
227 super::render(
228 b"RK_REPO RK_REPO OWNER RK_STYLE RK_SCOPE_SHAPE",
229 &super::Params::for_test(repo, Some(super::Style::Trunk))
230 ),
231 format!("{repo} {repo} acme trunk {}", super::SCOPE_SHAPE).as_bytes()
232 );
233 }
234 assert_eq!(super::kind_of("SECURITY.md"), Some(super::Kind::Rendered));
235 }
236
237 #[test]
242 fn each_forge_policy_carries_one_ordered_pair_of_every_span() {
243 for forge in ["github", "gitlab"] {
244 let bytes = embedded::SNIPPETS
245 .get_file(format!("_shared/{forge}/SECURITY.md"))
246 .expect("the policy ships")
247 .contents();
248 let text = String::from_utf8_lossy(bytes);
249 for (begin, end) in super::SECURITY_SPANS {
250 let begin = String::from_utf8_lossy(begin);
251 let end = String::from_utf8_lossy(end);
252 assert_eq!(text.matches(begin.as_ref()).count(), 1, "{forge} {begin}");
253 assert_eq!(text.matches(end.as_ref()).count(), 1, "{forge} {end}");
254 assert!(
255 text.find(begin.as_ref()) < text.find(end.as_ref()),
256 "{forge}: {begin} must precede {end}"
257 );
258 }
259 }
260 }
261
262 #[test]
267 fn the_security_spans_render_per_answer() {
268 for forge in ["github", "gitlab"] {
269 let bytes = embedded::SNIPPETS
270 .get_file(format!("_shared/{forge}/SECURITY.md"))
271 .expect("the policy ships")
272 .contents();
273 let authored = String::from_utf8_lossy(bytes);
274 let stripped = {
275 let mut text = authored.clone().into_owned();
276 for (begin, end) in super::SECURITY_SPANS {
277 text = text.replace(&String::from_utf8_lossy(begin).into_owned(), "");
278 text = text.replace(&String::from_utf8_lossy(end).into_owned(), "");
279 }
280 text
281 };
282 let mut default = super::Params::for_test_security("", crate::config::RESPONSE_DEFAULT);
283 default.set_pair_for_test("rust", forge);
284 let rendered = String::from_utf8(render(bytes, &default)).expect("text");
285 assert_eq!(
286 rendered,
287 stripped.replace("RK_REPO", "acme/widget"),
288 "{forge}: the default answers must reproduce the authored policy"
289 );
290 assert!(!rendered.contains("RK_SECURITY"), "{forge}: {rendered}");
291
292 let mut answered =
293 super::Params::for_test_security("OWNER RK_REPO <team@acme.example>", "14 days");
294 answered.set_pair_for_test("rust", forge);
295 let rendered = String::from_utf8(render(bytes, &answered)).expect("text");
296 assert!(
297 rendered.contains("OWNER RK_REPO <team@acme.example>"),
298 "{forge}: a contact spelling a token name lands literally: {rendered}"
299 );
300 assert!(
301 rendered.contains("Maintainers acknowledge a report within 14 days."),
302 "{forge}: {rendered}"
303 );
304 assert!(
305 rendered.contains("This policy commits to no disclosure deadline."),
306 "{forge}: {rendered}"
307 );
308 assert!(
309 !rendered.contains("best-effort basis"),
310 "{forge}: a stated window replaces the best-effort sentence: {rendered}"
311 );
312 assert!(
313 !rendered.contains("no response or disclosure deadline"),
314 "{forge}: a stated window contradicts the response disclaimer: {rendered}"
315 );
316 }
317 }
318
319 #[test]
322 fn a_defective_span_renders_unchanged() {
323 let (begin, end) = super::SECURITY_SPANS[0];
324 let begin = String::from_utf8_lossy(begin).into_owned();
325 let end = String::from_utf8_lossy(end).into_owned();
326 let params = super::Params::for_test_security("team@acme.example", "1 day");
327 for baseline in [
328 format!("contact {begin}a maintainer\n"),
329 format!("contact a maintainer{end}\n"),
330 format!("contact {end}a maintainer{begin}\n"),
331 "contact a maintainer\n".to_owned(),
332 ] {
333 assert_eq!(
334 render(baseline.as_bytes(), ¶ms),
335 baseline.as_bytes(),
336 "{baseline}"
337 );
338 }
339 }
340
341 #[test]
345 fn the_kind_table_closes_over_every_snippet() {
346 for tech_dir in embedded::SNIPPETS.dirs() {
347 for pair_dir in tech_dir.dirs() {
348 let prefix = format!("{}/", pair_dir.path().to_string_lossy());
349 for (path, _) in embedded::walk(pair_dir) {
350 let destination = path.strip_prefix(&prefix).unwrap_or(&path);
351 assert!(
352 kind_of(destination).is_some(),
353 "{destination}: no declared kind"
354 );
355 }
356 }
357 }
358 for block in BLOCK_DESTINATIONS {
359 assert_eq!(kind_of(block), Some(Kind::Rendered), "{block}");
360 }
361 assert_eq!(kind_of("something-else.txt"), None);
362 }
363
364 #[test]
369 fn rendering_substitutes_every_owner_occurrence() {
370 let baseline = b"if: repository_owner == 'OWNER'\n# OWNER again: OWNER\n";
371 let rendered = render(baseline, &super::Params::for_test("acme/sub/widget", None));
372 let text = String::from_utf8(rendered).expect("rendered bytes stay text");
373 assert_eq!(text, "if: repository_owner == 'acme'\n# acme again: acme\n");
374
375 let baseline = b"match (RK_SCOPE_SHAPE)\n";
376 let rendered = render(baseline, &super::Params::for_test("acme/widget", None));
377 let text = String::from_utf8(rendered).expect("rendered bytes stay text");
378 assert_eq!(text, format!("match ({SCOPE_SHAPE})\n"));
379 }
380
381 #[test]
385 fn the_scope_shape_drops_into_the_title_check() {
386 assert_eq!(SCOPE_SHAPE, "[a-z0-9._/-]+");
387 assert!(
388 !SCOPE_SHAPE.contains('\''),
389 "the title checks single-quote it"
390 );
391 }
392
393 #[test]
398 fn the_scope_predicate_and_the_rendered_pattern_agree() {
399 let body = SCOPE_SHAPE
400 .strip_prefix('[')
401 .and_then(|rest| rest.strip_suffix("]+"))
402 .expect("the shape is one bracket expression, repeated");
403 let chars: Vec<char> = body.chars().collect();
404 let mut admitted = std::collections::BTreeSet::new();
405 let mut at = 0;
406 while at < chars.len() {
407 if at + 2 < chars.len() && chars[at + 1] == '-' {
410 for c in chars[at]..=chars[at + 2] {
411 admitted.insert(c);
412 }
413 at += 3;
414 } else {
415 admitted.insert(chars[at]);
416 at += 1;
417 }
418 }
419 for byte in 0..=127u8 {
420 let c = char::from(byte);
421 assert_eq!(
422 super::scope_is_shaped(&c.to_string()),
423 admitted.contains(&c),
424 "the predicate and {SCOPE_SHAPE} disagree on {c:?}"
425 );
426 }
427 assert!(super::scope_is_shaped("guides/release"));
428 assert!(!super::scope_is_shaped(""), "a scope is never empty");
429 assert!(!super::scope_is_shaped("Specs Ugly"));
430 }
431
432 #[test]
436 fn the_shared_zone_composes_into_the_pair() {
437 let mut github = super::Params::for_test("acme/widget", Some(Style::Trunk));
438 github.set_pair_for_test("rust", "github");
439 let github = destinations(&github);
440 assert!(
441 github.contains(&".github/workflows/pr-title.yml".to_owned()),
442 "the shared title check lands with the pair"
443 );
444 assert!(github.contains(&"SECURITY.md".to_owned()));
445 let mut gitlab = super::Params::for_test("acme/widget", Some(Style::Trunk));
446 gitlab.set_pair_for_test("rust", "gitlab");
447 let gitlab = destinations(&gitlab);
448 assert!(
449 gitlab.contains(&".gitlab/ci/mr-title.yml".to_owned()),
450 "the shared title job lands with the pair"
451 );
452 assert!(
453 !crate::profile::catalog::known_drivers()
454 .iter()
455 .any(|driver| driver.starts_with('_')),
456 "the shared zone is no driver"
457 );
458 }
459
460 #[test]
463 fn params_from_a_record_round_trips() {
464 use super::{Params, manifest};
465 let dir = tempfile::tempdir().expect("a scratch target exists");
466 let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
467 for tech in ["rust", "bash"] {
468 for forge in ["github", "gitlab"] {
469 for (checkout_mode, integration) in MODE_PAIRS {
470 for style in [None, Some(Style::Trunk), Some(Style::Lines)] {
471 for ((nix, scorecard), code_scanning) in [
472 ((false, false), None),
473 ((false, true), Some(Provider::Semgrep)),
474 ((true, false), Some(Provider::CodeQl)),
475 ((true, true), None),
476 ] {
477 let record = manifest::Manifest {
478 schema_version: manifest::SCHEMA_VERSION,
479 rk_version: "0.1.0".to_owned(),
480 origin: "init".to_owned(),
481 landed_at: "2026-08-29T00:00:00Z".to_owned(),
482 profile: ProfileSnapshot {
483 technologies: vec![tech.to_owned()],
484 forge: Some(forge.to_owned()),
485 release: ReleaseIntent {
486 mode: ReleaseMode::Automatic,
487 driver: Some(tech.to_owned()),
488 style,
489 line_prefix: Some(
490 crate::config::LINE_PREFIX_DEFAULT.to_owned(),
491 ),
492 },
493 },
494 git: GitWorkflow {
495 trunk: crate::config::TRUNK_DEFAULT.to_owned(),
496 checkout_mode,
497 integration,
498 },
499 capabilities: CapabilityRequests {
500 nix_packaging: nix,
501 reporting_policy: true,
502 scorecard,
503 code_scanning,
504 },
505 parameters: manifest::Parameters {
506 repo: "acme/team/widget".to_owned(),
507 security_contact: String::new(),
508 security_response: crate::config::RESPONSE_DEFAULT.to_owned(),
509 },
510 files: Vec::new(),
511 pins: std::collections::BTreeMap::new(),
512 };
513 manifest::write(target, &record).expect("the record writes");
514 let loaded = manifest::load(target)
515 .expect("the record loads")
516 .expect("the record exists");
517 let params = Params::from_record(&loaded);
518 assert_eq!(params.driver(), Some(tech));
519 assert_eq!(params.forge(), Some(forge));
520 assert_eq!(params.repo(), "acme/team/widget");
521 assert_eq!(params.integration(), integration);
522 assert_eq!(params.style(), style);
523 assert_eq!(params.nix_packaging(), nix);
524 assert_eq!(params.scorecard(), scorecard);
525 assert_eq!(params.code_scanning(), code_scanning);
526 let mut direct = super::Params::for_test("acme/team/widget", style);
529 direct.set_pair_for_test(tech, forge);
530 direct.set_checkout_mode_for_test(checkout_mode);
531 direct.set_integration_for_test(integration);
532 direct.set_nix_for_test(nix);
533 direct.set_scorecard_for_test(scorecard);
534 direct.set_code_scanning_for_test(code_scanning);
535 assert_eq!(params, direct);
536 let projected = destinations(¶ms);
537 for block in
538 [AGENTS_DESTINATION, GLOSSARY_DESTINATION, HOOKS_DESTINATION]
539 {
540 assert!(projected.contains(&block.to_owned()), "{block}");
541 }
542 for destination in super::NIX_DESTINATIONS {
543 assert_eq!(
544 projected.contains(&destination.to_owned()),
545 nix && tech == "rust",
546 "{tech} {forge} nix={nix}: {destination}"
547 );
548 }
549 for destination in super::SCORECARD_DESTINATIONS {
553 assert_eq!(
554 projected.contains(&destination.to_owned()),
555 scorecard && forge == "github",
556 "{tech} {forge} scorecard={scorecard}: {destination}"
557 );
558 }
559 }
560 }
561 }
562 }
563 }
564 }
565
566 fn resolved_test_params(
567 tech: &str,
568 resolved: &super::Resolved,
569 checkout_mode: CheckoutMode,
570 style: Option<Style>,
571 nix: bool,
572 scorecard: bool,
573 code_scanning: Option<Provider>,
574 ) -> Result<super::Params, crate::error::RkError> {
575 let technologies = vec![tech.to_owned()];
576 super::Params::resolve(
577 camino::Utf8Path::new("."),
578 &super::Inputs {
579 technologies: &technologies,
580 forge: Some(&resolved.forge),
581 repo: resolved.repo.as_deref(),
582 release_mode: Some(ReleaseMode::Automatic),
583 release_driver: Some(tech),
584 style,
585 trunk: None,
586 checkout_mode: Some(checkout_mode),
587 integration: None,
588 nix: Some(nix),
589 reporting_policy: None,
590 scorecard: Some(scorecard),
591 code_scanning: Some(code_scanning),
592 },
593 None,
594 None,
595 super::Purpose::Init,
596 )
597 }
598
599 #[test]
603 fn a_projection_renders_owned_files_and_keeps_seeded_judgment() {
604 let params = resolved_test_params(
605 "rust",
606 &super::Resolved {
607 forge: "github".to_owned(),
608 repo: Some("acme/widget".to_owned()),
609 },
610 CheckoutMode::MainWorktree,
611 Some(Style::Trunk),
612 false,
613 false,
614 None,
615 )
616 .expect("the parameters resolve");
617 let entries = Projection::compute(&ProjectionInput {
618 params,
619 evidence: TargetEvidence::default(),
620 })
621 .expect("the pair projects")
622 .candidates;
623 let workflow = entries
624 .iter()
625 .find(|entry| entry.destination.ends_with("release-plz.yml"))
626 .expect("the workflow projects");
627 assert_eq!(workflow.kind, Kind::Rendered);
628 let text = String::from_utf8_lossy(&workflow.bytes);
629 assert!(!text.contains("OWNER"), "an owner token survived rendering");
630 assert!(text.contains("'acme'"));
631 assert!(!text.contains("TODO(release-kit)"));
632 let title = entries
633 .iter()
634 .find(|entry| entry.destination.ends_with("pr-title.yml"))
635 .expect("the title check projects");
636 let text = String::from_utf8_lossy(&title.bytes);
637 assert!(text.contains(SCOPE_SHAPE), "{text}");
638 assert!(
639 !text.contains("RK_SCOPE_SHAPE"),
640 "a scope token survived: {text}"
641 );
642 let seeded = entries
643 .iter()
644 .find(|entry| entry.destination == "release-plz.toml")
645 .expect("the seeded file projects");
646 assert_eq!(seeded.kind, Kind::Seeded);
647 let authored = embedded::SNIPPETS
648 .get_file("rust/github/release-plz.toml")
649 .expect("the seed ships")
650 .contents();
651 assert_eq!(seeded.bytes, authored, "a seeded file lands as authored");
652 assert!(String::from_utf8_lossy(&seeded.bytes).contains("TODO(release-kit)"));
653 for block in BLOCK_DESTINATIONS {
654 let entry = entries
655 .iter()
656 .find(|entry| entry.destination == block)
657 .expect("every block is part of the projection");
658 let text = String::from_utf8_lossy(&entry.bytes);
659 assert!(
660 !text.contains("RK_SCOPE_SHAPE"),
661 "{block} kept a token: {text}"
662 );
663 }
664 }
665
666 #[test]
671 fn the_nix_destinations_project_only_under_the_opt_in() {
672 use super::NIX_DESTINATIONS;
673 let paths = |nix: bool, forge: &str| -> Vec<String> {
674 destinations(
675 &resolved_test_params(
676 "rust",
677 &super::Resolved {
678 forge: forge.to_owned(),
679 repo: Some("acme/widget".to_owned()),
680 },
681 CheckoutMode::LinkedWorktree,
682 Some(Style::Trunk),
683 nix,
684 false,
685 None,
686 )
687 .expect("the parameters resolve"),
688 )
689 };
690 let off = paths(false, "github");
691 for destination in NIX_DESTINATIONS {
692 assert!(!off.contains(&destination.to_owned()), "{destination}");
693 }
694 let on = paths(true, "github");
695 for destination in ["nix/package.nix", "flake.nix", "flake.lock"] {
696 assert!(on.contains(&destination.to_owned()), "{destination}");
697 }
698 let gitlab = paths(true, "gitlab");
703 assert!(gitlab.contains(&"nix/package.nix".to_owned()));
704 assert!(
705 !on.iter()
706 .chain(gitlab.iter())
707 .any(|destination| destination.contains("nix.yml"))
708 );
709 let bash = destinations(
710 &resolved_test_params(
711 "bash",
712 &super::Resolved {
713 forge: "github".to_owned(),
714 repo: Some("acme/widget".to_owned()),
715 },
716 CheckoutMode::LinkedWorktree,
717 Some(Style::Trunk),
718 true,
719 false,
720 None,
721 )
722 .expect("the parameters resolve"),
723 );
724 assert!(
725 bash.iter()
726 .all(|destination| !NIX_DESTINATIONS.contains(&destination.as_str()))
727 );
728 }
729
730 #[test]
735 fn the_nix_seeds_are_identical_across_forge_pairs() {
736 for name in ["nix/package.nix", "flake.nix", "flake.lock"] {
737 let github = embedded::SNIPPETS
738 .get_file(format!("rust/github/{name}"))
739 .expect("the github copy ships")
740 .contents();
741 let gitlab = embedded::SNIPPETS
742 .get_file(format!("rust/gitlab/{name}"))
743 .expect("the gitlab copy ships")
744 .contents();
745 assert_eq!(github, gitlab, "{name} diverged between the pairs");
746 }
747 }
748
749 #[test]
754 fn the_nix_withhold_judgment_covers_the_three_shapes() {
755 use super::NIX_DESTINATIONS;
756 let dir = tempfile::tempdir().expect("a scratch target exists");
757 let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
758 let project = |nix: bool| {
759 let params = resolved_test_params(
760 "rust",
761 &super::Resolved {
762 forge: "github".to_owned(),
763 repo: Some("acme/widget".to_owned()),
764 },
765 CheckoutMode::LinkedWorktree,
766 Some(Style::Trunk),
767 nix,
768 false,
769 None,
770 )
771 .expect("the parameters resolve");
772 let evidence = TargetEvidence::gather(target, None).expect("the evidence reads");
773 Projection::compute(&ProjectionInput { params, evidence }).expect("the pair projects")
774 };
775 let withheld = |projection: &Projection| -> Vec<String> {
776 projection
777 .omissions
778 .iter()
779 .map(|omission| omission.destination.clone())
780 .collect()
781 };
782 let landed = |projection: &Projection, destination: &str| {
783 projection
784 .candidates
785 .iter()
786 .any(|candidate| candidate.destination == destination)
787 };
788
789 let all = project(true);
791 assert_eq!(
792 withheld(&all),
793 ["flake.lock", "flake.nix", "nix/package.nix"]
794 );
795 assert!(
796 all.candidates
797 .iter()
798 .all(|entry| !NIX_DESTINATIONS.contains(&entry.destination.as_str()))
799 );
800
801 std::fs::write(
804 target.join("Cargo.toml"),
805 "[package]\nname = \"widget\"\nversion = \"0.1.0\"\n",
806 )
807 .expect("the crate manifest writes");
808 std::fs::write(target.join("Cargo.lock"), "version = 4\n").expect("the lock writes");
809 std::fs::create_dir_all(target.join("src")).expect("the src dir exists");
810 std::fs::write(target.join("src/main.rs"), "fn main() {}\n").expect("the main writes");
811 std::fs::write(target.join("flake.nix"), "{ }\n").expect("the flake writes");
812 let all = project(true);
813 assert_eq!(withheld(&all), ["flake.lock", "flake.nix"]);
814 assert!(landed(&all, "nix/package.nix"));
815
816 std::fs::remove_file(target.join("flake.nix")).expect("the flake removes");
818 let all = project(true);
819 assert!(all.omissions.is_empty());
820 assert!(landed(&all, "flake.nix"));
821
822 let all = project(false);
824 assert!(all.omissions.is_empty());
825 assert!(!landed(&all, "flake.nix"));
826 }
827
828 #[test]
832 fn the_glossary_splices_into_every_shape() {
833 let owned = glossary_block();
834 let block = owned.as_str();
835
836 let fresh = spliced(None, block);
837 assert_eq!(fresh, format!("{block}\n"));
838 assert_eq!(extract_block(&fresh, BLOCK_BEGIN, BLOCK_END), Some(block));
839
840 let own = "# Glossary\n\n- `spike` — a throwaway branch.\n";
841 let appended = spliced(Some(own), block);
842 assert!(appended.starts_with(own));
843 assert_eq!(
844 extract_block(&appended, BLOCK_BEGIN, BLOCK_END),
845 Some(block)
846 );
847
848 let stale = appended.replace("full-implement", "do-everything");
849 let refreshed = spliced(Some(&stale), block);
850 assert_eq!(
851 extract_block(&refreshed, BLOCK_BEGIN, BLOCK_END),
852 Some(block)
853 );
854 assert_eq!(
855 refreshed.matches("BEGIN release-kit").count(),
856 1,
857 "a re-splice must replace, not accumulate"
858 );
859 }
860
861 #[test]
865 fn the_glossary_leaves_the_targets_region_alone() {
866 let owned = glossary_block();
867 let block = owned.as_str();
868 let below = "\n## Our own terms\n\n- `spike` — a throwaway branch, never merged.\n";
869 let landed = format!("{block}\n{below}");
870
871 let refreshed = spliced(Some(&landed), block);
872 assert!(
873 refreshed.ends_with(below),
874 "the target's own region changed: {refreshed}"
875 );
876 assert_eq!(
877 extract_block(&refreshed, BLOCK_BEGIN, BLOCK_END),
878 Some(block)
879 );
880 }
881
882 #[test]
886 fn an_append_rewrites_no_byte_the_target_wrote() {
887 let owned = glossary_block();
888 let block = owned.as_str();
889 for own in [
890 "# Glossary\n\n- `spike` — throwaway. \n\n\n",
891 "# Glossary\n\n- `spike` — throwaway.",
892 "# Glossary\r\n\r\n- `spike` — throwaway.\r\n",
893 ] {
894 let appended = spliced(Some(own), block);
895 assert!(
896 appended.starts_with(own),
897 "the target's bytes changed: {appended:?}"
898 );
899 assert_eq!(
900 extract_block(&appended, BLOCK_BEGIN, BLOCK_END),
901 Some(block),
902 "{appended:?}"
903 );
904 let marker = appended.find(BLOCK_BEGIN).expect("the block landed");
905 assert!(
906 appended[..marker].ends_with('\n'),
907 "the block must open its own line: {appended:?}"
908 );
909 }
910 }
911
912 #[test]
916 fn a_splice_decodes_no_byte_the_target_wrote() {
917 let owned = glossary_block();
918 let block = owned.as_str();
919
920 let own = b"# Glossary\n\ncaf\xe9\n";
922 let appended = splice_marked_block(Some(own), block);
923 assert!(
924 appended.starts_with(own),
925 "the target's bytes changed: {appended:?}"
926 );
927 assert!(!appended.contains(&0xEF), "a replacement character landed");
928
929 let mut landed = Vec::new();
931 landed.extend_from_slice(block.replace("full-implement", "do-everything").as_bytes());
932 landed.extend_from_slice(b"\n\ncaf\xe9\n");
933 let refreshed = splice_marked_block(Some(&landed), block);
934 assert!(
935 refreshed.ends_with(b"\n\ncaf\xe9\n"),
936 "the target's region below the markers changed: {refreshed:?}"
937 );
938 assert!(refreshed.starts_with(block.as_bytes()), "{refreshed:?}");
939 }
940
941 #[test]
944 fn the_glossary_block_carries_no_parameter() {
945 let block = glossary_block();
946 assert!(block.starts_with(BLOCK_BEGIN), "{block}");
947 assert!(block.ends_with(BLOCK_END), "{block}");
948 assert!(!block.contains("RK_"), "a token survived: {block}");
949 assert!(!block.contains("OWNER"), "an owner token survived: {block}");
950 for term in [
951 "implement-and-request",
952 "implement-and-merge",
953 "full-implement",
954 ] {
955 assert!(block.contains(term), "{term} is missing from {block}");
956 }
957 assert!(
958 routing_block(CheckoutMode::LinkedWorktree).contains(GLOSSARY_DESTINATION),
959 "the routing block must name the destination it indexes"
960 );
961 }
962
963 #[test]
964 fn the_block_splices_into_every_agents_shape() {
965 let owned = routing_block(CheckoutMode::MainWorktree);
966 let block = owned.as_str();
967 let fresh = spliced(None, block);
968 assert_eq!(fresh, format!("{block}\n"));
969 assert_eq!(extract_block(&fresh, BLOCK_BEGIN, BLOCK_END), Some(block));
970
971 let appended = spliced(Some("# My project\n\nOwn rules.\n"), block);
972 assert!(appended.starts_with("# My project\n\nOwn rules.\n\n<!-- BEGIN release-kit -->"));
973 assert_eq!(
974 extract_block(&appended, BLOCK_BEGIN, BLOCK_END),
975 Some(block)
976 );
977
978 let stale = appended.replace("Never author a tag", "Do author a tag");
979 let refreshed = spliced(Some(&stale), block);
980 assert_eq!(
981 extract_block(&refreshed, BLOCK_BEGIN, BLOCK_END),
982 Some(block)
983 );
984 assert!(refreshed.starts_with("# My project"));
985 assert_eq!(
986 refreshed.matches("BEGIN release-kit").count(),
987 1,
988 "a re-splice must replace, not accumulate"
989 );
990 }
991
992 #[test]
995 fn the_hook_block_splices_under_repos() {
996 let owned = hooks_block(CheckoutMode::MainWorktree);
997 let block = owned.as_str();
998 let fresh = splice_hooks_block(None, block).expect("a fresh file splices");
999 assert!(fresh.starts_with(HOOK_TYPES_LINE));
1000 assert!(fresh.contains("\nrepos:\n# BEGIN release-kit\n"));
1001 assert_eq!(extract_block(&fresh, HOOKS_BEGIN, HOOKS_END), Some(block));
1002
1003 let own =
1004 "repos:\n - repo: https://example.com/own\n rev: v1\n hooks:\n - id: own\n";
1005 let spliced = splice_hooks_block(Some(own), block).expect("an unmarked file splices");
1006 assert!(spliced.starts_with("repos:\n# BEGIN release-kit\n"));
1007 assert!(spliced.contains("- id: own"), "the target's hooks survive");
1008 assert!(
1009 !spliced.contains(HOOK_TYPES_LINE),
1010 "an existing file's top level is the skills' duty, not the splice's"
1011 );
1012
1013 let stale = spliced.replace("--force-scope", "--no-scope");
1014 let refreshed = splice_hooks_block(Some(&stale), block).expect("a marked file re-splices");
1015 assert_eq!(
1016 extract_block(&refreshed, HOOKS_BEGIN, HOOKS_END),
1017 Some(block)
1018 );
1019 assert_eq!(refreshed.matches(HOOKS_BEGIN).count(), 1);
1020
1021 let err = splice_hooks_block(Some("minimum_pre_commit_version: '3.2.0'\n"), block)
1022 .expect_err("no repos: line refuses");
1023 assert!(err.contains("repos:"), "{err}");
1024
1025 let doubled = format!("repos:\n{block}\n{block}\n");
1029 let err = splice_hooks_block(Some(&doubled), block).expect_err("a second block refuses");
1030 assert!(err.contains("one block"), "{err}");
1031 let unmatched = "repos:\n# BEGIN release-kit\n - repo: local\n";
1032 let err =
1033 splice_hooks_block(Some(unmatched), block).expect_err("an unmatched marker refuses");
1034 assert!(err.contains("unmatched"), "{err}");
1035 }
1036
1037 #[test]
1043 fn the_blocks_render_per_mode_and_carry_the_one_grammar() {
1044 let worktree_hooks = hooks_block(CheckoutMode::LinkedWorktree);
1045 let branches_hooks = hooks_block(CheckoutMode::MainWorktree);
1046 assert!(worktree_hooks.contains("- id: rk-worktree-location"));
1047 assert!(
1048 worktree_hooks.contains("SKIP=no-commit-to-branch,rk-worktree-location"),
1049 "{worktree_hooks}"
1050 );
1051 assert!(!branches_hooks.contains("rk-worktree-location"));
1052 assert!(branches_hooks.contains("SKIP=no-commit-to-branch in"));
1053 for block in [&worktree_hooks, &branches_hooks] {
1054 assert!(block.contains(BRANCH_GRAMMAR), "the grammar has one owner");
1055 for token in [
1056 "RK_BRANCH_GRAMMAR",
1057 "RK_SWEEP_SKIP",
1058 "RK_SWEEP_NOTE",
1059 "RK_WORKTREE_GUARD",
1060 "RK_TRUNK_COMMIT_GUARD",
1061 "RK_TRUNK_PUSH_GUARD",
1062 ] {
1063 assert!(!block.contains(token), "{token} survived: {block}");
1064 }
1065 }
1066 for block in [&worktree_hooks, &branches_hooks] {
1071 for line in block.lines() {
1072 if let Some(value) = line.trim_start().strip_prefix("entry: ") {
1073 assert!(
1074 !value.contains(": "),
1075 "an entry value breaks the YAML plain scalar: {line}"
1076 );
1077 }
1078 }
1079 }
1080 let guard_line = worktree_hooks
1081 .lines()
1082 .position(|line| line.contains("id: rk-worktree-location"))
1083 .expect("the guard entry exists");
1084 let name_line = worktree_hooks
1085 .lines()
1086 .position(|line| line.contains("id: rk-branch-name"))
1087 .expect("the name hook exists");
1088 assert!(
1089 guard_line > name_line,
1090 "the guard lands directly after rk-branch-name"
1091 );
1092
1093 let worktree_routing = routing_block(CheckoutMode::LinkedWorktree);
1094 let branches_routing = routing_block(CheckoutMode::MainWorktree);
1095 assert!(worktree_routing.contains("This project works in worktrees"));
1096 assert!(branches_routing.contains("Branches are worked in the main checkout"));
1097 for block in [&worktree_routing, &branches_routing] {
1098 assert!(block.contains("Create or remove a worktree"));
1099 assert!(block.contains("`rk worktree add <branch>`"));
1100 assert!(!block.contains("RK_WORKFLOW_LINE"), "{block}");
1101 assert!(!block.contains("RK_INTEGRATION_LINE"), "{block}");
1102 }
1103 let differing: Vec<(&str, &str)> = worktree_routing
1104 .lines()
1105 .zip(branches_routing.lines())
1106 .filter(|(a, b)| a != b)
1107 .collect();
1108 assert_eq!(
1109 differing.len(),
1110 1,
1111 "exactly one routing line differs per mode: {differing:?}"
1112 );
1113 }
1114
1115 #[test]
1125 fn the_integration_mode_decides_which_trunk_guards_render() {
1126 for mode in [CheckoutMode::LinkedWorktree, CheckoutMode::MainWorktree] {
1127 let forge = hooks_block_for(mode, Integration::Forge);
1128 let local = hooks_block_for(mode, Integration::Local);
1129 assert!(forge.contains("- id: no-commit-to-branch"), "{forge}");
1130 assert!(forge.contains("- id: rk-no-push-to-trunk"), "{forge}");
1131 assert!(!local.contains("no-commit-to-branch"), "{local}");
1132 assert!(!local.contains("rk-no-push-to-trunk"), "{local}");
1133 for kept in [
1135 "- id: conventional-pre-commit",
1136 "- id: rk-message",
1137 "- id: rk-branch-name",
1138 "- id: rk-no-hand-authored-tag",
1139 "- id: rk-status-check",
1140 ] {
1141 assert!(forge.contains(kept), "{kept}: {forge}");
1142 assert!(local.contains(kept), "{kept}: {local}");
1143 }
1144 let located = mode == CheckoutMode::LinkedWorktree;
1147 assert_eq!(forge.contains("- id: rk-worktree-location"), located);
1148 assert_eq!(local.contains("- id: rk-worktree-location"), located);
1149 assert!(
1151 local.contains("pre-commit run --hook-stage manual --all-files"),
1152 "{local}"
1153 );
1154 assert!(!forge.contains("--hook-stage manual"), "{forge}");
1155 for token in [
1156 "RK_TRUNK_COMMIT_GUARD",
1157 "RK_TRUNK_PUSH_GUARD",
1158 "RK_SWEEP_NOTE",
1159 ] {
1160 assert!(!local.contains(token), "{token} survived: {local}");
1161 assert!(!forge.contains(token), "{token} survived: {forge}");
1162 }
1163 }
1164 assert!(
1166 hooks_block_for(CheckoutMode::LinkedWorktree, Integration::Local)
1167 .contains("SKIP=rk-worktree-location in")
1168 );
1169 assert!(
1170 hooks_block_for(CheckoutMode::MainWorktree, Integration::Local)
1171 .contains("A CI sweep needs no SKIP here")
1172 );
1173 for mode in [CheckoutMode::LinkedWorktree, CheckoutMode::MainWorktree] {
1175 let forge = routing_block_for(mode, Integration::Forge);
1176 let local = routing_block_for(mode, Integration::Local);
1177 assert!(forge.contains("squash-merged pull request"), "{forge}");
1178 assert!(local.contains("`rk integrate <branch>`"), "{local}");
1179 let differing = forge
1180 .lines()
1181 .zip(local.lines())
1182 .filter(|(a, b)| a != b)
1183 .count();
1184 assert_eq!(differing, 1, "exactly one routing line differs per mode");
1185 }
1186 }
1187
1188 #[test]
1191 fn the_hook_marker_defects_are_named() {
1192 use super::hooks_marker_defect;
1193 let owned = hooks_block(CheckoutMode::MainWorktree);
1194 let block = owned.as_str();
1195 assert_eq!(hooks_marker_defect(""), None);
1196 assert_eq!(hooks_marker_defect(&format!("repos:\n{block}\n")), None);
1197 for (case, text) in [
1198 (
1199 "a second begin",
1200 format!("repos:\n{block}\n# BEGIN release-kit\n"),
1201 ),
1202 (
1203 "a second end",
1204 format!("repos:\n{block}\n# END release-kit\n"),
1205 ),
1206 (
1207 "an unpaired begin",
1208 "repos:\n# BEGIN release-kit\n".to_owned(),
1209 ),
1210 ("an unpaired end", "repos:\n# END release-kit\n".to_owned()),
1211 (
1212 "an end before its begin",
1213 "repos:\n# END release-kit\n# BEGIN release-kit\n".to_owned(),
1214 ),
1215 ] {
1216 assert!(
1217 hooks_marker_defect(&text).is_some(),
1218 "{case} must be a defect"
1219 );
1220 }
1221 }
1222}