1pub mod apply;
21pub mod invariants;
22pub mod lock;
23pub mod manifest;
24
25use camino::Utf8Path;
26
27pub use crate::projection::{
28 AGENTS_DESTINATION, BLOCK_BEGIN, BLOCK_DESTINATIONS, BLOCK_END, BRANCH_GRAMMAR,
29 GLOSSARY_DESTINATION, HOOK_TYPES_LINE, HOOKS_BEGIN, HOOKS_DESTINATION, HOOKS_END, Kind,
30 LINE_PREFIX_RE_TOKEN, LINE_PREFIX_TOKEN, NIX_DESTINATIONS, NIX_WITHHOLDABLE, OWNER_TOKEN,
31 REPO_PLACEHOLDER, REPO_TOKEN, SCOPE_SHAPE, SCOPE_SHAPE_TOKEN, SECURITY_SPANS, STYLE_TOKEN,
32 TRUNK_BRANCH_TOKEN, authored, block_markers, destinations, extract_block, hooks_marker_defect,
33 kind_of, marker_defect, render, scope_is_shaped, splice_hooks_block, splice_marked_block,
34 substitute,
35};
36pub use manifest::{Style, Workflow};
37use serde::Serialize;
38
39use crate::atomic;
40use crate::diagnostic::{Diagnostic, Reason};
41use crate::error::RkError;
42use crate::projection::{self as pure, evidence};
43use crate::release::{self, ReleaseManifest, ReleaseSource};
44
45#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct Params {
49 tech: String,
50 forge: String,
51 repo: String,
52 workflow: Workflow,
53 style: Option<Style>,
54 nix: bool,
55 trunk: String,
56 line_prefix: String,
57 security_contact: String,
58 security_response: String,
59}
60
61#[derive(Default)]
63pub struct Inputs<'a> {
64 pub tech: Option<&'a str>,
66 pub forge: Option<&'a str>,
68 pub repo: Option<&'a str>,
70 pub workflow: Option<Workflow>,
72 pub style: Option<Style>,
74 pub nix: Option<bool>,
76}
77
78#[derive(Clone, Copy, PartialEq, Eq)]
80pub enum Purpose {
81 Init,
83 Preview,
85 Upgrade,
87 Adopt,
89}
90
91impl Params {
92 #[must_use]
95 pub fn from_record(record: &manifest::Manifest) -> Self {
96 Self {
97 tech: record.tech.clone(),
98 forge: record.forge.clone(),
99 repo: record.parameters.repo.clone(),
100 workflow: record.parameters.workflow,
101 style: record.parameters.style,
102 nix: record.parameters.nix,
103 trunk: record.parameters.trunk.clone(),
104 line_prefix: record.parameters.line_prefix.clone(),
105 security_contact: record.parameters.security_contact.clone(),
106 security_response: record.parameters.security_response.clone(),
107 }
108 }
109
110 pub fn resolve(
116 source: &dyn ReleaseSource,
117 target: &Utf8Path,
118 flags: &Inputs<'_>,
119 config: Option<&crate::config::Config>,
120 record: Option<&manifest::Manifest>,
121 purpose: Purpose,
122 ) -> Result<Self, RkError> {
123 let answer = |flag: Option<&str>, configured: Option<&str>, recorded: Option<&str>| {
124 flag.or_else(|| configured.filter(|value| !value.is_empty()))
125 .or(recorded)
126 .map(str::to_owned)
127 };
128 let forge = answer(
129 flags.forge,
130 config.map(|c| c.project.forge.as_str()),
131 record.map(|r| r.forge.as_str()),
132 );
133 let repo = answer(
134 flags.repo,
135 config.map(|c| c.project.repo.as_str()),
136 record.map(|r| r.parameters.repo.as_str()),
137 );
138 let resolved = resolve(target, forge.as_deref(), repo.as_deref())?;
139 let tech = answer(
140 flags.tech,
141 config.map(|c| c.project.tech.as_str()),
142 record.map(|r| r.tech.as_str()),
143 )
144 .or_else(|| crate::detect::tech_of(target.as_std_path()).map(str::to_owned))
145 .ok_or_else(|| {
146 RkError::missing(
147 Diagnostic::new(
148 Reason::TargetNotFound,
149 "no technology detected: the target has no version file",
150 )
151 .action("pass --tech <rust|python|bash>"),
152 )
153 })?;
154 pair_files(source, &tech, &resolved.forge)?;
155 let workflow = flags
156 .workflow
157 .or_else(|| config.and_then(|c| c.landing.workflow))
158 .or_else(|| record.map(|r| r.parameters.workflow))
159 .unwrap_or(if purpose == Purpose::Adopt {
160 Workflow::Branches
161 } else {
162 Workflow::Worktree
163 });
164 let style = flags
165 .style
166 .or_else(|| config.and_then(|c| c.landing.style))
167 .or_else(|| record.and_then(|r| r.parameters.style));
168 let style = match (style, purpose) {
169 (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())),
170 (value, _) => Some(value.unwrap_or(Style::Trunk)),
171 };
172 let repo = resolved
173 .repo
174 .or_else(|| (purpose == Purpose::Preview).then(|| REPO_PLACEHOLDER.to_owned()))
175 .ok_or_else(repo_unresolved)?;
176 let trunk = config
177 .and_then(|c| c.project.trunk.clone())
178 .or_else(|| record.map(|r| r.parameters.trunk.clone()))
179 .unwrap_or_else(|| crate::config::TRUNK_DEFAULT.to_owned());
180 let line_prefix = config
181 .and_then(|c| c.setup.line_prefix.clone())
182 .or_else(|| record.map(|r| r.parameters.line_prefix.clone()))
183 .unwrap_or_else(|| crate::config::LINE_PREFIX_DEFAULT.to_owned());
184 let security_contact = config
189 .and_then(|c| c.security.contact.clone())
190 .or_else(|| record.map(|r| r.parameters.security_contact.clone()))
191 .unwrap_or_default();
192 let security_contact =
193 crate::config::canonical_contact(&security_contact).map_err(crate::config::invalid)?;
194 let security_response = config
195 .and_then(|c| c.security.response.clone())
196 .or_else(|| record.map(|r| r.parameters.security_response.clone()))
197 .unwrap_or_else(|| crate::config::RESPONSE_DEFAULT.to_owned());
198 let security_response = crate::config::canonical_response(&security_response)
199 .map_err(crate::config::invalid)?;
200 Ok(Self {
201 tech,
202 forge: resolved.forge,
203 repo,
204 workflow,
205 style,
206 nix: flags
207 .nix
208 .or_else(|| config.and_then(|c| c.landing.nix))
209 .or_else(|| record.map(|r| r.parameters.nix))
210 .unwrap_or(false),
211 trunk,
212 line_prefix,
213 security_contact,
214 security_response,
215 })
216 }
217
218 #[must_use]
220 pub fn tech(&self) -> &str {
221 &self.tech
222 }
223
224 #[must_use]
226 pub fn forge(&self) -> &str {
227 &self.forge
228 }
229
230 #[must_use]
232 pub const fn nix(&self) -> bool {
233 self.nix
234 }
235
236 #[must_use]
238 pub fn repo(&self) -> &str {
239 &self.repo
240 }
241
242 #[must_use]
244 pub const fn workflow(&self) -> Workflow {
245 self.workflow
246 }
247
248 #[must_use]
250 pub const fn style(&self) -> Option<Style> {
251 self.style
252 }
253
254 #[must_use]
256 pub fn trunk(&self) -> &str {
257 &self.trunk
258 }
259
260 #[must_use]
262 pub fn line_prefix(&self) -> &str {
263 &self.line_prefix
264 }
265
266 #[must_use]
269 pub fn security_contact(&self) -> &str {
270 &self.security_contact
271 }
272
273 #[must_use]
275 pub fn security_response(&self) -> &str {
276 &self.security_response
277 }
278}
279
280#[cfg(test)]
281impl Params {
282 pub(crate) fn for_test(repo: &str, style: Option<Style>) -> Self {
286 Self {
287 tech: "rust".to_owned(),
288 forge: "github".to_owned(),
289 repo: repo.to_owned(),
290 workflow: Workflow::Worktree,
291 style,
292 nix: false,
293 trunk: crate::config::TRUNK_DEFAULT.to_owned(),
294 line_prefix: crate::config::LINE_PREFIX_DEFAULT.to_owned(),
295 security_contact: String::new(),
296 security_response: crate::config::RESPONSE_DEFAULT.to_owned(),
297 }
298 }
299
300 pub(crate) fn for_test_security(contact: &str, response: &str) -> Self {
302 Self {
303 security_contact: contact.to_owned(),
304 security_response: response.to_owned(),
305 ..Self::for_test("acme/widget", Some(Style::Trunk))
306 }
307 }
308
309 pub(crate) fn set_nix_for_test(&mut self, nix: bool) {
311 self.nix = nix;
312 }
313}
314
315fn block(
317 source: &dyn ReleaseSource,
318 manifest: &ReleaseManifest,
319 path: &str,
320) -> Result<String, RkError> {
321 let bytes = release::read(source, manifest, path)?;
322 String::from_utf8(bytes).map_err(|_| anyhow::anyhow!("{path}: a block is UTF-8").into())
323}
324
325pub fn routing_block(source: &dyn ReleaseSource, workflow: Workflow) -> Result<String, RkError> {
333 let manifest = source.manifest()?;
334 let line = block(source, &manifest, pure::routing_line(workflow))?;
335 let template = block(source, &manifest, pure::AGENTS_BLOCK)?;
336 Ok(pure::compose_routing(&template, &line))
337}
338
339pub fn glossary_block(source: &dyn ReleaseSource) -> Result<String, RkError> {
346 let manifest = source.manifest()?;
347 Ok(pure::compose_glossary(&block(
348 source,
349 &manifest,
350 pure::GLOSSARY_BLOCK,
351 )?))
352}
353
354pub fn hooks_block(source: &dyn ReleaseSource, workflow: Workflow) -> Result<String, RkError> {
362 let manifest = source.manifest()?;
363 let guard = match workflow {
364 Workflow::Worktree => Some(block(source, &manifest, pure::PRE_COMMIT_WORKTREE_GUARD)?),
365 Workflow::Branches => None,
366 };
367 let template = block(source, &manifest, pure::PRE_COMMIT_BLOCK)?;
368 Ok(pure::compose_hooks(&template, guard.as_deref()))
369}
370
371#[derive(Debug, Clone, Copy, PartialEq, Eq)]
373pub enum Placement {
374 Whole,
376 Block,
378}
379
380#[derive(Debug)]
383pub struct Entry {
384 pub destination: String,
386 pub kind: Kind,
388 pub placement: Placement,
390 pub baseline: Vec<u8>,
393 pub rendered: Vec<u8>,
396}
397
398pub fn pair_files(
407 source: &dyn ReleaseSource,
408 tech: &str,
409 forge: &str,
410) -> Result<Vec<(String, Vec<u8>)>, RkError> {
411 let manifest = source.manifest()?;
412 let files: Vec<(String, crate::digest::Digest)> = manifest
413 .under("snippets")
414 .map(|(rel, artifact)| (format!("snippets/{rel}"), artifact.sha256.clone()))
415 .collect();
416 let mut out = Vec::new();
417 for selected in pure::select_pair(&files, tech, forge)? {
418 out.push((selected.destination, source.blob(selected.payload)?));
419 }
420 Ok(out)
421}
422
423pub fn projection(source: &dyn ReleaseSource, params: &Params) -> Result<Vec<Entry>, RkError> {
439 let mut entries = Vec::new();
440 for (destination, baseline) in pair_files(source, ¶ms.tech, ¶ms.forge)? {
441 if !params.nix && NIX_DESTINATIONS.contains(&destination.as_str()) {
442 continue;
443 }
444 let kind = kind_of(&destination).ok_or_else(|| {
445 anyhow::anyhow!("the payload does not classify {destination}; the kind table is stale")
446 })?;
447 let rendered = match kind {
448 Kind::Rendered => render(&baseline, params),
449 Kind::Seeded | Kind::State => baseline.clone(),
450 };
451 entries.push(Entry {
452 destination,
453 kind,
454 placement: Placement::Whole,
455 baseline,
456 rendered,
457 });
458 }
459 let mut blocks = vec![(AGENTS_DESTINATION, routing_block(source, params.workflow)?)];
463 if source.manifest()?.artifact(pure::GLOSSARY_BLOCK).is_some() {
464 blocks.push((GLOSSARY_DESTINATION, glossary_block(source)?));
465 }
466 blocks.push((HOOKS_DESTINATION, hooks_block(source, params.workflow)?));
467 for (destination, template) in blocks {
468 entries.push(Entry {
469 destination: destination.to_owned(),
470 kind: Kind::Rendered,
471 placement: Placement::Block,
472 baseline: template.as_bytes().to_vec(),
473 rendered: render(template.as_bytes(), params),
474 });
475 }
476 entries.sort_by(|a, b| a.destination.cmp(&b.destination));
477 Ok(entries)
478}
479
480#[must_use]
484pub fn nix_unsupported_shape(target: &Utf8Path) -> Option<String> {
485 pure::nix_unsupported_shape(&evidence::crate_shape(target))
486}
487
488pub fn nix_withheld(
499 target: &Utf8Path,
500 recorded: Option<&manifest::Manifest>,
501) -> std::io::Result<Option<String>> {
502 if evidence::flake_recorded(recorded) {
503 return Ok(None);
504 }
505 let (flake_nix, flake_lock) = evidence::flake_presence(target)?;
506 Ok(pure::flake_pair_withheld(false, flake_nix, flake_lock))
507}
508
509#[derive(Debug, Clone, Serialize)]
511pub struct Withheld {
512 pub path: String,
514 pub reason: String,
517}
518
519pub fn nix_withholding(
531 target: &Utf8Path,
532 recorded: Option<&manifest::Manifest>,
533) -> Result<Option<(&'static [&'static str], String)>, RkError> {
534 if let Some(reason) = nix_unsupported_shape(target) {
535 return Ok(Some((&NIX_DESTINATIONS[..], reason)));
536 }
537 if let Some(reason) = nix_withheld(target, recorded)? {
538 return Ok(Some((&NIX_WITHHOLDABLE[..], reason)));
539 }
540 Ok(None)
541}
542
543pub fn withhold_nix(
556 target: &Utf8Path,
557 nix: bool,
558 recorded: Option<&manifest::Manifest>,
559 entries: &mut Vec<Entry>,
560) -> Result<Vec<Withheld>, RkError> {
561 if !nix {
562 return Ok(Vec::new());
563 }
564 let Some((set, reason)) = nix_withholding(target, recorded)? else {
565 return Ok(Vec::new());
566 };
567 let mut withheld = Vec::new();
568 entries.retain(|entry| {
569 if set.contains(&entry.destination.as_str()) {
570 withheld.push(Withheld {
571 path: entry.destination.clone(),
572 reason: reason.clone(),
573 });
574 false
575 } else {
576 true
577 }
578 });
579 Ok(withheld)
580}
581
582pub fn read_destination(target: &Utf8Path, entry: &Entry) -> std::io::Result<Option<Vec<u8>>> {
590 read_recorded(target, &entry.destination)
591}
592
593pub fn read_recorded(target: &Utf8Path, destination: &str) -> std::io::Result<Option<Vec<u8>>> {
604 let path = target.join(destination);
605 let bytes = match std::fs::read(&path) {
606 Ok(bytes) => bytes,
607 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
608 Err(e) => return Err(e),
609 };
610 if let Some((begin, end)) = block_markers(destination) {
611 let text = String::from_utf8_lossy(&bytes);
612 Ok(extract_block(&text, begin, end).map(|block| block.as_bytes().to_vec()))
613 } else {
614 Ok(Some(bytes))
615 }
616}
617
618#[derive(Debug)]
621pub struct Resolved {
622 pub forge: String,
624 pub repo: Option<String>,
626}
627
628pub fn resolve(
640 target: &Utf8Path,
641 forge_flag: Option<&str>,
642 repo_flag: Option<&str>,
643) -> Result<Resolved, RkError> {
644 let forge_flag = forge_flag
645 .map(|name| {
646 crate::detect::Forge::parse(name).ok_or_else(|| {
647 RkError::Usage(format!(
648 "unknown forge '{name}'; the forges are: github, gitlab"
649 ))
650 })
651 })
652 .transpose()?;
653 let detected = crate::detect::detect(target.as_std_path());
654 let forge = forge_flag
655 .or(detected.forge)
656 .map(|forge| forge.as_str().to_owned())
657 .ok_or_else(|| {
658 let message = detected.host.map_or_else(
659 || "no forge detected: the target has no origin remote".to_owned(),
660 |host| format!("no forge detected: the host {host} is not recognized"),
661 );
662 RkError::refusal(
663 Diagnostic::new(Reason::ForgeUndetected, message)
664 .expected("a github.com or gitlab remote, or --forge")
665 .action("pass --forge <github|gitlab>"),
666 )
667 })?;
668 Ok(Resolved {
669 forge,
670 repo: repo_flag.map(str::to_owned).or(detected.repo),
671 })
672}
673
674#[must_use]
677pub fn repo_unresolved() -> RkError {
678 RkError::missing(
679 Diagnostic::new(
680 Reason::ForgeUndetected,
681 "no repository detected: the target has no origin remote",
682 )
683 .expected("an origin remote naming the project")
684 .action("pass --repo <path>"),
685 )
686}
687
688pub fn write_destination(target: &Utf8Path, entry: &Entry) -> std::io::Result<()> {
698 let path = target.join(&entry.destination);
699 match entry.placement {
700 Placement::Whole => atomic::write(path.as_std_path(), &entry.rendered),
701 Placement::Block => {
702 let existing = match std::fs::read(&path) {
703 Ok(bytes) => Some(bytes),
704 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
705 Err(e) => return Err(e),
706 };
707 let block = String::from_utf8_lossy(&entry.rendered).into_owned();
710 if entry.destination == HOOKS_DESTINATION {
711 let text = existing.map(|bytes| String::from_utf8_lossy(&bytes).into_owned());
712 let spliced =
713 splice_hooks_block(text.as_deref(), &block).map_err(std::io::Error::other)?;
714 atomic::write(path.as_std_path(), spliced.as_bytes())
715 } else {
716 let spliced = splice_marked_block(existing.as_deref(), &block);
717 atomic::write(path.as_std_path(), &spliced)
718 }
719 }
720 }
721}
722
723pub fn hooks_file_defect(
736 source: &dyn ReleaseSource,
737 target: &Utf8Path,
738) -> Result<Option<String>, RkError> {
739 let path = target.join(HOOKS_DESTINATION);
740 match std::fs::read(&path) {
741 Ok(bytes) => {
742 let text = String::from_utf8_lossy(&bytes);
743 let manifest = source.manifest()?;
744 let template = block(source, &manifest, pure::PRE_COMMIT_BLOCK)?;
745 Ok(splice_hooks_block(Some(&text), authored(&template)).err())
746 }
747 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
748 Err(e) => Err(e.into()),
749 }
750}
751
752pub fn hooks_splice_refusal(source: &dyn ReleaseSource, target: &Utf8Path) -> Result<(), RkError> {
762 hooks_file_defect(source, target)?.map_or(Ok(()), |reason| {
763 Err(RkError::refusal(
764 Diagnostic::new(
765 Reason::StateDrift,
766 format!("{reason}, and nothing was written"),
767 )
768 .expected("a .pre-commit-config.yaml the block can land in, or none")
769 .action(format!(
770 "resolve it in {}, then re-run",
771 target.join(HOOKS_DESTINATION)
772 ))
773 .target_state("unchanged"),
774 ))
775 })
776}
777
778#[cfg(test)]
779mod tests {
780 use super::{
781 AGENTS_DESTINATION, BLOCK_BEGIN, BLOCK_DESTINATIONS, BLOCK_END, BRANCH_GRAMMAR,
782 GLOSSARY_DESTINATION, HOOK_TYPES_LINE, HOOKS_BEGIN, HOOKS_DESTINATION, HOOKS_END, Kind,
783 SCOPE_SHAPE, Style, Workflow, extract_block, kind_of, render, splice_hooks_block,
784 splice_marked_block,
785 };
786 use crate::embedded;
787 use crate::release::EmbeddedReleaseSource;
788
789 const SOURCE: EmbeddedReleaseSource = EmbeddedReleaseSource;
791
792 fn pair_files(
793 tech: &str,
794 forge: &str,
795 ) -> Result<Vec<(String, Vec<u8>)>, crate::error::RkError> {
796 super::pair_files(&SOURCE, tech, forge)
797 }
798
799 fn projection(params: &super::Params) -> Result<Vec<super::Entry>, crate::error::RkError> {
800 super::projection(&SOURCE, params)
801 }
802
803 fn routing_block(workflow: Workflow) -> String {
804 super::routing_block(&SOURCE, workflow).expect("the embedded bundle carries the block")
805 }
806
807 fn hooks_block(workflow: Workflow) -> String {
808 super::hooks_block(&SOURCE, workflow).expect("the embedded bundle carries the block")
809 }
810
811 fn glossary_block() -> String {
812 super::glossary_block(&SOURCE).expect("the embedded bundle carries the block")
813 }
814
815 fn spliced(existing: Option<&str>, block: &str) -> String {
818 String::from_utf8(splice_marked_block(existing.map(str::as_bytes), block))
819 .expect("the fixtures are text")
820 }
821
822 #[test]
823 fn private_reporting_path_tokens_are_reproducible() {
824 for repo in [
825 "acme/widget",
826 "acme/group/widget",
827 "acme/OWNER-RK_STYLE-RK_SCOPE_SHAPE",
828 ] {
829 assert_eq!(
830 super::render(
831 b"RK_REPO RK_REPO OWNER RK_STYLE RK_SCOPE_SHAPE",
832 &super::Params::for_test(repo, Some(super::Style::Trunk))
833 ),
834 format!("{repo} {repo} acme trunk {}", super::SCOPE_SHAPE).as_bytes()
835 );
836 }
837 assert_eq!(super::kind_of("SECURITY.md"), Some(super::Kind::Rendered));
838 }
839
840 #[test]
845 fn each_forge_policy_carries_one_ordered_pair_of_every_span() {
846 for forge in ["github", "gitlab"] {
847 let bytes = embedded::SNIPPETS
848 .get_file(format!("_shared/{forge}/SECURITY.md"))
849 .expect("the policy ships")
850 .contents();
851 let text = String::from_utf8_lossy(bytes);
852 for (begin, end) in super::SECURITY_SPANS {
853 let begin = String::from_utf8_lossy(begin);
854 let end = String::from_utf8_lossy(end);
855 assert_eq!(text.matches(begin.as_ref()).count(), 1, "{forge} {begin}");
856 assert_eq!(text.matches(end.as_ref()).count(), 1, "{forge} {end}");
857 assert!(
858 text.find(begin.as_ref()) < text.find(end.as_ref()),
859 "{forge}: {begin} must precede {end}"
860 );
861 }
862 }
863 }
864
865 #[test]
870 fn the_security_spans_render_per_answer() {
871 for forge in ["github", "gitlab"] {
872 let bytes = embedded::SNIPPETS
873 .get_file(format!("_shared/{forge}/SECURITY.md"))
874 .expect("the policy ships")
875 .contents();
876 let authored = String::from_utf8_lossy(bytes);
877 let stripped = {
878 let mut text = authored.clone().into_owned();
879 for (begin, end) in super::SECURITY_SPANS {
880 text = text.replace(&String::from_utf8_lossy(begin).into_owned(), "");
881 text = text.replace(&String::from_utf8_lossy(end).into_owned(), "");
882 }
883 text
884 };
885 let default = super::Params {
886 forge: forge.to_owned(),
887 ..super::Params::for_test_security("", crate::config::RESPONSE_DEFAULT)
888 };
889 let rendered = String::from_utf8(render(bytes, &default)).expect("text");
890 assert_eq!(
891 rendered,
892 stripped.replace("RK_REPO", "acme/widget"),
893 "{forge}: the default answers must reproduce the authored policy"
894 );
895 assert!(!rendered.contains("RK_SECURITY"), "{forge}: {rendered}");
896
897 let answered = super::Params {
898 forge: forge.to_owned(),
899 ..super::Params::for_test_security("OWNER RK_REPO <team@acme.example>", "14 days")
900 };
901 let rendered = String::from_utf8(render(bytes, &answered)).expect("text");
902 assert!(
903 rendered.contains("OWNER RK_REPO <team@acme.example>"),
904 "{forge}: a contact spelling a token name lands literally: {rendered}"
905 );
906 assert!(
907 rendered.contains("Maintainers acknowledge a report within 14 days."),
908 "{forge}: {rendered}"
909 );
910 assert!(
911 rendered.contains("This policy commits to no disclosure deadline."),
912 "{forge}: {rendered}"
913 );
914 assert!(
915 !rendered.contains("best-effort basis"),
916 "{forge}: a stated window replaces the best-effort sentence: {rendered}"
917 );
918 assert!(
919 !rendered.contains("no response or disclosure deadline"),
920 "{forge}: a stated window contradicts the response disclaimer: {rendered}"
921 );
922 }
923 }
924
925 #[test]
928 fn a_defective_span_renders_unchanged() {
929 let (begin, end) = super::SECURITY_SPANS[0];
930 let begin = String::from_utf8_lossy(begin).into_owned();
931 let end = String::from_utf8_lossy(end).into_owned();
932 let params = super::Params::for_test_security("team@acme.example", "1 day");
933 for baseline in [
934 format!("contact {begin}a maintainer\n"),
935 format!("contact a maintainer{end}\n"),
936 format!("contact {end}a maintainer{begin}\n"),
937 "contact a maintainer\n".to_owned(),
938 ] {
939 assert_eq!(
940 render(baseline.as_bytes(), ¶ms),
941 baseline.as_bytes(),
942 "{baseline}"
943 );
944 }
945 }
946
947 #[test]
951 fn the_kind_table_closes_over_every_snippet() {
952 for tech_dir in embedded::SNIPPETS.dirs() {
953 for pair_dir in tech_dir.dirs() {
954 let prefix = format!("{}/", pair_dir.path().to_string_lossy());
955 for (path, _) in embedded::walk(pair_dir) {
956 let destination = path.strip_prefix(&prefix).unwrap_or(&path);
957 assert!(
958 kind_of(destination).is_some(),
959 "{destination}: no declared kind"
960 );
961 }
962 }
963 }
964 for block in BLOCK_DESTINATIONS {
965 assert_eq!(kind_of(block), Some(Kind::Rendered), "{block}");
966 }
967 assert_eq!(kind_of("something-else.txt"), None);
968 }
969
970 #[test]
975 fn rendering_substitutes_every_owner_occurrence() {
976 let baseline = b"if: repository_owner == 'OWNER'\n# OWNER again: OWNER\n";
977 let rendered = render(baseline, &super::Params::for_test("acme/sub/widget", None));
978 let text = String::from_utf8(rendered).expect("rendered bytes stay text");
979 assert_eq!(text, "if: repository_owner == 'acme'\n# acme again: acme\n");
980
981 let baseline = b"match (RK_SCOPE_SHAPE)\n";
982 let rendered = render(baseline, &super::Params::for_test("acme/widget", None));
983 let text = String::from_utf8(rendered).expect("rendered bytes stay text");
984 assert_eq!(text, format!("match ({SCOPE_SHAPE})\n"));
985 }
986
987 #[test]
991 fn the_scope_shape_drops_into_the_title_check() {
992 assert_eq!(SCOPE_SHAPE, "[a-z0-9._/-]+");
993 assert!(
994 !SCOPE_SHAPE.contains('\''),
995 "the title checks single-quote it"
996 );
997 }
998
999 #[test]
1004 fn the_scope_predicate_and_the_rendered_pattern_agree() {
1005 let body = SCOPE_SHAPE
1006 .strip_prefix('[')
1007 .and_then(|rest| rest.strip_suffix("]+"))
1008 .expect("the shape is one bracket expression, repeated");
1009 let chars: Vec<char> = body.chars().collect();
1010 let mut admitted = std::collections::BTreeSet::new();
1011 let mut at = 0;
1012 while at < chars.len() {
1013 if at + 2 < chars.len() && chars[at + 1] == '-' {
1016 for c in chars[at]..=chars[at + 2] {
1017 admitted.insert(c);
1018 }
1019 at += 3;
1020 } else {
1021 admitted.insert(chars[at]);
1022 at += 1;
1023 }
1024 }
1025 for byte in 0..=127u8 {
1026 let c = char::from(byte);
1027 assert_eq!(
1028 super::scope_is_shaped(&c.to_string()),
1029 admitted.contains(&c),
1030 "the predicate and {SCOPE_SHAPE} disagree on {c:?}"
1031 );
1032 }
1033 assert!(super::scope_is_shaped("guides/release"));
1034 assert!(!super::scope_is_shaped(""), "a scope is never empty");
1035 assert!(!super::scope_is_shaped("Specs Ugly"));
1036 }
1037
1038 #[test]
1041 fn the_shared_zone_composes_into_the_pair() {
1042 let files = pair_files("rust", "github").expect("the pair lists");
1043 assert!(
1044 files
1045 .iter()
1046 .any(|(dest, _)| dest == ".github/workflows/pr-title.yml"),
1047 "the shared title check lands with the pair"
1048 );
1049 let files = pair_files("rust", "gitlab").expect("the pair lists");
1050 assert!(
1051 files
1052 .iter()
1053 .any(|(dest, _)| dest == ".gitlab/ci/mr-title.yml"),
1054 "the shared title job lands with the pair"
1055 );
1056 let err = pair_files("_shared", "github").expect_err("the shared zone is no tech");
1057 let listing = err.to_string();
1058 let bindings = listing
1059 .split("the bindings are:")
1060 .nth(1)
1061 .expect("the refusal lists the bindings");
1062 assert!(!bindings.contains("_shared"), "{listing}");
1063 }
1064
1065 #[test]
1068 fn params_from_a_record_round_trips() {
1069 use super::{Params, manifest};
1070 let dir = tempfile::tempdir().expect("a scratch target exists");
1071 let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
1072 for tech in ["rust", "bash"] {
1073 for forge in ["github", "gitlab"] {
1074 for workflow in [Workflow::Branches, Workflow::Worktree] {
1075 for style in [None, Some(Style::Trunk), Some(Style::Lines)] {
1076 for nix in [false, true] {
1077 let record = manifest::Manifest {
1078 schema_version: manifest::SCHEMA_VERSION,
1079 rk_version: "0.1.0".to_owned(),
1080 origin: "init".to_owned(),
1081 tech: tech.to_owned(),
1082 forge: forge.to_owned(),
1083 landed_at: "2026-08-29T00:00:00Z".to_owned(),
1084 parameters: manifest::Parameters {
1085 repo: "acme/team/widget".to_owned(),
1086 workflow,
1087 style,
1088 nix,
1089 trunk: crate::config::TRUNK_DEFAULT.to_owned(),
1090 line_prefix: crate::config::LINE_PREFIX_DEFAULT.to_owned(),
1091 security_contact: String::new(),
1092 security_response: crate::config::RESPONSE_DEFAULT.to_owned(),
1093 },
1094 files: Vec::new(),
1095 pins: std::collections::BTreeMap::new(),
1096 };
1097 manifest::write(target, &record).expect("the record writes");
1098 let loaded = manifest::load(target)
1099 .expect("the record loads")
1100 .expect("the record exists");
1101 let params = Params::from_record(&loaded);
1102 assert_eq!(params.tech, tech);
1103 assert_eq!(params.forge, forge);
1104 assert_eq!(params.repo(), "acme/team/widget");
1105 assert_eq!(params.workflow(), workflow);
1106 assert_eq!(params.style(), style);
1107 assert_eq!(params.nix, nix);
1108 let entries = projection(¶ms).expect("the record projects");
1109 let mut expected: Vec<_> = pair_files(tech, forge)
1110 .expect("the pair lists")
1111 .into_iter()
1112 .filter(|(path, _)| {
1113 nix || !super::NIX_DESTINATIONS.contains(&path.as_str())
1114 })
1115 .collect();
1116 let routing = routing_block(workflow);
1117 let hooks = hooks_block(workflow);
1118 let glossary = glossary_block();
1119 expected.push((AGENTS_DESTINATION.to_owned(), routing.into_bytes()));
1120 expected.push((GLOSSARY_DESTINATION.to_owned(), glossary.into_bytes()));
1121 expected.push((HOOKS_DESTINATION.to_owned(), hooks.into_bytes()));
1122 expected.sort_by(|a, b| a.0.cmp(&b.0));
1123 assert_eq!(entries.len(), expected.len());
1124 for (entry, (destination, baseline)) in entries.iter().zip(expected) {
1125 assert_eq!(entry.destination, destination);
1126 assert_eq!(entry.baseline, baseline);
1127 let rendered = match entry.kind {
1128 Kind::Rendered => super::render(
1129 &baseline,
1130 &super::Params::for_test("acme/team/widget", style),
1131 ),
1132 Kind::Seeded | Kind::State => baseline.clone(),
1133 };
1134 assert_eq!(entry.rendered, rendered, "{destination}");
1135 }
1136 }
1137 }
1138 }
1139 }
1140 }
1141 }
1142
1143 fn resolved_test_params(
1144 tech: &str,
1145 resolved: &super::Resolved,
1146 workflow: Workflow,
1147 style: Option<Style>,
1148 nix: bool,
1149 ) -> Result<super::Params, crate::error::RkError> {
1150 super::Params::resolve(
1151 &SOURCE,
1152 camino::Utf8Path::new("."),
1153 &super::Inputs {
1154 tech: Some(tech),
1155 forge: Some(&resolved.forge),
1156 repo: resolved.repo.as_deref(),
1157 workflow: Some(workflow),
1158 style,
1159 nix: Some(nix),
1160 },
1161 None,
1162 None,
1163 super::Purpose::Init,
1164 )
1165 }
1166
1167 #[test]
1171 fn a_projection_renders_owned_files_and_keeps_seeded_judgment() {
1172 let entries = projection(
1173 &resolved_test_params(
1174 "rust",
1175 &super::Resolved {
1176 forge: "github".to_owned(),
1177 repo: Some("acme/widget".to_owned()),
1178 },
1179 Workflow::Branches,
1180 Some(Style::Trunk),
1181 false,
1182 )
1183 .expect("the parameters resolve"),
1184 )
1185 .expect("the pair projects");
1186 let workflow = entries
1187 .iter()
1188 .find(|entry| entry.destination.ends_with("release-plz.yml"))
1189 .expect("the workflow projects");
1190 assert_eq!(workflow.kind, Kind::Rendered);
1191 let text = String::from_utf8_lossy(&workflow.rendered);
1192 assert!(!text.contains("OWNER"), "an owner token survived rendering");
1193 assert!(text.contains("'acme'"));
1194 assert!(!text.contains("TODO(release-kit)"));
1195 let title = entries
1196 .iter()
1197 .find(|entry| entry.destination.ends_with("pr-title.yml"))
1198 .expect("the title check projects");
1199 let text = String::from_utf8_lossy(&title.rendered);
1200 assert!(text.contains(SCOPE_SHAPE), "{text}");
1201 assert!(
1202 !text.contains("RK_SCOPE_SHAPE"),
1203 "a scope token survived: {text}"
1204 );
1205 let seeded = entries
1206 .iter()
1207 .find(|entry| entry.destination == "release-plz.toml")
1208 .expect("the seeded file projects");
1209 assert_eq!(seeded.kind, Kind::Seeded);
1210 assert_eq!(seeded.rendered, seeded.baseline);
1211 assert!(String::from_utf8_lossy(&seeded.rendered).contains("TODO(release-kit)"));
1212 for block in BLOCK_DESTINATIONS {
1213 let entry = entries
1214 .iter()
1215 .find(|entry| entry.destination == block)
1216 .expect("every block is part of the projection");
1217 let text = String::from_utf8_lossy(&entry.rendered);
1218 assert!(
1219 !text.contains("RK_SCOPE_SHAPE"),
1220 "{block} kept a token: {text}"
1221 );
1222 }
1223 }
1224
1225 #[test]
1230 fn the_nix_destinations_project_only_under_the_opt_in() {
1231 use super::NIX_DESTINATIONS;
1232 let paths = |nix: bool, forge: &str| -> Vec<String> {
1233 projection(
1234 &resolved_test_params(
1235 "rust",
1236 &super::Resolved {
1237 forge: forge.to_owned(),
1238 repo: Some("acme/widget".to_owned()),
1239 },
1240 Workflow::Worktree,
1241 Some(Style::Trunk),
1242 nix,
1243 )
1244 .expect("the parameters resolve"),
1245 )
1246 .expect("the pair projects")
1247 .into_iter()
1248 .map(|entry| entry.destination)
1249 .collect()
1250 };
1251 let off = paths(false, "github");
1252 for destination in NIX_DESTINATIONS {
1253 assert!(!off.contains(&destination.to_owned()), "{destination}");
1254 }
1255 let on = paths(true, "github");
1256 for destination in ["nix/package.nix", "flake.nix", "flake.lock"] {
1257 assert!(on.contains(&destination.to_owned()), "{destination}");
1258 }
1259 let gitlab = paths(true, "gitlab");
1264 assert!(gitlab.contains(&"nix/package.nix".to_owned()));
1265 assert!(
1266 !on.iter()
1267 .chain(gitlab.iter())
1268 .any(|destination| destination.contains("nix.yml"))
1269 );
1270 let bash = projection(
1271 &resolved_test_params(
1272 "bash",
1273 &super::Resolved {
1274 forge: "github".to_owned(),
1275 repo: Some("acme/widget".to_owned()),
1276 },
1277 Workflow::Worktree,
1278 Some(Style::Trunk),
1279 true,
1280 )
1281 .expect("the parameters resolve"),
1282 )
1283 .expect("an out-of-matrix pair projects the smaller product");
1284 assert!(
1285 bash.iter()
1286 .all(|entry| !NIX_DESTINATIONS.contains(&entry.destination.as_str()))
1287 );
1288 }
1289
1290 #[test]
1295 fn the_nix_seeds_are_identical_across_forge_pairs() {
1296 for name in ["nix/package.nix", "flake.nix", "flake.lock"] {
1297 let github = embedded::SNIPPETS
1298 .get_file(format!("rust/github/{name}"))
1299 .expect("the github copy ships")
1300 .contents();
1301 let gitlab = embedded::SNIPPETS
1302 .get_file(format!("rust/gitlab/{name}"))
1303 .expect("the gitlab copy ships")
1304 .contents();
1305 assert_eq!(github, gitlab, "{name} diverged between the pairs");
1306 }
1307 }
1308
1309 #[test]
1314 fn the_nix_withhold_judgment_covers_the_three_shapes() {
1315 use super::{NIX_DESTINATIONS, withhold_nix};
1316 let dir = tempfile::tempdir().expect("a scratch target exists");
1317 let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
1318 let entries = || {
1319 projection(
1320 &resolved_test_params(
1321 "rust",
1322 &super::Resolved {
1323 forge: "github".to_owned(),
1324 repo: Some("acme/widget".to_owned()),
1325 },
1326 Workflow::Worktree,
1327 Some(Style::Trunk),
1328 true,
1329 )
1330 .expect("the parameters resolve"),
1331 )
1332 .expect("the pair projects")
1333 };
1334
1335 let mut all = entries();
1337 let withheld = withhold_nix(target, true, None, &mut all).expect("the judgment runs");
1338 let paths: Vec<&str> = withheld.iter().map(|w| w.path.as_str()).collect();
1339 assert_eq!(paths, ["flake.lock", "flake.nix", "nix/package.nix"]);
1340 assert!(
1341 all.iter()
1342 .all(|entry| !NIX_DESTINATIONS.contains(&entry.destination.as_str()))
1343 );
1344
1345 std::fs::write(
1348 target.join("Cargo.toml"),
1349 "[package]\nname = \"widget\"\nversion = \"0.1.0\"\n",
1350 )
1351 .expect("the crate manifest writes");
1352 std::fs::write(target.join("Cargo.lock"), "version = 4\n").expect("the lock writes");
1353 std::fs::create_dir_all(target.join("src")).expect("the src dir exists");
1354 std::fs::write(target.join("src/main.rs"), "fn main() {}\n").expect("the main writes");
1355 std::fs::write(target.join("flake.nix"), "{ }\n").expect("the flake writes");
1356 let mut all = entries();
1357 let withheld = withhold_nix(target, true, None, &mut all).expect("the judgment runs");
1358 let paths: Vec<&str> = withheld.iter().map(|w| w.path.as_str()).collect();
1359 assert_eq!(paths, ["flake.lock", "flake.nix"]);
1360 assert!(
1361 all.iter()
1362 .any(|entry| entry.destination == "nix/package.nix")
1363 );
1364
1365 std::fs::remove_file(target.join("flake.nix")).expect("the flake removes");
1367 let mut all = entries();
1368 let withheld = withhold_nix(target, true, None, &mut all).expect("the judgment runs");
1369 assert!(withheld.is_empty());
1370 assert!(all.iter().any(|entry| entry.destination == "flake.nix"));
1371
1372 let mut all = entries();
1374 let withheld = withhold_nix(target, false, None, &mut all).expect("the judgment runs");
1375 assert!(withheld.is_empty());
1376 }
1377
1378 #[test]
1382 fn the_glossary_splices_into_every_shape() {
1383 let owned = glossary_block();
1384 let block = owned.as_str();
1385
1386 let fresh = spliced(None, block);
1387 assert_eq!(fresh, format!("{block}\n"));
1388 assert_eq!(extract_block(&fresh, BLOCK_BEGIN, BLOCK_END), Some(block));
1389
1390 let own = "# Glossary\n\n- `spike` — a throwaway branch.\n";
1391 let appended = spliced(Some(own), block);
1392 assert!(appended.starts_with(own));
1393 assert_eq!(
1394 extract_block(&appended, BLOCK_BEGIN, BLOCK_END),
1395 Some(block)
1396 );
1397
1398 let stale = appended.replace("full-implement", "do-everything");
1399 let refreshed = spliced(Some(&stale), block);
1400 assert_eq!(
1401 extract_block(&refreshed, BLOCK_BEGIN, BLOCK_END),
1402 Some(block)
1403 );
1404 assert_eq!(
1405 refreshed.matches("BEGIN release-kit").count(),
1406 1,
1407 "a re-splice must replace, not accumulate"
1408 );
1409 }
1410
1411 #[test]
1415 fn the_glossary_leaves_the_targets_region_alone() {
1416 let owned = glossary_block();
1417 let block = owned.as_str();
1418 let below = "\n## Our own terms\n\n- `spike` — a throwaway branch, never merged.\n";
1419 let landed = format!("{block}\n{below}");
1420
1421 let refreshed = spliced(Some(&landed), block);
1422 assert!(
1423 refreshed.ends_with(below),
1424 "the target's own region changed: {refreshed}"
1425 );
1426 assert_eq!(
1427 extract_block(&refreshed, BLOCK_BEGIN, BLOCK_END),
1428 Some(block)
1429 );
1430 }
1431
1432 #[test]
1436 fn an_append_rewrites_no_byte_the_target_wrote() {
1437 let owned = glossary_block();
1438 let block = owned.as_str();
1439 for own in [
1440 "# Glossary\n\n- `spike` — throwaway. \n\n\n",
1441 "# Glossary\n\n- `spike` — throwaway.",
1442 "# Glossary\r\n\r\n- `spike` — throwaway.\r\n",
1443 ] {
1444 let appended = spliced(Some(own), block);
1445 assert!(
1446 appended.starts_with(own),
1447 "the target's bytes changed: {appended:?}"
1448 );
1449 assert_eq!(
1450 extract_block(&appended, BLOCK_BEGIN, BLOCK_END),
1451 Some(block),
1452 "{appended:?}"
1453 );
1454 let marker = appended.find(BLOCK_BEGIN).expect("the block landed");
1455 assert!(
1456 appended[..marker].ends_with('\n'),
1457 "the block must open its own line: {appended:?}"
1458 );
1459 }
1460 }
1461
1462 #[test]
1466 fn a_splice_decodes_no_byte_the_target_wrote() {
1467 let owned = glossary_block();
1468 let block = owned.as_str();
1469
1470 let own = b"# Glossary\n\ncaf\xe9\n";
1472 let appended = splice_marked_block(Some(own), block);
1473 assert!(
1474 appended.starts_with(own),
1475 "the target's bytes changed: {appended:?}"
1476 );
1477 assert!(!appended.contains(&0xEF), "a replacement character landed");
1478
1479 let mut landed = Vec::new();
1481 landed.extend_from_slice(block.replace("full-implement", "do-everything").as_bytes());
1482 landed.extend_from_slice(b"\n\ncaf\xe9\n");
1483 let refreshed = splice_marked_block(Some(&landed), block);
1484 assert!(
1485 refreshed.ends_with(b"\n\ncaf\xe9\n"),
1486 "the target's region below the markers changed: {refreshed:?}"
1487 );
1488 assert!(refreshed.starts_with(block.as_bytes()), "{refreshed:?}");
1489 }
1490
1491 #[test]
1494 fn the_glossary_block_carries_no_parameter() {
1495 let block = glossary_block();
1496 assert!(block.starts_with(BLOCK_BEGIN), "{block}");
1497 assert!(block.ends_with(BLOCK_END), "{block}");
1498 assert!(!block.contains("RK_"), "a token survived: {block}");
1499 assert!(!block.contains("OWNER"), "an owner token survived: {block}");
1500 for term in [
1501 "implement-and-request",
1502 "implement-and-merge",
1503 "full-implement",
1504 ] {
1505 assert!(block.contains(term), "{term} is missing from {block}");
1506 }
1507 assert!(
1508 routing_block(Workflow::Worktree).contains(GLOSSARY_DESTINATION),
1509 "the routing block must name the destination it indexes"
1510 );
1511 }
1512
1513 #[test]
1514 fn the_block_splices_into_every_agents_shape() {
1515 let owned = routing_block(Workflow::Branches);
1516 let block = owned.as_str();
1517 let fresh = spliced(None, block);
1518 assert_eq!(fresh, format!("{block}\n"));
1519 assert_eq!(extract_block(&fresh, BLOCK_BEGIN, BLOCK_END), Some(block));
1520
1521 let appended = spliced(Some("# My project\n\nOwn rules.\n"), block);
1522 assert!(appended.starts_with("# My project\n\nOwn rules.\n\n<!-- BEGIN release-kit -->"));
1523 assert_eq!(
1524 extract_block(&appended, BLOCK_BEGIN, BLOCK_END),
1525 Some(block)
1526 );
1527
1528 let stale = appended.replace("Never author a tag", "Do author a tag");
1529 let refreshed = spliced(Some(&stale), block);
1530 assert_eq!(
1531 extract_block(&refreshed, BLOCK_BEGIN, BLOCK_END),
1532 Some(block)
1533 );
1534 assert!(refreshed.starts_with("# My project"));
1535 assert_eq!(
1536 refreshed.matches("BEGIN release-kit").count(),
1537 1,
1538 "a re-splice must replace, not accumulate"
1539 );
1540 }
1541
1542 #[test]
1545 fn the_hook_block_splices_under_repos() {
1546 let owned = hooks_block(Workflow::Branches);
1547 let block = owned.as_str();
1548 let fresh = splice_hooks_block(None, block).expect("a fresh file splices");
1549 assert!(fresh.starts_with(HOOK_TYPES_LINE));
1550 assert!(fresh.contains("\nrepos:\n# BEGIN release-kit\n"));
1551 assert_eq!(extract_block(&fresh, HOOKS_BEGIN, HOOKS_END), Some(block));
1552
1553 let own =
1554 "repos:\n - repo: https://example.com/own\n rev: v1\n hooks:\n - id: own\n";
1555 let spliced = splice_hooks_block(Some(own), block).expect("an unmarked file splices");
1556 assert!(spliced.starts_with("repos:\n# BEGIN release-kit\n"));
1557 assert!(spliced.contains("- id: own"), "the target's hooks survive");
1558 assert!(
1559 !spliced.contains(HOOK_TYPES_LINE),
1560 "an existing file's top level is the skills' duty, not the splice's"
1561 );
1562
1563 let stale = spliced.replace("--force-scope", "--no-scope");
1564 let refreshed = splice_hooks_block(Some(&stale), block).expect("a marked file re-splices");
1565 assert_eq!(
1566 extract_block(&refreshed, HOOKS_BEGIN, HOOKS_END),
1567 Some(block)
1568 );
1569 assert_eq!(refreshed.matches(HOOKS_BEGIN).count(), 1);
1570
1571 let err = splice_hooks_block(Some("minimum_pre_commit_version: '3.2.0'\n"), block)
1572 .expect_err("no repos: line refuses");
1573 assert!(err.contains("repos:"), "{err}");
1574
1575 let doubled = format!("repos:\n{block}\n{block}\n");
1579 let err = splice_hooks_block(Some(&doubled), block).expect_err("a second block refuses");
1580 assert!(err.contains("one block"), "{err}");
1581 let unmatched = "repos:\n# BEGIN release-kit\n - repo: local\n";
1582 let err =
1583 splice_hooks_block(Some(unmatched), block).expect_err("an unmatched marker refuses");
1584 assert!(err.contains("unmatched"), "{err}");
1585 }
1586
1587 #[test]
1593 fn the_blocks_render_per_mode_and_carry_the_one_grammar() {
1594 let worktree_hooks = hooks_block(Workflow::Worktree);
1595 let branches_hooks = hooks_block(Workflow::Branches);
1596 assert!(worktree_hooks.contains("- id: rk-worktree-location"));
1597 assert!(
1598 worktree_hooks.contains("SKIP=no-commit-to-branch,rk-worktree-location"),
1599 "{worktree_hooks}"
1600 );
1601 assert!(!branches_hooks.contains("rk-worktree-location"));
1602 assert!(branches_hooks.contains("SKIP=no-commit-to-branch in"));
1603 for block in [&worktree_hooks, &branches_hooks] {
1604 assert!(block.contains(BRANCH_GRAMMAR), "the grammar has one owner");
1605 for token in ["RK_BRANCH_GRAMMAR", "RK_SWEEP_SKIP", "RK_WORKTREE_GUARD"] {
1606 assert!(!block.contains(token), "{token} survived: {block}");
1607 }
1608 }
1609 for block in [&worktree_hooks, &branches_hooks] {
1614 for line in block.lines() {
1615 if let Some(value) = line.trim_start().strip_prefix("entry: ") {
1616 assert!(
1617 !value.contains(": "),
1618 "an entry value breaks the YAML plain scalar: {line}"
1619 );
1620 }
1621 }
1622 }
1623 let guard_line = worktree_hooks
1624 .lines()
1625 .position(|line| line.contains("id: rk-worktree-location"))
1626 .expect("the guard entry exists");
1627 let name_line = worktree_hooks
1628 .lines()
1629 .position(|line| line.contains("id: rk-branch-name"))
1630 .expect("the name hook exists");
1631 assert!(
1632 guard_line > name_line,
1633 "the guard lands directly after rk-branch-name"
1634 );
1635
1636 let worktree_routing = routing_block(Workflow::Worktree);
1637 let branches_routing = routing_block(Workflow::Branches);
1638 assert!(worktree_routing.contains("This project works in worktrees"));
1639 assert!(branches_routing.contains("Branches are worked in the main checkout"));
1640 for block in [&worktree_routing, &branches_routing] {
1641 assert!(block.contains("Create or remove a worktree"));
1642 assert!(block.contains("`rk worktree add <branch>`"));
1643 assert!(!block.contains("RK_WORKFLOW_LINE"), "{block}");
1644 }
1645 let differing: Vec<(&str, &str)> = worktree_routing
1646 .lines()
1647 .zip(branches_routing.lines())
1648 .filter(|(a, b)| a != b)
1649 .collect();
1650 assert_eq!(
1651 differing.len(),
1652 1,
1653 "exactly one routing line differs per mode: {differing:?}"
1654 );
1655 }
1656
1657 #[test]
1660 fn the_hook_marker_defects_are_named() {
1661 use super::hooks_marker_defect;
1662 let owned = hooks_block(Workflow::Branches);
1663 let block = owned.as_str();
1664 assert_eq!(hooks_marker_defect(""), None);
1665 assert_eq!(hooks_marker_defect(&format!("repos:\n{block}\n")), None);
1666 for (case, text) in [
1667 (
1668 "a second begin",
1669 format!("repos:\n{block}\n# BEGIN release-kit\n"),
1670 ),
1671 (
1672 "a second end",
1673 format!("repos:\n{block}\n# END release-kit\n"),
1674 ),
1675 (
1676 "an unpaired begin",
1677 "repos:\n# BEGIN release-kit\n".to_owned(),
1678 ),
1679 ("an unpaired end", "repos:\n# END release-kit\n".to_owned()),
1680 (
1681 "an end before its begin",
1682 "repos:\n# END release-kit\n# BEGIN release-kit\n".to_owned(),
1683 ),
1684 ] {
1685 assert!(
1686 hooks_marker_defect(&text).is_some(),
1687 "{case} must be a defect"
1688 );
1689 }
1690 }
1691}