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 (template, sources) =
286 block_template(destination, params.checkout_mode(), params.integration())?;
287 if let Some(whole) = candidates
288 .iter()
289 .find(|candidate| candidate.destination == destination)
290 {
291 return Err(anyhow::anyhow!(
292 "{destination} is both a whole file from {} and a marked region from {}; the embedded sources are defective",
293 whole.sources.join(", "),
294 sources.join(", ")
295 )
296 .into());
297 }
298 let region = render(template.as_bytes(), params);
299 let (begin, end) = block_markers(destination).ok_or_else(|| {
300 anyhow::anyhow!("{destination} is a block destination with no markers")
301 })?;
302 match propose_document(destination, evidence.document(destination), ®ion) {
303 Ok(bytes) => candidates.push(Candidate {
304 capability: catalog::GUARDS,
305 destination: destination.to_owned(),
306 kind: Kind::Rendered,
307 placement: Placement::Region { begin, end },
308 bytes,
309 region: Some(region),
310 sources,
311 }),
312 Err(reason) => collisions.push(Collision {
313 destination: destination.to_owned(),
314 reason,
315 }),
316 }
317 }
318 let mut omissions = Vec::new();
319 if let Some((set, reason)) = nix_withholding(params.nix_packaging(), evidence)
320 && candidates
321 .iter()
322 .any(|candidate| candidate.capability == catalog::PACKAGING_NIX)
323 {
324 candidates.retain(|candidate| {
325 if set.contains(&candidate.destination.as_str()) {
326 omissions.push(Omission {
327 destination: candidate.destination.clone(),
328 reason: reason.clone(),
329 action: None,
330 });
331 false
332 } else {
333 true
334 }
335 });
336 withhold(&mut capabilities, catalog::PACKAGING_NIX, &reason, None);
337 }
338 let title_root = candidates.iter().position(|candidate| {
344 candidate.capability == catalog::TITLE_CHECK
345 && candidate.destination == GITLAB_ROOT_PIPELINE
346 });
347 if let Some(index) = title_root
348 && evidence.root_pipeline_present
349 && !evidence.root_pipeline_recorded
350 {
351 let candidate = candidates.remove(index);
352 let reason = format!(
353 "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"
354 );
355 let action = format!(
356 "add the include to the target's own {GITLAB_ROOT_PIPELINE}: include:\n - local: {GITLAB_TITLE_FRAGMENT}"
357 );
358 omissions.push(Omission {
359 destination: candidate.destination,
360 reason: reason.clone(),
361 action: Some(action.clone()),
362 });
363 withhold(
364 &mut capabilities,
365 catalog::TITLE_CHECK,
366 &reason,
367 Some(action),
368 );
369 }
370 candidates.sort_by(|a, b| a.destination.cmp(&b.destination));
371 omissions.sort_by(|a, b| a.destination.cmp(&b.destination));
372 let scanning_selected = capabilities.iter().any(|selection| {
378 selection.id == catalog::CODE_SCANNING && selection.status == Status::Selected
379 });
380 let licence_refusal = scanning_selected
381 .then(|| {
382 code_scanning_licence_refusal(
383 params.code_scanning(),
384 params.driver(),
385 &evidence.crate_shape,
386 )
387 })
388 .flatten();
389 let mut record_defects: Vec<String> = Vec::new();
394 if params.release_mode() == ReleaseMode::Automatic
398 && let Some(selection) = capabilities
399 .iter()
400 .find(|selection| selection.id == catalog::RELEASE_AUTOMATION)
401 && matches!(selection.status, Status::Unavailable | Status::Unknown)
402 && let Some(reason) = &selection.reason
403 {
404 record_defects.push(reason.clone());
405 }
406 Ok(Self {
407 capabilities,
408 candidates,
409 omissions,
410 collisions,
411 licence_refusal,
412 record_defects,
413 })
414 }
415
416 #[must_use]
420 pub fn release_unavailable(&self) -> Option<&str> {
421 self.capabilities
422 .iter()
423 .find(|selection| {
424 selection.id == catalog::RELEASE_AUTOMATION
425 && matches!(selection.status, Status::Unavailable | Status::Unknown)
426 })
427 .and_then(|selection| selection.reason.as_deref())
428 }
429
430 #[must_use]
432 pub fn capability(&self, id: &str) -> Option<&Selection> {
433 self.capabilities
434 .iter()
435 .find(|selection| selection.id == id)
436 }
437}
438
439fn withhold(capabilities: &mut [Selection], id: &str, reason: &str, action: Option<String>) {
441 if let Some(selection) = capabilities.iter_mut().find(|selection| selection.id == id) {
442 selection.status = Status::Withheld;
443 selection.reason = Some(reason.to_owned());
444 selection.action = action;
445 }
446}
447
448pub const GITLAB_ROOT_PIPELINE: &str = ".gitlab-ci.yml";
451
452pub const GITLAB_TITLE_FRAGMENT: &str = ".gitlab/ci/mr-title.yml";
454
455fn embedded_snippets() -> Vec<(String, &'static [u8])> {
458 embedded::walk(&embedded::SNIPPETS)
459 .into_iter()
460 .map(|(path, bytes)| (format!("snippets/{path}"), bytes))
461 .collect()
462}
463
464#[must_use]
467pub fn supported_pairs() -> Vec<(String, String)> {
468 Availability::embedded()
469 .automation_tuples()
470 .into_iter()
471 .filter_map(|entry| {
472 entry
473 .split_once(", ")
474 .map(|(driver, forge)| (driver.to_owned(), forge.to_owned()))
475 })
476 .collect()
477}
478
479#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
481#[serde(rename_all = "lowercase")]
482pub enum Kind {
483 Rendered,
486 Seeded,
489 State,
492}
493
494impl Kind {
495 #[must_use]
497 pub const fn as_str(self) -> &'static str {
498 match self {
499 Self::Rendered => "rendered",
500 Self::Seeded => "seeded",
501 Self::State => "state",
502 }
503 }
504}
505
506const KINDS: [(&str, Kind); 20] = [
512 (".github/workflows/release-plz.yml", Kind::Rendered),
513 (".github/workflows/release-please.yml", Kind::Rendered),
514 (".github/workflows/release.yml", Kind::Rendered),
515 (".github/workflows/pr-title.yml", Kind::Rendered),
516 (".github/workflows/scorecard.yml", Kind::Rendered),
517 (".github/workflows/code-scanning-codeql.yml", Kind::Rendered),
518 (
519 ".github/workflows/code-scanning-semgrep.yml",
520 Kind::Rendered,
521 ),
522 (".gitlab/ci/code-scanning-semgrep.yml", Kind::Rendered),
523 (".gitlab-ci.yml", Kind::Rendered),
524 ("SECURITY.md", Kind::Rendered),
525 (".gitlab/ci/mr-title.yml", Kind::Rendered),
526 ("release-plz.toml", Kind::Seeded),
527 ("dist-workspace.toml", Kind::Seeded),
528 ("release-please-config.json", Kind::Seeded),
529 ("cliff.toml", Kind::Seeded),
530 ("nix/package.nix", Kind::Seeded),
531 ("flake.nix", Kind::Seeded),
532 (".release-please-manifest.json", Kind::State),
533 ("VERSION", Kind::State),
534 ("flake.lock", Kind::State),
535];
536
537pub const NIX_DESTINATIONS: [&str; 3] = ["nix/package.nix", "flake.nix", "flake.lock"];
551
552pub const NIX_WITHHOLDABLE: [&str; 2] = ["flake.nix", "flake.lock"];
558
559pub const SCORECARD_DESTINATIONS: [&str; 1] = [".github/workflows/scorecard.yml"];
574
575pub const CODE_SCANNING_DESTINATIONS: [(&str, Provider); 3] = [
589 (
590 ".github/workflows/code-scanning-codeql.yml",
591 Provider::CodeQl,
592 ),
593 (
594 ".github/workflows/code-scanning-semgrep.yml",
595 Provider::Semgrep,
596 ),
597 (".gitlab/ci/code-scanning-semgrep.yml", Provider::Semgrep),
598];
599
600pub const CODE_SCANNING_TECHS: [&str; 1] = ["rust"];
608
609#[must_use]
626pub fn code_scanning_incompatibility(
627 provider: Option<Provider>,
628 driver: Option<&str>,
629 forge: Option<&str>,
630) -> Option<String> {
631 let named = provider?;
632 let Some(driver) = driver else {
633 return Some(format!(
634 "a scanner reads the release driver's language, and the profile names no automatic release driver; the bindings that carry one are: {}",
635 CODE_SCANNING_TECHS.join(", ")
636 ));
637 };
638 if !CODE_SCANNING_TECHS.contains(&driver) {
639 return Some(format!(
640 "the {driver} binding ships no code scanning workflow; a scanner reads one language, and the bindings that carry one are: {}",
641 CODE_SCANNING_TECHS.join(", ")
642 ));
643 }
644 let Some(forge) = forge else {
645 return Some(
646 "a code scanning workflow runs at a forge, and the profile names none".to_owned(),
647 );
648 };
649 if named == Provider::CodeQl && forge != "github" {
650 return Some(format!(
651 "codeql is GitHub's own analyzer and the {forge} pair ships no workflow for it; pass --code-scanning semgrep"
652 ));
653 }
654 None
655}
656
657const OSI_APPROVED: [&str; 18] = [
667 "0bsd",
668 "agpl-3.0",
669 "agpl-3.0-only",
670 "agpl-3.0-or-later",
671 "apache-2.0",
672 "bsd-2-clause",
673 "bsd-3-clause",
674 "bsl-1.0",
675 "epl-2.0",
676 "gpl-2.0",
677 "gpl-2.0-only",
678 "gpl-2.0-or-later",
679 "gpl-3.0",
680 "gpl-3.0-only",
681 "gpl-3.0-or-later",
682 "isc",
683 "mit",
684 "mpl-2.0",
685];
686
687const CONTENT_LICENCES: [&str; 4] = ["cc-by-3.0", "cc-by-4.0", "cc-by-sa-3.0", "cc-by-sa-4.0"];
705
706const SPDX_EXCEPTIONS: [&str; 86] = [
723 "389-exception",
724 "asterisk-exception",
725 "asterisk-linking-protocols-exception",
726 "autoconf-exception-2.0",
727 "autoconf-exception-3.0",
728 "autoconf-exception-generic",
729 "autoconf-exception-generic-3.0",
730 "autoconf-exception-macro",
731 "bison-exception-1.24",
732 "bison-exception-2.2",
733 "bootloader-exception",
734 "cgal-linking-exception",
735 "classpath-exception-2.0",
736 "classpath-exception-2.0-short",
737 "clisp-exception-2.0",
738 "cryptsetup-openssl-exception",
739 "digia-qt-lgpl-exception-1.1",
740 "digirule-foss-exception",
741 "ecos-exception-2.0",
742 "erlang-otp-linking-exception",
743 "fawkes-runtime-exception",
744 "fltk-exception",
745 "fmt-exception",
746 "font-exception-2.0",
747 "freertos-exception-2.0",
748 "gcc-exception-2.0",
749 "gcc-exception-2.0-note",
750 "gcc-exception-3.1",
751 "gmsh-exception",
752 "gnat-exception",
753 "gnome-examples-exception",
754 "gnu-compiler-exception",
755 "gnu-javamail-exception",
756 "google-patent-webm",
757 "gpl-3.0-389-ds-base-exception",
758 "gpl-3.0-interface-exception",
759 "gpl-3.0-linking-exception",
760 "gpl-3.0-linking-source-exception",
761 "gpl-cc-1.0",
762 "gstreamer-exception-2005",
763 "gstreamer-exception-2008",
764 "harbour-exception",
765 "i2p-gpl-java-exception",
766 "independent-modules-exception",
767 "kicad-libraries-exception",
768 "kvirc-openssl-exception",
769 "lgpl-3.0-linking-exception",
770 "libpri-openh323-exception",
771 "libtool-exception",
772 "linux-syscall-note",
773 "llgpl",
774 "llvm-exception",
775 "lzma-exception",
776 "mif-exception",
777 "mxml-exception",
778 "nokia-qt-exception-1.1",
779 "ocaml-lgpl-linking-exception",
780 "occt-exception-1.0",
781 "openjdk-assembly-exception-1.0",
782 "openvpn-openssl-exception",
783 "pcre2-exception",
784 "polyparse-exception",
785 "ps-or-pdf-font-exception-20170817",
786 "qpl-1.0-inria-2004-exception",
787 "qt-gpl-exception-1.0",
788 "qt-lgpl-exception-1.1",
789 "qwt-exception-1.0",
790 "romic-exception",
791 "rrdtool-floss-exception-2.0",
792 "rsync-linking-exception",
793 "sane-exception",
794 "shl-2.0",
795 "shl-2.1",
796 "simple-library-usage-exception",
797 "spelling-provider-lgpl-exception",
798 "sqlitestudio-openssl-exception",
799 "stunnel-exception",
800 "swi-exception",
801 "swift-exception",
802 "texinfo-exception",
803 "u-boot-exception-2.0",
804 "ubdl-exception",
805 "universal-foss-exception-1.0",
806 "vsftpd-openssl-exception",
807 "wxwindows-exception-3.1",
808 "x11vnc-openssl-exception",
809];
810
811fn listed(list: &[&str], identifier: &str) -> bool {
818 let lowered = identifier.to_ascii_lowercase();
819 list.contains(&lowered.as_str())
820}
821
822#[derive(Debug, Clone, Copy, PartialEq, Eq)]
824enum Token<'a> {
825 Open,
827 Close,
829 And,
831 Or,
833 With,
836 Identifier(&'a str),
839}
840
841fn tokenize(expression: &str) -> Option<Vec<Token<'_>>> {
849 let mut tokens = Vec::new();
850 let bytes = expression.as_bytes();
851 let mut at = 0;
852 while at < bytes.len() {
853 let byte = bytes[at];
854 if byte.is_ascii_whitespace() {
855 at += 1;
856 continue;
857 }
858 if byte == b'(' {
859 tokens.push(Token::Open);
860 at += 1;
861 continue;
862 }
863 if byte == b')' {
864 tokens.push(Token::Close);
865 at += 1;
866 continue;
867 }
868 let start = at;
869 while at < bytes.len() {
870 let byte = bytes[at];
871 if byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'+' | b':') {
872 at += 1;
873 } else {
874 break;
875 }
876 }
877 if at == start {
878 return None;
879 }
880 let word = &expression[start..at];
881 tokens.push(match word {
882 "AND" => Token::And,
883 "OR" => Token::Or,
884 "WITH" => Token::With,
885 other => Token::Identifier(other),
886 });
887 }
888 Some(tokens)
889}
890
891#[derive(Debug, Clone, Copy)]
898struct Verdict {
899 grants: bool,
901 clean: bool,
903}
904
905struct Spdx<'a> {
914 tokens: &'a [Token<'a>],
915 at: usize,
916}
917
918impl<'a> Spdx<'a> {
919 fn peek(&self) -> Option<Token<'a>> {
921 self.tokens.get(self.at).copied()
922 }
923
924 fn bump(&mut self) -> Option<Token<'a>> {
926 let token = self.peek()?;
927 self.at += 1;
928 Some(token)
929 }
930
931 fn expression(&mut self) -> Option<Verdict> {
941 let mut verdict = self.conjunction()?;
942 while self.peek() == Some(Token::Or) {
943 self.bump();
944 let right = self.conjunction()?;
945 verdict = Verdict {
946 grants: verdict.grants && right.grants,
947 clean: verdict.clean && right.clean,
948 };
949 }
950 Some(verdict)
951 }
952
953 fn conjunction(&mut self) -> Option<Verdict> {
963 let mut verdict = self.operand()?;
964 while self.peek() == Some(Token::And) {
965 self.bump();
966 let right = self.operand()?;
967 verdict = Verdict {
968 grants: verdict.grants || right.grants,
969 clean: verdict.clean && right.clean,
970 };
971 }
972 Some(verdict)
973 }
974
975 fn operand(&mut self) -> Option<Verdict> {
985 match self.bump()? {
986 Token::Open => {
987 let inner = self.expression()?;
990 (self.bump()? == Token::Close).then_some(inner)
991 }
992 Token::Identifier(name) => {
993 let identifier = name.strip_suffix('+').unwrap_or(name);
994 let approved = listed(&OSI_APPROVED, identifier);
995 let verdict = Verdict {
996 grants: approved,
997 clean: approved || listed(&CONTENT_LICENCES, identifier),
998 };
999 if self.peek() == Some(Token::With) {
1000 self.bump();
1001 match self.bump()? {
1005 Token::Identifier(exception) if listed(&SPDX_EXCEPTIONS, exception) => {}
1006 _ => return None,
1007 }
1008 }
1009 Some(verdict)
1010 }
1011 Token::And | Token::Or | Token::With | Token::Close => None,
1012 }
1013 }
1014}
1015
1016#[must_use]
1032pub fn licence_states_an_open_source_codebase(expression: &str) -> bool {
1033 let Some(tokens) = tokenize(expression) else {
1034 return false;
1035 };
1036 let mut reader = Spdx {
1037 tokens: &tokens,
1038 at: 0,
1039 };
1040 let Some(verdict) = reader.expression() else {
1041 return false;
1042 };
1043 verdict.grants && verdict.clean && reader.at == tokens.len()
1044}
1045
1046#[must_use]
1060pub fn code_scanning_licence_refusal(
1061 provider: Option<Provider>,
1062 driver: Option<&str>,
1063 shape: &CrateShape,
1064) -> Option<String> {
1065 if provider != Some(Provider::CodeQl) {
1066 return None;
1067 }
1068 if driver != Some("rust") {
1069 return Some(format!(
1070 "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",
1071 driver.unwrap_or("release-less")
1072 ));
1073 }
1074 let fallback = "land --code-scanning semgrep, which carries no licence condition";
1075 let Some(text) = shape.cargo_toml.as_deref() else {
1076 return Some(format!(
1077 "the target has no readable Cargo.toml, so the licence codeql's terms depend on cannot be read; {fallback}"
1078 ));
1079 };
1080 let Ok(table) = text.parse::<toml::Table>() else {
1081 return Some(format!(
1082 "the target's Cargo.toml does not parse, so the licence codeql's terms depend on cannot be read; {fallback}"
1083 ));
1084 };
1085 let licence = table
1086 .get("package")
1087 .and_then(toml::Value::as_table)
1088 .and_then(|package| package.get("license"))
1089 .and_then(toml::Value::as_str);
1090 match licence {
1091 None => Some(format!(
1092 "the target's Cargo.toml declares no license field, and codeql's terms cover an open-source codebase alone; declare one, or {fallback}"
1093 )),
1094 Some(expression) if !licence_states_an_open_source_codebase(expression) => Some(format!(
1095 "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}"
1096 )),
1097 Some(_) => None,
1098 }
1099}
1100
1101#[must_use]
1104pub fn kind_of(destination: &str) -> Option<Kind> {
1105 if BLOCK_DESTINATIONS.contains(&destination) {
1106 return Some(Kind::Rendered);
1107 }
1108 KINDS
1109 .iter()
1110 .find(|(name, _)| *name == destination)
1111 .map(|(_, kind)| *kind)
1112}
1113
1114pub fn destinations() -> impl Iterator<Item = &'static str> {
1119 KINDS
1120 .iter()
1121 .map(|(name, _)| *name)
1122 .chain(BLOCK_DESTINATIONS)
1123}
1124
1125pub const OWNER_TOKEN: &[u8] = b"OWNER";
1132
1133pub const REPO_PLACEHOLDER: &str = "OWNER";
1138
1139pub const REPO_TOKEN: &[u8] = b"RK_REPO";
1141
1142pub const SCOPE_SHAPE_TOKEN: &[u8] = b"RK_SCOPE_SHAPE";
1144
1145pub const STYLE_TOKEN: &[u8] = b"RK_STYLE";
1148
1149pub const TRUNK_BRANCH_TOKEN: &[u8] = b"RK_TRUNK_BRANCH";
1153
1154pub const LINE_PREFIX_TOKEN: &[u8] = b"RK_LINE_PREFIX";
1157
1158pub const LINE_PREFIX_RE_TOKEN: &[u8] = b"RK_LINE_PREFIX_RE";
1164
1165pub const PUSH_GUARD_TOKEN: &[u8] = b" # RK_PUSH_GUARD";
1175
1176pub const REQUIRED_CHECK_TOKEN: &[u8] = b"RK_REQUIRED_CHECK";
1178
1179pub const REQUIRED_WORKFLOW_TOKEN: &[u8] = b"RK_REQUIRED_WORKFLOW";
1181
1182pub const BRANCH_SHAPE_TOKEN: &[u8] = b"RK_BRANCH_SHAPE";
1184
1185pub const REQUEST_JOB_TOKEN: &[u8] = b"RK_REQUEST_JOB";
1187
1188pub const RELEASE_GATE_TRIGGER: &str = "blocks/release-gate-trigger.yaml.in";
1190
1191pub const RELEASE_GATE_JOB: &str = "blocks/release-gate-job.yaml.in";
1193
1194#[must_use]
1201pub fn release_driver_shape(driver: Option<&str>) -> Option<(&'static str, &'static str)> {
1202 match driver {
1203 Some("rust") => Some(("release-plz-", "release-plz-pr")),
1206 Some("bash") => Some(("chore/release-v", "release-request")),
1208 Some("python") => Some((
1212 "release-please--branches--RK_TRUNK_BRANCH",
1213 "release-please-pr",
1214 )),
1215 _ => None,
1216 }
1217}
1218
1219#[must_use]
1230pub fn release_gate_spans(driver: Option<&str>) -> [(&'static [u8], &'static [u8]); 3] {
1231 let arm: (&[u8], &[u8]) = if driver == Some("bash") {
1235 (
1236 b"\n # RK_GATE_ARM_BEGIN",
1237 b"\n # RK_GATE_ARM_END",
1238 )
1239 } else {
1240 (b"\n# RK_GATE_ARM_BEGIN", b"\n# RK_GATE_ARM_END")
1241 };
1242 [
1243 (b"\n# RK_GATE_TRIGGER_BEGIN", b"\n# RK_GATE_TRIGGER_END"),
1244 arm,
1245 (b"\n# RK_GATE_JOB_BEGIN", b"\n# RK_GATE_JOB_END"),
1246 ]
1247}
1248
1249#[must_use]
1257fn release_gates(params: &Params) -> bool {
1258 params.forge() == Some("github")
1259 && params.integration() == crate::landing::Integration::Local
1260 && params.release_mode() == ReleaseMode::Automatic
1261 && params.style() == Some(crate::landing::Style::Trunk)
1262 && !params.required_check().is_empty()
1263 && !params.required_workflow().is_empty()
1264 && release_driver_shape(params.driver()).is_some()
1265}
1266
1267fn release_gate_replacements(params: &Params) -> Result<[Option<Vec<u8>>; 3], RkError> {
1279 if !release_gates(params) {
1280 return Ok([None, None, None]);
1281 }
1282 let opened = |block: &str| -> Result<Vec<u8>, RkError> {
1285 let text = embedded_block(block)?;
1286 Ok(substitute_tokens(
1287 format!("\n{}", text.trim_end_matches('\n')).as_bytes(),
1288 params,
1289 ))
1290 };
1291 Ok([
1292 Some(opened(RELEASE_GATE_TRIGGER)?),
1293 Some(Vec::new()),
1296 Some(opened(RELEASE_GATE_JOB)?),
1297 ])
1298}
1299
1300pub const SECURITY_SPANS: [(&[u8], &[u8]); 3] = [
1311 (
1312 b"<!--RK_SECURITY_CONTACT_BEGIN-->",
1313 b"<!--RK_SECURITY_CONTACT_END-->",
1314 ),
1315 (
1316 b"<!--RK_SECURITY_RESPONSE_BEGIN-->",
1317 b"<!--RK_SECURITY_RESPONSE_END-->",
1318 ),
1319 (
1320 b"<!--RK_SECURITY_DEADLINE_BEGIN-->",
1321 b"<!--RK_SECURITY_DEADLINE_END-->",
1322 ),
1323];
1324
1325fn acknowledgment(response: &str) -> String {
1328 format!("Maintainers acknowledge a report within {response}.")
1329}
1330
1331const DISCLOSURE_ONLY: &[u8] = b"This policy commits to no disclosure deadline.";
1335
1336fn security_replacements(params: &Params) -> [Option<Vec<u8>>; 3] {
1339 let contact = (!params.security_contact().is_empty())
1340 .then(|| params.security_contact().as_bytes().to_vec());
1341 let promised = params.security_response() != crate::config::RESPONSE_DEFAULT;
1342 [
1343 contact,
1344 promised.then(|| acknowledgment(params.security_response()).into_bytes()),
1345 promised.then(|| DISCLOSURE_ONLY.to_vec()),
1346 ]
1347}
1348
1349fn replace_span(baseline: &[u8], begin: &[u8], end: &[u8], value: Option<&[u8]>) -> Vec<u8> {
1355 let ordered = find(baseline, begin)
1356 .zip(find(baseline, end))
1357 .filter(|(start, stop)| stop > start);
1358 let Some((start, stop)) = ordered else {
1359 return baseline.to_vec();
1360 };
1361 let mut out = Vec::with_capacity(baseline.len());
1362 out.extend_from_slice(&baseline[..start]);
1363 out.extend_from_slice(value.unwrap_or_else(|| &baseline[start + begin.len()..stop]));
1364 out.extend_from_slice(&baseline[stop + end.len()..]);
1365 out
1366}
1367
1368#[must_use]
1386pub fn render(baseline: &[u8], params: &Params) -> Vec<u8> {
1387 let mut out = substitute_tokens(baseline, params);
1388 if let Ok(values) = release_gate_replacements(params) {
1394 for ((begin, end), value) in release_gate_spans(params.driver()).iter().zip(values) {
1395 out = replace_span(&out, begin, end, value.as_deref());
1396 }
1397 }
1398 for ((begin, end), value) in SECURITY_SPANS.iter().zip(security_replacements(params)) {
1399 out = replace_span(&out, begin, end, value.as_deref());
1400 }
1401 out
1402}
1403
1404#[must_use]
1409fn substitute_tokens(baseline: &[u8], params: &Params) -> Vec<u8> {
1410 let repo = params.repo();
1411 let owner = repo.split('/').next().unwrap_or(repo);
1412 let mut out = substitute(baseline, OWNER_TOKEN, owner.as_bytes());
1413 if let Some(style) = params.style() {
1414 out = substitute(&out, STYLE_TOKEN, style.as_str().as_bytes());
1415 }
1416 out = substitute(&out, SCOPE_SHAPE_TOKEN, SCOPE_SHAPE.as_bytes());
1417 let (shape, job) = release_driver_shape(params.driver()).unwrap_or_default();
1422 out = substitute(&out, BRANCH_SHAPE_TOKEN, shape.as_bytes());
1423 out = substitute(&out, REQUEST_JOB_TOKEN, job.as_bytes());
1424 out = substitute(&out, TRUNK_BRANCH_TOKEN, params.trunk().as_bytes());
1425 let escaped = params.line_prefix().replace('/', "\\/");
1426 out = substitute(&out, LINE_PREFIX_RE_TOKEN, escaped.as_bytes());
1427 out = substitute(&out, LINE_PREFIX_TOKEN, params.line_prefix().as_bytes());
1428 let required_check = serde_json::Value::String(params.required_check().to_owned()).to_string();
1432 let required_workflow =
1433 serde_json::Value::String(params.required_workflow().to_owned()).to_string();
1434 out = substitute(&out, REQUIRED_CHECK_TOKEN, required_check.as_bytes());
1435 out = substitute(&out, REQUIRED_WORKFLOW_TOKEN, required_workflow.as_bytes());
1436 let guard: &[u8] = if release_gates(params) {
1439 b" && github.event_name == 'push'"
1440 } else {
1441 b""
1442 };
1443 out = substitute(&out, PUSH_GUARD_TOKEN, guard);
1444 out = substitute(&out, REPO_TOKEN, repo.as_bytes());
1445 out
1446}
1447
1448#[must_use]
1450pub fn substitute(baseline: &[u8], token: &[u8], value: &[u8]) -> Vec<u8> {
1451 let mut out = Vec::with_capacity(baseline.len());
1452 let mut rest = baseline;
1453 while let Some(at) = find(rest, token) {
1454 out.extend_from_slice(&rest[..at]);
1455 out.extend_from_slice(value);
1456 rest = &rest[at + token.len()..];
1457 }
1458 out.extend_from_slice(rest);
1459 out
1460}
1461
1462fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
1464 haystack
1465 .windows(needle.len())
1466 .position(|window| window == needle)
1467}
1468
1469pub const AGENTS_DESTINATION: &str = "AGENTS.md";
1471
1472pub const BLOCK_BEGIN: &str = "<!-- BEGIN release-kit -->";
1474
1475pub const BLOCK_END: &str = "<!-- END release-kit -->";
1477
1478pub const GLOSSARY_DESTINATION: &str = "GLOSSARY.md";
1483
1484pub const HOOKS_DESTINATION: &str = ".pre-commit-config.yaml";
1486
1487pub const BLOCK_DESTINATIONS: [&str; 3] =
1493 [AGENTS_DESTINATION, GLOSSARY_DESTINATION, HOOKS_DESTINATION];
1494
1495pub const HOOKS_BEGIN: &str = "# BEGIN release-kit";
1497
1498pub const HOOKS_END: &str = "# END release-kit";
1500
1501pub const HOOK_TYPES_LINE: &str = "default_install_hook_types: [pre-commit, commit-msg, pre-push]";
1505
1506pub const AGENTS_BLOCK: &str = "blocks/agents-block.md.in";
1508
1509pub const GLOSSARY_BLOCK: &str = "blocks/glossary.md.in";
1511
1512pub const AGENTS_LINE_WORKTREE: &str = "blocks/agents-line-worktree.md.in";
1514
1515pub const AGENTS_LINE_BRANCHES: &str = "blocks/agents-line-branches.md.in";
1517
1518pub const AGENTS_LINE_INTEGRATION_FORGE: &str = "blocks/agents-line-integration-forge.md.in";
1520
1521pub const AGENTS_LINE_INTEGRATION_LOCAL: &str = "blocks/agents-line-integration-local.md.in";
1523
1524#[must_use]
1526pub const fn integration_line(integration: Integration) -> &'static str {
1527 match integration {
1528 Integration::Forge => AGENTS_LINE_INTEGRATION_FORGE,
1529 Integration::Local => AGENTS_LINE_INTEGRATION_LOCAL,
1530 }
1531}
1532
1533pub const PRE_COMMIT_BLOCK: &str = "blocks/pre-commit-block.yaml.in";
1535
1536pub const PRE_COMMIT_WORKTREE_GUARD: &str = "blocks/pre-commit-worktree-guard.yaml.in";
1538
1539pub const PRE_COMMIT_TRUNK_COMMIT_GUARD: &str = "blocks/pre-commit-trunk-commit-guard.yaml.in";
1541
1542pub const PRE_COMMIT_TRUNK_PUSH_GUARD: &str = "blocks/pre-commit-trunk-push-guard.yaml.in";
1544
1545pub const PRE_COMMIT_SWEEP_SKIP: &str = "blocks/pre-commit-sweep-skip.yaml.in";
1547
1548pub const PRE_COMMIT_SWEEP_NONE: &str = "blocks/pre-commit-sweep-none.yaml.in";
1550
1551pub const PRE_COMMIT_INTEGRATE_NOTE: &str = "blocks/pre-commit-integrate-note.yaml.in";
1553
1554#[must_use]
1556pub const fn routing_line(mode: CheckoutMode) -> &'static str {
1557 match mode {
1558 CheckoutMode::LinkedWorktree => AGENTS_LINE_WORKTREE,
1559 CheckoutMode::MainWorktree => AGENTS_LINE_BRANCHES,
1560 }
1561}
1562
1563pub fn embedded_block(path: &str) -> Result<&'static str, RkError> {
1570 let name = path.strip_prefix("blocks/").unwrap_or(path);
1571 let file = embedded::BLOCKS
1572 .get_file(name)
1573 .ok_or_else(|| anyhow::anyhow!("{path}: this binary embeds no such block"))?;
1574 std::str::from_utf8(file.contents())
1575 .map_err(|_| anyhow::anyhow!("{path}: a block is UTF-8").into())
1576}
1577
1578#[must_use]
1582pub fn authored(text: &str) -> &str {
1583 text.strip_suffix('\n').unwrap_or(text)
1584}
1585
1586pub 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[-/].+)$";
1594
1595pub const SCOPE_SHAPE: &str = "[a-z0-9._/-]+";
1605
1606#[must_use]
1613pub fn scope_is_shaped(scope: &str) -> bool {
1614 !scope.is_empty()
1615 && scope.chars().all(|c| {
1616 c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '/' | '-')
1617 })
1618}
1619
1620#[must_use]
1627pub fn compose_routing(template: &str, line: &str, integration_line: &str) -> String {
1628 authored(template)
1629 .replacen("RK_WORKFLOW_LINE", authored(line), 1)
1630 .replacen("RK_INTEGRATION_LINE", authored(integration_line), 1)
1631}
1632
1633#[must_use]
1637pub fn compose_glossary(template: &str) -> String {
1638 authored(template).to_owned()
1639}
1640
1641#[must_use]
1660pub fn compose_hooks(template: &str, guard: Option<&str>, parts: &TrunkGuards) -> String {
1661 let guard = guard.map_or_else(String::new, |entry| format!("{}\n", authored(entry)));
1662 let mut skips: Vec<&str> = Vec::new();
1663 if parts.integration == Integration::Forge {
1664 skips.push("no-commit-to-branch");
1665 }
1666 if !guard.is_empty() {
1667 skips.push("rk-worktree-location");
1668 }
1669 let sweep = if skips.is_empty() {
1670 format!("{}\n", authored(parts.sweep_none))
1671 } else {
1672 format!(
1673 "{}\n",
1674 authored(parts.sweep_skip).replacen("RK_SWEEP_SKIP", &skips.join(","), 1)
1675 )
1676 };
1677 let (commit_guard, push_guard, note) = match parts.integration {
1678 Integration::Forge => (
1679 format!("{}\n", authored(parts.trunk_commit)),
1680 format!("{}\n", authored(parts.trunk_push)),
1681 String::new(),
1682 ),
1683 Integration::Local => (
1684 String::new(),
1685 String::new(),
1686 format!("{}\n", authored(parts.integrate_note)),
1687 ),
1688 };
1689 authored(template)
1690 .replacen("RK_BRANCH_GRAMMAR", BRANCH_GRAMMAR, 1)
1691 .replacen("RK_SWEEP_NOTE", &format!("{sweep}{note}"), 1)
1692 .replacen("RK_TRUNK_COMMIT_GUARD", &commit_guard, 1)
1693 .replacen("RK_WORKTREE_GUARD", &guard, 1)
1694 .replacen("RK_TRUNK_PUSH_GUARD", &push_guard, 1)
1695}
1696
1697pub struct TrunkGuards<'a> {
1700 pub integration: Integration,
1702 pub trunk_commit: &'a str,
1704 pub trunk_push: &'a str,
1706 pub sweep_skip: &'a str,
1708 pub sweep_none: &'a str,
1710 pub integrate_note: &'a str,
1712}
1713
1714pub fn routing_block(mode: CheckoutMode, integration: Integration) -> Result<String, RkError> {
1721 Ok(compose_routing(
1722 embedded_block(AGENTS_BLOCK)?,
1723 embedded_block(routing_line(mode))?,
1724 embedded_block(integration_line(integration))?,
1725 ))
1726}
1727
1728pub fn glossary_block() -> Result<String, RkError> {
1734 Ok(compose_glossary(embedded_block(GLOSSARY_BLOCK)?))
1735}
1736
1737pub fn hooks_block(mode: CheckoutMode, integration: Integration) -> Result<String, RkError> {
1744 let guard = match mode {
1745 CheckoutMode::LinkedWorktree => Some(embedded_block(PRE_COMMIT_WORKTREE_GUARD)?),
1746 CheckoutMode::MainWorktree => None,
1747 };
1748 let parts = TrunkGuards {
1749 integration,
1750 trunk_commit: embedded_block(PRE_COMMIT_TRUNK_COMMIT_GUARD)?,
1751 trunk_push: embedded_block(PRE_COMMIT_TRUNK_PUSH_GUARD)?,
1752 sweep_skip: embedded_block(PRE_COMMIT_SWEEP_SKIP)?,
1753 sweep_none: embedded_block(PRE_COMMIT_SWEEP_NONE)?,
1754 integrate_note: embedded_block(PRE_COMMIT_INTEGRATE_NOTE)?,
1755 };
1756 Ok(compose_hooks(
1757 embedded_block(PRE_COMMIT_BLOCK)?,
1758 guard,
1759 &parts,
1760 ))
1761}
1762
1763fn block_template(
1766 destination: &str,
1767 mode: CheckoutMode,
1768 integration: Integration,
1769) -> Result<(String, Vec<String>), RkError> {
1770 match destination {
1771 AGENTS_DESTINATION => Ok((
1772 routing_block(mode, integration)?,
1773 vec![
1774 AGENTS_BLOCK.to_owned(),
1775 routing_line(mode).to_owned(),
1776 integration_line(integration).to_owned(),
1777 ],
1778 )),
1779 GLOSSARY_DESTINATION => Ok((glossary_block()?, vec![GLOSSARY_BLOCK.to_owned()])),
1780 HOOKS_DESTINATION => {
1781 let mut sources = vec![PRE_COMMIT_BLOCK.to_owned()];
1782 if mode == CheckoutMode::LinkedWorktree {
1783 sources.push(PRE_COMMIT_WORKTREE_GUARD.to_owned());
1784 }
1785 match integration {
1786 Integration::Forge => {
1787 sources.push(PRE_COMMIT_TRUNK_COMMIT_GUARD.to_owned());
1788 sources.push(PRE_COMMIT_TRUNK_PUSH_GUARD.to_owned());
1789 sources.push(PRE_COMMIT_SWEEP_SKIP.to_owned());
1790 }
1791 Integration::Local => {
1792 if mode == CheckoutMode::LinkedWorktree {
1793 sources.push(PRE_COMMIT_SWEEP_SKIP.to_owned());
1794 } else {
1795 sources.push(PRE_COMMIT_SWEEP_NONE.to_owned());
1796 }
1797 sources.push(PRE_COMMIT_INTEGRATE_NOTE.to_owned());
1798 }
1799 }
1800 Ok((hooks_block(mode, integration)?, sources))
1801 }
1802 other => Err(anyhow::anyhow!("{other} is not a block destination").into()),
1803 }
1804}
1805
1806#[must_use]
1808pub fn block_markers(destination: &str) -> Option<(&'static str, &'static str)> {
1809 match destination {
1810 AGENTS_DESTINATION | GLOSSARY_DESTINATION => Some((BLOCK_BEGIN, BLOCK_END)),
1811 HOOKS_DESTINATION => Some((HOOKS_BEGIN, HOOKS_END)),
1812 _ => None,
1813 }
1814}
1815
1816#[must_use]
1819pub fn extract_block<'a>(text: &'a str, begin: &str, end: &str) -> Option<&'a str> {
1820 let start = text.find(begin)?;
1821 let stop = text[start..].find(end)? + start + end.len();
1822 Some(&text[start..stop])
1823}
1824
1825#[must_use]
1832pub fn splice_marked_block(existing: Option<&[u8]>, block: &str) -> Vec<u8> {
1833 let block = block.as_bytes();
1834 let Some(text) = existing else {
1835 return [block, b"\n"].concat();
1836 };
1837 if let Some(start) = find(text, BLOCK_BEGIN.as_bytes())
1841 && let Some(offset) = find(&text[start..], BLOCK_END.as_bytes())
1842 {
1843 let stop = start + offset + BLOCK_END.len();
1844 return [&text[..start], block, &text[stop..]].concat();
1845 }
1846 let mut out = Vec::with_capacity(text.len() + block.len() + 3);
1850 out.extend_from_slice(text);
1851 if !text.ends_with(b"\n") {
1852 out.push(b'\n');
1853 }
1854 out.push(b'\n');
1855 out.extend_from_slice(block);
1856 out.push(b'\n');
1857 out
1858}
1859
1860pub fn splice_hooks_block(existing: Option<&str>, block: &str) -> Result<String, String> {
1873 let Some(text) = existing else {
1874 return Ok(format!("{HOOK_TYPES_LINE}\n\nrepos:\n{block}\n"));
1875 };
1876 if let Some(defect) = hooks_marker_defect(text) {
1877 return Err(defect);
1878 }
1879 if let Some(found) = extract_block(text, HOOKS_BEGIN, HOOKS_END) {
1880 return Ok(text.replacen(found, block, 1));
1881 }
1882 let mut out = String::with_capacity(text.len() + block.len() + 1);
1883 let mut placed = false;
1884 for line in text.split_inclusive('\n') {
1885 out.push_str(line);
1886 if !placed && line.trim_end() == "repos:" {
1887 if !out.ends_with('\n') {
1888 out.push('\n');
1889 }
1890 out.push_str(block);
1891 out.push('\n');
1892 placed = true;
1893 }
1894 }
1895 if placed {
1896 Ok(out)
1897 } else {
1898 Err(format!(
1899 "{HOOKS_DESTINATION} exists with no repos: line, so the hook block has nowhere to land"
1900 ))
1901 }
1902}
1903
1904#[must_use]
1914pub fn marker_defect(destination: &str, text: &str) -> Option<String> {
1915 let (begin, end) = block_markers(destination)?;
1916 let begins = text.matches(begin).count();
1917 let ends = text.matches(end).count();
1918 if begins > 1 || ends > 1 {
1919 return Some(format!(
1920 "{destination} carries more than one release-kit marker pair; release-kit owns exactly one block"
1921 ));
1922 }
1923 match (text.find(begin), text.find(end)) {
1924 (Some(begin), Some(end)) if end > begin => None,
1925 (None, None) => None,
1926 _ => Some(format!(
1927 "{destination} carries an unmatched or misordered release-kit marker, so the block's extent is ambiguous"
1928 )),
1929 }
1930}
1931
1932#[must_use]
1935pub fn hooks_marker_defect(text: &str) -> Option<String> {
1936 marker_defect(HOOKS_DESTINATION, text)
1937}
1938
1939fn propose_document(
1943 destination: &str,
1944 existing: Option<&[u8]>,
1945 region: &[u8],
1946) -> Result<Vec<u8>, String> {
1947 let block = String::from_utf8_lossy(region).into_owned();
1952 if let Some(text) = existing
1953 && let Some(defect) = marker_defect(destination, &String::from_utf8_lossy(text))
1954 {
1955 return Err(defect);
1956 }
1957 if destination == HOOKS_DESTINATION {
1958 let text = match existing {
1962 None => None,
1963 Some(bytes) => Some(std::str::from_utf8(bytes).map_err(|_| {
1964 format!(
1965 "{destination} is not UTF-8, so the hook block has nowhere to land without rewriting the target's bytes"
1966 )
1967 })?),
1968 };
1969 return splice_hooks_block(text, &block).map(String::into_bytes);
1970 }
1971 Ok(splice_marked_block(existing, &block))
1972}
1973
1974#[must_use]
1986pub fn nix_unsupported_shape(shape: &CrateShape) -> Option<String> {
1987 let Some(text) = shape.cargo_toml.as_deref() else {
1988 return Some(
1989 "the target has no readable Cargo.toml, which the seeded package expression reads; no Nix file lands".to_owned(),
1990 );
1991 };
1992 let Ok(table) = text.parse::<toml::Table>() else {
1993 return Some(
1994 "the target's Cargo.toml does not parse, and the seeded package expression reads it; no Nix file lands".to_owned(),
1995 );
1996 };
1997 if !table.contains_key("package") {
1998 return Some(
1999 "the target's Cargo.toml has no [package] table; the seed supports a single crate, so no Nix file lands".to_owned(),
2000 );
2001 }
2002 if !shape.cargo_lock {
2003 return Some(
2004 "the target has no Cargo.lock, which the seeded package expression builds from; commit one, then opt in".to_owned(),
2005 );
2006 }
2007 let implicit_bin = shape.main_rs
2008 && table
2009 .get("package")
2010 .and_then(toml::Value::as_table)
2011 .and_then(|package| package.get("autobins"))
2012 .and_then(toml::Value::as_bool)
2013 != Some(false);
2014 let explicit_bins = table.get("bin").and_then(toml::Value::as_array);
2015 if explicit_bins.is_none() && !implicit_bin {
2016 return Some(
2017 "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(),
2018 );
2019 }
2020 if let Some(bins) = explicit_bins {
2026 let required = bins
2027 .first()
2028 .and_then(toml::Value::as_table)
2029 .and_then(|bin| bin.get("required-features"))
2030 .and_then(toml::Value::as_array);
2031 if let Some(required) = required {
2032 let enabled = default_features(&table);
2033 let missing = required
2034 .iter()
2035 .filter_map(toml::Value::as_str)
2036 .any(|feature| !enabled.contains(feature));
2037 if missing {
2038 return Some(
2039 "the target's first [[bin]] entry requires features a default build does not enable; no Nix file lands".to_owned(),
2040 );
2041 }
2042 }
2043 }
2044 None
2045}
2046
2047fn dep_edge_suppresses(features: &toml::Table, name: &str) -> bool {
2050 let edge = format!("dep:{name}");
2051 features.values().any(|list| {
2052 list.as_array().is_some_and(|entries| {
2053 entries
2054 .iter()
2055 .filter_map(toml::Value::as_str)
2056 .any(|entry| entry == edge)
2057 })
2058 })
2059}
2060
2061fn is_optional_dependency(table: &toml::Table, name: &str) -> bool {
2064 ["dependencies", "build-dependencies"]
2065 .iter()
2066 .any(|section| {
2067 table
2068 .get(*section)
2069 .and_then(toml::Value::as_table)
2070 .and_then(|dependencies| dependencies.get(name))
2071 .and_then(toml::Value::as_table)
2072 .and_then(|dependency| dependency.get("optional"))
2073 .and_then(toml::Value::as_bool)
2074 == Some(true)
2075 })
2076}
2077
2078fn default_features(table: &toml::Table) -> std::collections::BTreeSet<String> {
2085 let Some(features) = table.get("features").and_then(toml::Value::as_table) else {
2086 return std::collections::BTreeSet::new();
2087 };
2088 let mut enabled = std::collections::BTreeSet::new();
2089 let mut queue = vec!["default".to_owned()];
2090 while let Some(name) = queue.pop() {
2091 if !enabled.insert(name.clone()) {
2092 continue;
2093 }
2094 if let Some(implies) = features.get(&name).and_then(toml::Value::as_array) {
2095 for implied in implies.iter().filter_map(toml::Value::as_str) {
2096 if implied.starts_with("dep:") || implied.contains("?/") {
2097 continue;
2101 }
2102 if let Some((package, _)) = implied.split_once('/') {
2103 let feature_exists =
2111 features.contains_key(package) || !dep_edge_suppresses(features, package);
2112 if is_optional_dependency(table, package) && feature_exists {
2113 queue.push(package.to_owned());
2114 }
2115 } else {
2116 queue.push(implied.to_owned());
2117 }
2118 }
2119 }
2120 }
2121 enabled
2122}
2123
2124#[must_use]
2132pub fn flake_pair_withheld(
2133 flake_recorded: bool,
2134 flake_nix_present: bool,
2135 flake_lock_present: bool,
2136) -> Option<String> {
2137 if flake_recorded {
2138 return None;
2139 }
2140 let present: Vec<&str> = [
2141 ("flake.nix", flake_nix_present),
2142 ("flake.lock", flake_lock_present),
2143 ]
2144 .into_iter()
2145 .filter_map(|(name, present)| present.then_some(name))
2146 .collect();
2147 if present.is_empty() {
2148 return None;
2149 }
2150 Some(format!(
2151 "the target already carries {}; its flake pair stays its own",
2152 present.join(" and ")
2153 ))
2154}
2155
2156#[must_use]
2166pub fn nix_withholding(
2167 nix: bool,
2168 evidence: &TargetEvidence,
2169) -> Option<(&'static [&'static str], String)> {
2170 if !nix {
2171 return None;
2172 }
2173 if let Some(reason) = nix_unsupported_shape(&evidence.crate_shape) {
2174 return Some((&NIX_DESTINATIONS[..], reason));
2175 }
2176 flake_pair_withheld(
2177 evidence.flake_recorded,
2178 evidence.flake_nix_present,
2179 evidence.flake_lock_present,
2180 )
2181 .map(|reason| (&NIX_WITHHOLDABLE[..], reason))
2182}
2183
2184#[cfg(test)]
2185mod tests {
2186 use super::{
2187 AGENTS_DESTINATION, BLOCK_BEGIN, BLOCK_DESTINATIONS, BLOCK_END, Candidate, Collision,
2188 CrateShape, GLOSSARY_DESTINATION, HOOK_TYPES_LINE, HOOKS_BEGIN, HOOKS_DESTINATION,
2189 HOOKS_END, Placement, Projection, ProjectionInput, TargetEvidence, extract_block,
2190 };
2191 use crate::landing::{Params, Style};
2192
2193 fn supported_shape() -> CrateShape {
2195 CrateShape {
2196 cargo_toml: Some("[package]\nname = \"widget\"\nversion = \"0.1.0\"\n".to_owned()),
2197 cargo_lock: true,
2198 main_rs: true,
2199 }
2200 }
2201
2202 fn input(evidence: TargetEvidence) -> ProjectionInput {
2203 let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
2204 params.set_nix_for_test(true);
2205 ProjectionInput { params, evidence }
2206 }
2207
2208 fn compute(evidence: TargetEvidence) -> Projection {
2209 Projection::compute(&input(evidence)).expect("the embedded pair projects")
2210 }
2211
2212 fn candidate<'a>(projection: &'a Projection, destination: &str) -> &'a Candidate {
2213 projection
2214 .candidates
2215 .iter()
2216 .find(|candidate| candidate.destination == destination)
2217 .expect("the destination projects")
2218 }
2219
2220 fn outside(bytes: &[u8], begin: &str, end: &str) -> (Vec<u8>, Vec<u8>) {
2222 let text = String::from_utf8_lossy(bytes);
2223 let start = text.find(begin).expect("the begin marker is present");
2224 let stop = text[start..].find(end).expect("the end marker is present") + start + end.len();
2225 (bytes[..start].to_vec(), bytes[stop..].to_vec())
2226 }
2227
2228 #[test]
2229 fn equal_projection_inputs_yield_byte_identical_projections() {
2230 let mut documents = std::collections::BTreeMap::new();
2231 documents.insert(
2232 AGENTS_DESTINATION.to_owned(),
2233 b"# Widget\n\nOwn rules.\n".to_vec(),
2234 );
2235 let evidence = TargetEvidence {
2236 documents,
2237 crate_shape: supported_shape(),
2238 ..TargetEvidence::default()
2239 };
2240 let first = input(evidence.clone());
2241 let second = input(evidence);
2242 assert_eq!(first, second, "the inputs are values and compare equal");
2243 let a = Projection::compute(&first).expect("the pair projects");
2244 let b = Projection::compute(&second).expect("the pair projects");
2245 assert_eq!(a.candidates.len(), b.candidates.len());
2246 for (x, y) in a.candidates.iter().zip(&b.candidates) {
2247 assert_eq!(x.destination, y.destination);
2248 assert_eq!(x.kind, y.kind);
2249 assert_eq!(x.placement, y.placement);
2250 assert_eq!(x.bytes, y.bytes, "{}", x.destination);
2251 assert_eq!(x.region, y.region, "{}", x.destination);
2252 assert_eq!(x.sources, y.sources, "{}", x.destination);
2253 }
2254 assert_eq!(a, b);
2255 let destinations: Vec<&str> = a
2256 .candidates
2257 .iter()
2258 .map(|candidate| candidate.destination.as_str())
2259 .collect();
2260 let mut sorted = destinations.clone();
2261 sorted.sort_unstable();
2262 assert_eq!(destinations, sorted, "candidates sort by destination");
2263 assert!(a.omissions.is_empty(), "{:?}", a.omissions);
2264 assert!(a.collisions.is_empty(), "{:?}", a.collisions);
2265 }
2266
2267 #[test]
2271 fn the_scorecard_destination_projects_only_under_the_opt_in() {
2272 use super::{Kind, SCORECARD_DESTINATIONS, kind_of};
2273 let destination = SCORECARD_DESTINATIONS[0];
2274 assert_eq!(kind_of(destination), Some(Kind::Rendered));
2275
2276 let project = |scorecard: bool| {
2277 let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
2278 params.set_scorecard_for_test(scorecard);
2279 Projection::compute(&ProjectionInput {
2280 params,
2281 evidence: TargetEvidence::default(),
2282 })
2283 .expect("the embedded pair projects")
2284 };
2285
2286 let off = project(false);
2287 assert!(
2288 !off.candidates
2289 .iter()
2290 .any(|candidate| candidate.destination == destination),
2291 "off by default, and an absent candidate is no omission"
2292 );
2293 assert!(off.omissions.is_empty(), "{:?}", off.omissions);
2294
2295 let on = project(true);
2296 let candidate = candidate(&on, destination);
2297 assert_eq!(candidate.kind, Kind::Rendered);
2298 assert_eq!(candidate.placement, Placement::Whole);
2299 let text = String::from_utf8_lossy(&candidate.bytes);
2300 assert!(!text.contains("RK_"), "a token survived: {text}");
2301 }
2302
2303 #[test]
2308 fn the_licence_judgment_reads_what_every_reader_obtains() {
2309 use super::licence_states_an_open_source_codebase as approved;
2310 for expression in [
2311 "MIT",
2312 "Apache-2.0",
2313 "MIT OR Apache-2.0",
2314 "MIT AND Apache-2.0",
2315 "(MIT OR Apache-2.0) AND ISC",
2316 "Apache-2.0 WITH LLVM-exception OR MIT",
2317 "GPL-3.0+",
2318 ] {
2319 assert!(approved(expression), "{expression} is OSI-approved");
2320 }
2321 for expression in [
2326 "MIT AND CC-BY-4.0",
2327 "CC-BY-4.0 AND MIT",
2328 "MIT AND (CC-BY-4.0 OR CC-BY-SA-4.0)",
2329 "(MIT OR CC-BY-4.0) AND MIT",
2330 "MIT OR Apache-2.0 AND CC-BY-4.0",
2331 "Apache-2.0 WITH LLVM-exception AND CC-BY-SA-3.0",
2332 ] {
2333 assert!(approved(expression), "{expression} states open source");
2334 }
2335 for expression in [
2336 "",
2337 " ",
2338 "LicenseRef-proprietary",
2339 "MIT AND LicenseRef-proprietary",
2340 "SEE LICENSE IN COPYING",
2341 "CC-BY-4.0",
2342 "DocumentRef-spdx:LicenseRef-proprietary",
2343 ] {
2344 assert!(!approved(expression), "{expression} is not recognized");
2345 }
2346 for expression in [
2351 "MIT OR CC-BY-4.0",
2352 "CC-BY-4.0 AND CC-BY-SA-4.0",
2353 "CC-BY-4.0 AND LicenseRef-proprietary",
2354 "CC0-1.0",
2355 "MIT AND CC-BY-4.0 AND LicenseRef-proprietary",
2356 ] {
2357 assert!(!approved(expression), "{expression} grants no code terms");
2358 }
2359 assert!(
2364 !approved("CC-BY-4.0 OR CC-BY-SA-4.0 AND MIT"),
2365 "AND binds tighter than OR"
2366 );
2367 }
2368
2369 #[test]
2375 fn the_licence_judgment_holds_the_expression_to_the_spdx_grammar() {
2376 use super::licence_states_an_open_source_codebase as approved;
2377 for expression in [
2382 "MIT OR",
2383 "OR MIT",
2384 "MIT AND",
2385 "MIT WITH",
2386 "WITH LLVM-exception",
2387 "(MIT",
2388 "MIT)",
2389 "MIT Apache-2.0",
2390 "()",
2391 "(MIT OR Apache-2.0",
2392 "MIT OR (Apache-2.0",
2393 "MIT OR ()",
2394 "AND",
2395 "(",
2396 ")",
2397 "MIT WITH AND ISC",
2398 "MIT OR OR ISC",
2399 "MIT @ Apache-2.0",
2400 ] {
2401 assert!(!approved(expression), "{expression} is malformed");
2402 }
2403 for expression in [
2405 "MIT AND (Apache-2.0 OR ISC)",
2406 "((MIT))",
2407 "Apache-2.0 WITH LLVM-exception AND ISC",
2408 "(Apache-2.0 WITH LLVM-exception)",
2409 ] {
2410 assert!(approved(expression), "{expression} is well formed");
2411 }
2412 for expression in [
2417 "mit",
2418 "MiT",
2419 "apache-2.0 OR mit",
2420 "APACHE-2.0 WITH llvm-exception",
2421 ] {
2422 assert!(
2423 approved(expression),
2424 "{expression} names an approved licence"
2425 );
2426 }
2427 for expression in [
2431 "MIT or Apache-2.0",
2432 "MIT and Apache-2.0",
2433 "MIT with LLVM-exception",
2434 ] {
2435 assert!(!approved(expression), "{expression} carries no operator");
2436 }
2437 for expression in [
2441 "MIT WITH definitely-not-an-spdx-exception",
2442 "MIT WITH MIT",
2443 "MIT WITH Apache-2.0",
2444 ] {
2445 assert!(!approved(expression), "{expression} names no exception");
2446 }
2447 for expression in [
2451 "GPL-2.0-only WITH GCC-exception-2.0",
2452 "GPL-2.0-or-later WITH Classpath-exception-2.0",
2453 "Apache-2.0 WITH Swift-exception",
2454 "GPL-3.0-only WITH Autoconf-exception-generic",
2455 ] {
2456 assert!(approved(expression), "{expression} names a real exception");
2457 }
2458 }
2459
2460 #[test]
2466 fn a_recorded_provider_its_pair_cannot_run_is_named() {
2467 use super::{Provider, code_scanning_incompatibility};
2468
2469 assert!(code_scanning_incompatibility(None, Some("bash"), Some("gitlab")).is_none());
2470 assert!(
2471 code_scanning_incompatibility(Some(Provider::Semgrep), Some("rust"), Some("gitlab"))
2472 .is_none()
2473 );
2474 assert!(
2475 code_scanning_incompatibility(Some(Provider::CodeQl), Some("rust"), Some("github"))
2476 .is_none()
2477 );
2478
2479 let bash =
2480 code_scanning_incompatibility(Some(Provider::Semgrep), Some("bash"), Some("github"))
2481 .expect("a binding with no scanner is named");
2482 assert!(bash.contains("bash binding"), "{bash}");
2483 let gitlab =
2484 code_scanning_incompatibility(Some(Provider::CodeQl), Some("rust"), Some("gitlab"))
2485 .expect("codeql on gitlab is named");
2486 assert!(gitlab.contains("codeql"), "{gitlab}");
2487 let release_less =
2488 code_scanning_incompatibility(Some(Provider::Semgrep), None, Some("github"))
2489 .expect("no driver is named");
2490 assert!(
2491 release_less.contains("no automatic release driver"),
2492 "{release_less}"
2493 );
2494
2495 let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
2498 params.set_code_scanning_for_test(Some(Provider::Semgrep));
2499 let clean = Projection::compute(&ProjectionInput {
2500 params: params.clone(),
2501 evidence: TargetEvidence::default(),
2502 })
2503 .expect("the pair projects");
2504 assert!(
2505 clean.record_defects.is_empty(),
2506 "{:?}",
2507 clean.record_defects
2508 );
2509 }
2510
2511 #[test]
2515 fn the_code_scanning_destinations_follow_the_recorded_provider() {
2516 use super::{
2517 CODE_SCANNING_DESTINATIONS, Kind, Provider, code_scanning_licence_refusal, kind_of,
2518 };
2519 for (destination, _) in CODE_SCANNING_DESTINATIONS {
2520 assert_eq!(kind_of(destination), Some(Kind::Rendered), "{destination}");
2521 }
2522
2523 let project = |provider: Option<Provider>| {
2524 let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
2525 params.set_code_scanning_for_test(provider);
2526 Projection::compute(&ProjectionInput {
2527 params,
2528 evidence: TargetEvidence {
2529 crate_shape: CrateShape {
2530 cargo_toml: Some(
2531 "[package]\nname = \"widget\"\nlicense = \"MIT\"\n".to_owned(),
2532 ),
2533 ..CrateShape::default()
2534 },
2535 ..TargetEvidence::default()
2536 },
2537 })
2538 .expect("the embedded pair projects")
2539 };
2540 let landed = |projection: &Projection, destination: &str| {
2541 projection
2542 .candidates
2543 .iter()
2544 .any(|candidate| candidate.destination == destination)
2545 };
2546
2547 let off = project(None);
2548 for (destination, _) in CODE_SCANNING_DESTINATIONS {
2549 assert!(!landed(&off, destination), "{destination}");
2550 }
2551 assert!(off.licence_refusal.is_none());
2552
2553 let codeql = project(Some(Provider::CodeQl));
2554 assert!(landed(
2555 &codeql,
2556 ".github/workflows/code-scanning-codeql.yml"
2557 ));
2558 assert!(!landed(
2559 &codeql,
2560 ".github/workflows/code-scanning-semgrep.yml"
2561 ));
2562 assert!(codeql.licence_refusal.is_none(), "MIT satisfies the terms");
2563
2564 let semgrep = project(Some(Provider::Semgrep));
2565 assert!(landed(
2566 &semgrep,
2567 ".github/workflows/code-scanning-semgrep.yml"
2568 ));
2569 assert!(!landed(
2570 &semgrep,
2571 ".github/workflows/code-scanning-codeql.yml"
2572 ));
2573
2574 let proprietary = CrateShape {
2576 cargo_toml: Some("[package]\nlicense = \"LicenseRef-proprietary\"\n".to_owned()),
2577 ..CrateShape::default()
2578 };
2579 assert!(
2580 code_scanning_licence_refusal(Some(Provider::Semgrep), Some("rust"), &proprietary)
2581 .is_none()
2582 );
2583 let refusal =
2584 code_scanning_licence_refusal(Some(Provider::CodeQl), Some("rust"), &proprietary)
2585 .expect("codeql refuses a licence its terms do not cover");
2586 assert!(refusal.contains("LicenseRef-proprietary"), "{refusal}");
2587 assert!(refusal.contains("semgrep"), "{refusal}");
2588 let bash =
2591 code_scanning_licence_refusal(Some(Provider::CodeQl), Some("bash"), &proprietary)
2592 .expect("an unread binding refuses");
2593 assert!(bash.contains("bash binding"), "{bash}");
2594 }
2595
2596 #[test]
2602 fn the_projection_performs_no_filesystem_git_environment_clock_registry_or_network_read() {
2603 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/projection.rs");
2604 let text = std::fs::read_to_string(&path).expect("the source reads");
2605 let production = text.split("#[cfg(test)]").next().unwrap_or("");
2606 let needles = [
2607 "std::fs",
2608 "std::env",
2609 "std::process",
2610 "std::time",
2611 "SystemTime",
2612 "Instant",
2613 "std::net",
2614 "Command::new",
2615 "registry::",
2616 "curl",
2617 "reqwest",
2618 "blob(",
2619 ];
2620 let mut hits = Vec::new();
2621 for (index, line) in production.lines().enumerate() {
2622 if line.trim_start().starts_with("//") {
2623 continue;
2624 }
2625 for needle in needles {
2626 if line.contains(needle) {
2627 hits.push(format!("src/projection.rs:{}: {needle}", index + 1));
2628 }
2629 }
2630 }
2631 assert!(
2632 hits.is_empty(),
2633 "the projection reads beyond its inputs: {hits:?}"
2634 );
2635 }
2636
2637 #[test]
2638 #[allow(
2639 clippy::too_many_lines,
2640 reason = "one test walks the three marked destinations and the three unmarked shapes"
2641 )]
2642 fn marked_region_projection_preserves_every_target_byte_outside_the_markers() {
2643 let agents_before = "# Widget\n\nOperator prose above.\n\n";
2644 let agents_after = "\n\n## Our rules\n\nOperator prose below. \n";
2645 let glossary_before = "# Glossary\n\n- `spike` is a throwaway branch.\n\n";
2646 let glossary_after = "\n\n## More terms\n\n- `own` is ours.";
2647 let hooks_before = "default_install_hook_types: [pre-commit]\n\nrepos:\n";
2648 let hooks_after =
2649 "\n - repo: https://example.com/own\n rev: v1\n hooks:\n - id: own\n";
2650 let stale = |begin: &str, end: &str| format!("{begin}\nstale block\n{end}");
2651 let mut documents = std::collections::BTreeMap::new();
2652 documents.insert(
2653 AGENTS_DESTINATION.to_owned(),
2654 format!(
2655 "{agents_before}{}{agents_after}",
2656 stale(BLOCK_BEGIN, BLOCK_END)
2657 )
2658 .into_bytes(),
2659 );
2660 documents.insert(
2661 GLOSSARY_DESTINATION.to_owned(),
2662 format!(
2663 "{glossary_before}{}{glossary_after}",
2664 stale(BLOCK_BEGIN, BLOCK_END)
2665 )
2666 .into_bytes(),
2667 );
2668 documents.insert(
2669 HOOKS_DESTINATION.to_owned(),
2670 format!(
2671 "{hooks_before}{}{hooks_after}",
2672 stale(HOOKS_BEGIN, HOOKS_END)
2673 )
2674 .into_bytes(),
2675 );
2676 let projection = compute(TargetEvidence {
2677 documents: documents.clone(),
2678 crate_shape: supported_shape(),
2679 ..TargetEvidence::default()
2680 });
2681 assert!(
2682 projection.collisions.is_empty(),
2683 "{:?}",
2684 projection.collisions
2685 );
2686 for (destination, before, after) in [
2687 (AGENTS_DESTINATION, agents_before, agents_after),
2688 (GLOSSARY_DESTINATION, glossary_before, glossary_after),
2689 (HOOKS_DESTINATION, hooks_before, hooks_after),
2690 ] {
2691 let candidate = candidate(&projection, destination);
2692 let Placement::Region { begin, end } = candidate.placement else {
2693 panic!("{destination} is a region");
2694 };
2695 let region = candidate
2696 .region
2697 .as_deref()
2698 .expect("a region carries its block");
2699 let (head, tail) = outside(&candidate.bytes, begin, end);
2700 assert_eq!(
2701 head,
2702 before.as_bytes(),
2703 "{destination}: bytes before the markers"
2704 );
2705 assert_eq!(
2706 tail,
2707 after.as_bytes(),
2708 "{destination}: bytes after the markers"
2709 );
2710 let inside = &candidate.bytes[head.len()..candidate.bytes.len() - tail.len()];
2711 assert_eq!(
2712 inside, region,
2713 "{destination}: the region is the rendered block"
2714 );
2715 let (existing_head, existing_tail) = outside(&documents[destination], begin, end);
2716 assert_eq!(head, existing_head);
2717 assert_eq!(tail, existing_tail);
2718 }
2719
2720 let own_hooks =
2724 "repos:\n - repo: https://example.com/own\n rev: v1\n hooks:\n - id: own\n";
2725 let own_agents = "# Widget\n\nOwn rules.";
2726 let mut documents = std::collections::BTreeMap::new();
2727 documents.insert(HOOKS_DESTINATION.to_owned(), own_hooks.as_bytes().to_vec());
2728 documents.insert(
2729 AGENTS_DESTINATION.to_owned(),
2730 own_agents.as_bytes().to_vec(),
2731 );
2732 let projection = compute(TargetEvidence {
2733 documents,
2734 crate_shape: supported_shape(),
2735 ..TargetEvidence::default()
2736 });
2737 assert!(
2738 projection.collisions.is_empty(),
2739 "{:?}",
2740 projection.collisions
2741 );
2742 let hooks = candidate(&projection, HOOKS_DESTINATION);
2743 let hooks_text = String::from_utf8_lossy(&hooks.bytes);
2744 let region = String::from_utf8_lossy(hooks.region.as_deref().expect("a region"));
2745 assert!(
2746 hooks_text.starts_with(&format!(
2747 "repos:\n{region}\n - repo: https://example.com/own"
2748 )),
2749 "{hooks_text}"
2750 );
2751 assert!(!hooks_text.contains(HOOK_TYPES_LINE));
2752 let agents = candidate(&projection, AGENTS_DESTINATION);
2753 assert!(agents.bytes.starts_with(own_agents.as_bytes()));
2754 assert_eq!(
2755 extract_block(
2756 &String::from_utf8_lossy(&agents.bytes),
2757 BLOCK_BEGIN,
2758 BLOCK_END
2759 )
2760 .map(str::as_bytes),
2761 agents.region.as_deref()
2762 );
2763 let glossary = candidate(&projection, GLOSSARY_DESTINATION);
2764 let region = glossary.region.as_deref().expect("a region");
2765 assert_eq!(
2766 glossary.bytes,
2767 [region, b"\n"].concat(),
2768 "an absent file is fresh"
2769 );
2770 }
2771
2772 #[test]
2778 fn a_hook_document_that_is_not_utf8_collides_instead_of_being_rewritten() {
2779 let mut documents = std::collections::BTreeMap::new();
2780 let mut invalid = b"repos:\n# own \xff above\n".to_vec();
2781 invalid.extend_from_slice(format!("{HOOKS_BEGIN}\nstale\n{HOOKS_END}\n").as_bytes());
2782 invalid.extend_from_slice(b" - repo: local \xff below\n");
2783 documents.insert(HOOKS_DESTINATION.to_owned(), invalid);
2784 let mut agents = b"# Widget r\xe9sum\xe9\n\n".to_vec();
2785 agents.extend_from_slice(format!("{BLOCK_BEGIN}\nstale\n{BLOCK_END}\n\n").as_bytes());
2786 agents.extend_from_slice(b"r\xe9sum\xe9\n");
2787 documents.insert(AGENTS_DESTINATION.to_owned(), agents.clone());
2788 let projection = compute(TargetEvidence {
2789 documents,
2790 crate_shape: supported_shape(),
2791 ..TargetEvidence::default()
2792 });
2793 let collided: Vec<&str> = projection
2794 .collisions
2795 .iter()
2796 .map(|c| c.destination.as_str())
2797 .collect();
2798 assert_eq!(collided, [HOOKS_DESTINATION]);
2799 assert!(
2800 projection.collisions[0].reason.contains("not UTF-8"),
2801 "{}",
2802 projection.collisions[0].reason
2803 );
2804 assert!(
2805 !projection
2806 .candidates
2807 .iter()
2808 .any(|c| c.destination == HOOKS_DESTINATION),
2809 "a colliding destination projects no candidate"
2810 );
2811 let agents = candidate(&projection, AGENTS_DESTINATION);
2812 assert!(
2813 agents.bytes.starts_with(b"# Widget r\xe9sum\xe9\n\n"),
2814 "{:?}",
2815 agents.bytes
2816 );
2817 assert!(
2818 agents.bytes.ends_with(b"\n\nr\xe9sum\xe9\n"),
2819 "{:?}",
2820 agents.bytes
2821 );
2822 assert!(
2823 !agents.bytes.contains(&0xEF),
2824 "a replacement character landed"
2825 );
2826
2827 let mut documents = std::collections::BTreeMap::new();
2828 let valid =
2829 format!("repos:\n# own above\n{HOOKS_BEGIN}\nstale\n{HOOKS_END}\n - repo: local\n");
2830 documents.insert(HOOKS_DESTINATION.to_owned(), valid.into_bytes());
2831 let projection = compute(TargetEvidence {
2832 documents,
2833 crate_shape: supported_shape(),
2834 ..TargetEvidence::default()
2835 });
2836 assert!(
2837 projection.collisions.is_empty(),
2838 "{:?}",
2839 projection.collisions
2840 );
2841 let hooks = candidate(&projection, HOOKS_DESTINATION);
2842 let text = String::from_utf8(hooks.bytes.clone()).expect("a valid document stays text");
2843 assert!(text.starts_with("repos:\n# own above\n"), "{text}");
2844 assert!(text.ends_with("\n - repo: local\n"), "{text}");
2845 assert!(!text.contains("stale"), "the region is replaced: {text}");
2846 }
2847
2848 #[test]
2852 fn a_whole_file_colliding_with_a_marked_region_names_both_source_paths() {
2853 let files: Vec<(String, &[u8])> = vec![
2854 ("snippets/_shared/github/SECURITY.md".to_owned(), b"policy"),
2855 ("snippets/rust/github/AGENTS.md".to_owned(), b"whole"),
2856 ];
2857 let err = Projection::compute_over(&files, &input(TargetEvidence::default()))
2858 .expect_err("a whole file at a block destination refuses");
2859 let text = err.to_string();
2860 assert!(text.contains("snippets/rust/github/AGENTS.md"), "{text}");
2861 assert!(text.contains(super::AGENTS_BLOCK), "{text}");
2862 assert!(text.contains(super::AGENTS_LINE_WORKTREE), "{text}");
2863 assert!(text.contains("embedded sources are defective"), "{text}");
2864 }
2865
2866 #[test]
2867 fn duplicate_whole_file_destinations_and_overlapping_marked_regions_refuse_with_the_conflicting_source_names()
2868 {
2869 let files: Vec<(String, &[u8])> = vec![
2872 ("snippets/_shared/github/SECURITY.md".to_owned(), b"shared"),
2873 ("snippets/rust/github/SECURITY.md".to_owned(), b"pair"),
2874 ("snippets/rust/github/release-plz.toml".to_owned(), b"seed"),
2875 ];
2876 let err = Projection::compute_over(&files, &input(TargetEvidence::default()))
2877 .expect_err("a doubled destination refuses");
2878 let text = err.to_string();
2879 assert!(
2880 text.contains("snippets/_shared/github/SECURITY.md"),
2881 "{text}"
2882 );
2883 assert!(text.contains("snippets/rust/github/SECURITY.md"), "{text}");
2884 assert!(text.contains("embedded sources are defective"), "{text}");
2885
2886 let clean: Vec<(String, &[u8])> = vec![
2887 ("snippets/_shared/github/SECURITY.md".to_owned(), b"shared"),
2888 ("snippets/rust/github/release-plz.toml".to_owned(), b"seed"),
2889 ];
2890 let projection = Projection::compute_over(&clean, &input(TargetEvidence::default()))
2891 .expect("a clean list projects");
2892 let whole: Vec<&str> = projection
2893 .candidates
2894 .iter()
2895 .filter(|c| c.placement == Placement::Whole)
2896 .map(|c| c.destination.as_str())
2897 .collect();
2898 assert_eq!(whole, ["SECURITY.md", "release-plz.toml"]);
2899 assert_eq!(
2900 projection
2901 .candidates
2902 .iter()
2903 .find(|c| c.destination == "SECURITY.md")
2904 .map(|c| c.sources.clone()),
2905 Some(vec!["snippets/_shared/github/SECURITY.md".to_owned()])
2906 );
2907
2908 let doubled = format!("{BLOCK_BEGIN}\na\n{BLOCK_END}\n{BLOCK_BEGIN}\nb\n{BLOCK_END}\n");
2909 let unmatched = format!("repos:\n{HOOKS_BEGIN}\n - repo: local\n");
2910 let misordered = format!("# G\n{BLOCK_END}\n{BLOCK_BEGIN}\n");
2911 let mut documents = std::collections::BTreeMap::new();
2912 documents.insert(AGENTS_DESTINATION.to_owned(), doubled.into_bytes());
2913 documents.insert(HOOKS_DESTINATION.to_owned(), unmatched.into_bytes());
2914 documents.insert(GLOSSARY_DESTINATION.to_owned(), misordered.into_bytes());
2915 let projection = compute(TargetEvidence {
2916 documents,
2917 crate_shape: supported_shape(),
2918 ..TargetEvidence::default()
2919 });
2920 let mut collided: Vec<&str> = projection
2921 .collisions
2922 .iter()
2923 .map(|Collision { destination, .. }| destination.as_str())
2924 .collect();
2925 collided.sort_unstable();
2926 let mut expected = BLOCK_DESTINATIONS.to_vec();
2927 expected.sort_unstable();
2928 assert_eq!(collided, expected);
2929 for collision in &projection.collisions {
2930 assert!(
2931 collision.reason.contains(&collision.destination),
2932 "{collision:?}"
2933 );
2934 assert!(
2935 !projection
2936 .candidates
2937 .iter()
2938 .any(|candidate| candidate.destination == collision.destination),
2939 "{} collided and still projects",
2940 collision.destination
2941 );
2942 }
2943 let agents = projection
2944 .collisions
2945 .iter()
2946 .find(|c| c.destination == AGENTS_DESTINATION)
2947 .expect("the doubled document collides");
2948 assert!(agents.reason.contains("more than one"), "{}", agents.reason);
2949 let hooks = projection
2950 .collisions
2951 .iter()
2952 .find(|c| c.destination == HOOKS_DESTINATION)
2953 .expect("the unmatched document collides");
2954 assert!(hooks.reason.contains("unmatched"), "{}", hooks.reason);
2955 }
2956}