1pub mod apply;
19pub mod invariants;
20pub mod lock;
21pub mod manifest;
22
23use camino::Utf8Path;
24
25pub use crate::projection::{
26 AGENTS_DESTINATION, BLOCK_BEGIN, BLOCK_DESTINATIONS, BLOCK_END, BRANCH_GRAMMAR,
27 GLOSSARY_DESTINATION, HOOK_TYPES_LINE, HOOKS_BEGIN, HOOKS_DESTINATION, HOOKS_END, Kind,
28 LINE_PREFIX_RE_TOKEN, LINE_PREFIX_TOKEN, NIX_DESTINATIONS, NIX_WITHHOLDABLE, OWNER_TOKEN,
29 REPO_PLACEHOLDER, REPO_TOKEN, SCOPE_SHAPE, SCOPE_SHAPE_TOKEN, SECURITY_SPANS, STYLE_TOKEN,
30 TRUNK_BRANCH_TOKEN, authored, block_markers, destinations, extract_block, hooks_marker_defect,
31 kind_of, marker_defect, render, scope_is_shaped, splice_hooks_block, splice_marked_block,
32 substitute,
33};
34pub use manifest::{Style, Workflow};
35use serde::Serialize;
36
37use crate::diagnostic::{Diagnostic, Reason};
38use crate::error::RkError;
39
40#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct Params {
44 tech: String,
45 forge: String,
46 repo: String,
47 workflow: Workflow,
48 style: Option<Style>,
49 nix: bool,
50 trunk: String,
51 line_prefix: String,
52 security_contact: String,
53 security_response: String,
54}
55
56#[derive(Default)]
58pub struct Inputs<'a> {
59 pub tech: Option<&'a str>,
61 pub forge: Option<&'a str>,
63 pub repo: Option<&'a str>,
65 pub workflow: Option<Workflow>,
67 pub style: Option<Style>,
69 pub nix: Option<bool>,
71}
72
73#[derive(Clone, Copy, PartialEq, Eq)]
75pub enum Purpose {
76 Init,
78 Preview,
80 Upgrade,
82 Adopt,
84}
85
86impl Params {
87 #[must_use]
90 pub fn from_record(record: &manifest::Manifest) -> Self {
91 Self {
92 tech: record.tech.clone(),
93 forge: record.forge.clone(),
94 repo: record.parameters.repo.clone(),
95 workflow: record.parameters.workflow,
96 style: record.parameters.style,
97 nix: record.parameters.nix,
98 trunk: record.parameters.trunk.clone(),
99 line_prefix: record.parameters.line_prefix.clone(),
100 security_contact: record.parameters.security_contact.clone(),
101 security_response: record.parameters.security_response.clone(),
102 }
103 }
104
105 pub fn resolve(
111 target: &Utf8Path,
112 flags: &Inputs<'_>,
113 config: Option<&crate::config::Config>,
114 record: Option<&manifest::Manifest>,
115 purpose: Purpose,
116 ) -> Result<Self, RkError> {
117 let answer = |flag: Option<&str>, configured: Option<&str>, recorded: Option<&str>| {
118 flag.or_else(|| configured.filter(|value| !value.is_empty()))
119 .or(recorded)
120 .map(str::to_owned)
121 };
122 let forge = answer(
123 flags.forge,
124 config.map(|c| c.project.forge.as_str()),
125 record.map(|r| r.forge.as_str()),
126 );
127 let repo = answer(
128 flags.repo,
129 config.map(|c| c.project.repo.as_str()),
130 record.map(|r| r.parameters.repo.as_str()),
131 );
132 let resolved = resolve(target, forge.as_deref(), repo.as_deref())?;
133 let tech = answer(
134 flags.tech,
135 config.map(|c| c.project.tech.as_str()),
136 record.map(|r| r.tech.as_str()),
137 )
138 .or_else(|| crate::detect::tech_of(target.as_std_path()).map(str::to_owned))
139 .ok_or_else(|| {
140 RkError::missing(
141 Diagnostic::new(
142 Reason::TargetNotFound,
143 "no technology detected: the target has no version file",
144 )
145 .action("pass --tech <rust|python|bash>"),
146 )
147 })?;
148 crate::projection::check_pair(&tech, &resolved.forge)?;
149 let workflow = flags
150 .workflow
151 .or_else(|| config.and_then(|c| c.landing.workflow))
152 .or_else(|| record.map(|r| r.parameters.workflow))
153 .unwrap_or(if purpose == Purpose::Adopt {
154 Workflow::Branches
155 } else {
156 Workflow::Worktree
157 });
158 let style = flags
159 .style
160 .or_else(|| config.and_then(|c| c.landing.style))
161 .or_else(|| record.and_then(|r| r.parameters.style));
162 let style = match (style, purpose) {
163 (None, Purpose::Upgrade | Purpose::Adopt) => return Err(RkError::Usage("the target carries no style parameter; set landing.style in .release-kit/config.toml or pass --style <trunk|lines>".into())),
164 (value, _) => Some(value.unwrap_or(Style::Trunk)),
165 };
166 let repo = resolved
167 .repo
168 .or_else(|| (purpose == Purpose::Preview).then(|| REPO_PLACEHOLDER.to_owned()))
169 .ok_or_else(repo_unresolved)?;
170 let trunk = config
171 .and_then(|c| c.project.trunk.clone())
172 .or_else(|| record.map(|r| r.parameters.trunk.clone()))
173 .unwrap_or_else(|| crate::config::TRUNK_DEFAULT.to_owned());
174 let line_prefix = config
175 .and_then(|c| c.setup.line_prefix.clone())
176 .or_else(|| record.map(|r| r.parameters.line_prefix.clone()))
177 .unwrap_or_else(|| crate::config::LINE_PREFIX_DEFAULT.to_owned());
178 let security_contact = config
183 .and_then(|c| c.security.contact.clone())
184 .or_else(|| record.map(|r| r.parameters.security_contact.clone()))
185 .unwrap_or_default();
186 let security_contact =
187 crate::config::canonical_contact(&security_contact).map_err(crate::config::invalid)?;
188 let security_response = config
189 .and_then(|c| c.security.response.clone())
190 .or_else(|| record.map(|r| r.parameters.security_response.clone()))
191 .unwrap_or_else(|| crate::config::RESPONSE_DEFAULT.to_owned());
192 let security_response = crate::config::canonical_response(&security_response)
193 .map_err(crate::config::invalid)?;
194 Ok(Self {
195 tech,
196 forge: resolved.forge,
197 repo,
198 workflow,
199 style,
200 nix: flags
201 .nix
202 .or_else(|| config.and_then(|c| c.landing.nix))
203 .or_else(|| record.map(|r| r.parameters.nix))
204 .unwrap_or(false),
205 trunk,
206 line_prefix,
207 security_contact,
208 security_response,
209 })
210 }
211
212 #[must_use]
214 pub fn tech(&self) -> &str {
215 &self.tech
216 }
217
218 #[must_use]
220 pub fn forge(&self) -> &str {
221 &self.forge
222 }
223
224 #[must_use]
226 pub const fn nix(&self) -> bool {
227 self.nix
228 }
229
230 #[must_use]
232 pub fn repo(&self) -> &str {
233 &self.repo
234 }
235
236 #[must_use]
238 pub const fn workflow(&self) -> Workflow {
239 self.workflow
240 }
241
242 #[must_use]
244 pub const fn style(&self) -> Option<Style> {
245 self.style
246 }
247
248 #[must_use]
250 pub fn trunk(&self) -> &str {
251 &self.trunk
252 }
253
254 #[must_use]
256 pub fn line_prefix(&self) -> &str {
257 &self.line_prefix
258 }
259
260 #[must_use]
263 pub fn security_contact(&self) -> &str {
264 &self.security_contact
265 }
266
267 #[must_use]
269 pub fn security_response(&self) -> &str {
270 &self.security_response
271 }
272}
273
274#[cfg(test)]
275impl Params {
276 pub(crate) fn for_test(repo: &str, style: Option<Style>) -> Self {
280 Self {
281 tech: "rust".to_owned(),
282 forge: "github".to_owned(),
283 repo: repo.to_owned(),
284 workflow: Workflow::Worktree,
285 style,
286 nix: false,
287 trunk: crate::config::TRUNK_DEFAULT.to_owned(),
288 line_prefix: crate::config::LINE_PREFIX_DEFAULT.to_owned(),
289 security_contact: String::new(),
290 security_response: crate::config::RESPONSE_DEFAULT.to_owned(),
291 }
292 }
293
294 pub(crate) fn for_test_security(contact: &str, response: &str) -> Self {
296 Self {
297 security_contact: contact.to_owned(),
298 security_response: response.to_owned(),
299 ..Self::for_test("acme/widget", Some(Style::Trunk))
300 }
301 }
302
303 pub(crate) fn set_nix_for_test(&mut self, nix: bool) {
305 self.nix = nix;
306 }
307}
308
309#[derive(Debug, Clone, Serialize)]
311pub struct Withheld {
312 pub path: String,
314 pub reason: String,
317}
318
319pub fn read_recorded(target: &Utf8Path, destination: &str) -> std::io::Result<Option<Vec<u8>>> {
330 let path = target.join(destination);
331 let bytes = match std::fs::read(&path) {
332 Ok(bytes) => bytes,
333 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
334 Err(e) => return Err(e),
335 };
336 if let Some((begin, end)) = block_markers(destination) {
337 let text = String::from_utf8_lossy(&bytes);
338 Ok(extract_block(&text, begin, end).map(|block| block.as_bytes().to_vec()))
339 } else {
340 Ok(Some(bytes))
341 }
342}
343
344#[derive(Debug)]
347pub struct Resolved {
348 pub forge: String,
350 pub repo: Option<String>,
352}
353
354pub fn resolve(
366 target: &Utf8Path,
367 forge_flag: Option<&str>,
368 repo_flag: Option<&str>,
369) -> Result<Resolved, RkError> {
370 let forge_flag = forge_flag
371 .map(|name| {
372 crate::detect::Forge::parse(name).ok_or_else(|| {
373 RkError::Usage(format!(
374 "unknown forge '{name}'; the forges are: github, gitlab"
375 ))
376 })
377 })
378 .transpose()?;
379 let detected = crate::detect::detect(target.as_std_path());
380 let forge = forge_flag
381 .or(detected.forge)
382 .map(|forge| forge.as_str().to_owned())
383 .ok_or_else(|| {
384 let message = detected.host.map_or_else(
385 || "no forge detected: the target has no origin remote".to_owned(),
386 |host| format!("no forge detected: the host {host} is not recognized"),
387 );
388 RkError::refusal(
389 Diagnostic::new(Reason::ForgeUndetected, message)
390 .expected("a github.com or gitlab remote, or --forge")
391 .action("pass --forge <github|gitlab>"),
392 )
393 })?;
394 Ok(Resolved {
395 forge,
396 repo: repo_flag.map(str::to_owned).or(detected.repo),
397 })
398}
399
400#[must_use]
403pub fn repo_unresolved() -> RkError {
404 RkError::missing(
405 Diagnostic::new(
406 Reason::ForgeUndetected,
407 "no repository detected: the target has no origin remote",
408 )
409 .expected("an origin remote naming the project")
410 .action("pass --repo <path>"),
411 )
412}
413
414#[cfg(test)]
415mod tests {
416 use super::{
417 AGENTS_DESTINATION, BLOCK_BEGIN, BLOCK_DESTINATIONS, BLOCK_END, BRANCH_GRAMMAR,
418 GLOSSARY_DESTINATION, HOOK_TYPES_LINE, HOOKS_BEGIN, HOOKS_DESTINATION, HOOKS_END, Kind,
419 SCOPE_SHAPE, Style, Workflow, extract_block, kind_of, render, splice_hooks_block,
420 splice_marked_block,
421 };
422 use crate::embedded;
423 use crate::projection::{self, Projection, ProjectionInput, TargetEvidence};
424
425 fn destinations(params: &super::Params) -> Vec<String> {
428 Projection::compute(&ProjectionInput {
429 params: params.clone(),
430 evidence: TargetEvidence {
431 crate_shape: projection::CrateShape {
432 cargo_toml: Some(
433 "[package]\nname = \"widget\"\nversion = \"0.1.0\"\n".to_owned(),
434 ),
435 cargo_lock: true,
436 main_rs: true,
437 },
438 ..TargetEvidence::default()
439 },
440 })
441 .expect("the pair projects")
442 .candidates
443 .into_iter()
444 .map(|candidate| candidate.destination)
445 .collect()
446 }
447
448 fn routing_block(workflow: Workflow) -> String {
449 projection::routing_block(workflow).expect("the binary embeds the block")
450 }
451
452 fn hooks_block(workflow: Workflow) -> String {
453 projection::hooks_block(workflow).expect("the binary embeds the block")
454 }
455
456 fn glossary_block() -> String {
457 projection::glossary_block().expect("the binary embeds the block")
458 }
459
460 fn spliced(existing: Option<&str>, block: &str) -> String {
463 String::from_utf8(splice_marked_block(existing.map(str::as_bytes), block))
464 .expect("the fixtures are text")
465 }
466
467 #[test]
468 fn private_reporting_path_tokens_are_reproducible() {
469 for repo in [
470 "acme/widget",
471 "acme/group/widget",
472 "acme/OWNER-RK_STYLE-RK_SCOPE_SHAPE",
473 ] {
474 assert_eq!(
475 super::render(
476 b"RK_REPO RK_REPO OWNER RK_STYLE RK_SCOPE_SHAPE",
477 &super::Params::for_test(repo, Some(super::Style::Trunk))
478 ),
479 format!("{repo} {repo} acme trunk {}", super::SCOPE_SHAPE).as_bytes()
480 );
481 }
482 assert_eq!(super::kind_of("SECURITY.md"), Some(super::Kind::Rendered));
483 }
484
485 #[test]
490 fn each_forge_policy_carries_one_ordered_pair_of_every_span() {
491 for forge in ["github", "gitlab"] {
492 let bytes = embedded::SNIPPETS
493 .get_file(format!("_shared/{forge}/SECURITY.md"))
494 .expect("the policy ships")
495 .contents();
496 let text = String::from_utf8_lossy(bytes);
497 for (begin, end) in super::SECURITY_SPANS {
498 let begin = String::from_utf8_lossy(begin);
499 let end = String::from_utf8_lossy(end);
500 assert_eq!(text.matches(begin.as_ref()).count(), 1, "{forge} {begin}");
501 assert_eq!(text.matches(end.as_ref()).count(), 1, "{forge} {end}");
502 assert!(
503 text.find(begin.as_ref()) < text.find(end.as_ref()),
504 "{forge}: {begin} must precede {end}"
505 );
506 }
507 }
508 }
509
510 #[test]
515 fn the_security_spans_render_per_answer() {
516 for forge in ["github", "gitlab"] {
517 let bytes = embedded::SNIPPETS
518 .get_file(format!("_shared/{forge}/SECURITY.md"))
519 .expect("the policy ships")
520 .contents();
521 let authored = String::from_utf8_lossy(bytes);
522 let stripped = {
523 let mut text = authored.clone().into_owned();
524 for (begin, end) in super::SECURITY_SPANS {
525 text = text.replace(&String::from_utf8_lossy(begin).into_owned(), "");
526 text = text.replace(&String::from_utf8_lossy(end).into_owned(), "");
527 }
528 text
529 };
530 let default = super::Params {
531 forge: forge.to_owned(),
532 ..super::Params::for_test_security("", crate::config::RESPONSE_DEFAULT)
533 };
534 let rendered = String::from_utf8(render(bytes, &default)).expect("text");
535 assert_eq!(
536 rendered,
537 stripped.replace("RK_REPO", "acme/widget"),
538 "{forge}: the default answers must reproduce the authored policy"
539 );
540 assert!(!rendered.contains("RK_SECURITY"), "{forge}: {rendered}");
541
542 let answered = super::Params {
543 forge: forge.to_owned(),
544 ..super::Params::for_test_security("OWNER RK_REPO <team@acme.example>", "14 days")
545 };
546 let rendered = String::from_utf8(render(bytes, &answered)).expect("text");
547 assert!(
548 rendered.contains("OWNER RK_REPO <team@acme.example>"),
549 "{forge}: a contact spelling a token name lands literally: {rendered}"
550 );
551 assert!(
552 rendered.contains("Maintainers acknowledge a report within 14 days."),
553 "{forge}: {rendered}"
554 );
555 assert!(
556 rendered.contains("This policy commits to no disclosure deadline."),
557 "{forge}: {rendered}"
558 );
559 assert!(
560 !rendered.contains("best-effort basis"),
561 "{forge}: a stated window replaces the best-effort sentence: {rendered}"
562 );
563 assert!(
564 !rendered.contains("no response or disclosure deadline"),
565 "{forge}: a stated window contradicts the response disclaimer: {rendered}"
566 );
567 }
568 }
569
570 #[test]
573 fn a_defective_span_renders_unchanged() {
574 let (begin, end) = super::SECURITY_SPANS[0];
575 let begin = String::from_utf8_lossy(begin).into_owned();
576 let end = String::from_utf8_lossy(end).into_owned();
577 let params = super::Params::for_test_security("team@acme.example", "1 day");
578 for baseline in [
579 format!("contact {begin}a maintainer\n"),
580 format!("contact a maintainer{end}\n"),
581 format!("contact {end}a maintainer{begin}\n"),
582 "contact a maintainer\n".to_owned(),
583 ] {
584 assert_eq!(
585 render(baseline.as_bytes(), ¶ms),
586 baseline.as_bytes(),
587 "{baseline}"
588 );
589 }
590 }
591
592 #[test]
596 fn the_kind_table_closes_over_every_snippet() {
597 for tech_dir in embedded::SNIPPETS.dirs() {
598 for pair_dir in tech_dir.dirs() {
599 let prefix = format!("{}/", pair_dir.path().to_string_lossy());
600 for (path, _) in embedded::walk(pair_dir) {
601 let destination = path.strip_prefix(&prefix).unwrap_or(&path);
602 assert!(
603 kind_of(destination).is_some(),
604 "{destination}: no declared kind"
605 );
606 }
607 }
608 }
609 for block in BLOCK_DESTINATIONS {
610 assert_eq!(kind_of(block), Some(Kind::Rendered), "{block}");
611 }
612 assert_eq!(kind_of("something-else.txt"), None);
613 }
614
615 #[test]
620 fn rendering_substitutes_every_owner_occurrence() {
621 let baseline = b"if: repository_owner == 'OWNER'\n# OWNER again: OWNER\n";
622 let rendered = render(baseline, &super::Params::for_test("acme/sub/widget", None));
623 let text = String::from_utf8(rendered).expect("rendered bytes stay text");
624 assert_eq!(text, "if: repository_owner == 'acme'\n# acme again: acme\n");
625
626 let baseline = b"match (RK_SCOPE_SHAPE)\n";
627 let rendered = render(baseline, &super::Params::for_test("acme/widget", None));
628 let text = String::from_utf8(rendered).expect("rendered bytes stay text");
629 assert_eq!(text, format!("match ({SCOPE_SHAPE})\n"));
630 }
631
632 #[test]
636 fn the_scope_shape_drops_into_the_title_check() {
637 assert_eq!(SCOPE_SHAPE, "[a-z0-9._/-]+");
638 assert!(
639 !SCOPE_SHAPE.contains('\''),
640 "the title checks single-quote it"
641 );
642 }
643
644 #[test]
649 fn the_scope_predicate_and_the_rendered_pattern_agree() {
650 let body = SCOPE_SHAPE
651 .strip_prefix('[')
652 .and_then(|rest| rest.strip_suffix("]+"))
653 .expect("the shape is one bracket expression, repeated");
654 let chars: Vec<char> = body.chars().collect();
655 let mut admitted = std::collections::BTreeSet::new();
656 let mut at = 0;
657 while at < chars.len() {
658 if at + 2 < chars.len() && chars[at + 1] == '-' {
661 for c in chars[at]..=chars[at + 2] {
662 admitted.insert(c);
663 }
664 at += 3;
665 } else {
666 admitted.insert(chars[at]);
667 at += 1;
668 }
669 }
670 for byte in 0..=127u8 {
671 let c = char::from(byte);
672 assert_eq!(
673 super::scope_is_shaped(&c.to_string()),
674 admitted.contains(&c),
675 "the predicate and {SCOPE_SHAPE} disagree on {c:?}"
676 );
677 }
678 assert!(super::scope_is_shaped("guides/release"));
679 assert!(!super::scope_is_shaped(""), "a scope is never empty");
680 assert!(!super::scope_is_shaped("Specs Ugly"));
681 }
682
683 #[test]
686 fn the_shared_zone_composes_into_the_pair() {
687 let github = destinations(&super::Params {
688 forge: "github".to_owned(),
689 ..super::Params::for_test("acme/widget", Some(Style::Trunk))
690 });
691 assert!(
692 github.contains(&".github/workflows/pr-title.yml".to_owned()),
693 "the shared title check lands with the pair"
694 );
695 let gitlab = destinations(&super::Params {
696 forge: "gitlab".to_owned(),
697 ..super::Params::for_test("acme/widget", Some(Style::Trunk))
698 });
699 assert!(
700 gitlab.contains(&".gitlab/ci/mr-title.yml".to_owned()),
701 "the shared title job lands with the pair"
702 );
703 let err =
704 projection::check_pair("_shared", "github").expect_err("the shared zone is no tech");
705 let listing = err.to_string();
706 let bindings = listing
707 .split("the bindings are:")
708 .nth(1)
709 .expect("the refusal lists the bindings");
710 assert!(!bindings.contains("_shared"), "{listing}");
711 }
712
713 #[test]
716 fn params_from_a_record_round_trips() {
717 use super::{Params, manifest};
718 let dir = tempfile::tempdir().expect("a scratch target exists");
719 let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
720 for tech in ["rust", "bash"] {
721 for forge in ["github", "gitlab"] {
722 for workflow in [Workflow::Branches, Workflow::Worktree] {
723 for style in [None, Some(Style::Trunk), Some(Style::Lines)] {
724 for nix in [false, true] {
725 let record = manifest::Manifest {
726 schema_version: manifest::SCHEMA_VERSION,
727 rk_version: "0.1.0".to_owned(),
728 origin: "init".to_owned(),
729 tech: tech.to_owned(),
730 forge: forge.to_owned(),
731 landed_at: "2026-08-29T00:00:00Z".to_owned(),
732 parameters: manifest::Parameters {
733 repo: "acme/team/widget".to_owned(),
734 workflow,
735 style,
736 nix,
737 trunk: crate::config::TRUNK_DEFAULT.to_owned(),
738 line_prefix: crate::config::LINE_PREFIX_DEFAULT.to_owned(),
739 security_contact: String::new(),
740 security_response: crate::config::RESPONSE_DEFAULT.to_owned(),
741 },
742 files: Vec::new(),
743 pins: std::collections::BTreeMap::new(),
744 };
745 manifest::write(target, &record).expect("the record writes");
746 let loaded = manifest::load(target)
747 .expect("the record loads")
748 .expect("the record exists");
749 let params = Params::from_record(&loaded);
750 assert_eq!(params.tech, tech);
751 assert_eq!(params.forge, forge);
752 assert_eq!(params.repo(), "acme/team/widget");
753 assert_eq!(params.workflow(), workflow);
754 assert_eq!(params.style(), style);
755 assert_eq!(params.nix, nix);
756 let mut direct = super::Params::for_test("acme/team/widget", style);
759 direct.tech = tech.to_owned();
760 direct.forge = forge.to_owned();
761 direct.workflow = workflow;
762 direct.nix = nix;
763 assert_eq!(params, direct);
764 let projected = destinations(¶ms);
765 for block in
766 [AGENTS_DESTINATION, GLOSSARY_DESTINATION, HOOKS_DESTINATION]
767 {
768 assert!(projected.contains(&block.to_owned()), "{block}");
769 }
770 for destination in super::NIX_DESTINATIONS {
771 assert_eq!(
772 projected.contains(&destination.to_owned()),
773 nix && tech == "rust",
774 "{tech} {forge} nix={nix}: {destination}"
775 );
776 }
777 }
778 }
779 }
780 }
781 }
782 }
783
784 fn resolved_test_params(
785 tech: &str,
786 resolved: &super::Resolved,
787 workflow: Workflow,
788 style: Option<Style>,
789 nix: bool,
790 ) -> Result<super::Params, crate::error::RkError> {
791 super::Params::resolve(
792 camino::Utf8Path::new("."),
793 &super::Inputs {
794 tech: Some(tech),
795 forge: Some(&resolved.forge),
796 repo: resolved.repo.as_deref(),
797 workflow: Some(workflow),
798 style,
799 nix: Some(nix),
800 },
801 None,
802 None,
803 super::Purpose::Init,
804 )
805 }
806
807 #[test]
811 fn a_projection_renders_owned_files_and_keeps_seeded_judgment() {
812 let params = resolved_test_params(
813 "rust",
814 &super::Resolved {
815 forge: "github".to_owned(),
816 repo: Some("acme/widget".to_owned()),
817 },
818 Workflow::Branches,
819 Some(Style::Trunk),
820 false,
821 )
822 .expect("the parameters resolve");
823 let entries = Projection::compute(&ProjectionInput {
824 params,
825 evidence: TargetEvidence::default(),
826 })
827 .expect("the pair projects")
828 .candidates;
829 let workflow = entries
830 .iter()
831 .find(|entry| entry.destination.ends_with("release-plz.yml"))
832 .expect("the workflow projects");
833 assert_eq!(workflow.kind, Kind::Rendered);
834 let text = String::from_utf8_lossy(&workflow.bytes);
835 assert!(!text.contains("OWNER"), "an owner token survived rendering");
836 assert!(text.contains("'acme'"));
837 assert!(!text.contains("TODO(release-kit)"));
838 let title = entries
839 .iter()
840 .find(|entry| entry.destination.ends_with("pr-title.yml"))
841 .expect("the title check projects");
842 let text = String::from_utf8_lossy(&title.bytes);
843 assert!(text.contains(SCOPE_SHAPE), "{text}");
844 assert!(
845 !text.contains("RK_SCOPE_SHAPE"),
846 "a scope token survived: {text}"
847 );
848 let seeded = entries
849 .iter()
850 .find(|entry| entry.destination == "release-plz.toml")
851 .expect("the seeded file projects");
852 assert_eq!(seeded.kind, Kind::Seeded);
853 let authored = embedded::SNIPPETS
854 .get_file("rust/github/release-plz.toml")
855 .expect("the seed ships")
856 .contents();
857 assert_eq!(seeded.bytes, authored, "a seeded file lands as authored");
858 assert!(String::from_utf8_lossy(&seeded.bytes).contains("TODO(release-kit)"));
859 for block in BLOCK_DESTINATIONS {
860 let entry = entries
861 .iter()
862 .find(|entry| entry.destination == block)
863 .expect("every block is part of the projection");
864 let text = String::from_utf8_lossy(&entry.bytes);
865 assert!(
866 !text.contains("RK_SCOPE_SHAPE"),
867 "{block} kept a token: {text}"
868 );
869 }
870 }
871
872 #[test]
877 fn the_nix_destinations_project_only_under_the_opt_in() {
878 use super::NIX_DESTINATIONS;
879 let paths = |nix: bool, forge: &str| -> Vec<String> {
880 destinations(
881 &resolved_test_params(
882 "rust",
883 &super::Resolved {
884 forge: forge.to_owned(),
885 repo: Some("acme/widget".to_owned()),
886 },
887 Workflow::Worktree,
888 Some(Style::Trunk),
889 nix,
890 )
891 .expect("the parameters resolve"),
892 )
893 };
894 let off = paths(false, "github");
895 for destination in NIX_DESTINATIONS {
896 assert!(!off.contains(&destination.to_owned()), "{destination}");
897 }
898 let on = paths(true, "github");
899 for destination in ["nix/package.nix", "flake.nix", "flake.lock"] {
900 assert!(on.contains(&destination.to_owned()), "{destination}");
901 }
902 let gitlab = paths(true, "gitlab");
907 assert!(gitlab.contains(&"nix/package.nix".to_owned()));
908 assert!(
909 !on.iter()
910 .chain(gitlab.iter())
911 .any(|destination| destination.contains("nix.yml"))
912 );
913 let bash = destinations(
914 &resolved_test_params(
915 "bash",
916 &super::Resolved {
917 forge: "github".to_owned(),
918 repo: Some("acme/widget".to_owned()),
919 },
920 Workflow::Worktree,
921 Some(Style::Trunk),
922 true,
923 )
924 .expect("the parameters resolve"),
925 );
926 assert!(
927 bash.iter()
928 .all(|destination| !NIX_DESTINATIONS.contains(&destination.as_str()))
929 );
930 }
931
932 #[test]
937 fn the_nix_seeds_are_identical_across_forge_pairs() {
938 for name in ["nix/package.nix", "flake.nix", "flake.lock"] {
939 let github = embedded::SNIPPETS
940 .get_file(format!("rust/github/{name}"))
941 .expect("the github copy ships")
942 .contents();
943 let gitlab = embedded::SNIPPETS
944 .get_file(format!("rust/gitlab/{name}"))
945 .expect("the gitlab copy ships")
946 .contents();
947 assert_eq!(github, gitlab, "{name} diverged between the pairs");
948 }
949 }
950
951 #[test]
956 fn the_nix_withhold_judgment_covers_the_three_shapes() {
957 use super::NIX_DESTINATIONS;
958 let dir = tempfile::tempdir().expect("a scratch target exists");
959 let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
960 let project = |nix: bool| {
961 let params = resolved_test_params(
962 "rust",
963 &super::Resolved {
964 forge: "github".to_owned(),
965 repo: Some("acme/widget".to_owned()),
966 },
967 Workflow::Worktree,
968 Some(Style::Trunk),
969 nix,
970 )
971 .expect("the parameters resolve");
972 let evidence = TargetEvidence::gather(target, None).expect("the evidence reads");
973 Projection::compute(&ProjectionInput { params, evidence }).expect("the pair projects")
974 };
975 let withheld = |projection: &Projection| -> Vec<String> {
976 projection
977 .omissions
978 .iter()
979 .map(|omission| omission.destination.clone())
980 .collect()
981 };
982 let landed = |projection: &Projection, destination: &str| {
983 projection
984 .candidates
985 .iter()
986 .any(|candidate| candidate.destination == destination)
987 };
988
989 let all = project(true);
991 assert_eq!(
992 withheld(&all),
993 ["flake.lock", "flake.nix", "nix/package.nix"]
994 );
995 assert!(
996 all.candidates
997 .iter()
998 .all(|entry| !NIX_DESTINATIONS.contains(&entry.destination.as_str()))
999 );
1000
1001 std::fs::write(
1004 target.join("Cargo.toml"),
1005 "[package]\nname = \"widget\"\nversion = \"0.1.0\"\n",
1006 )
1007 .expect("the crate manifest writes");
1008 std::fs::write(target.join("Cargo.lock"), "version = 4\n").expect("the lock writes");
1009 std::fs::create_dir_all(target.join("src")).expect("the src dir exists");
1010 std::fs::write(target.join("src/main.rs"), "fn main() {}\n").expect("the main writes");
1011 std::fs::write(target.join("flake.nix"), "{ }\n").expect("the flake writes");
1012 let all = project(true);
1013 assert_eq!(withheld(&all), ["flake.lock", "flake.nix"]);
1014 assert!(landed(&all, "nix/package.nix"));
1015
1016 std::fs::remove_file(target.join("flake.nix")).expect("the flake removes");
1018 let all = project(true);
1019 assert!(all.omissions.is_empty());
1020 assert!(landed(&all, "flake.nix"));
1021
1022 let all = project(false);
1024 assert!(all.omissions.is_empty());
1025 assert!(!landed(&all, "flake.nix"));
1026 }
1027
1028 #[test]
1032 fn the_glossary_splices_into_every_shape() {
1033 let owned = glossary_block();
1034 let block = owned.as_str();
1035
1036 let fresh = spliced(None, block);
1037 assert_eq!(fresh, format!("{block}\n"));
1038 assert_eq!(extract_block(&fresh, BLOCK_BEGIN, BLOCK_END), Some(block));
1039
1040 let own = "# Glossary\n\n- `spike` — a throwaway branch.\n";
1041 let appended = spliced(Some(own), block);
1042 assert!(appended.starts_with(own));
1043 assert_eq!(
1044 extract_block(&appended, BLOCK_BEGIN, BLOCK_END),
1045 Some(block)
1046 );
1047
1048 let stale = appended.replace("full-implement", "do-everything");
1049 let refreshed = spliced(Some(&stale), block);
1050 assert_eq!(
1051 extract_block(&refreshed, BLOCK_BEGIN, BLOCK_END),
1052 Some(block)
1053 );
1054 assert_eq!(
1055 refreshed.matches("BEGIN release-kit").count(),
1056 1,
1057 "a re-splice must replace, not accumulate"
1058 );
1059 }
1060
1061 #[test]
1065 fn the_glossary_leaves_the_targets_region_alone() {
1066 let owned = glossary_block();
1067 let block = owned.as_str();
1068 let below = "\n## Our own terms\n\n- `spike` — a throwaway branch, never merged.\n";
1069 let landed = format!("{block}\n{below}");
1070
1071 let refreshed = spliced(Some(&landed), block);
1072 assert!(
1073 refreshed.ends_with(below),
1074 "the target's own region changed: {refreshed}"
1075 );
1076 assert_eq!(
1077 extract_block(&refreshed, BLOCK_BEGIN, BLOCK_END),
1078 Some(block)
1079 );
1080 }
1081
1082 #[test]
1086 fn an_append_rewrites_no_byte_the_target_wrote() {
1087 let owned = glossary_block();
1088 let block = owned.as_str();
1089 for own in [
1090 "# Glossary\n\n- `spike` — throwaway. \n\n\n",
1091 "# Glossary\n\n- `spike` — throwaway.",
1092 "# Glossary\r\n\r\n- `spike` — throwaway.\r\n",
1093 ] {
1094 let appended = spliced(Some(own), block);
1095 assert!(
1096 appended.starts_with(own),
1097 "the target's bytes changed: {appended:?}"
1098 );
1099 assert_eq!(
1100 extract_block(&appended, BLOCK_BEGIN, BLOCK_END),
1101 Some(block),
1102 "{appended:?}"
1103 );
1104 let marker = appended.find(BLOCK_BEGIN).expect("the block landed");
1105 assert!(
1106 appended[..marker].ends_with('\n'),
1107 "the block must open its own line: {appended:?}"
1108 );
1109 }
1110 }
1111
1112 #[test]
1116 fn a_splice_decodes_no_byte_the_target_wrote() {
1117 let owned = glossary_block();
1118 let block = owned.as_str();
1119
1120 let own = b"# Glossary\n\ncaf\xe9\n";
1122 let appended = splice_marked_block(Some(own), block);
1123 assert!(
1124 appended.starts_with(own),
1125 "the target's bytes changed: {appended:?}"
1126 );
1127 assert!(!appended.contains(&0xEF), "a replacement character landed");
1128
1129 let mut landed = Vec::new();
1131 landed.extend_from_slice(block.replace("full-implement", "do-everything").as_bytes());
1132 landed.extend_from_slice(b"\n\ncaf\xe9\n");
1133 let refreshed = splice_marked_block(Some(&landed), block);
1134 assert!(
1135 refreshed.ends_with(b"\n\ncaf\xe9\n"),
1136 "the target's region below the markers changed: {refreshed:?}"
1137 );
1138 assert!(refreshed.starts_with(block.as_bytes()), "{refreshed:?}");
1139 }
1140
1141 #[test]
1144 fn the_glossary_block_carries_no_parameter() {
1145 let block = glossary_block();
1146 assert!(block.starts_with(BLOCK_BEGIN), "{block}");
1147 assert!(block.ends_with(BLOCK_END), "{block}");
1148 assert!(!block.contains("RK_"), "a token survived: {block}");
1149 assert!(!block.contains("OWNER"), "an owner token survived: {block}");
1150 for term in [
1151 "implement-and-request",
1152 "implement-and-merge",
1153 "full-implement",
1154 ] {
1155 assert!(block.contains(term), "{term} is missing from {block}");
1156 }
1157 assert!(
1158 routing_block(Workflow::Worktree).contains(GLOSSARY_DESTINATION),
1159 "the routing block must name the destination it indexes"
1160 );
1161 }
1162
1163 #[test]
1164 fn the_block_splices_into_every_agents_shape() {
1165 let owned = routing_block(Workflow::Branches);
1166 let block = owned.as_str();
1167 let fresh = spliced(None, block);
1168 assert_eq!(fresh, format!("{block}\n"));
1169 assert_eq!(extract_block(&fresh, BLOCK_BEGIN, BLOCK_END), Some(block));
1170
1171 let appended = spliced(Some("# My project\n\nOwn rules.\n"), block);
1172 assert!(appended.starts_with("# My project\n\nOwn rules.\n\n<!-- BEGIN release-kit -->"));
1173 assert_eq!(
1174 extract_block(&appended, BLOCK_BEGIN, BLOCK_END),
1175 Some(block)
1176 );
1177
1178 let stale = appended.replace("Never author a tag", "Do author a tag");
1179 let refreshed = spliced(Some(&stale), block);
1180 assert_eq!(
1181 extract_block(&refreshed, BLOCK_BEGIN, BLOCK_END),
1182 Some(block)
1183 );
1184 assert!(refreshed.starts_with("# My project"));
1185 assert_eq!(
1186 refreshed.matches("BEGIN release-kit").count(),
1187 1,
1188 "a re-splice must replace, not accumulate"
1189 );
1190 }
1191
1192 #[test]
1195 fn the_hook_block_splices_under_repos() {
1196 let owned = hooks_block(Workflow::Branches);
1197 let block = owned.as_str();
1198 let fresh = splice_hooks_block(None, block).expect("a fresh file splices");
1199 assert!(fresh.starts_with(HOOK_TYPES_LINE));
1200 assert!(fresh.contains("\nrepos:\n# BEGIN release-kit\n"));
1201 assert_eq!(extract_block(&fresh, HOOKS_BEGIN, HOOKS_END), Some(block));
1202
1203 let own =
1204 "repos:\n - repo: https://example.com/own\n rev: v1\n hooks:\n - id: own\n";
1205 let spliced = splice_hooks_block(Some(own), block).expect("an unmarked file splices");
1206 assert!(spliced.starts_with("repos:\n# BEGIN release-kit\n"));
1207 assert!(spliced.contains("- id: own"), "the target's hooks survive");
1208 assert!(
1209 !spliced.contains(HOOK_TYPES_LINE),
1210 "an existing file's top level is the skills' duty, not the splice's"
1211 );
1212
1213 let stale = spliced.replace("--force-scope", "--no-scope");
1214 let refreshed = splice_hooks_block(Some(&stale), block).expect("a marked file re-splices");
1215 assert_eq!(
1216 extract_block(&refreshed, HOOKS_BEGIN, HOOKS_END),
1217 Some(block)
1218 );
1219 assert_eq!(refreshed.matches(HOOKS_BEGIN).count(), 1);
1220
1221 let err = splice_hooks_block(Some("minimum_pre_commit_version: '3.2.0'\n"), block)
1222 .expect_err("no repos: line refuses");
1223 assert!(err.contains("repos:"), "{err}");
1224
1225 let doubled = format!("repos:\n{block}\n{block}\n");
1229 let err = splice_hooks_block(Some(&doubled), block).expect_err("a second block refuses");
1230 assert!(err.contains("one block"), "{err}");
1231 let unmatched = "repos:\n# BEGIN release-kit\n - repo: local\n";
1232 let err =
1233 splice_hooks_block(Some(unmatched), block).expect_err("an unmatched marker refuses");
1234 assert!(err.contains("unmatched"), "{err}");
1235 }
1236
1237 #[test]
1243 fn the_blocks_render_per_mode_and_carry_the_one_grammar() {
1244 let worktree_hooks = hooks_block(Workflow::Worktree);
1245 let branches_hooks = hooks_block(Workflow::Branches);
1246 assert!(worktree_hooks.contains("- id: rk-worktree-location"));
1247 assert!(
1248 worktree_hooks.contains("SKIP=no-commit-to-branch,rk-worktree-location"),
1249 "{worktree_hooks}"
1250 );
1251 assert!(!branches_hooks.contains("rk-worktree-location"));
1252 assert!(branches_hooks.contains("SKIP=no-commit-to-branch in"));
1253 for block in [&worktree_hooks, &branches_hooks] {
1254 assert!(block.contains(BRANCH_GRAMMAR), "the grammar has one owner");
1255 for token in ["RK_BRANCH_GRAMMAR", "RK_SWEEP_SKIP", "RK_WORKTREE_GUARD"] {
1256 assert!(!block.contains(token), "{token} survived: {block}");
1257 }
1258 }
1259 for block in [&worktree_hooks, &branches_hooks] {
1264 for line in block.lines() {
1265 if let Some(value) = line.trim_start().strip_prefix("entry: ") {
1266 assert!(
1267 !value.contains(": "),
1268 "an entry value breaks the YAML plain scalar: {line}"
1269 );
1270 }
1271 }
1272 }
1273 let guard_line = worktree_hooks
1274 .lines()
1275 .position(|line| line.contains("id: rk-worktree-location"))
1276 .expect("the guard entry exists");
1277 let name_line = worktree_hooks
1278 .lines()
1279 .position(|line| line.contains("id: rk-branch-name"))
1280 .expect("the name hook exists");
1281 assert!(
1282 guard_line > name_line,
1283 "the guard lands directly after rk-branch-name"
1284 );
1285
1286 let worktree_routing = routing_block(Workflow::Worktree);
1287 let branches_routing = routing_block(Workflow::Branches);
1288 assert!(worktree_routing.contains("This project works in worktrees"));
1289 assert!(branches_routing.contains("Branches are worked in the main checkout"));
1290 for block in [&worktree_routing, &branches_routing] {
1291 assert!(block.contains("Create or remove a worktree"));
1292 assert!(block.contains("`rk worktree add <branch>`"));
1293 assert!(!block.contains("RK_WORKFLOW_LINE"), "{block}");
1294 }
1295 let differing: Vec<(&str, &str)> = worktree_routing
1296 .lines()
1297 .zip(branches_routing.lines())
1298 .filter(|(a, b)| a != b)
1299 .collect();
1300 assert_eq!(
1301 differing.len(),
1302 1,
1303 "exactly one routing line differs per mode: {differing:?}"
1304 );
1305 }
1306
1307 #[test]
1310 fn the_hook_marker_defects_are_named() {
1311 use super::hooks_marker_defect;
1312 let owned = hooks_block(Workflow::Branches);
1313 let block = owned.as_str();
1314 assert_eq!(hooks_marker_defect(""), None);
1315 assert_eq!(hooks_marker_defect(&format!("repos:\n{block}\n")), None);
1316 for (case, text) in [
1317 (
1318 "a second begin",
1319 format!("repos:\n{block}\n# BEGIN release-kit\n"),
1320 ),
1321 (
1322 "a second end",
1323 format!("repos:\n{block}\n# END release-kit\n"),
1324 ),
1325 (
1326 "an unpaired begin",
1327 "repos:\n# BEGIN release-kit\n".to_owned(),
1328 ),
1329 ("an unpaired end", "repos:\n# END release-kit\n".to_owned()),
1330 (
1331 "an end before its begin",
1332 "repos:\n# END release-kit\n# BEGIN release-kit\n".to_owned(),
1333 ),
1334 ] {
1335 assert!(
1336 hooks_marker_defect(&text).is_some(),
1337 "{case} must be a defect"
1338 );
1339 }
1340 }
1341}