1pub mod evidence;
32
33use std::collections::BTreeMap;
34
35use serde::{Deserialize, Serialize};
36
37use crate::embedded;
38use crate::error::RkError;
39pub use crate::landing::manifest::Provider;
40use crate::landing::{CheckoutMode, Integration, Params};
41use crate::profile::ReleaseMode;
42use crate::profile::catalog::{self, Availability, Selection, Status};
43
44#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct ProjectionInput {
48 pub params: Params,
50 pub evidence: TargetEvidence,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, Default)]
57#[allow(
58 clippy::struct_excessive_bools,
59 reason = "each field is one independent fact read off the target, not a state a smaller type could carry"
60)]
61pub struct TargetEvidence {
62 pub documents: BTreeMap<String, Vec<u8>>,
66 pub crate_shape: CrateShape,
68 pub flake_nix_present: bool,
70 pub flake_lock_present: bool,
72 pub flake_recorded: bool,
75 pub root_pipeline_present: bool,
77 pub root_pipeline_recorded: bool,
80}
81
82impl TargetEvidence {
83 #[must_use]
85 pub fn document(&self, destination: &str) -> Option<&[u8]> {
86 self.documents.get(destination).map(Vec::as_slice)
87 }
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Default)]
93pub struct CrateShape {
94 pub cargo_toml: Option<String>,
96 pub cargo_lock: bool,
98 pub main_rs: bool,
100}
101
102#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct Projection {
109 pub capabilities: Vec<Selection>,
112 pub candidates: Vec<Candidate>,
114 pub omissions: Vec<Omission>,
119 pub collisions: Vec<Collision>,
122 pub licence_refusal: Option<String>,
128 pub record_defects: Vec<String>,
133}
134
135#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct Candidate {
138 pub capability: &'static str,
140 pub destination: String,
142 pub kind: Kind,
144 pub placement: Placement,
146 pub bytes: Vec<u8>,
150 pub region: Option<Vec<u8>>,
153 pub sources: Vec<String>,
156}
157
158#[derive(Debug, Clone, Copy, PartialEq, Eq)]
160pub enum Placement {
161 Whole,
163 Region {
166 begin: &'static str,
168 end: &'static str,
170 },
171}
172
173#[derive(Debug, Clone, PartialEq, Eq)]
175pub struct Omission {
176 pub destination: String,
178 pub reason: String,
180 pub action: Option<String>,
184}
185
186#[derive(Debug, Clone, PartialEq, Eq)]
188pub struct Collision {
189 pub destination: String,
191 pub reason: String,
193}
194
195impl Projection {
196 pub fn compute(input: &ProjectionInput) -> Result<Self, RkError> {
206 Self::compute_over(&embedded_snippets(), input)
207 }
208
209 #[allow(
213 clippy::too_many_lines,
214 reason = "one pass selects, renders, splices, and withholds; splitting it would separate a withheld destination from the selection that offered it"
215 )]
216 fn compute_over(files: &[(String, &[u8])], input: &ProjectionInput) -> Result<Self, RkError> {
217 let params = &input.params;
218 let evidence = &input.evidence;
219 let availability = Availability::over(
220 files
221 .iter()
222 .filter_map(|(path, _)| path.strip_prefix("snippets/").map(str::to_owned))
223 .collect(),
224 );
225 let mut capabilities = catalog::select(params, &availability);
226 let mut candidates: Vec<Candidate> = Vec::new();
227 for selection in &capabilities {
228 if !selection.lands() {
229 continue;
230 }
231 for source in &selection.sources {
232 let destination = source
233 .splitn(3, '/')
234 .nth(2)
235 .ok_or_else(|| {
236 anyhow::anyhow!(
237 "{source}: a snippet path has a zone, a forge, and a destination"
238 )
239 })?
240 .to_owned();
241 let path = format!("snippets/{source}");
242 let bytes = files
243 .iter()
244 .find(|(candidate, _)| *candidate == path)
245 .map(|(_, bytes)| *bytes)
246 .ok_or_else(|| {
247 anyhow::anyhow!(
248 "{path}: the catalog selected a source the tree does not carry"
249 )
250 })?;
251 if let Some(existing) = candidates
252 .iter()
253 .find(|candidate| candidate.destination == destination)
254 {
255 return Err(anyhow::anyhow!(
256 "{} and {} both ship {destination}: {} and {path}; the embedded sources are defective",
257 existing.capability,
258 selection.id,
259 existing.sources.join(", ")
260 )
261 .into());
262 }
263 let kind = kind_of(&destination).ok_or_else(|| {
264 anyhow::anyhow!(
265 "the embedded sources do not classify {destination}; the kind table is stale"
266 )
267 })?;
268 let rendered = match kind {
269 Kind::Rendered => render(bytes, params),
270 Kind::Seeded | Kind::State => bytes.to_vec(),
271 };
272 candidates.push(Candidate {
273 capability: selection.id,
274 destination,
275 kind,
276 placement: Placement::Whole,
277 bytes: rendered,
278 region: None,
279 sources: vec![path],
280 });
281 }
282 }
283 let mut collisions = Vec::new();
284 for destination in BLOCK_DESTINATIONS {
285 let Some((template, sources, capability)) = block_template(destination, params)? else {
286 continue;
287 };
288 if let Some(whole) = candidates
289 .iter()
290 .find(|candidate| candidate.destination == destination)
291 {
292 return Err(anyhow::anyhow!(
293 "{destination} is both a whole file from {} and a marked region from {}; the embedded sources are defective",
294 whole.sources.join(", "),
295 sources.join(", ")
296 )
297 .into());
298 }
299 let region = render(template.as_bytes(), params);
300 let (begin, end) = block_markers(destination).ok_or_else(|| {
301 anyhow::anyhow!("{destination} is a block destination with no markers")
302 })?;
303 match propose_document(destination, evidence.document(destination), ®ion) {
304 Ok(bytes) => candidates.push(Candidate {
305 capability,
306 destination: destination.to_owned(),
307 kind: Kind::Rendered,
308 placement: Placement::Region { begin, end },
309 bytes,
310 region: Some(region),
311 sources,
312 }),
313 Err(reason) => collisions.push(Collision {
314 destination: destination.to_owned(),
315 reason,
316 }),
317 }
318 }
319 let mut omissions = Vec::new();
320 if let Some((set, reason)) = nix_withholding(params.nix_packaging(), evidence)
321 && candidates
322 .iter()
323 .any(|candidate| candidate.capability == catalog::PACKAGING_NIX)
324 {
325 candidates.retain(|candidate| {
326 if set.contains(&candidate.destination.as_str()) {
327 omissions.push(Omission {
328 destination: candidate.destination.clone(),
329 reason: reason.clone(),
330 action: None,
331 });
332 false
333 } else {
334 true
335 }
336 });
337 withhold(&mut capabilities, catalog::PACKAGING_NIX, &reason, None);
338 }
339 let title_root = candidates.iter().position(|candidate| {
345 candidate.capability == catalog::TITLE_CHECK
346 && candidate.destination == GITLAB_ROOT_PIPELINE
347 });
348 if let Some(index) = title_root
349 && evidence.root_pipeline_present
350 && !evidence.root_pipeline_recorded
351 {
352 let candidate = candidates.remove(index);
353 let reason = format!(
354 "the target owns {GITLAB_ROOT_PIPELINE}, so the release-less root pipeline that would activate the title gate stays out; the fragment lands as the gate's prerequisite"
355 );
356 let action = format!(
357 "add the include to the target's own {GITLAB_ROOT_PIPELINE}: include:\n - local: {GITLAB_TITLE_FRAGMENT}"
358 );
359 omissions.push(Omission {
360 destination: candidate.destination,
361 reason: reason.clone(),
362 action: Some(action.clone()),
363 });
364 withhold(
365 &mut capabilities,
366 catalog::TITLE_CHECK,
367 &reason,
368 Some(action),
369 );
370 }
371 candidates.sort_by(|a, b| a.destination.cmp(&b.destination));
372 omissions.sort_by(|a, b| a.destination.cmp(&b.destination));
373 let scanning_selected = capabilities.iter().any(|selection| {
379 selection.id == catalog::CODE_SCANNING && selection.status == Status::Selected
380 });
381 let licence_refusal = scanning_selected
382 .then(|| {
383 code_scanning_licence_refusal(
384 params.code_scanning(),
385 params.driver(),
386 &evidence.crate_shape,
387 )
388 })
389 .flatten();
390 let mut record_defects: Vec<String> = Vec::new();
395 if params.release_mode() == ReleaseMode::Automatic
399 && let Some(selection) = capabilities
400 .iter()
401 .find(|selection| selection.id == catalog::RELEASE_AUTOMATION)
402 && matches!(selection.status, Status::Unavailable | Status::Unknown)
403 && let Some(reason) = &selection.reason
404 {
405 record_defects.push(reason.clone());
406 }
407 Ok(Self {
408 capabilities,
409 candidates,
410 omissions,
411 collisions,
412 licence_refusal,
413 record_defects,
414 })
415 }
416
417 #[must_use]
421 pub fn release_unavailable(&self) -> Option<&str> {
422 self.capabilities
423 .iter()
424 .find(|selection| {
425 selection.id == catalog::RELEASE_AUTOMATION
426 && matches!(selection.status, Status::Unavailable | Status::Unknown)
427 })
428 .and_then(|selection| selection.reason.as_deref())
429 }
430
431 #[must_use]
433 pub fn capability(&self, id: &str) -> Option<&Selection> {
434 self.capabilities
435 .iter()
436 .find(|selection| selection.id == id)
437 }
438}
439
440fn withhold(capabilities: &mut [Selection], id: &str, reason: &str, action: Option<String>) {
442 if let Some(selection) = capabilities.iter_mut().find(|selection| selection.id == id) {
443 selection.status = Status::Withheld;
444 selection.reason = Some(reason.to_owned());
445 selection.action = action;
446 }
447}
448
449pub const GITLAB_ROOT_PIPELINE: &str = ".gitlab-ci.yml";
452
453pub const GITLAB_TITLE_FRAGMENT: &str = ".gitlab/ci/mr-title.yml";
455
456fn embedded_snippets() -> Vec<(String, &'static [u8])> {
459 embedded::walk(&embedded::SNIPPETS)
460 .into_iter()
461 .map(|(path, bytes)| (format!("snippets/{path}"), bytes))
462 .collect()
463}
464
465#[must_use]
468pub fn supported_pairs() -> Vec<(String, String)> {
469 Availability::embedded()
470 .automation_tuples()
471 .into_iter()
472 .filter_map(|entry| {
473 entry
474 .split_once(", ")
475 .map(|(driver, forge)| (driver.to_owned(), forge.to_owned()))
476 })
477 .collect()
478}
479
480#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
482#[serde(rename_all = "lowercase")]
483pub enum Kind {
484 Rendered,
487 Seeded,
490 State,
493}
494
495impl Kind {
496 #[must_use]
498 pub const fn as_str(self) -> &'static str {
499 match self {
500 Self::Rendered => "rendered",
501 Self::Seeded => "seeded",
502 Self::State => "state",
503 }
504 }
505}
506
507const KINDS: [(&str, Kind); 20] = [
513 (".github/workflows/release-plz.yml", Kind::Rendered),
514 (".github/workflows/release-please.yml", Kind::Rendered),
515 (".github/workflows/release.yml", Kind::Rendered),
516 (".github/workflows/pr-title.yml", Kind::Rendered),
517 (".github/workflows/scorecard.yml", Kind::Rendered),
518 (".github/workflows/code-scanning-codeql.yml", Kind::Rendered),
519 (
520 ".github/workflows/code-scanning-semgrep.yml",
521 Kind::Rendered,
522 ),
523 (".gitlab/ci/code-scanning-semgrep.yml", Kind::Rendered),
524 (".gitlab-ci.yml", Kind::Rendered),
525 ("SECURITY.md", Kind::Rendered),
526 (".gitlab/ci/mr-title.yml", Kind::Rendered),
527 ("release-plz.toml", Kind::Seeded),
528 ("dist-workspace.toml", Kind::Seeded),
529 ("release-please-config.json", Kind::Seeded),
530 ("cliff.toml", Kind::Seeded),
531 ("nix/package.nix", Kind::Seeded),
532 ("flake.nix", Kind::Seeded),
533 (".release-please-manifest.json", Kind::State),
534 ("VERSION", Kind::State),
535 ("flake.lock", Kind::State),
536];
537
538pub const NIX_DESTINATIONS: [&str; 3] = ["nix/package.nix", "flake.nix", "flake.lock"];
552
553pub const NIX_WITHHOLDABLE: [&str; 2] = ["flake.nix", "flake.lock"];
559
560pub const SCORECARD_DESTINATIONS: [&str; 1] = [".github/workflows/scorecard.yml"];
575
576pub const CODE_SCANNING_DESTINATIONS: [(&str, Provider); 3] = [
590 (
591 ".github/workflows/code-scanning-codeql.yml",
592 Provider::CodeQl,
593 ),
594 (
595 ".github/workflows/code-scanning-semgrep.yml",
596 Provider::Semgrep,
597 ),
598 (".gitlab/ci/code-scanning-semgrep.yml", Provider::Semgrep),
599];
600
601pub const CODE_SCANNING_TECHS: [&str; 1] = ["rust"];
609
610#[must_use]
627pub fn code_scanning_incompatibility(
628 provider: Option<Provider>,
629 driver: Option<&str>,
630 forge: Option<&str>,
631) -> Option<String> {
632 let named = provider?;
633 let Some(driver) = driver else {
634 return Some(format!(
635 "a scanner reads the release driver's language, and the profile names no automatic release driver; the bindings that carry one are: {}",
636 CODE_SCANNING_TECHS.join(", ")
637 ));
638 };
639 if !CODE_SCANNING_TECHS.contains(&driver) {
640 return Some(format!(
641 "the {driver} binding ships no code scanning workflow; a scanner reads one language, and the bindings that carry one are: {}",
642 CODE_SCANNING_TECHS.join(", ")
643 ));
644 }
645 let Some(forge) = forge else {
646 return Some(
647 "a code scanning workflow runs at a forge, and the profile names none".to_owned(),
648 );
649 };
650 if named == Provider::CodeQl && forge != "github" {
651 return Some(format!(
652 "codeql is GitHub's own analyzer and the {forge} pair ships no workflow for it; pass --code-scanning semgrep"
653 ));
654 }
655 None
656}
657
658const OSI_APPROVED: [&str; 18] = [
668 "0bsd",
669 "agpl-3.0",
670 "agpl-3.0-only",
671 "agpl-3.0-or-later",
672 "apache-2.0",
673 "bsd-2-clause",
674 "bsd-3-clause",
675 "bsl-1.0",
676 "epl-2.0",
677 "gpl-2.0",
678 "gpl-2.0-only",
679 "gpl-2.0-or-later",
680 "gpl-3.0",
681 "gpl-3.0-only",
682 "gpl-3.0-or-later",
683 "isc",
684 "mit",
685 "mpl-2.0",
686];
687
688const CONTENT_LICENCES: [&str; 4] = ["cc-by-3.0", "cc-by-4.0", "cc-by-sa-3.0", "cc-by-sa-4.0"];
706
707const SPDX_EXCEPTIONS: [&str; 86] = [
724 "389-exception",
725 "asterisk-exception",
726 "asterisk-linking-protocols-exception",
727 "autoconf-exception-2.0",
728 "autoconf-exception-3.0",
729 "autoconf-exception-generic",
730 "autoconf-exception-generic-3.0",
731 "autoconf-exception-macro",
732 "bison-exception-1.24",
733 "bison-exception-2.2",
734 "bootloader-exception",
735 "cgal-linking-exception",
736 "classpath-exception-2.0",
737 "classpath-exception-2.0-short",
738 "clisp-exception-2.0",
739 "cryptsetup-openssl-exception",
740 "digia-qt-lgpl-exception-1.1",
741 "digirule-foss-exception",
742 "ecos-exception-2.0",
743 "erlang-otp-linking-exception",
744 "fawkes-runtime-exception",
745 "fltk-exception",
746 "fmt-exception",
747 "font-exception-2.0",
748 "freertos-exception-2.0",
749 "gcc-exception-2.0",
750 "gcc-exception-2.0-note",
751 "gcc-exception-3.1",
752 "gmsh-exception",
753 "gnat-exception",
754 "gnome-examples-exception",
755 "gnu-compiler-exception",
756 "gnu-javamail-exception",
757 "google-patent-webm",
758 "gpl-3.0-389-ds-base-exception",
759 "gpl-3.0-interface-exception",
760 "gpl-3.0-linking-exception",
761 "gpl-3.0-linking-source-exception",
762 "gpl-cc-1.0",
763 "gstreamer-exception-2005",
764 "gstreamer-exception-2008",
765 "harbour-exception",
766 "i2p-gpl-java-exception",
767 "independent-modules-exception",
768 "kicad-libraries-exception",
769 "kvirc-openssl-exception",
770 "lgpl-3.0-linking-exception",
771 "libpri-openh323-exception",
772 "libtool-exception",
773 "linux-syscall-note",
774 "llgpl",
775 "llvm-exception",
776 "lzma-exception",
777 "mif-exception",
778 "mxml-exception",
779 "nokia-qt-exception-1.1",
780 "ocaml-lgpl-linking-exception",
781 "occt-exception-1.0",
782 "openjdk-assembly-exception-1.0",
783 "openvpn-openssl-exception",
784 "pcre2-exception",
785 "polyparse-exception",
786 "ps-or-pdf-font-exception-20170817",
787 "qpl-1.0-inria-2004-exception",
788 "qt-gpl-exception-1.0",
789 "qt-lgpl-exception-1.1",
790 "qwt-exception-1.0",
791 "romic-exception",
792 "rrdtool-floss-exception-2.0",
793 "rsync-linking-exception",
794 "sane-exception",
795 "shl-2.0",
796 "shl-2.1",
797 "simple-library-usage-exception",
798 "spelling-provider-lgpl-exception",
799 "sqlitestudio-openssl-exception",
800 "stunnel-exception",
801 "swi-exception",
802 "swift-exception",
803 "texinfo-exception",
804 "u-boot-exception-2.0",
805 "ubdl-exception",
806 "universal-foss-exception-1.0",
807 "vsftpd-openssl-exception",
808 "wxwindows-exception-3.1",
809 "x11vnc-openssl-exception",
810];
811
812fn listed(list: &[&str], identifier: &str) -> bool {
819 let lowered = identifier.to_ascii_lowercase();
820 list.contains(&lowered.as_str())
821}
822
823#[derive(Debug, Clone, Copy, PartialEq, Eq)]
825enum Token<'a> {
826 Open,
828 Close,
830 And,
832 Or,
834 With,
837 Identifier(&'a str),
840}
841
842fn tokenize(expression: &str) -> Option<Vec<Token<'_>>> {
850 let mut tokens = Vec::new();
851 let bytes = expression.as_bytes();
852 let mut at = 0;
853 while at < bytes.len() {
854 let byte = bytes[at];
855 if byte.is_ascii_whitespace() {
856 at += 1;
857 continue;
858 }
859 if byte == b'(' {
860 tokens.push(Token::Open);
861 at += 1;
862 continue;
863 }
864 if byte == b')' {
865 tokens.push(Token::Close);
866 at += 1;
867 continue;
868 }
869 let start = at;
870 while at < bytes.len() {
871 let byte = bytes[at];
872 if byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'+' | b':') {
873 at += 1;
874 } else {
875 break;
876 }
877 }
878 if at == start {
879 return None;
880 }
881 let word = &expression[start..at];
882 tokens.push(match word {
883 "AND" => Token::And,
884 "OR" => Token::Or,
885 "WITH" => Token::With,
886 other => Token::Identifier(other),
887 });
888 }
889 Some(tokens)
890}
891
892#[derive(Debug, Clone, Copy)]
899struct Verdict {
900 grants: bool,
902 clean: bool,
904}
905
906struct Spdx<'a> {
915 tokens: &'a [Token<'a>],
916 at: usize,
917}
918
919impl<'a> Spdx<'a> {
920 fn peek(&self) -> Option<Token<'a>> {
922 self.tokens.get(self.at).copied()
923 }
924
925 fn bump(&mut self) -> Option<Token<'a>> {
927 let token = self.peek()?;
928 self.at += 1;
929 Some(token)
930 }
931
932 fn expression(&mut self) -> Option<Verdict> {
942 let mut verdict = self.conjunction()?;
943 while self.peek() == Some(Token::Or) {
944 self.bump();
945 let right = self.conjunction()?;
946 verdict = Verdict {
947 grants: verdict.grants && right.grants,
948 clean: verdict.clean && right.clean,
949 };
950 }
951 Some(verdict)
952 }
953
954 fn conjunction(&mut self) -> Option<Verdict> {
964 let mut verdict = self.operand()?;
965 while self.peek() == Some(Token::And) {
966 self.bump();
967 let right = self.operand()?;
968 verdict = Verdict {
969 grants: verdict.grants || right.grants,
970 clean: verdict.clean && right.clean,
971 };
972 }
973 Some(verdict)
974 }
975
976 fn operand(&mut self) -> Option<Verdict> {
986 match self.bump()? {
987 Token::Open => {
988 let inner = self.expression()?;
991 (self.bump()? == Token::Close).then_some(inner)
992 }
993 Token::Identifier(name) => {
994 let identifier = name.strip_suffix('+').unwrap_or(name);
995 let approved = listed(&OSI_APPROVED, identifier);
996 let verdict = Verdict {
997 grants: approved,
998 clean: approved || listed(&CONTENT_LICENCES, identifier),
999 };
1000 if self.peek() == Some(Token::With) {
1001 self.bump();
1002 match self.bump()? {
1006 Token::Identifier(exception) if listed(&SPDX_EXCEPTIONS, exception) => {}
1007 _ => return None,
1008 }
1009 }
1010 Some(verdict)
1011 }
1012 Token::And | Token::Or | Token::With | Token::Close => None,
1013 }
1014 }
1015}
1016
1017#[must_use]
1033pub fn licence_states_an_open_source_codebase(expression: &str) -> bool {
1034 let Some(tokens) = tokenize(expression) else {
1035 return false;
1036 };
1037 let mut reader = Spdx {
1038 tokens: &tokens,
1039 at: 0,
1040 };
1041 let Some(verdict) = reader.expression() else {
1042 return false;
1043 };
1044 verdict.grants && verdict.clean && reader.at == tokens.len()
1045}
1046
1047#[must_use]
1061pub fn code_scanning_licence_refusal(
1062 provider: Option<Provider>,
1063 driver: Option<&str>,
1064 shape: &CrateShape,
1065) -> Option<String> {
1066 if provider != Some(Provider::CodeQl) {
1067 return None;
1068 }
1069 if driver != Some("rust") {
1070 return Some(format!(
1071 "the {} binding declares no licence field this release reads, and codeql's terms cover an open-source codebase alone; land --code-scanning semgrep, which carries no licence condition",
1072 driver.unwrap_or("release-less")
1073 ));
1074 }
1075 let fallback = "land --code-scanning semgrep, which carries no licence condition";
1076 let Some(text) = shape.cargo_toml.as_deref() else {
1077 return Some(format!(
1078 "the target has no readable Cargo.toml, so the licence codeql's terms depend on cannot be read; {fallback}"
1079 ));
1080 };
1081 let Ok(table) = text.parse::<toml::Table>() else {
1082 return Some(format!(
1083 "the target's Cargo.toml does not parse, so the licence codeql's terms depend on cannot be read; {fallback}"
1084 ));
1085 };
1086 let licence = table
1087 .get("package")
1088 .and_then(toml::Value::as_table)
1089 .and_then(|package| package.get("license"))
1090 .and_then(toml::Value::as_str);
1091 match licence {
1092 None => Some(format!(
1093 "the target's Cargo.toml declares no license field, and codeql's terms cover an open-source codebase alone; declare one, or {fallback}"
1094 )),
1095 Some(expression) if !licence_states_an_open_source_codebase(expression) => Some(format!(
1096 "the target's license, {expression}, does not state a codebase this release recognizes as open source, and codeql's terms cover an open-source codebase alone; {fallback}"
1097 )),
1098 Some(_) => None,
1099 }
1100}
1101
1102#[must_use]
1105pub fn kind_of(destination: &str) -> Option<Kind> {
1106 if BLOCK_DESTINATIONS.contains(&destination) {
1107 return Some(Kind::Rendered);
1108 }
1109 KINDS
1110 .iter()
1111 .find(|(name, _)| *name == destination)
1112 .map(|(_, kind)| *kind)
1113}
1114
1115pub fn destinations() -> impl Iterator<Item = &'static str> {
1120 KINDS
1121 .iter()
1122 .map(|(name, _)| *name)
1123 .chain(BLOCK_DESTINATIONS)
1124}
1125
1126pub const OWNER_TOKEN: &[u8] = b"OWNER";
1133
1134pub const REPO_PLACEHOLDER: &str = "OWNER";
1139
1140pub const REPO_TOKEN: &[u8] = b"RK_REPO";
1142
1143pub const SCOPE_SHAPE_TOKEN: &[u8] = b"RK_SCOPE_SHAPE";
1145
1146pub const STYLE_TOKEN: &[u8] = b"RK_STYLE";
1149
1150pub const TRUNK_BRANCH_TOKEN: &[u8] = b"RK_TRUNK_BRANCH";
1154
1155pub const LINE_PREFIX_TOKEN: &[u8] = b"RK_LINE_PREFIX";
1158
1159pub const LINE_PREFIX_RE_TOKEN: &[u8] = b"RK_LINE_PREFIX_RE";
1165
1166pub const PUSH_GUARD_TOKEN: &[u8] = b" # RK_PUSH_GUARD";
1176
1177pub const REQUIRED_CHECK_TOKEN: &[u8] = b"RK_REQUIRED_CHECK";
1179
1180pub const REQUIRED_WORKFLOW_TOKEN: &[u8] = b"RK_REQUIRED_WORKFLOW";
1182
1183pub const BRANCH_SHAPE_TOKEN: &[u8] = b"RK_BRANCH_SHAPE";
1185
1186pub const REQUEST_JOB_TOKEN: &[u8] = b"RK_REQUEST_JOB";
1188
1189pub const RELEASE_GATE_TRIGGER: &str = "blocks/release-gate-trigger.yaml.in";
1191
1192pub const RELEASE_GATE_JOB: &str = "blocks/release-gate-job.yaml.in";
1194
1195#[must_use]
1202pub fn release_driver_shape(driver: Option<&str>) -> Option<(&'static str, &'static str)> {
1203 match driver {
1204 Some("rust") => Some(("release-plz-", "release-plz-pr")),
1207 Some("bash") => Some(("chore/release-v", "release-request")),
1209 Some("python") => Some((
1213 "release-please--branches--RK_TRUNK_BRANCH",
1214 "release-please-pr",
1215 )),
1216 _ => None,
1217 }
1218}
1219
1220#[must_use]
1231pub fn release_gate_spans(driver: Option<&str>) -> [(&'static [u8], &'static [u8]); 3] {
1232 let arm: (&[u8], &[u8]) = if driver == Some("bash") {
1236 (
1237 b"\n # RK_GATE_ARM_BEGIN",
1238 b"\n # RK_GATE_ARM_END",
1239 )
1240 } else {
1241 (b"\n# RK_GATE_ARM_BEGIN", b"\n# RK_GATE_ARM_END")
1242 };
1243 [
1244 (b"\n# RK_GATE_TRIGGER_BEGIN", b"\n# RK_GATE_TRIGGER_END"),
1245 arm,
1246 (b"\n# RK_GATE_JOB_BEGIN", b"\n# RK_GATE_JOB_END"),
1247 ]
1248}
1249
1250#[must_use]
1258fn release_gates(params: &Params) -> bool {
1259 params.forge() == Some("github")
1260 && params.integration() == crate::landing::Integration::Local
1261 && params.release_mode() == ReleaseMode::Automatic
1262 && params.style() == Some(crate::landing::Style::Trunk)
1263 && !params.required_check().is_empty()
1264 && !params.required_workflow().is_empty()
1265 && release_driver_shape(params.driver()).is_some()
1266}
1267
1268fn release_gate_replacements(params: &Params) -> Result<[Option<Vec<u8>>; 3], RkError> {
1280 if !release_gates(params) {
1281 return Ok([None, None, None]);
1282 }
1283 let opened = |block: &str| -> Result<Vec<u8>, RkError> {
1286 let text = embedded_block(block)?;
1287 Ok(substitute_tokens(
1288 format!("\n{}", text.trim_end_matches('\n')).as_bytes(),
1289 params,
1290 ))
1291 };
1292 Ok([
1293 Some(opened(RELEASE_GATE_TRIGGER)?),
1294 Some(Vec::new()),
1297 Some(opened(RELEASE_GATE_JOB)?),
1298 ])
1299}
1300
1301pub const SECURITY_SPANS: [(&[u8], &[u8]); 3] = [
1312 (
1313 b"<!--RK_SECURITY_CONTACT_BEGIN-->",
1314 b"<!--RK_SECURITY_CONTACT_END-->",
1315 ),
1316 (
1317 b"<!--RK_SECURITY_RESPONSE_BEGIN-->",
1318 b"<!--RK_SECURITY_RESPONSE_END-->",
1319 ),
1320 (
1321 b"<!--RK_SECURITY_DEADLINE_BEGIN-->",
1322 b"<!--RK_SECURITY_DEADLINE_END-->",
1323 ),
1324];
1325
1326fn acknowledgment(response: &str) -> String {
1329 format!("Maintainers acknowledge a report within {response}.")
1330}
1331
1332const DISCLOSURE_ONLY: &[u8] = b"This policy commits to no disclosure deadline.";
1336
1337fn security_replacements(params: &Params) -> [Option<Vec<u8>>; 3] {
1340 let contact = (!params.security_contact().is_empty())
1341 .then(|| params.security_contact().as_bytes().to_vec());
1342 let promised = params.security_response() != crate::config::RESPONSE_DEFAULT;
1343 [
1344 contact,
1345 promised.then(|| acknowledgment(params.security_response()).into_bytes()),
1346 promised.then(|| DISCLOSURE_ONLY.to_vec()),
1347 ]
1348}
1349
1350fn replace_span(baseline: &[u8], begin: &[u8], end: &[u8], value: Option<&[u8]>) -> Vec<u8> {
1356 let ordered = find(baseline, begin)
1357 .zip(find(baseline, end))
1358 .filter(|(start, stop)| stop > start);
1359 let Some((start, stop)) = ordered else {
1360 return baseline.to_vec();
1361 };
1362 let mut out = Vec::with_capacity(baseline.len());
1363 out.extend_from_slice(&baseline[..start]);
1364 out.extend_from_slice(value.unwrap_or_else(|| &baseline[start + begin.len()..stop]));
1365 out.extend_from_slice(&baseline[stop + end.len()..]);
1366 out
1367}
1368
1369#[must_use]
1387pub fn render(baseline: &[u8], params: &Params) -> Vec<u8> {
1388 let mut out = substitute_tokens(baseline, params);
1389 if let Ok(values) = release_gate_replacements(params) {
1395 for ((begin, end), value) in release_gate_spans(params.driver()).iter().zip(values) {
1396 out = replace_span(&out, begin, end, value.as_deref());
1397 }
1398 }
1399 for ((begin, end), value) in SECURITY_SPANS.iter().zip(security_replacements(params)) {
1400 out = replace_span(&out, begin, end, value.as_deref());
1401 }
1402 out
1403}
1404
1405#[must_use]
1410fn substitute_tokens(baseline: &[u8], params: &Params) -> Vec<u8> {
1411 let repo = params.repo();
1412 let owner = repo.split('/').next().unwrap_or(repo);
1413 let mut out = substitute(baseline, OWNER_TOKEN, owner.as_bytes());
1414 if let Some(style) = params.style() {
1415 out = substitute(&out, STYLE_TOKEN, style.as_str().as_bytes());
1416 }
1417 out = substitute(&out, SCOPE_SHAPE_TOKEN, SCOPE_SHAPE.as_bytes());
1418 let (shape, job) = release_driver_shape(params.driver()).unwrap_or_default();
1423 out = substitute(&out, BRANCH_SHAPE_TOKEN, shape.as_bytes());
1424 out = substitute(&out, REQUEST_JOB_TOKEN, job.as_bytes());
1425 out = substitute(&out, TRUNK_BRANCH_TOKEN, params.trunk().as_bytes());
1426 let escaped = params.line_prefix().replace('/', "\\/");
1427 out = substitute(&out, LINE_PREFIX_RE_TOKEN, escaped.as_bytes());
1428 out = substitute(&out, LINE_PREFIX_TOKEN, params.line_prefix().as_bytes());
1429 let required_check = serde_json::Value::String(params.required_check().to_owned()).to_string();
1433 let required_workflow =
1434 serde_json::Value::String(params.required_workflow().to_owned()).to_string();
1435 out = substitute(&out, REQUIRED_CHECK_TOKEN, required_check.as_bytes());
1436 out = substitute(&out, REQUIRED_WORKFLOW_TOKEN, required_workflow.as_bytes());
1437 let guard: &[u8] = if release_gates(params) {
1440 b" && github.event_name == 'push'"
1441 } else {
1442 b""
1443 };
1444 out = substitute(&out, PUSH_GUARD_TOKEN, guard);
1445 out = substitute(&out, REPO_TOKEN, repo.as_bytes());
1446 out
1447}
1448
1449#[must_use]
1451pub fn substitute(baseline: &[u8], token: &[u8], value: &[u8]) -> Vec<u8> {
1452 let mut out = Vec::with_capacity(baseline.len());
1453 let mut rest = baseline;
1454 while let Some(at) = find(rest, token) {
1455 out.extend_from_slice(&rest[..at]);
1456 out.extend_from_slice(value);
1457 rest = &rest[at + token.len()..];
1458 }
1459 out.extend_from_slice(rest);
1460 out
1461}
1462
1463fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
1465 haystack
1466 .windows(needle.len())
1467 .position(|window| window == needle)
1468}
1469
1470pub const AGENTS_DESTINATION: &str = "AGENTS.md";
1472
1473pub const BLOCK_BEGIN: &str = "<!-- BEGIN release-kit -->";
1475
1476pub const BLOCK_END: &str = "<!-- END release-kit -->";
1478
1479pub const GLOSSARY_DESTINATION: &str = "GLOSSARY.md";
1484
1485pub const HOOKS_DESTINATION: &str = ".pre-commit-config.yaml";
1487
1488pub const BLOCK_DESTINATIONS: [&str; 3] =
1494 [AGENTS_DESTINATION, GLOSSARY_DESTINATION, HOOKS_DESTINATION];
1495
1496pub const HOOKS_BEGIN: &str = "# BEGIN release-kit";
1498
1499pub const HOOKS_END: &str = "# END release-kit";
1501
1502pub const HOOK_TYPES_LINE: &str = "default_install_hook_types: [pre-commit, commit-msg, pre-push]";
1506
1507pub const AGENTS_BLOCK: &str = "blocks/agents-block.md.in";
1509
1510pub const GLOSSARY_BLOCK: &str = "blocks/glossary.md.in";
1512
1513pub const AGENTS_LINE_AGENT_POLICY: &str = "blocks/agents-line-agent-policy.md.in";
1515
1516pub const AGENTS_LINE_COMMIT_POLICY: &str = "blocks/agents-line-commit-policy.md.in";
1518
1519pub const PRE_COMMIT_BLOCK: &str = "blocks/pre-commit-block.yaml.in";
1521
1522pub const PRE_COMMIT_MESSAGE_GUARDS: &str = "blocks/pre-commit-message-guards.yaml.in";
1524
1525pub const PRE_COMMIT_BRANCH_NAME_GUARD: &str = "blocks/pre-commit-branch-name-guard.yaml.in";
1527
1528pub const PRE_COMMIT_WORKTREE_GUARD: &str = "blocks/pre-commit-worktree-guard.yaml.in";
1530
1531pub const PRE_COMMIT_TRUNK_COMMIT_GUARD: &str = "blocks/pre-commit-trunk-commit-guard.yaml.in";
1533
1534pub const PRE_COMMIT_TRUNK_PUSH_GUARD: &str = "blocks/pre-commit-trunk-push-guard.yaml.in";
1536
1537pub const PRE_COMMIT_SWEEP_SKIP: &str = "blocks/pre-commit-sweep-skip.yaml.in";
1539
1540pub const PRE_COMMIT_SWEEP_NONE: &str = "blocks/pre-commit-sweep-none.yaml.in";
1542
1543pub const PRE_COMMIT_INTEGRATE_NOTE: &str = "blocks/pre-commit-integrate-note.yaml.in";
1545
1546pub fn embedded_block(path: &str) -> Result<&'static str, RkError> {
1553 let name = path.strip_prefix("blocks/").unwrap_or(path);
1554 let file = embedded::BLOCKS
1555 .get_file(name)
1556 .ok_or_else(|| anyhow::anyhow!("{path}: this binary embeds no such block"))?;
1557 std::str::from_utf8(file.contents())
1558 .map_err(|_| anyhow::anyhow!("{path}: a block is UTF-8").into())
1559}
1560
1561#[must_use]
1565pub fn authored(text: &str) -> &str {
1566 text.strip_suffix('\n').unwrap_or(text)
1567}
1568
1569pub const BRANCH_GRAMMAR: &str = r"^((build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)/[A-Za-z0-9._/-]+|([0-9]+|[A-Z][A-Z0-9]+-[0-9]+)-[A-Za-z0-9._-]+|release[-/].+)$";
1577
1578pub const SCOPE_SHAPE: &str = "[a-z0-9._/-]+";
1588
1589#[must_use]
1596pub fn scope_is_shaped(scope: &str) -> bool {
1597 !scope.is_empty()
1598 && scope.chars().all(|c| {
1599 c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '/' | '-')
1600 })
1601}
1602
1603pub struct RoutingGuards<'a> {
1605 pub agent_policy: &'a str,
1607 pub commit_policy: &'a str,
1609}
1610
1611#[must_use]
1618pub fn compose_routing(template: &str, guards: Option<&RoutingGuards<'_>>) -> String {
1619 let fragment = |text: &str| format!("{}\n", authored(text));
1620 let (agent, commit) = guards.map_or_else(
1621 || (String::new(), String::new()),
1622 |guards| {
1623 (
1624 fragment(guards.agent_policy),
1625 fragment(guards.commit_policy),
1626 )
1627 },
1628 );
1629 authored(template)
1630 .replacen("RK_AGENT_POLICY", &agent, 1)
1631 .replacen("RK_COMMIT_POLICY", &commit, 1)
1632}
1633
1634#[must_use]
1638pub fn compose_glossary(template: &str) -> String {
1639 authored(template).to_owned()
1640}
1641
1642pub struct WorkflowHooks<'a> {
1644 pub message: &'a str,
1646 pub branch_name: &'a str,
1648 pub worktree: Option<&'a str>,
1653}
1654
1655#[must_use]
1671pub fn compose_hooks(
1672 template: &str,
1673 workflow: Option<&WorkflowHooks<'_>>,
1674 parts: &TrunkGuards,
1675) -> String {
1676 let fragment = |text: &str| format!("{}\n", authored(text));
1677 let worktree = workflow
1678 .and_then(|hooks| hooks.worktree)
1679 .map_or_else(String::new, fragment);
1680 let trunk_guards = workflow.is_some() && parts.integration == Integration::Forge;
1681 let mut skips: Vec<&str> = Vec::new();
1682 if trunk_guards {
1683 skips.push("no-commit-to-branch");
1684 }
1685 if !worktree.is_empty() {
1686 skips.push("rk-worktree-location");
1687 }
1688 let sweep = if skips.is_empty() {
1689 fragment(parts.sweep_none)
1690 } else {
1691 fragment(&authored(parts.sweep_skip).replacen("RK_SWEEP_SKIP", &skips.join(","), 1))
1692 };
1693 let (commit_guard, push_guard) = if trunk_guards {
1694 (fragment(parts.trunk_commit), fragment(parts.trunk_push))
1695 } else {
1696 (String::new(), String::new())
1697 };
1698 let note = match parts.integration {
1699 Integration::Forge => String::new(),
1700 Integration::Local => fragment(parts.integrate_note),
1701 };
1702 let (message, branch_name) = workflow.map_or_else(
1703 || (String::new(), String::new()),
1704 |hooks| (fragment(hooks.message), fragment(hooks.branch_name)),
1705 );
1706 authored(template)
1707 .replacen("RK_SWEEP_NOTE", &format!("{sweep}{note}"), 1)
1708 .replacen("RK_MESSAGE_GUARDS", &message, 1)
1709 .replacen("RK_TRUNK_COMMIT_GUARD", &commit_guard, 1)
1710 .replacen("RK_BRANCH_NAME_GUARD", &branch_name, 1)
1711 .replacen("RK_WORKTREE_GUARD", &worktree, 1)
1712 .replacen("RK_TRUNK_PUSH_GUARD", &push_guard, 1)
1713 .replacen("RK_BRANCH_GRAMMAR", BRANCH_GRAMMAR, 1)
1714}
1715
1716pub struct TrunkGuards<'a> {
1719 pub integration: Integration,
1721 pub trunk_commit: &'a str,
1723 pub trunk_push: &'a str,
1725 pub sweep_skip: &'a str,
1727 pub sweep_none: &'a str,
1729 pub integrate_note: &'a str,
1731}
1732
1733pub fn routing_block(workflow_guards: bool) -> Result<String, RkError> {
1740 let guards = if workflow_guards {
1741 Some(RoutingGuards {
1742 agent_policy: embedded_block(AGENTS_LINE_AGENT_POLICY)?,
1743 commit_policy: embedded_block(AGENTS_LINE_COMMIT_POLICY)?,
1744 })
1745 } else {
1746 None
1747 };
1748 Ok(compose_routing(
1749 embedded_block(AGENTS_BLOCK)?,
1750 guards.as_ref(),
1751 ))
1752}
1753
1754pub fn glossary_block() -> Result<String, RkError> {
1760 Ok(compose_glossary(embedded_block(GLOSSARY_BLOCK)?))
1761}
1762
1763pub fn hooks_block(
1770 mode: CheckoutMode,
1771 integration: Integration,
1772 workflow_guards: bool,
1773) -> Result<String, RkError> {
1774 let workflow = if workflow_guards {
1775 Some(WorkflowHooks {
1776 message: embedded_block(PRE_COMMIT_MESSAGE_GUARDS)?,
1777 branch_name: embedded_block(PRE_COMMIT_BRANCH_NAME_GUARD)?,
1778 worktree: match mode {
1779 CheckoutMode::LinkedWorktree => Some(embedded_block(PRE_COMMIT_WORKTREE_GUARD)?),
1780 CheckoutMode::MainWorktree => None,
1781 },
1782 })
1783 } else {
1784 None
1785 };
1786 let parts = TrunkGuards {
1787 integration,
1788 trunk_commit: embedded_block(PRE_COMMIT_TRUNK_COMMIT_GUARD)?,
1789 trunk_push: embedded_block(PRE_COMMIT_TRUNK_PUSH_GUARD)?,
1790 sweep_skip: embedded_block(PRE_COMMIT_SWEEP_SKIP)?,
1791 sweep_none: embedded_block(PRE_COMMIT_SWEEP_NONE)?,
1792 integrate_note: embedded_block(PRE_COMMIT_INTEGRATE_NOTE)?,
1793 };
1794 Ok(compose_hooks(
1795 embedded_block(PRE_COMMIT_BLOCK)?,
1796 workflow.as_ref(),
1797 &parts,
1798 ))
1799}
1800
1801type BlockTemplate = (String, Vec<String>, &'static str);
1804
1805fn block_template(destination: &str, params: &Params) -> Result<Option<BlockTemplate>, RkError> {
1808 let guards = params.workflow_guards();
1809 let mode = params.checkout_mode();
1810 let integration = params.integration();
1811 match destination {
1812 AGENTS_DESTINATION => {
1813 let mut sources = vec![AGENTS_BLOCK.to_owned()];
1814 if guards {
1815 sources.push(AGENTS_LINE_AGENT_POLICY.to_owned());
1816 sources.push(AGENTS_LINE_COMMIT_POLICY.to_owned());
1817 }
1818 Ok(Some((routing_block(guards)?, sources, catalog::GUARDS)))
1819 }
1820 GLOSSARY_DESTINATION => Ok(guards
1821 .then(|| -> Result<_, RkError> {
1822 Ok((
1823 glossary_block()?,
1824 vec![GLOSSARY_BLOCK.to_owned()],
1825 catalog::WORKFLOW_GUARDS,
1826 ))
1827 })
1828 .transpose()?),
1829 HOOKS_DESTINATION => {
1830 let mut sources = vec![PRE_COMMIT_BLOCK.to_owned()];
1831 let mut skips = false;
1832 if guards {
1833 sources.push(PRE_COMMIT_MESSAGE_GUARDS.to_owned());
1834 sources.push(PRE_COMMIT_BRANCH_NAME_GUARD.to_owned());
1835 if mode == CheckoutMode::LinkedWorktree {
1836 sources.push(PRE_COMMIT_WORKTREE_GUARD.to_owned());
1837 skips = true;
1838 }
1839 if integration == Integration::Forge {
1840 sources.push(PRE_COMMIT_TRUNK_COMMIT_GUARD.to_owned());
1841 sources.push(PRE_COMMIT_TRUNK_PUSH_GUARD.to_owned());
1842 skips = true;
1843 }
1844 }
1845 sources.push(if skips {
1846 PRE_COMMIT_SWEEP_SKIP.to_owned()
1847 } else {
1848 PRE_COMMIT_SWEEP_NONE.to_owned()
1849 });
1850 if integration == Integration::Local {
1851 sources.push(PRE_COMMIT_INTEGRATE_NOTE.to_owned());
1852 }
1853 Ok(Some((
1854 hooks_block(mode, integration, guards)?,
1855 sources,
1856 catalog::GUARDS,
1857 )))
1858 }
1859 other => Err(anyhow::anyhow!("{other} is not a block destination").into()),
1860 }
1861}
1862
1863#[must_use]
1865pub fn block_markers(destination: &str) -> Option<(&'static str, &'static str)> {
1866 match destination {
1867 AGENTS_DESTINATION | GLOSSARY_DESTINATION => Some((BLOCK_BEGIN, BLOCK_END)),
1868 HOOKS_DESTINATION => Some((HOOKS_BEGIN, HOOKS_END)),
1869 _ => None,
1870 }
1871}
1872
1873#[must_use]
1876pub fn extract_block<'a>(text: &'a str, begin: &str, end: &str) -> Option<&'a str> {
1877 let start = text.find(begin)?;
1878 let stop = text[start..].find(end)? + start + end.len();
1879 Some(&text[start..stop])
1880}
1881
1882#[must_use]
1889pub fn splice_marked_block(existing: Option<&[u8]>, block: &str) -> Vec<u8> {
1890 let block = block.as_bytes();
1891 let Some(text) = existing else {
1892 return [block, b"\n"].concat();
1893 };
1894 if let Some(start) = find(text, BLOCK_BEGIN.as_bytes())
1898 && let Some(offset) = find(&text[start..], BLOCK_END.as_bytes())
1899 {
1900 let stop = start + offset + BLOCK_END.len();
1901 return [&text[..start], block, &text[stop..]].concat();
1902 }
1903 let mut out = Vec::with_capacity(text.len() + block.len() + 3);
1907 out.extend_from_slice(text);
1908 if !text.ends_with(b"\n") {
1909 out.push(b'\n');
1910 }
1911 out.push(b'\n');
1912 out.extend_from_slice(block);
1913 out.push(b'\n');
1914 out
1915}
1916
1917pub fn splice_hooks_block(existing: Option<&str>, block: &str) -> Result<String, String> {
1930 let Some(text) = existing else {
1931 return Ok(format!("{HOOK_TYPES_LINE}\n\nrepos:\n{block}\n"));
1932 };
1933 if let Some(defect) = hooks_marker_defect(text) {
1934 return Err(defect);
1935 }
1936 if let Some(found) = extract_block(text, HOOKS_BEGIN, HOOKS_END) {
1937 return Ok(text.replacen(found, block, 1));
1938 }
1939 let mut out = String::with_capacity(text.len() + block.len() + 1);
1940 let mut placed = false;
1941 for line in text.split_inclusive('\n') {
1942 out.push_str(line);
1943 if !placed && line.trim_end() == "repos:" {
1944 if !out.ends_with('\n') {
1945 out.push('\n');
1946 }
1947 out.push_str(block);
1948 out.push('\n');
1949 placed = true;
1950 }
1951 }
1952 if placed {
1953 Ok(out)
1954 } else {
1955 Err(format!(
1956 "{HOOKS_DESTINATION} exists with no repos: line, so the hook block has nowhere to land"
1957 ))
1958 }
1959}
1960
1961#[must_use]
1971pub fn marker_defect(destination: &str, text: &str) -> Option<String> {
1972 let (begin, end) = block_markers(destination)?;
1973 let begins = text.matches(begin).count();
1974 let ends = text.matches(end).count();
1975 if begins > 1 || ends > 1 {
1976 return Some(format!(
1977 "{destination} carries more than one release-kit marker pair; release-kit owns exactly one block"
1978 ));
1979 }
1980 match (text.find(begin), text.find(end)) {
1981 (Some(begin), Some(end)) if end > begin => None,
1982 (None, None) => None,
1983 _ => Some(format!(
1984 "{destination} carries an unmatched or misordered release-kit marker, so the block's extent is ambiguous"
1985 )),
1986 }
1987}
1988
1989#[must_use]
1992pub fn hooks_marker_defect(text: &str) -> Option<String> {
1993 marker_defect(HOOKS_DESTINATION, text)
1994}
1995
1996fn propose_document(
2000 destination: &str,
2001 existing: Option<&[u8]>,
2002 region: &[u8],
2003) -> Result<Vec<u8>, String> {
2004 let block = String::from_utf8_lossy(region).into_owned();
2009 if let Some(text) = existing
2010 && let Some(defect) = marker_defect(destination, &String::from_utf8_lossy(text))
2011 {
2012 return Err(defect);
2013 }
2014 if destination == HOOKS_DESTINATION {
2015 let text = match existing {
2019 None => None,
2020 Some(bytes) => Some(std::str::from_utf8(bytes).map_err(|_| {
2021 format!(
2022 "{destination} is not UTF-8, so the hook block has nowhere to land without rewriting the target's bytes"
2023 )
2024 })?),
2025 };
2026 return splice_hooks_block(text, &block).map(String::into_bytes);
2027 }
2028 Ok(splice_marked_block(existing, &block))
2029}
2030
2031#[must_use]
2043pub fn nix_unsupported_shape(shape: &CrateShape) -> Option<String> {
2044 let Some(text) = shape.cargo_toml.as_deref() else {
2045 return Some(
2046 "the target has no readable Cargo.toml, which the seeded package expression reads; no Nix file lands".to_owned(),
2047 );
2048 };
2049 let Ok(table) = text.parse::<toml::Table>() else {
2050 return Some(
2051 "the target's Cargo.toml does not parse, and the seeded package expression reads it; no Nix file lands".to_owned(),
2052 );
2053 };
2054 if !table.contains_key("package") {
2055 return Some(
2056 "the target's Cargo.toml has no [package] table; the seed supports a single crate, so no Nix file lands".to_owned(),
2057 );
2058 }
2059 if !shape.cargo_lock {
2060 return Some(
2061 "the target has no Cargo.lock, which the seeded package expression builds from; commit one, then opt in".to_owned(),
2062 );
2063 }
2064 let implicit_bin = shape.main_rs
2065 && table
2066 .get("package")
2067 .and_then(toml::Value::as_table)
2068 .and_then(|package| package.get("autobins"))
2069 .and_then(toml::Value::as_bool)
2070 != Some(false);
2071 let explicit_bins = table.get("bin").and_then(toml::Value::as_array);
2072 if explicit_bins.is_none() && !implicit_bin {
2073 return Some(
2074 "the target declares no binary — no effective src/main.rs and no [[bin]] entry — and the seed flake's smoke check runs one; no Nix file lands".to_owned(),
2075 );
2076 }
2077 if let Some(bins) = explicit_bins {
2083 let required = bins
2084 .first()
2085 .and_then(toml::Value::as_table)
2086 .and_then(|bin| bin.get("required-features"))
2087 .and_then(toml::Value::as_array);
2088 if let Some(required) = required {
2089 let enabled = default_features(&table);
2090 let missing = required
2091 .iter()
2092 .filter_map(toml::Value::as_str)
2093 .any(|feature| !enabled.contains(feature));
2094 if missing {
2095 return Some(
2096 "the target's first [[bin]] entry requires features a default build does not enable; no Nix file lands".to_owned(),
2097 );
2098 }
2099 }
2100 }
2101 None
2102}
2103
2104fn dep_edge_suppresses(features: &toml::Table, name: &str) -> bool {
2107 let edge = format!("dep:{name}");
2108 features.values().any(|list| {
2109 list.as_array().is_some_and(|entries| {
2110 entries
2111 .iter()
2112 .filter_map(toml::Value::as_str)
2113 .any(|entry| entry == edge)
2114 })
2115 })
2116}
2117
2118fn is_optional_dependency(table: &toml::Table, name: &str) -> bool {
2121 ["dependencies", "build-dependencies"]
2122 .iter()
2123 .any(|section| {
2124 table
2125 .get(*section)
2126 .and_then(toml::Value::as_table)
2127 .and_then(|dependencies| dependencies.get(name))
2128 .and_then(toml::Value::as_table)
2129 .and_then(|dependency| dependency.get("optional"))
2130 .and_then(toml::Value::as_bool)
2131 == Some(true)
2132 })
2133}
2134
2135fn default_features(table: &toml::Table) -> std::collections::BTreeSet<String> {
2142 let Some(features) = table.get("features").and_then(toml::Value::as_table) else {
2143 return std::collections::BTreeSet::new();
2144 };
2145 let mut enabled = std::collections::BTreeSet::new();
2146 let mut queue = vec!["default".to_owned()];
2147 while let Some(name) = queue.pop() {
2148 if !enabled.insert(name.clone()) {
2149 continue;
2150 }
2151 if let Some(implies) = features.get(&name).and_then(toml::Value::as_array) {
2152 for implied in implies.iter().filter_map(toml::Value::as_str) {
2153 if implied.starts_with("dep:") || implied.contains("?/") {
2154 continue;
2158 }
2159 if let Some((package, _)) = implied.split_once('/') {
2160 let feature_exists =
2168 features.contains_key(package) || !dep_edge_suppresses(features, package);
2169 if is_optional_dependency(table, package) && feature_exists {
2170 queue.push(package.to_owned());
2171 }
2172 } else {
2173 queue.push(implied.to_owned());
2174 }
2175 }
2176 }
2177 }
2178 enabled
2179}
2180
2181#[must_use]
2189pub fn flake_pair_withheld(
2190 flake_recorded: bool,
2191 flake_nix_present: bool,
2192 flake_lock_present: bool,
2193) -> Option<String> {
2194 if flake_recorded {
2195 return None;
2196 }
2197 let present: Vec<&str> = [
2198 ("flake.nix", flake_nix_present),
2199 ("flake.lock", flake_lock_present),
2200 ]
2201 .into_iter()
2202 .filter_map(|(name, present)| present.then_some(name))
2203 .collect();
2204 if present.is_empty() {
2205 return None;
2206 }
2207 Some(format!(
2208 "the target already carries {}; its flake pair stays its own",
2209 present.join(" and ")
2210 ))
2211}
2212
2213#[must_use]
2223pub fn nix_withholding(
2224 nix: bool,
2225 evidence: &TargetEvidence,
2226) -> Option<(&'static [&'static str], String)> {
2227 if !nix {
2228 return None;
2229 }
2230 if let Some(reason) = nix_unsupported_shape(&evidence.crate_shape) {
2231 return Some((&NIX_DESTINATIONS[..], reason));
2232 }
2233 flake_pair_withheld(
2234 evidence.flake_recorded,
2235 evidence.flake_nix_present,
2236 evidence.flake_lock_present,
2237 )
2238 .map(|reason| (&NIX_WITHHOLDABLE[..], reason))
2239}
2240
2241#[cfg(test)]
2242mod tests {
2243 use super::{
2244 AGENTS_DESTINATION, BLOCK_BEGIN, BLOCK_DESTINATIONS, BLOCK_END, Candidate, Collision,
2245 CrateShape, GLOSSARY_DESTINATION, HOOK_TYPES_LINE, HOOKS_BEGIN, HOOKS_DESTINATION,
2246 HOOKS_END, Placement, Projection, ProjectionInput, TargetEvidence, extract_block,
2247 };
2248 use crate::landing::{Params, Style};
2249
2250 fn supported_shape() -> CrateShape {
2252 CrateShape {
2253 cargo_toml: Some("[package]\nname = \"widget\"\nversion = \"0.1.0\"\n".to_owned()),
2254 cargo_lock: true,
2255 main_rs: true,
2256 }
2257 }
2258
2259 fn input(evidence: TargetEvidence) -> ProjectionInput {
2260 let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
2261 params.set_nix_for_test(true);
2262 ProjectionInput { params, evidence }
2263 }
2264
2265 fn compute(evidence: TargetEvidence) -> Projection {
2266 Projection::compute(&input(evidence)).expect("the embedded pair projects")
2267 }
2268
2269 fn candidate<'a>(projection: &'a Projection, destination: &str) -> &'a Candidate {
2270 projection
2271 .candidates
2272 .iter()
2273 .find(|candidate| candidate.destination == destination)
2274 .expect("the destination projects")
2275 }
2276
2277 #[test]
2282 fn the_workflow_guards_land_only_where_requested() {
2283 let mut plain = input(TargetEvidence {
2284 crate_shape: supported_shape(),
2285 ..TargetEvidence::default()
2286 });
2287 plain.params.set_workflow_guards_for_test(false);
2288 let plain = Projection::compute(&plain).expect("the embedded pair projects");
2289 assert!(
2290 !plain
2291 .candidates
2292 .iter()
2293 .any(|candidate| candidate.destination == GLOSSARY_DESTINATION),
2294 "a target without the guards takes no glossary"
2295 );
2296 let agents =
2297 String::from_utf8(candidate(&plain, AGENTS_DESTINATION).bytes.clone()).expect("text");
2298 assert!(!agents.contains("guides and never drives"), "{agents}");
2299 let hooks =
2300 String::from_utf8(candidate(&plain, HOOKS_DESTINATION).bytes.clone()).expect("text");
2301 assert!(!hooks.contains("conventional-pre-commit"), "{hooks}");
2302 assert!(hooks.contains("rk-no-hand-authored-tag"), "{hooks}");
2303
2304 let guarded = compute(TargetEvidence {
2305 crate_shape: supported_shape(),
2306 ..TargetEvidence::default()
2307 });
2308 let glossary = candidate(&guarded, GLOSSARY_DESTINATION);
2309 assert_eq!(
2310 glossary.capability,
2311 crate::profile::catalog::WORKFLOW_GUARDS
2312 );
2313 assert_eq!(
2314 candidate(&guarded, AGENTS_DESTINATION).capability,
2315 crate::profile::catalog::GUARDS
2316 );
2317 let hooks =
2318 String::from_utf8(candidate(&guarded, HOOKS_DESTINATION).bytes.clone()).expect("text");
2319 for hook in ["conventional-pre-commit", "rk-message", "rk-branch-name"] {
2320 assert!(hooks.contains(hook), "{hook}: {hooks}");
2321 }
2322 }
2323
2324 fn outside(bytes: &[u8], begin: &str, end: &str) -> (Vec<u8>, Vec<u8>) {
2326 let text = String::from_utf8_lossy(bytes);
2327 let start = text.find(begin).expect("the begin marker is present");
2328 let stop = text[start..].find(end).expect("the end marker is present") + start + end.len();
2329 (bytes[..start].to_vec(), bytes[stop..].to_vec())
2330 }
2331
2332 #[test]
2333 fn equal_projection_inputs_yield_byte_identical_projections() {
2334 let mut documents = std::collections::BTreeMap::new();
2335 documents.insert(
2336 AGENTS_DESTINATION.to_owned(),
2337 b"# Widget\n\nOwn rules.\n".to_vec(),
2338 );
2339 let evidence = TargetEvidence {
2340 documents,
2341 crate_shape: supported_shape(),
2342 ..TargetEvidence::default()
2343 };
2344 let first = input(evidence.clone());
2345 let second = input(evidence);
2346 assert_eq!(first, second, "the inputs are values and compare equal");
2347 let a = Projection::compute(&first).expect("the pair projects");
2348 let b = Projection::compute(&second).expect("the pair projects");
2349 assert_eq!(a.candidates.len(), b.candidates.len());
2350 for (x, y) in a.candidates.iter().zip(&b.candidates) {
2351 assert_eq!(x.destination, y.destination);
2352 assert_eq!(x.kind, y.kind);
2353 assert_eq!(x.placement, y.placement);
2354 assert_eq!(x.bytes, y.bytes, "{}", x.destination);
2355 assert_eq!(x.region, y.region, "{}", x.destination);
2356 assert_eq!(x.sources, y.sources, "{}", x.destination);
2357 }
2358 assert_eq!(a, b);
2359 let destinations: Vec<&str> = a
2360 .candidates
2361 .iter()
2362 .map(|candidate| candidate.destination.as_str())
2363 .collect();
2364 let mut sorted = destinations.clone();
2365 sorted.sort_unstable();
2366 assert_eq!(destinations, sorted, "candidates sort by destination");
2367 assert!(a.omissions.is_empty(), "{:?}", a.omissions);
2368 assert!(a.collisions.is_empty(), "{:?}", a.collisions);
2369 }
2370
2371 #[test]
2375 fn the_scorecard_destination_projects_only_under_the_opt_in() {
2376 use super::{Kind, SCORECARD_DESTINATIONS, kind_of};
2377 let destination = SCORECARD_DESTINATIONS[0];
2378 assert_eq!(kind_of(destination), Some(Kind::Rendered));
2379
2380 let project = |scorecard: bool| {
2381 let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
2382 params.set_scorecard_for_test(scorecard);
2383 Projection::compute(&ProjectionInput {
2384 params,
2385 evidence: TargetEvidence::default(),
2386 })
2387 .expect("the embedded pair projects")
2388 };
2389
2390 let off = project(false);
2391 assert!(
2392 !off.candidates
2393 .iter()
2394 .any(|candidate| candidate.destination == destination),
2395 "off by default, and an absent candidate is no omission"
2396 );
2397 assert!(off.omissions.is_empty(), "{:?}", off.omissions);
2398
2399 let on = project(true);
2400 let candidate = candidate(&on, destination);
2401 assert_eq!(candidate.kind, Kind::Rendered);
2402 assert_eq!(candidate.placement, Placement::Whole);
2403 let text = String::from_utf8_lossy(&candidate.bytes);
2404 assert!(!text.contains("RK_"), "a token survived: {text}");
2405 }
2406
2407 #[test]
2412 fn the_licence_judgment_reads_what_every_reader_obtains() {
2413 use super::licence_states_an_open_source_codebase as approved;
2414 for expression in [
2415 "MIT",
2416 "Apache-2.0",
2417 "MIT OR Apache-2.0",
2418 "MIT AND Apache-2.0",
2419 "(MIT OR Apache-2.0) AND ISC",
2420 "Apache-2.0 WITH LLVM-exception OR MIT",
2421 "GPL-3.0+",
2422 ] {
2423 assert!(approved(expression), "{expression} is OSI-approved");
2424 }
2425 for expression in [
2430 "MIT AND CC-BY-4.0",
2431 "CC-BY-4.0 AND MIT",
2432 "MIT AND (CC-BY-4.0 OR CC-BY-SA-4.0)",
2433 "(MIT OR CC-BY-4.0) AND MIT",
2434 "MIT OR Apache-2.0 AND CC-BY-4.0",
2435 "Apache-2.0 WITH LLVM-exception AND CC-BY-SA-3.0",
2436 ] {
2437 assert!(approved(expression), "{expression} states open source");
2438 }
2439 for expression in [
2440 "",
2441 " ",
2442 "LicenseRef-proprietary",
2443 "MIT AND LicenseRef-proprietary",
2444 "SEE LICENSE IN COPYING",
2445 "CC-BY-4.0",
2446 "DocumentRef-spdx:LicenseRef-proprietary",
2447 ] {
2448 assert!(!approved(expression), "{expression} is not recognized");
2449 }
2450 for expression in [
2455 "MIT OR CC-BY-4.0",
2456 "CC-BY-4.0 AND CC-BY-SA-4.0",
2457 "CC-BY-4.0 AND LicenseRef-proprietary",
2458 "CC0-1.0",
2459 "MIT AND CC-BY-4.0 AND LicenseRef-proprietary",
2460 ] {
2461 assert!(!approved(expression), "{expression} grants no code terms");
2462 }
2463 assert!(
2468 !approved("CC-BY-4.0 OR CC-BY-SA-4.0 AND MIT"),
2469 "AND binds tighter than OR"
2470 );
2471 }
2472
2473 #[test]
2479 fn the_licence_judgment_holds_the_expression_to_the_spdx_grammar() {
2480 use super::licence_states_an_open_source_codebase as approved;
2481 for expression in [
2486 "MIT OR",
2487 "OR MIT",
2488 "MIT AND",
2489 "MIT WITH",
2490 "WITH LLVM-exception",
2491 "(MIT",
2492 "MIT)",
2493 "MIT Apache-2.0",
2494 "()",
2495 "(MIT OR Apache-2.0",
2496 "MIT OR (Apache-2.0",
2497 "MIT OR ()",
2498 "AND",
2499 "(",
2500 ")",
2501 "MIT WITH AND ISC",
2502 "MIT OR OR ISC",
2503 "MIT @ Apache-2.0",
2504 ] {
2505 assert!(!approved(expression), "{expression} is malformed");
2506 }
2507 for expression in [
2509 "MIT AND (Apache-2.0 OR ISC)",
2510 "((MIT))",
2511 "Apache-2.0 WITH LLVM-exception AND ISC",
2512 "(Apache-2.0 WITH LLVM-exception)",
2513 ] {
2514 assert!(approved(expression), "{expression} is well formed");
2515 }
2516 for expression in [
2521 "mit",
2522 "MiT",
2523 "apache-2.0 OR mit",
2524 "APACHE-2.0 WITH llvm-exception",
2525 ] {
2526 assert!(
2527 approved(expression),
2528 "{expression} names an approved licence"
2529 );
2530 }
2531 for expression in [
2535 "MIT or Apache-2.0",
2536 "MIT and Apache-2.0",
2537 "MIT with LLVM-exception",
2538 ] {
2539 assert!(!approved(expression), "{expression} carries no operator");
2540 }
2541 for expression in [
2545 "MIT WITH definitely-not-an-spdx-exception",
2546 "MIT WITH MIT",
2547 "MIT WITH Apache-2.0",
2548 ] {
2549 assert!(!approved(expression), "{expression} names no exception");
2550 }
2551 for expression in [
2555 "GPL-2.0-only WITH GCC-exception-2.0",
2556 "GPL-2.0-or-later WITH Classpath-exception-2.0",
2557 "Apache-2.0 WITH Swift-exception",
2558 "GPL-3.0-only WITH Autoconf-exception-generic",
2559 ] {
2560 assert!(approved(expression), "{expression} names a real exception");
2561 }
2562 }
2563
2564 #[test]
2570 fn a_recorded_provider_its_pair_cannot_run_is_named() {
2571 use super::{Provider, code_scanning_incompatibility};
2572
2573 assert!(code_scanning_incompatibility(None, Some("bash"), Some("gitlab")).is_none());
2574 assert!(
2575 code_scanning_incompatibility(Some(Provider::Semgrep), Some("rust"), Some("gitlab"))
2576 .is_none()
2577 );
2578 assert!(
2579 code_scanning_incompatibility(Some(Provider::CodeQl), Some("rust"), Some("github"))
2580 .is_none()
2581 );
2582
2583 let bash =
2584 code_scanning_incompatibility(Some(Provider::Semgrep), Some("bash"), Some("github"))
2585 .expect("a binding with no scanner is named");
2586 assert!(bash.contains("bash binding"), "{bash}");
2587 let gitlab =
2588 code_scanning_incompatibility(Some(Provider::CodeQl), Some("rust"), Some("gitlab"))
2589 .expect("codeql on gitlab is named");
2590 assert!(gitlab.contains("codeql"), "{gitlab}");
2591 let release_less =
2592 code_scanning_incompatibility(Some(Provider::Semgrep), None, Some("github"))
2593 .expect("no driver is named");
2594 assert!(
2595 release_less.contains("no automatic release driver"),
2596 "{release_less}"
2597 );
2598
2599 let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
2602 params.set_code_scanning_for_test(Some(Provider::Semgrep));
2603 let clean = Projection::compute(&ProjectionInput {
2604 params: params.clone(),
2605 evidence: TargetEvidence::default(),
2606 })
2607 .expect("the pair projects");
2608 assert!(
2609 clean.record_defects.is_empty(),
2610 "{:?}",
2611 clean.record_defects
2612 );
2613 }
2614
2615 #[test]
2619 fn the_code_scanning_destinations_follow_the_recorded_provider() {
2620 use super::{
2621 CODE_SCANNING_DESTINATIONS, Kind, Provider, code_scanning_licence_refusal, kind_of,
2622 };
2623 for (destination, _) in CODE_SCANNING_DESTINATIONS {
2624 assert_eq!(kind_of(destination), Some(Kind::Rendered), "{destination}");
2625 }
2626
2627 let project = |provider: Option<Provider>| {
2628 let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
2629 params.set_code_scanning_for_test(provider);
2630 Projection::compute(&ProjectionInput {
2631 params,
2632 evidence: TargetEvidence {
2633 crate_shape: CrateShape {
2634 cargo_toml: Some(
2635 "[package]\nname = \"widget\"\nlicense = \"MIT\"\n".to_owned(),
2636 ),
2637 ..CrateShape::default()
2638 },
2639 ..TargetEvidence::default()
2640 },
2641 })
2642 .expect("the embedded pair projects")
2643 };
2644 let landed = |projection: &Projection, destination: &str| {
2645 projection
2646 .candidates
2647 .iter()
2648 .any(|candidate| candidate.destination == destination)
2649 };
2650
2651 let off = project(None);
2652 for (destination, _) in CODE_SCANNING_DESTINATIONS {
2653 assert!(!landed(&off, destination), "{destination}");
2654 }
2655 assert!(off.licence_refusal.is_none());
2656
2657 let codeql = project(Some(Provider::CodeQl));
2658 assert!(landed(
2659 &codeql,
2660 ".github/workflows/code-scanning-codeql.yml"
2661 ));
2662 assert!(!landed(
2663 &codeql,
2664 ".github/workflows/code-scanning-semgrep.yml"
2665 ));
2666 assert!(codeql.licence_refusal.is_none(), "MIT satisfies the terms");
2667
2668 let semgrep = project(Some(Provider::Semgrep));
2669 assert!(landed(
2670 &semgrep,
2671 ".github/workflows/code-scanning-semgrep.yml"
2672 ));
2673 assert!(!landed(
2674 &semgrep,
2675 ".github/workflows/code-scanning-codeql.yml"
2676 ));
2677
2678 let proprietary = CrateShape {
2680 cargo_toml: Some("[package]\nlicense = \"LicenseRef-proprietary\"\n".to_owned()),
2681 ..CrateShape::default()
2682 };
2683 assert!(
2684 code_scanning_licence_refusal(Some(Provider::Semgrep), Some("rust"), &proprietary)
2685 .is_none()
2686 );
2687 let refusal =
2688 code_scanning_licence_refusal(Some(Provider::CodeQl), Some("rust"), &proprietary)
2689 .expect("codeql refuses a licence its terms do not cover");
2690 assert!(refusal.contains("LicenseRef-proprietary"), "{refusal}");
2691 assert!(refusal.contains("semgrep"), "{refusal}");
2692 let bash =
2695 code_scanning_licence_refusal(Some(Provider::CodeQl), Some("bash"), &proprietary)
2696 .expect("an unread binding refuses");
2697 assert!(bash.contains("bash binding"), "{bash}");
2698 }
2699
2700 #[test]
2706 fn the_projection_performs_no_filesystem_git_environment_clock_registry_or_network_read() {
2707 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/projection.rs");
2708 let text = std::fs::read_to_string(&path).expect("the source reads");
2709 let production = text.split("#[cfg(test)]").next().unwrap_or("");
2710 let needles = [
2711 "std::fs",
2712 "std::env",
2713 "std::process",
2714 "std::time",
2715 "SystemTime",
2716 "Instant",
2717 "std::net",
2718 "Command::new",
2719 "registry::",
2720 "curl",
2721 "reqwest",
2722 "blob(",
2723 ];
2724 let mut hits = Vec::new();
2725 for (index, line) in production.lines().enumerate() {
2726 if line.trim_start().starts_with("//") {
2727 continue;
2728 }
2729 for needle in needles {
2730 if line.contains(needle) {
2731 hits.push(format!("src/projection.rs:{}: {needle}", index + 1));
2732 }
2733 }
2734 }
2735 assert!(
2736 hits.is_empty(),
2737 "the projection reads beyond its inputs: {hits:?}"
2738 );
2739 }
2740
2741 #[test]
2742 #[allow(
2743 clippy::too_many_lines,
2744 reason = "one test walks the three marked destinations and the three unmarked shapes"
2745 )]
2746 fn marked_region_projection_preserves_every_target_byte_outside_the_markers() {
2747 let agents_before = "# Widget\n\nOperator prose above.\n\n";
2748 let agents_after = "\n\n## Our rules\n\nOperator prose below. \n";
2749 let glossary_before = "# Glossary\n\n- `spike` is a throwaway branch.\n\n";
2750 let glossary_after = "\n\n## More terms\n\n- `own` is ours.";
2751 let hooks_before = "default_install_hook_types: [pre-commit]\n\nrepos:\n";
2752 let hooks_after =
2753 "\n - repo: https://example.com/own\n rev: v1\n hooks:\n - id: own\n";
2754 let stale = |begin: &str, end: &str| format!("{begin}\nstale block\n{end}");
2755 let mut documents = std::collections::BTreeMap::new();
2756 documents.insert(
2757 AGENTS_DESTINATION.to_owned(),
2758 format!(
2759 "{agents_before}{}{agents_after}",
2760 stale(BLOCK_BEGIN, BLOCK_END)
2761 )
2762 .into_bytes(),
2763 );
2764 documents.insert(
2765 GLOSSARY_DESTINATION.to_owned(),
2766 format!(
2767 "{glossary_before}{}{glossary_after}",
2768 stale(BLOCK_BEGIN, BLOCK_END)
2769 )
2770 .into_bytes(),
2771 );
2772 documents.insert(
2773 HOOKS_DESTINATION.to_owned(),
2774 format!(
2775 "{hooks_before}{}{hooks_after}",
2776 stale(HOOKS_BEGIN, HOOKS_END)
2777 )
2778 .into_bytes(),
2779 );
2780 let projection = compute(TargetEvidence {
2781 documents: documents.clone(),
2782 crate_shape: supported_shape(),
2783 ..TargetEvidence::default()
2784 });
2785 assert!(
2786 projection.collisions.is_empty(),
2787 "{:?}",
2788 projection.collisions
2789 );
2790 for (destination, before, after) in [
2791 (AGENTS_DESTINATION, agents_before, agents_after),
2792 (GLOSSARY_DESTINATION, glossary_before, glossary_after),
2793 (HOOKS_DESTINATION, hooks_before, hooks_after),
2794 ] {
2795 let candidate = candidate(&projection, destination);
2796 let Placement::Region { begin, end } = candidate.placement else {
2797 panic!("{destination} is a region");
2798 };
2799 let region = candidate
2800 .region
2801 .as_deref()
2802 .expect("a region carries its block");
2803 let (head, tail) = outside(&candidate.bytes, begin, end);
2804 assert_eq!(
2805 head,
2806 before.as_bytes(),
2807 "{destination}: bytes before the markers"
2808 );
2809 assert_eq!(
2810 tail,
2811 after.as_bytes(),
2812 "{destination}: bytes after the markers"
2813 );
2814 let inside = &candidate.bytes[head.len()..candidate.bytes.len() - tail.len()];
2815 assert_eq!(
2816 inside, region,
2817 "{destination}: the region is the rendered block"
2818 );
2819 let (existing_head, existing_tail) = outside(&documents[destination], begin, end);
2820 assert_eq!(head, existing_head);
2821 assert_eq!(tail, existing_tail);
2822 }
2823
2824 let own_hooks =
2828 "repos:\n - repo: https://example.com/own\n rev: v1\n hooks:\n - id: own\n";
2829 let own_agents = "# Widget\n\nOwn rules.";
2830 let mut documents = std::collections::BTreeMap::new();
2831 documents.insert(HOOKS_DESTINATION.to_owned(), own_hooks.as_bytes().to_vec());
2832 documents.insert(
2833 AGENTS_DESTINATION.to_owned(),
2834 own_agents.as_bytes().to_vec(),
2835 );
2836 let projection = compute(TargetEvidence {
2837 documents,
2838 crate_shape: supported_shape(),
2839 ..TargetEvidence::default()
2840 });
2841 assert!(
2842 projection.collisions.is_empty(),
2843 "{:?}",
2844 projection.collisions
2845 );
2846 let hooks = candidate(&projection, HOOKS_DESTINATION);
2847 let hooks_text = String::from_utf8_lossy(&hooks.bytes);
2848 let region = String::from_utf8_lossy(hooks.region.as_deref().expect("a region"));
2849 assert!(
2850 hooks_text.starts_with(&format!(
2851 "repos:\n{region}\n - repo: https://example.com/own"
2852 )),
2853 "{hooks_text}"
2854 );
2855 assert!(!hooks_text.contains(HOOK_TYPES_LINE));
2856 let agents = candidate(&projection, AGENTS_DESTINATION);
2857 assert!(agents.bytes.starts_with(own_agents.as_bytes()));
2858 assert_eq!(
2859 extract_block(
2860 &String::from_utf8_lossy(&agents.bytes),
2861 BLOCK_BEGIN,
2862 BLOCK_END
2863 )
2864 .map(str::as_bytes),
2865 agents.region.as_deref()
2866 );
2867 let glossary = candidate(&projection, GLOSSARY_DESTINATION);
2868 let region = glossary.region.as_deref().expect("a region");
2869 assert_eq!(
2870 glossary.bytes,
2871 [region, b"\n"].concat(),
2872 "an absent file is fresh"
2873 );
2874 }
2875
2876 #[test]
2882 fn a_hook_document_that_is_not_utf8_collides_instead_of_being_rewritten() {
2883 let mut documents = std::collections::BTreeMap::new();
2884 let mut invalid = b"repos:\n# own \xff above\n".to_vec();
2885 invalid.extend_from_slice(format!("{HOOKS_BEGIN}\nstale\n{HOOKS_END}\n").as_bytes());
2886 invalid.extend_from_slice(b" - repo: local \xff below\n");
2887 documents.insert(HOOKS_DESTINATION.to_owned(), invalid);
2888 let mut agents = b"# Widget r\xe9sum\xe9\n\n".to_vec();
2889 agents.extend_from_slice(format!("{BLOCK_BEGIN}\nstale\n{BLOCK_END}\n\n").as_bytes());
2890 agents.extend_from_slice(b"r\xe9sum\xe9\n");
2891 documents.insert(AGENTS_DESTINATION.to_owned(), agents.clone());
2892 let projection = compute(TargetEvidence {
2893 documents,
2894 crate_shape: supported_shape(),
2895 ..TargetEvidence::default()
2896 });
2897 let collided: Vec<&str> = projection
2898 .collisions
2899 .iter()
2900 .map(|c| c.destination.as_str())
2901 .collect();
2902 assert_eq!(collided, [HOOKS_DESTINATION]);
2903 assert!(
2904 projection.collisions[0].reason.contains("not UTF-8"),
2905 "{}",
2906 projection.collisions[0].reason
2907 );
2908 assert!(
2909 !projection
2910 .candidates
2911 .iter()
2912 .any(|c| c.destination == HOOKS_DESTINATION),
2913 "a colliding destination projects no candidate"
2914 );
2915 let agents = candidate(&projection, AGENTS_DESTINATION);
2916 assert!(
2917 agents.bytes.starts_with(b"# Widget r\xe9sum\xe9\n\n"),
2918 "{:?}",
2919 agents.bytes
2920 );
2921 assert!(
2922 agents.bytes.ends_with(b"\n\nr\xe9sum\xe9\n"),
2923 "{:?}",
2924 agents.bytes
2925 );
2926 assert!(
2927 !agents.bytes.contains(&0xEF),
2928 "a replacement character landed"
2929 );
2930
2931 let mut documents = std::collections::BTreeMap::new();
2932 let valid =
2933 format!("repos:\n# own above\n{HOOKS_BEGIN}\nstale\n{HOOKS_END}\n - repo: local\n");
2934 documents.insert(HOOKS_DESTINATION.to_owned(), valid.into_bytes());
2935 let projection = compute(TargetEvidence {
2936 documents,
2937 crate_shape: supported_shape(),
2938 ..TargetEvidence::default()
2939 });
2940 assert!(
2941 projection.collisions.is_empty(),
2942 "{:?}",
2943 projection.collisions
2944 );
2945 let hooks = candidate(&projection, HOOKS_DESTINATION);
2946 let text = String::from_utf8(hooks.bytes.clone()).expect("a valid document stays text");
2947 assert!(text.starts_with("repos:\n# own above\n"), "{text}");
2948 assert!(text.ends_with("\n - repo: local\n"), "{text}");
2949 assert!(!text.contains("stale"), "the region is replaced: {text}");
2950 }
2951
2952 #[test]
2956 fn a_whole_file_colliding_with_a_marked_region_names_both_source_paths() {
2957 let files: Vec<(String, &[u8])> = vec![
2958 ("snippets/_shared/github/SECURITY.md".to_owned(), b"policy"),
2959 ("snippets/rust/github/AGENTS.md".to_owned(), b"whole"),
2960 ];
2961 let err = Projection::compute_over(&files, &input(TargetEvidence::default()))
2962 .expect_err("a whole file at a block destination refuses");
2963 let text = err.to_string();
2964 assert!(text.contains("snippets/rust/github/AGENTS.md"), "{text}");
2965 assert!(text.contains(super::AGENTS_BLOCK), "{text}");
2966 assert!(text.contains(super::AGENTS_LINE_AGENT_POLICY), "{text}");
2967 assert!(text.contains("embedded sources are defective"), "{text}");
2968 }
2969
2970 #[test]
2971 fn duplicate_whole_file_destinations_and_overlapping_marked_regions_refuse_with_the_conflicting_source_names()
2972 {
2973 let files: Vec<(String, &[u8])> = vec![
2976 ("snippets/_shared/github/SECURITY.md".to_owned(), b"shared"),
2977 ("snippets/rust/github/SECURITY.md".to_owned(), b"pair"),
2978 ("snippets/rust/github/release-plz.toml".to_owned(), b"seed"),
2979 ];
2980 let err = Projection::compute_over(&files, &input(TargetEvidence::default()))
2981 .expect_err("a doubled destination refuses");
2982 let text = err.to_string();
2983 assert!(
2984 text.contains("snippets/_shared/github/SECURITY.md"),
2985 "{text}"
2986 );
2987 assert!(text.contains("snippets/rust/github/SECURITY.md"), "{text}");
2988 assert!(text.contains("embedded sources are defective"), "{text}");
2989
2990 let clean: Vec<(String, &[u8])> = vec![
2991 ("snippets/_shared/github/SECURITY.md".to_owned(), b"shared"),
2992 ("snippets/rust/github/release-plz.toml".to_owned(), b"seed"),
2993 ];
2994 let projection = Projection::compute_over(&clean, &input(TargetEvidence::default()))
2995 .expect("a clean list projects");
2996 let whole: Vec<&str> = projection
2997 .candidates
2998 .iter()
2999 .filter(|c| c.placement == Placement::Whole)
3000 .map(|c| c.destination.as_str())
3001 .collect();
3002 assert_eq!(whole, ["SECURITY.md", "release-plz.toml"]);
3003 assert_eq!(
3004 projection
3005 .candidates
3006 .iter()
3007 .find(|c| c.destination == "SECURITY.md")
3008 .map(|c| c.sources.clone()),
3009 Some(vec!["snippets/_shared/github/SECURITY.md".to_owned()])
3010 );
3011
3012 let doubled = format!("{BLOCK_BEGIN}\na\n{BLOCK_END}\n{BLOCK_BEGIN}\nb\n{BLOCK_END}\n");
3013 let unmatched = format!("repos:\n{HOOKS_BEGIN}\n - repo: local\n");
3014 let misordered = format!("# G\n{BLOCK_END}\n{BLOCK_BEGIN}\n");
3015 let mut documents = std::collections::BTreeMap::new();
3016 documents.insert(AGENTS_DESTINATION.to_owned(), doubled.into_bytes());
3017 documents.insert(HOOKS_DESTINATION.to_owned(), unmatched.into_bytes());
3018 documents.insert(GLOSSARY_DESTINATION.to_owned(), misordered.into_bytes());
3019 let projection = compute(TargetEvidence {
3020 documents,
3021 crate_shape: supported_shape(),
3022 ..TargetEvidence::default()
3023 });
3024 let mut collided: Vec<&str> = projection
3025 .collisions
3026 .iter()
3027 .map(|Collision { destination, .. }| destination.as_str())
3028 .collect();
3029 collided.sort_unstable();
3030 let mut expected = BLOCK_DESTINATIONS.to_vec();
3031 expected.sort_unstable();
3032 assert_eq!(collided, expected);
3033 for collision in &projection.collisions {
3034 assert!(
3035 collision.reason.contains(&collision.destination),
3036 "{collision:?}"
3037 );
3038 assert!(
3039 !projection
3040 .candidates
3041 .iter()
3042 .any(|candidate| candidate.destination == collision.destination),
3043 "{} collided and still projects",
3044 collision.destination
3045 );
3046 }
3047 let agents = projection
3048 .collisions
3049 .iter()
3050 .find(|c| c.destination == AGENTS_DESTINATION)
3051 .expect("the doubled document collides");
3052 assert!(agents.reason.contains("more than one"), "{}", agents.reason);
3053 let hooks = projection
3054 .collisions
3055 .iter()
3056 .find(|c| c.destination == HOOKS_DESTINATION)
3057 .expect("the unmatched document collides");
3058 assert!(hooks.reason.contains("unmatched"), "{}", hooks.reason);
3059 }
3060}