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 required_check: "gate".to_owned(),
510 required_workflow: "ci".to_owned(),
511 },
512 files: Vec::new(),
513 pins: std::collections::BTreeMap::new(),
514 };
515 manifest::write(target, &record).expect("the record writes");
516 let loaded = manifest::load(target)
517 .expect("the record loads")
518 .expect("the record exists");
519 let params = Params::from_record(&loaded);
520 assert_eq!(params.driver(), Some(tech));
521 assert_eq!(params.forge(), Some(forge));
522 assert_eq!(params.repo(), "acme/team/widget");
523 assert_eq!(params.integration(), integration);
524 assert_eq!(params.style(), style);
525 assert_eq!(params.nix_packaging(), nix);
526 assert_eq!(params.scorecard(), scorecard);
527 assert_eq!(params.code_scanning(), code_scanning);
528 let mut direct = super::Params::for_test("acme/team/widget", style);
531 direct.set_pair_for_test(tech, forge);
532 direct.set_checkout_mode_for_test(checkout_mode);
533 direct.set_integration_for_test(integration);
534 direct.set_nix_for_test(nix);
535 direct.set_scorecard_for_test(scorecard);
536 direct.set_code_scanning_for_test(code_scanning);
537 assert_eq!(params, direct);
538 let projected = destinations(¶ms);
539 for block in
540 [AGENTS_DESTINATION, GLOSSARY_DESTINATION, HOOKS_DESTINATION]
541 {
542 assert!(projected.contains(&block.to_owned()), "{block}");
543 }
544 for destination in super::NIX_DESTINATIONS {
545 assert_eq!(
546 projected.contains(&destination.to_owned()),
547 nix && tech == "rust",
548 "{tech} {forge} nix={nix}: {destination}"
549 );
550 }
551 for destination in super::SCORECARD_DESTINATIONS {
555 assert_eq!(
556 projected.contains(&destination.to_owned()),
557 scorecard && forge == "github",
558 "{tech} {forge} scorecard={scorecard}: {destination}"
559 );
560 }
561 }
562 }
563 }
564 }
565 }
566 }
567
568 fn resolved_test_params(
569 tech: &str,
570 resolved: &super::Resolved,
571 checkout_mode: CheckoutMode,
572 style: Option<Style>,
573 nix: bool,
574 scorecard: bool,
575 code_scanning: Option<Provider>,
576 ) -> Result<super::Params, crate::error::RkError> {
577 let technologies = vec![tech.to_owned()];
578 super::Params::resolve(
579 camino::Utf8Path::new("."),
580 &super::Inputs {
581 technologies: &technologies,
582 forge: Some(&resolved.forge),
583 repo: resolved.repo.as_deref(),
584 release_mode: Some(ReleaseMode::Automatic),
585 release_driver: Some(tech),
586 style,
587 required_check: (resolved.forge == "github").then_some("gate"),
589 required_workflow: (resolved.forge == "github").then_some("ci"),
590 trunk: None,
591 checkout_mode: Some(checkout_mode),
592 integration: None,
593 nix: Some(nix),
594 reporting_policy: None,
595 scorecard: Some(scorecard),
596 code_scanning: Some(code_scanning),
597 },
598 None,
599 None,
600 super::Purpose::Init,
601 )
602 }
603
604 #[test]
608 fn a_projection_renders_owned_files_and_keeps_seeded_judgment() {
609 let params = resolved_test_params(
610 "rust",
611 &super::Resolved {
612 forge: "github".to_owned(),
613 repo: Some("acme/widget".to_owned()),
614 },
615 CheckoutMode::MainWorktree,
616 Some(Style::Trunk),
617 false,
618 false,
619 None,
620 )
621 .expect("the parameters resolve");
622 let entries = Projection::compute(&ProjectionInput {
623 params,
624 evidence: TargetEvidence::default(),
625 })
626 .expect("the pair projects")
627 .candidates;
628 let workflow = entries
629 .iter()
630 .find(|entry| entry.destination.ends_with("release-plz.yml"))
631 .expect("the workflow projects");
632 assert_eq!(workflow.kind, Kind::Rendered);
633 let text = String::from_utf8_lossy(&workflow.bytes);
634 assert!(!text.contains("OWNER"), "an owner token survived rendering");
635 assert!(text.contains("'acme'"));
636 assert!(!text.contains("TODO(release-kit)"));
637 let title = entries
638 .iter()
639 .find(|entry| entry.destination.ends_with("pr-title.yml"))
640 .expect("the title check projects");
641 let text = String::from_utf8_lossy(&title.bytes);
642 assert!(text.contains(SCOPE_SHAPE), "{text}");
643 assert!(
644 !text.contains("RK_SCOPE_SHAPE"),
645 "a scope token survived: {text}"
646 );
647 let seeded = entries
648 .iter()
649 .find(|entry| entry.destination == "release-plz.toml")
650 .expect("the seeded file projects");
651 assert_eq!(seeded.kind, Kind::Seeded);
652 let authored = embedded::SNIPPETS
653 .get_file("rust/github/release-plz.toml")
654 .expect("the seed ships")
655 .contents();
656 assert_eq!(seeded.bytes, authored, "a seeded file lands as authored");
657 assert!(String::from_utf8_lossy(&seeded.bytes).contains("TODO(release-kit)"));
658 for block in BLOCK_DESTINATIONS {
659 let entry = entries
660 .iter()
661 .find(|entry| entry.destination == block)
662 .expect("every block is part of the projection");
663 let text = String::from_utf8_lossy(&entry.bytes);
664 assert!(
665 !text.contains("RK_SCOPE_SHAPE"),
666 "{block} kept a token: {text}"
667 );
668 }
669 }
670
671 #[test]
676 fn the_nix_destinations_project_only_under_the_opt_in() {
677 use super::NIX_DESTINATIONS;
678 let paths = |nix: bool, forge: &str| -> Vec<String> {
679 destinations(
680 &resolved_test_params(
681 "rust",
682 &super::Resolved {
683 forge: forge.to_owned(),
684 repo: Some("acme/widget".to_owned()),
685 },
686 CheckoutMode::LinkedWorktree,
687 Some(Style::Trunk),
688 nix,
689 false,
690 None,
691 )
692 .expect("the parameters resolve"),
693 )
694 };
695 let off = paths(false, "github");
696 for destination in NIX_DESTINATIONS {
697 assert!(!off.contains(&destination.to_owned()), "{destination}");
698 }
699 let on = paths(true, "github");
700 for destination in ["nix/package.nix", "flake.nix", "flake.lock"] {
701 assert!(on.contains(&destination.to_owned()), "{destination}");
702 }
703 let gitlab = paths(true, "gitlab");
708 assert!(gitlab.contains(&"nix/package.nix".to_owned()));
709 assert!(
710 !on.iter()
711 .chain(gitlab.iter())
712 .any(|destination| destination.contains("nix.yml"))
713 );
714 let bash = destinations(
715 &resolved_test_params(
716 "bash",
717 &super::Resolved {
718 forge: "github".to_owned(),
719 repo: Some("acme/widget".to_owned()),
720 },
721 CheckoutMode::LinkedWorktree,
722 Some(Style::Trunk),
723 true,
724 false,
725 None,
726 )
727 .expect("the parameters resolve"),
728 );
729 assert!(
730 bash.iter()
731 .all(|destination| !NIX_DESTINATIONS.contains(&destination.as_str()))
732 );
733 }
734
735 #[test]
740 fn the_nix_seeds_are_identical_across_forge_pairs() {
741 for name in ["nix/package.nix", "flake.nix", "flake.lock"] {
742 let github = embedded::SNIPPETS
743 .get_file(format!("rust/github/{name}"))
744 .expect("the github copy ships")
745 .contents();
746 let gitlab = embedded::SNIPPETS
747 .get_file(format!("rust/gitlab/{name}"))
748 .expect("the gitlab copy ships")
749 .contents();
750 assert_eq!(github, gitlab, "{name} diverged between the pairs");
751 }
752 }
753
754 #[test]
759 fn the_nix_withhold_judgment_covers_the_three_shapes() {
760 use super::NIX_DESTINATIONS;
761 let dir = tempfile::tempdir().expect("a scratch target exists");
762 let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
763 let project = |nix: bool| {
764 let params = resolved_test_params(
765 "rust",
766 &super::Resolved {
767 forge: "github".to_owned(),
768 repo: Some("acme/widget".to_owned()),
769 },
770 CheckoutMode::LinkedWorktree,
771 Some(Style::Trunk),
772 nix,
773 false,
774 None,
775 )
776 .expect("the parameters resolve");
777 let evidence = TargetEvidence::gather(target, None).expect("the evidence reads");
778 Projection::compute(&ProjectionInput { params, evidence }).expect("the pair projects")
779 };
780 let withheld = |projection: &Projection| -> Vec<String> {
781 projection
782 .omissions
783 .iter()
784 .map(|omission| omission.destination.clone())
785 .collect()
786 };
787 let landed = |projection: &Projection, destination: &str| {
788 projection
789 .candidates
790 .iter()
791 .any(|candidate| candidate.destination == destination)
792 };
793
794 let all = project(true);
796 assert_eq!(
797 withheld(&all),
798 ["flake.lock", "flake.nix", "nix/package.nix"]
799 );
800 assert!(
801 all.candidates
802 .iter()
803 .all(|entry| !NIX_DESTINATIONS.contains(&entry.destination.as_str()))
804 );
805
806 std::fs::write(
809 target.join("Cargo.toml"),
810 "[package]\nname = \"widget\"\nversion = \"0.1.0\"\n",
811 )
812 .expect("the crate manifest writes");
813 std::fs::write(target.join("Cargo.lock"), "version = 4\n").expect("the lock writes");
814 std::fs::create_dir_all(target.join("src")).expect("the src dir exists");
815 std::fs::write(target.join("src/main.rs"), "fn main() {}\n").expect("the main writes");
816 std::fs::write(target.join("flake.nix"), "{ }\n").expect("the flake writes");
817 let all = project(true);
818 assert_eq!(withheld(&all), ["flake.lock", "flake.nix"]);
819 assert!(landed(&all, "nix/package.nix"));
820
821 std::fs::remove_file(target.join("flake.nix")).expect("the flake removes");
823 let all = project(true);
824 assert!(all.omissions.is_empty());
825 assert!(landed(&all, "flake.nix"));
826
827 let all = project(false);
829 assert!(all.omissions.is_empty());
830 assert!(!landed(&all, "flake.nix"));
831 }
832
833 #[test]
837 fn the_glossary_splices_into_every_shape() {
838 let owned = glossary_block();
839 let block = owned.as_str();
840
841 let fresh = spliced(None, block);
842 assert_eq!(fresh, format!("{block}\n"));
843 assert_eq!(extract_block(&fresh, BLOCK_BEGIN, BLOCK_END), Some(block));
844
845 let own = "# Glossary\n\n- `spike` — a throwaway branch.\n";
846 let appended = spliced(Some(own), block);
847 assert!(appended.starts_with(own));
848 assert_eq!(
849 extract_block(&appended, BLOCK_BEGIN, BLOCK_END),
850 Some(block)
851 );
852
853 let stale = appended.replace("full-implement", "do-everything");
854 let refreshed = spliced(Some(&stale), block);
855 assert_eq!(
856 extract_block(&refreshed, BLOCK_BEGIN, BLOCK_END),
857 Some(block)
858 );
859 assert_eq!(
860 refreshed.matches("BEGIN release-kit").count(),
861 1,
862 "a re-splice must replace, not accumulate"
863 );
864 }
865
866 #[test]
870 fn the_glossary_leaves_the_targets_region_alone() {
871 let owned = glossary_block();
872 let block = owned.as_str();
873 let below = "\n## Our own terms\n\n- `spike` — a throwaway branch, never merged.\n";
874 let landed = format!("{block}\n{below}");
875
876 let refreshed = spliced(Some(&landed), block);
877 assert!(
878 refreshed.ends_with(below),
879 "the target's own region changed: {refreshed}"
880 );
881 assert_eq!(
882 extract_block(&refreshed, BLOCK_BEGIN, BLOCK_END),
883 Some(block)
884 );
885 }
886
887 #[test]
891 fn an_append_rewrites_no_byte_the_target_wrote() {
892 let owned = glossary_block();
893 let block = owned.as_str();
894 for own in [
895 "# Glossary\n\n- `spike` — throwaway. \n\n\n",
896 "# Glossary\n\n- `spike` — throwaway.",
897 "# Glossary\r\n\r\n- `spike` — throwaway.\r\n",
898 ] {
899 let appended = spliced(Some(own), block);
900 assert!(
901 appended.starts_with(own),
902 "the target's bytes changed: {appended:?}"
903 );
904 assert_eq!(
905 extract_block(&appended, BLOCK_BEGIN, BLOCK_END),
906 Some(block),
907 "{appended:?}"
908 );
909 let marker = appended.find(BLOCK_BEGIN).expect("the block landed");
910 assert!(
911 appended[..marker].ends_with('\n'),
912 "the block must open its own line: {appended:?}"
913 );
914 }
915 }
916
917 #[test]
921 fn a_splice_decodes_no_byte_the_target_wrote() {
922 let owned = glossary_block();
923 let block = owned.as_str();
924
925 let own = b"# Glossary\n\ncaf\xe9\n";
927 let appended = splice_marked_block(Some(own), block);
928 assert!(
929 appended.starts_with(own),
930 "the target's bytes changed: {appended:?}"
931 );
932 assert!(!appended.contains(&0xEF), "a replacement character landed");
933
934 let mut landed = Vec::new();
936 landed.extend_from_slice(block.replace("full-implement", "do-everything").as_bytes());
937 landed.extend_from_slice(b"\n\ncaf\xe9\n");
938 let refreshed = splice_marked_block(Some(&landed), block);
939 assert!(
940 refreshed.ends_with(b"\n\ncaf\xe9\n"),
941 "the target's region below the markers changed: {refreshed:?}"
942 );
943 assert!(refreshed.starts_with(block.as_bytes()), "{refreshed:?}");
944 }
945
946 #[test]
949 fn the_glossary_block_carries_no_parameter() {
950 let block = glossary_block();
951 assert!(block.starts_with(BLOCK_BEGIN), "{block}");
952 assert!(block.ends_with(BLOCK_END), "{block}");
953 assert!(!block.contains("RK_"), "a token survived: {block}");
954 assert!(!block.contains("OWNER"), "an owner token survived: {block}");
955 for term in [
956 "implement-and-request",
957 "implement-and-merge",
958 "full-implement",
959 ] {
960 assert!(block.contains(term), "{term} is missing from {block}");
961 }
962 assert!(
963 routing_block(CheckoutMode::LinkedWorktree).contains(GLOSSARY_DESTINATION),
964 "the routing block must name the destination it indexes"
965 );
966 }
967
968 #[test]
969 fn the_block_splices_into_every_agents_shape() {
970 let owned = routing_block(CheckoutMode::MainWorktree);
971 let block = owned.as_str();
972 let fresh = spliced(None, block);
973 assert_eq!(fresh, format!("{block}\n"));
974 assert_eq!(extract_block(&fresh, BLOCK_BEGIN, BLOCK_END), Some(block));
975
976 let appended = spliced(Some("# My project\n\nOwn rules.\n"), block);
977 assert!(appended.starts_with("# My project\n\nOwn rules.\n\n<!-- BEGIN release-kit -->"));
978 assert_eq!(
979 extract_block(&appended, BLOCK_BEGIN, BLOCK_END),
980 Some(block)
981 );
982
983 let stale = appended.replace("Never author a tag", "Do author a tag");
984 let refreshed = spliced(Some(&stale), block);
985 assert_eq!(
986 extract_block(&refreshed, BLOCK_BEGIN, BLOCK_END),
987 Some(block)
988 );
989 assert!(refreshed.starts_with("# My project"));
990 assert_eq!(
991 refreshed.matches("BEGIN release-kit").count(),
992 1,
993 "a re-splice must replace, not accumulate"
994 );
995 }
996
997 #[test]
1000 fn the_hook_block_splices_under_repos() {
1001 let owned = hooks_block(CheckoutMode::MainWorktree);
1002 let block = owned.as_str();
1003 let fresh = splice_hooks_block(None, block).expect("a fresh file splices");
1004 assert!(fresh.starts_with(HOOK_TYPES_LINE));
1005 assert!(fresh.contains("\nrepos:\n# BEGIN release-kit\n"));
1006 assert_eq!(extract_block(&fresh, HOOKS_BEGIN, HOOKS_END), Some(block));
1007
1008 let own =
1009 "repos:\n - repo: https://example.com/own\n rev: v1\n hooks:\n - id: own\n";
1010 let spliced = splice_hooks_block(Some(own), block).expect("an unmarked file splices");
1011 assert!(spliced.starts_with("repos:\n# BEGIN release-kit\n"));
1012 assert!(spliced.contains("- id: own"), "the target's hooks survive");
1013 assert!(
1014 !spliced.contains(HOOK_TYPES_LINE),
1015 "an existing file's top level is the skills' duty, not the splice's"
1016 );
1017
1018 let stale = spliced.replace("--force-scope", "--no-scope");
1019 let refreshed = splice_hooks_block(Some(&stale), block).expect("a marked file re-splices");
1020 assert_eq!(
1021 extract_block(&refreshed, HOOKS_BEGIN, HOOKS_END),
1022 Some(block)
1023 );
1024 assert_eq!(refreshed.matches(HOOKS_BEGIN).count(), 1);
1025
1026 let err = splice_hooks_block(Some("minimum_pre_commit_version: '3.2.0'\n"), block)
1027 .expect_err("no repos: line refuses");
1028 assert!(err.contains("repos:"), "{err}");
1029
1030 let doubled = format!("repos:\n{block}\n{block}\n");
1034 let err = splice_hooks_block(Some(&doubled), block).expect_err("a second block refuses");
1035 assert!(err.contains("one block"), "{err}");
1036 let unmatched = "repos:\n# BEGIN release-kit\n - repo: local\n";
1037 let err =
1038 splice_hooks_block(Some(unmatched), block).expect_err("an unmatched marker refuses");
1039 assert!(err.contains("unmatched"), "{err}");
1040 }
1041
1042 #[test]
1048 fn the_blocks_render_per_mode_and_carry_the_one_grammar() {
1049 let worktree_hooks = hooks_block(CheckoutMode::LinkedWorktree);
1050 let branches_hooks = hooks_block(CheckoutMode::MainWorktree);
1051 assert!(worktree_hooks.contains("- id: rk-worktree-location"));
1052 assert!(
1053 worktree_hooks.contains("SKIP=no-commit-to-branch,rk-worktree-location"),
1054 "{worktree_hooks}"
1055 );
1056 assert!(!branches_hooks.contains("rk-worktree-location"));
1057 assert!(branches_hooks.contains("SKIP=no-commit-to-branch in"));
1058 for block in [&worktree_hooks, &branches_hooks] {
1059 assert!(block.contains(BRANCH_GRAMMAR), "the grammar has one owner");
1060 for token in [
1061 "RK_BRANCH_GRAMMAR",
1062 "RK_SWEEP_SKIP",
1063 "RK_SWEEP_NOTE",
1064 "RK_WORKTREE_GUARD",
1065 "RK_TRUNK_COMMIT_GUARD",
1066 "RK_TRUNK_PUSH_GUARD",
1067 ] {
1068 assert!(!block.contains(token), "{token} survived: {block}");
1069 }
1070 }
1071 for block in [&worktree_hooks, &branches_hooks] {
1076 for line in block.lines() {
1077 if let Some(value) = line.trim_start().strip_prefix("entry: ") {
1078 assert!(
1079 !value.contains(": "),
1080 "an entry value breaks the YAML plain scalar: {line}"
1081 );
1082 }
1083 }
1084 }
1085 let guard_line = worktree_hooks
1086 .lines()
1087 .position(|line| line.contains("id: rk-worktree-location"))
1088 .expect("the guard entry exists");
1089 let name_line = worktree_hooks
1090 .lines()
1091 .position(|line| line.contains("id: rk-branch-name"))
1092 .expect("the name hook exists");
1093 assert!(
1094 guard_line > name_line,
1095 "the guard lands directly after rk-branch-name"
1096 );
1097
1098 let worktree_routing = routing_block(CheckoutMode::LinkedWorktree);
1099 let branches_routing = routing_block(CheckoutMode::MainWorktree);
1100 assert!(worktree_routing.contains("This project works in worktrees"));
1101 assert!(branches_routing.contains("Branches are worked in the main checkout"));
1102 for block in [&worktree_routing, &branches_routing] {
1103 assert!(block.contains("Create or remove a worktree"));
1104 assert!(block.contains("`rk worktree add <branch>`"));
1105 assert!(!block.contains("RK_WORKFLOW_LINE"), "{block}");
1106 assert!(!block.contains("RK_INTEGRATION_LINE"), "{block}");
1107 }
1108 let differing: Vec<(&str, &str)> = worktree_routing
1109 .lines()
1110 .zip(branches_routing.lines())
1111 .filter(|(a, b)| a != b)
1112 .collect();
1113 assert_eq!(
1114 differing.len(),
1115 1,
1116 "exactly one routing line differs per mode: {differing:?}"
1117 );
1118 }
1119
1120 #[test]
1130 fn the_integration_mode_decides_which_trunk_guards_render() {
1131 for mode in [CheckoutMode::LinkedWorktree, CheckoutMode::MainWorktree] {
1132 let forge = hooks_block_for(mode, Integration::Forge);
1133 let local = hooks_block_for(mode, Integration::Local);
1134 assert!(forge.contains("- id: no-commit-to-branch"), "{forge}");
1135 assert!(forge.contains("- id: rk-no-push-to-trunk"), "{forge}");
1136 assert!(!local.contains("no-commit-to-branch"), "{local}");
1137 assert!(!local.contains("rk-no-push-to-trunk"), "{local}");
1138 for kept in [
1140 "- id: conventional-pre-commit",
1141 "- id: rk-message",
1142 "- id: rk-branch-name",
1143 "- id: rk-no-hand-authored-tag",
1144 "- id: rk-status-check",
1145 ] {
1146 assert!(forge.contains(kept), "{kept}: {forge}");
1147 assert!(local.contains(kept), "{kept}: {local}");
1148 }
1149 let located = mode == CheckoutMode::LinkedWorktree;
1152 assert_eq!(forge.contains("- id: rk-worktree-location"), located);
1153 assert_eq!(local.contains("- id: rk-worktree-location"), located);
1154 assert!(
1156 local.contains("pre-commit run --hook-stage manual --all-files"),
1157 "{local}"
1158 );
1159 assert!(!forge.contains("--hook-stage manual"), "{forge}");
1160 for token in [
1161 "RK_TRUNK_COMMIT_GUARD",
1162 "RK_TRUNK_PUSH_GUARD",
1163 "RK_SWEEP_NOTE",
1164 ] {
1165 assert!(!local.contains(token), "{token} survived: {local}");
1166 assert!(!forge.contains(token), "{token} survived: {forge}");
1167 }
1168 }
1169 assert!(
1171 hooks_block_for(CheckoutMode::LinkedWorktree, Integration::Local)
1172 .contains("SKIP=rk-worktree-location in")
1173 );
1174 assert!(
1175 hooks_block_for(CheckoutMode::MainWorktree, Integration::Local)
1176 .contains("A CI sweep needs no SKIP here")
1177 );
1178 for mode in [CheckoutMode::LinkedWorktree, CheckoutMode::MainWorktree] {
1180 let forge = routing_block_for(mode, Integration::Forge);
1181 let local = routing_block_for(mode, Integration::Local);
1182 assert!(forge.contains("squash-merged pull request"), "{forge}");
1183 assert!(local.contains("`rk integrate <branch>`"), "{local}");
1184 let differing = forge
1185 .lines()
1186 .zip(local.lines())
1187 .filter(|(a, b)| a != b)
1188 .count();
1189 assert_eq!(differing, 1, "exactly one routing line differs per mode");
1190 }
1191 }
1192
1193 #[test]
1199 fn a_landed_sweep_note_invites_the_projects_own_skips() {
1200 let with_skip = hooks_block_for(CheckoutMode::LinkedWorktree, Integration::Local);
1201 assert!(
1202 with_skip.contains("SKIP=rk-worktree-location in"),
1203 "{with_skip}"
1204 );
1205 assert!(with_skip.contains("local-only"), "{with_skip}");
1206 assert!(with_skip.contains("the value is a list the"), "{with_skip}");
1207
1208 let without = hooks_block_for(CheckoutMode::MainWorktree, Integration::Local);
1209 assert!(
1210 without.contains("A CI sweep needs no SKIP here"),
1211 "{without}"
1212 );
1213 assert!(without.contains("local-only"), "{without}");
1214 }
1215
1216 #[test]
1222 fn a_landed_integrate_note_names_both_axes() {
1223 for mode in [CheckoutMode::LinkedWorktree, CheckoutMode::MainWorktree] {
1224 let local = hooks_block_for(mode, Integration::Local);
1225 assert!(
1226 local.contains("pre-commit run --hook-stage manual --all-files"),
1227 "{local}"
1228 );
1229 assert!(
1230 local.contains("continuous integration invoke the same stages"),
1231 "{local}"
1232 );
1233 assert!(
1234 local.contains("guards one side alone"),
1235 "the note states the consequence: {local}"
1236 );
1237 let forge = hooks_block_for(mode, Integration::Forge);
1239 assert!(!forge.contains("--hook-stage manual"), "{forge}");
1240 }
1241 }
1242
1243 #[test]
1247 fn the_hook_marker_defects_are_named() {
1248 use super::hooks_marker_defect;
1249 let owned = hooks_block(CheckoutMode::MainWorktree);
1250 let block = owned.as_str();
1251 assert_eq!(hooks_marker_defect(""), None);
1252 assert_eq!(hooks_marker_defect(&format!("repos:\n{block}\n")), None);
1253 for (case, text) in [
1254 (
1255 "a second begin",
1256 format!("repos:\n{block}\n# BEGIN release-kit\n"),
1257 ),
1258 (
1259 "a second end",
1260 format!("repos:\n{block}\n# END release-kit\n"),
1261 ),
1262 (
1263 "an unpaired begin",
1264 "repos:\n# BEGIN release-kit\n".to_owned(),
1265 ),
1266 ("an unpaired end", "repos:\n# END release-kit\n".to_owned()),
1267 (
1268 "an end before its begin",
1269 "repos:\n# END release-kit\n# BEGIN release-kit\n".to_owned(),
1270 ),
1271 ] {
1272 assert!(
1273 hooks_marker_defect(&text).is_some(),
1274 "{case} must be a defect"
1275 );
1276 }
1277 }
1278}