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