1pub mod evidence;
32
33use std::collections::BTreeMap;
34use std::fmt::Write as _;
35
36use serde::{Deserialize, Serialize};
37
38use crate::embedded;
39use crate::error::RkError;
40pub use crate::landing::manifest::Provider;
41use crate::landing::{CheckoutMode, Integration, MergeForm, Params};
42use crate::profile::ReleaseMode;
43use crate::profile::catalog::{self, Availability, Selection, Status};
44
45#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct ProjectionInput {
49 pub params: Params,
51 pub evidence: TargetEvidence,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Default)]
58#[allow(
59 clippy::struct_excessive_bools,
60 reason = "each field is one independent fact read off the target, not a state a smaller type could carry"
61)]
62pub struct TargetEvidence {
63 pub documents: BTreeMap<String, Vec<u8>>,
67 pub crate_shape: CrateShape,
69 pub flake_nix_present: bool,
71 pub flake_lock_present: bool,
73 pub flake_recorded: bool,
76 pub root_pipeline_present: bool,
78 pub root_pipeline_recorded: bool,
81}
82
83impl TargetEvidence {
84 #[must_use]
86 pub fn document(&self, destination: &str) -> Option<&[u8]> {
87 self.documents.get(destination).map(Vec::as_slice)
88 }
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, Default)]
94pub struct CrateShape {
95 pub cargo_toml: Option<String>,
97 pub cargo_lock: bool,
99 pub main_rs: bool,
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct Projection {
110 pub capabilities: Vec<Selection>,
113 pub candidates: Vec<Candidate>,
115 pub omissions: Vec<Omission>,
120 pub collisions: Vec<Collision>,
123 pub licence_refusal: Option<String>,
129 pub record_defects: Vec<String>,
134}
135
136#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct Candidate {
139 pub capability: &'static str,
141 pub destination: String,
143 pub kind: Kind,
145 pub placement: Placement,
147 pub bytes: Vec<u8>,
151 pub region: Option<Vec<u8>>,
154 pub sources: Vec<String>,
157}
158
159#[derive(Debug, Clone, Copy, PartialEq, Eq)]
161pub enum Placement {
162 Whole,
164 Region {
167 begin: &'static str,
169 end: &'static str,
171 },
172}
173
174#[derive(Debug, Clone, PartialEq, Eq)]
176pub struct Omission {
177 pub destination: String,
179 pub reason: String,
181 pub action: Option<String>,
185}
186
187#[derive(Debug, Clone, PartialEq, Eq)]
189pub struct Collision {
190 pub destination: String,
192 pub reason: String,
194}
195
196impl Projection {
197 pub fn compute(input: &ProjectionInput) -> Result<Self, RkError> {
207 Self::compute_over(&embedded_snippets(), input)
208 }
209
210 #[allow(
214 clippy::too_many_lines,
215 reason = "one pass selects, renders, splices, and withholds; splitting it would separate a withheld destination from the selection that offered it"
216 )]
217 fn compute_over(files: &[(String, &[u8])], input: &ProjectionInput) -> Result<Self, RkError> {
218 let params = &input.params;
219 let evidence = &input.evidence;
220 let availability = Availability::over(
221 files
222 .iter()
223 .filter_map(|(path, _)| path.strip_prefix("snippets/").map(str::to_owned))
224 .collect(),
225 );
226 let mut capabilities = catalog::select(params, &availability);
227 let mut candidates: Vec<Candidate> = Vec::new();
228 for selection in &capabilities {
229 if !selection.lands() {
230 continue;
231 }
232 for source in &selection.sources {
233 let destination = source
234 .splitn(3, '/')
235 .nth(2)
236 .ok_or_else(|| {
237 anyhow::anyhow!(
238 "{source}: a snippet path has a zone, a forge, and a destination"
239 )
240 })?
241 .to_owned();
242 let path = format!("snippets/{source}");
243 let bytes = files
244 .iter()
245 .find(|(candidate, _)| *candidate == path)
246 .map(|(_, bytes)| *bytes)
247 .ok_or_else(|| {
248 anyhow::anyhow!(
249 "{path}: the catalog selected a source the tree does not carry"
250 )
251 })?;
252 if let Some(existing) = candidates
253 .iter()
254 .find(|candidate| candidate.destination == destination)
255 {
256 return Err(anyhow::anyhow!(
257 "{} and {} both ship {destination}: {} and {path}; the embedded sources are defective",
258 existing.capability,
259 selection.id,
260 existing.sources.join(", ")
261 )
262 .into());
263 }
264 let kind = kind_of(&destination).ok_or_else(|| {
265 anyhow::anyhow!(
266 "the embedded sources do not classify {destination}; the kind table is stale"
267 )
268 })?;
269 let rendered = match kind {
270 Kind::Rendered => render(bytes, params),
271 Kind::Seeded | Kind::State => bytes.to_vec(),
272 };
273 candidates.push(Candidate {
274 capability: selection.id,
275 destination,
276 kind,
277 placement: Placement::Whole,
278 bytes: rendered,
279 region: None,
280 sources: vec![path],
281 });
282 }
283 }
284 let mut collisions = Vec::new();
285 for destination in BLOCK_DESTINATIONS {
286 let Some((template, sources, capability)) = block_template(destination, params)? else {
287 continue;
288 };
289 if let Some(whole) = candidates
290 .iter()
291 .find(|candidate| candidate.destination == destination)
292 {
293 return Err(anyhow::anyhow!(
294 "{destination} is both a whole file from {} and a marked region from {}; the embedded sources are defective",
295 whole.sources.join(", "),
296 sources.join(", ")
297 )
298 .into());
299 }
300 let region = render(template.as_bytes(), params);
301 let (begin, end) = block_markers(destination).ok_or_else(|| {
302 anyhow::anyhow!("{destination} is a block destination with no markers")
303 })?;
304 match propose_document(destination, evidence.document(destination), ®ion) {
305 Ok(bytes) => candidates.push(Candidate {
306 capability,
307 destination: destination.to_owned(),
308 kind: Kind::Rendered,
309 placement: Placement::Region { begin, end },
310 bytes,
311 region: Some(region),
312 sources,
313 }),
314 Err(reason) => collisions.push(Collision {
315 destination: destination.to_owned(),
316 reason,
317 }),
318 }
319 }
320 let mut omissions = Vec::new();
321 if let Some((set, reason)) = nix_withholding(params.nix_packaging(), evidence)
322 && candidates
323 .iter()
324 .any(|candidate| candidate.capability == catalog::PACKAGING_NIX)
325 {
326 candidates.retain(|candidate| {
327 if set.contains(&candidate.destination.as_str()) {
328 omissions.push(Omission {
329 destination: candidate.destination.clone(),
330 reason: reason.clone(),
331 action: None,
332 });
333 false
334 } else {
335 true
336 }
337 });
338 withhold(&mut capabilities, catalog::PACKAGING_NIX, &reason, None);
339 }
340 let gate_root = candidates.iter().position(|candidate| {
346 (candidate.capability == catalog::TITLE_CHECK
347 || candidate.capability == catalog::COMMIT_CHECK)
348 && candidate.destination == GITLAB_ROOT_PIPELINE
349 });
350 if let Some(index) = gate_root
351 && evidence.root_pipeline_present
352 && !evidence.root_pipeline_recorded
353 {
354 let candidate = candidates.remove(index);
355 let (gate, fragment) = if candidate.capability == catalog::COMMIT_CHECK {
356 ("commit gate", GITLAB_COMMIT_FRAGMENT)
357 } else {
358 ("title gate", GITLAB_TITLE_FRAGMENT)
359 };
360 let reason = format!(
361 "the target owns {GITLAB_ROOT_PIPELINE}, so the release-less root pipeline that would activate the {gate} stays out; the fragment lands as the gate's prerequisite"
362 );
363 let action = format!(
364 "add the include to the target's own {GITLAB_ROOT_PIPELINE}: include:\n - local: {fragment}"
365 );
366 omissions.push(Omission {
367 destination: candidate.destination,
368 reason: reason.clone(),
369 action: Some(action.clone()),
370 });
371 withhold(
372 &mut capabilities,
373 candidate.capability,
374 &reason,
375 Some(action),
376 );
377 }
378 candidates.sort_by(|a, b| a.destination.cmp(&b.destination));
379 omissions.sort_by(|a, b| a.destination.cmp(&b.destination));
380 let scanning_selected = capabilities.iter().any(|selection| {
386 selection.id == catalog::CODE_SCANNING && selection.status == Status::Selected
387 });
388 let licence_refusal = scanning_selected
389 .then(|| {
390 code_scanning_licence_refusal(
391 params.code_scanning(),
392 params.driver(),
393 &evidence.crate_shape,
394 )
395 })
396 .flatten();
397 let mut record_defects: Vec<String> = Vec::new();
402 if params.release_mode() == ReleaseMode::Automatic
406 && let Some(selection) = capabilities
407 .iter()
408 .find(|selection| selection.id == catalog::RELEASE_AUTOMATION)
409 && matches!(selection.status, Status::Unavailable | Status::Unknown)
410 && let Some(reason) = &selection.reason
411 {
412 record_defects.push(reason.clone());
413 }
414 Ok(Self {
415 capabilities,
416 candidates,
417 omissions,
418 collisions,
419 licence_refusal,
420 record_defects,
421 })
422 }
423
424 #[must_use]
428 pub fn release_unavailable(&self) -> Option<&str> {
429 self.capabilities
430 .iter()
431 .find(|selection| {
432 selection.id == catalog::RELEASE_AUTOMATION
433 && matches!(selection.status, Status::Unavailable | Status::Unknown)
434 })
435 .and_then(|selection| selection.reason.as_deref())
436 }
437
438 #[must_use]
440 pub fn capability(&self, id: &str) -> Option<&Selection> {
441 self.capabilities
442 .iter()
443 .find(|selection| selection.id == id)
444 }
445}
446
447fn withhold(capabilities: &mut [Selection], id: &str, reason: &str, action: Option<String>) {
449 if let Some(selection) = capabilities.iter_mut().find(|selection| selection.id == id) {
450 selection.status = Status::Withheld;
451 selection.reason = Some(reason.to_owned());
452 selection.action = action;
453 }
454}
455
456pub const GITLAB_ROOT_PIPELINE: &str = ".gitlab-ci.yml";
459
460pub const GITLAB_TITLE_FRAGMENT: &str = ".gitlab/ci/mr-title.yml";
462
463pub const GITLAB_COMMIT_FRAGMENT: &str = ".gitlab/ci/mr-commits.yml";
465
466fn embedded_snippets() -> Vec<(String, &'static [u8])> {
469 embedded::walk(&embedded::SNIPPETS)
470 .into_iter()
471 .map(|(path, bytes)| (format!("snippets/{path}"), bytes))
472 .collect()
473}
474
475#[must_use]
478pub fn supported_pairs() -> Vec<(String, String)> {
479 Availability::embedded()
480 .automation_tuples()
481 .into_iter()
482 .filter_map(|entry| {
483 entry
484 .split_once(", ")
485 .map(|(driver, forge)| (driver.to_owned(), forge.to_owned()))
486 })
487 .collect()
488}
489
490#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
492#[serde(rename_all = "lowercase")]
493pub enum Kind {
494 Rendered,
497 Seeded,
500 State,
503}
504
505impl Kind {
506 #[must_use]
508 pub const fn as_str(self) -> &'static str {
509 match self {
510 Self::Rendered => "rendered",
511 Self::Seeded => "seeded",
512 Self::State => "state",
513 }
514 }
515}
516
517const KINDS: [(&str, Kind); 22] = [
523 (".github/workflows/release-plz.yml", Kind::Rendered),
524 (".github/workflows/release-please.yml", Kind::Rendered),
525 (".github/workflows/release.yml", Kind::Rendered),
526 (".github/workflows/pr-title.yml", Kind::Rendered),
527 (".github/workflows/pr-commits.yml", Kind::Rendered),
528 (".github/workflows/scorecard.yml", Kind::Rendered),
529 (".github/workflows/code-scanning-codeql.yml", Kind::Rendered),
530 (
531 ".github/workflows/code-scanning-semgrep.yml",
532 Kind::Rendered,
533 ),
534 (".gitlab/ci/code-scanning-semgrep.yml", Kind::Rendered),
535 (".gitlab-ci.yml", Kind::Rendered),
536 ("SECURITY.md", Kind::Rendered),
537 (".gitlab/ci/mr-title.yml", Kind::Rendered),
538 (".gitlab/ci/mr-commits.yml", Kind::Rendered),
539 ("release-plz.toml", Kind::Seeded),
540 ("dist-workspace.toml", Kind::Seeded),
541 ("release-please-config.json", Kind::Seeded),
542 ("cliff.toml", Kind::Seeded),
543 ("nix/package.nix", Kind::Seeded),
544 ("flake.nix", Kind::Seeded),
545 (".release-please-manifest.json", Kind::State),
546 ("VERSION", Kind::State),
547 ("flake.lock", Kind::State),
548];
549
550pub const NIX_DESTINATIONS: [&str; 3] = ["nix/package.nix", "flake.nix", "flake.lock"];
564
565pub const NIX_WITHHOLDABLE: [&str; 2] = ["flake.nix", "flake.lock"];
571
572pub const SCORECARD_DESTINATIONS: [&str; 1] = [".github/workflows/scorecard.yml"];
587
588pub const CODE_SCANNING_DESTINATIONS: [(&str, Provider); 3] = [
602 (
603 ".github/workflows/code-scanning-codeql.yml",
604 Provider::CodeQl,
605 ),
606 (
607 ".github/workflows/code-scanning-semgrep.yml",
608 Provider::Semgrep,
609 ),
610 (".gitlab/ci/code-scanning-semgrep.yml", Provider::Semgrep),
611];
612
613pub const CODE_SCANNING_TECHS: [&str; 1] = ["rust"];
621
622#[must_use]
639pub fn code_scanning_incompatibility(
640 provider: Option<Provider>,
641 driver: Option<&str>,
642 forge: Option<&str>,
643) -> Option<String> {
644 let named = provider?;
645 let Some(driver) = driver else {
646 return Some(format!(
647 "a scanner reads the release driver's language, and the profile names no automatic release driver; the bindings that carry one are: {}",
648 CODE_SCANNING_TECHS.join(", ")
649 ));
650 };
651 if !CODE_SCANNING_TECHS.contains(&driver) {
652 return Some(format!(
653 "the {driver} binding ships no code scanning workflow; a scanner reads one language, and the bindings that carry one are: {}",
654 CODE_SCANNING_TECHS.join(", ")
655 ));
656 }
657 let Some(forge) = forge else {
658 return Some(
659 "a code scanning workflow runs at a forge, and the profile names none".to_owned(),
660 );
661 };
662 if named == Provider::CodeQl && forge != "github" {
663 return Some(format!(
664 "codeql is GitHub's own analyzer and the {forge} pair ships no workflow for it; pass --code-scanning semgrep"
665 ));
666 }
667 None
668}
669
670const OSI_APPROVED: [&str; 18] = [
680 "0bsd",
681 "agpl-3.0",
682 "agpl-3.0-only",
683 "agpl-3.0-or-later",
684 "apache-2.0",
685 "bsd-2-clause",
686 "bsd-3-clause",
687 "bsl-1.0",
688 "epl-2.0",
689 "gpl-2.0",
690 "gpl-2.0-only",
691 "gpl-2.0-or-later",
692 "gpl-3.0",
693 "gpl-3.0-only",
694 "gpl-3.0-or-later",
695 "isc",
696 "mit",
697 "mpl-2.0",
698];
699
700const CONTENT_LICENCES: [&str; 4] = ["cc-by-3.0", "cc-by-4.0", "cc-by-sa-3.0", "cc-by-sa-4.0"];
718
719const SPDX_EXCEPTIONS: [&str; 86] = [
736 "389-exception",
737 "asterisk-exception",
738 "asterisk-linking-protocols-exception",
739 "autoconf-exception-2.0",
740 "autoconf-exception-3.0",
741 "autoconf-exception-generic",
742 "autoconf-exception-generic-3.0",
743 "autoconf-exception-macro",
744 "bison-exception-1.24",
745 "bison-exception-2.2",
746 "bootloader-exception",
747 "cgal-linking-exception",
748 "classpath-exception-2.0",
749 "classpath-exception-2.0-short",
750 "clisp-exception-2.0",
751 "cryptsetup-openssl-exception",
752 "digia-qt-lgpl-exception-1.1",
753 "digirule-foss-exception",
754 "ecos-exception-2.0",
755 "erlang-otp-linking-exception",
756 "fawkes-runtime-exception",
757 "fltk-exception",
758 "fmt-exception",
759 "font-exception-2.0",
760 "freertos-exception-2.0",
761 "gcc-exception-2.0",
762 "gcc-exception-2.0-note",
763 "gcc-exception-3.1",
764 "gmsh-exception",
765 "gnat-exception",
766 "gnome-examples-exception",
767 "gnu-compiler-exception",
768 "gnu-javamail-exception",
769 "google-patent-webm",
770 "gpl-3.0-389-ds-base-exception",
771 "gpl-3.0-interface-exception",
772 "gpl-3.0-linking-exception",
773 "gpl-3.0-linking-source-exception",
774 "gpl-cc-1.0",
775 "gstreamer-exception-2005",
776 "gstreamer-exception-2008",
777 "harbour-exception",
778 "i2p-gpl-java-exception",
779 "independent-modules-exception",
780 "kicad-libraries-exception",
781 "kvirc-openssl-exception",
782 "lgpl-3.0-linking-exception",
783 "libpri-openh323-exception",
784 "libtool-exception",
785 "linux-syscall-note",
786 "llgpl",
787 "llvm-exception",
788 "lzma-exception",
789 "mif-exception",
790 "mxml-exception",
791 "nokia-qt-exception-1.1",
792 "ocaml-lgpl-linking-exception",
793 "occt-exception-1.0",
794 "openjdk-assembly-exception-1.0",
795 "openvpn-openssl-exception",
796 "pcre2-exception",
797 "polyparse-exception",
798 "ps-or-pdf-font-exception-20170817",
799 "qpl-1.0-inria-2004-exception",
800 "qt-gpl-exception-1.0",
801 "qt-lgpl-exception-1.1",
802 "qwt-exception-1.0",
803 "romic-exception",
804 "rrdtool-floss-exception-2.0",
805 "rsync-linking-exception",
806 "sane-exception",
807 "shl-2.0",
808 "shl-2.1",
809 "simple-library-usage-exception",
810 "spelling-provider-lgpl-exception",
811 "sqlitestudio-openssl-exception",
812 "stunnel-exception",
813 "swi-exception",
814 "swift-exception",
815 "texinfo-exception",
816 "u-boot-exception-2.0",
817 "ubdl-exception",
818 "universal-foss-exception-1.0",
819 "vsftpd-openssl-exception",
820 "wxwindows-exception-3.1",
821 "x11vnc-openssl-exception",
822];
823
824fn listed(list: &[&str], identifier: &str) -> bool {
831 let lowered = identifier.to_ascii_lowercase();
832 list.contains(&lowered.as_str())
833}
834
835#[derive(Debug, Clone, Copy, PartialEq, Eq)]
837enum Token<'a> {
838 Open,
840 Close,
842 And,
844 Or,
846 With,
849 Identifier(&'a str),
852}
853
854fn tokenize(expression: &str) -> Option<Vec<Token<'_>>> {
862 let mut tokens = Vec::new();
863 let bytes = expression.as_bytes();
864 let mut at = 0;
865 while at < bytes.len() {
866 let byte = bytes[at];
867 if byte.is_ascii_whitespace() {
868 at += 1;
869 continue;
870 }
871 if byte == b'(' {
872 tokens.push(Token::Open);
873 at += 1;
874 continue;
875 }
876 if byte == b')' {
877 tokens.push(Token::Close);
878 at += 1;
879 continue;
880 }
881 let start = at;
882 while at < bytes.len() {
883 let byte = bytes[at];
884 if byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'+' | b':') {
885 at += 1;
886 } else {
887 break;
888 }
889 }
890 if at == start {
891 return None;
892 }
893 let word = &expression[start..at];
894 tokens.push(match word {
895 "AND" => Token::And,
896 "OR" => Token::Or,
897 "WITH" => Token::With,
898 other => Token::Identifier(other),
899 });
900 }
901 Some(tokens)
902}
903
904#[derive(Debug, Clone, Copy)]
911struct Verdict {
912 grants: bool,
914 clean: bool,
916}
917
918struct Spdx<'a> {
927 tokens: &'a [Token<'a>],
928 at: usize,
929}
930
931impl<'a> Spdx<'a> {
932 fn peek(&self) -> Option<Token<'a>> {
934 self.tokens.get(self.at).copied()
935 }
936
937 fn bump(&mut self) -> Option<Token<'a>> {
939 let token = self.peek()?;
940 self.at += 1;
941 Some(token)
942 }
943
944 fn expression(&mut self) -> Option<Verdict> {
954 let mut verdict = self.conjunction()?;
955 while self.peek() == Some(Token::Or) {
956 self.bump();
957 let right = self.conjunction()?;
958 verdict = Verdict {
959 grants: verdict.grants && right.grants,
960 clean: verdict.clean && right.clean,
961 };
962 }
963 Some(verdict)
964 }
965
966 fn conjunction(&mut self) -> Option<Verdict> {
976 let mut verdict = self.operand()?;
977 while self.peek() == Some(Token::And) {
978 self.bump();
979 let right = self.operand()?;
980 verdict = Verdict {
981 grants: verdict.grants || right.grants,
982 clean: verdict.clean && right.clean,
983 };
984 }
985 Some(verdict)
986 }
987
988 fn operand(&mut self) -> Option<Verdict> {
998 match self.bump()? {
999 Token::Open => {
1000 let inner = self.expression()?;
1003 (self.bump()? == Token::Close).then_some(inner)
1004 }
1005 Token::Identifier(name) => {
1006 let identifier = name.strip_suffix('+').unwrap_or(name);
1007 let approved = listed(&OSI_APPROVED, identifier);
1008 let verdict = Verdict {
1009 grants: approved,
1010 clean: approved || listed(&CONTENT_LICENCES, identifier),
1011 };
1012 if self.peek() == Some(Token::With) {
1013 self.bump();
1014 match self.bump()? {
1018 Token::Identifier(exception) if listed(&SPDX_EXCEPTIONS, exception) => {}
1019 _ => return None,
1020 }
1021 }
1022 Some(verdict)
1023 }
1024 Token::And | Token::Or | Token::With | Token::Close => None,
1025 }
1026 }
1027}
1028
1029#[must_use]
1045pub fn licence_states_an_open_source_codebase(expression: &str) -> bool {
1046 let Some(tokens) = tokenize(expression) else {
1047 return false;
1048 };
1049 let mut reader = Spdx {
1050 tokens: &tokens,
1051 at: 0,
1052 };
1053 let Some(verdict) = reader.expression() else {
1054 return false;
1055 };
1056 verdict.grants && verdict.clean && reader.at == tokens.len()
1057}
1058
1059#[must_use]
1073pub fn code_scanning_licence_refusal(
1074 provider: Option<Provider>,
1075 driver: Option<&str>,
1076 shape: &CrateShape,
1077) -> Option<String> {
1078 if provider != Some(Provider::CodeQl) {
1079 return None;
1080 }
1081 if driver != Some("rust") {
1082 return Some(format!(
1083 "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",
1084 driver.unwrap_or("release-less")
1085 ));
1086 }
1087 let fallback = "land --code-scanning semgrep, which carries no licence condition";
1088 let Some(text) = shape.cargo_toml.as_deref() else {
1089 return Some(format!(
1090 "the target has no readable Cargo.toml, so the licence codeql's terms depend on cannot be read; {fallback}"
1091 ));
1092 };
1093 let Ok(table) = text.parse::<toml::Table>() else {
1094 return Some(format!(
1095 "the target's Cargo.toml does not parse, so the licence codeql's terms depend on cannot be read; {fallback}"
1096 ));
1097 };
1098 let licence = table
1099 .get("package")
1100 .and_then(toml::Value::as_table)
1101 .and_then(|package| package.get("license"))
1102 .and_then(toml::Value::as_str);
1103 match licence {
1104 None => Some(format!(
1105 "the target's Cargo.toml declares no license field, and codeql's terms cover an open-source codebase alone; declare one, or {fallback}"
1106 )),
1107 Some(expression) if !licence_states_an_open_source_codebase(expression) => Some(format!(
1108 "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}"
1109 )),
1110 Some(_) => None,
1111 }
1112}
1113
1114#[must_use]
1117pub fn kind_of(destination: &str) -> Option<Kind> {
1118 if BLOCK_DESTINATIONS.contains(&destination) {
1119 return Some(Kind::Rendered);
1120 }
1121 KINDS
1122 .iter()
1123 .find(|(name, _)| *name == destination)
1124 .map(|(_, kind)| *kind)
1125}
1126
1127pub fn destinations() -> impl Iterator<Item = &'static str> {
1132 KINDS
1133 .iter()
1134 .map(|(name, _)| *name)
1135 .chain(BLOCK_DESTINATIONS)
1136}
1137
1138pub const OWNER_TOKEN: &[u8] = b"OWNER";
1145
1146pub const REPO_PLACEHOLDER: &str = "OWNER";
1151
1152pub const REPO_TOKEN: &[u8] = b"RK_REPO";
1154
1155pub const SCOPE_SHAPE_TOKEN: &[u8] = b"RK_SCOPE_SHAPE";
1157
1158pub const IF_RELEASE_TOKEN: &[u8] = b"RK_IF_RELEASE_";
1163
1164pub const IF_NO_RELEASE_TOKEN: &[u8] = b"RK_IF_NO_RELEASE_";
1167
1168pub const SCOPED_TOKEN: &[u8] = b"RK_SCOPED";
1172
1173pub const SCOPE_OPEN_TOKEN: &[u8] = b"RK_SCOPE_OPEN";
1179
1180pub const SCOPE_CLOSE_TOKEN: &[u8] = b"RK_SCOPE_CLOSE";
1182
1183fn release_lines(baseline: &[u8], release: bool) -> Vec<u8> {
1188 if find(baseline, IF_RELEASE_TOKEN).is_none() && find(baseline, IF_NO_RELEASE_TOKEN).is_none() {
1189 return baseline.to_vec();
1190 }
1191 let mut out = Vec::with_capacity(baseline.len());
1192 for line in baseline.split_inclusive(|byte| *byte == b'\n') {
1193 let (token, wanted) = if find(line, IF_NO_RELEASE_TOKEN).is_some() {
1196 (IF_NO_RELEASE_TOKEN, !release)
1197 } else if find(line, IF_RELEASE_TOKEN).is_some() {
1198 (IF_RELEASE_TOKEN, release)
1199 } else {
1200 out.extend_from_slice(line);
1201 continue;
1202 };
1203 if wanted {
1204 out.extend(substitute(line, token, b""));
1205 }
1206 }
1207 out
1208}
1209
1210pub const GITLAB_GATE_FRAGMENT_TOKEN: &[u8] = b"RK_GITLAB_GATE_FRAGMENT";
1213
1214pub const MERGE_FLAG_TOKEN: &[u8] = b"RK_MERGE_FLAG";
1217
1218pub const SQUASH_ON_MERGE_TOKEN: &[u8] = b"RK_SQUASH_ON_MERGE";
1222
1223pub const STYLE_TOKEN: &[u8] = b"RK_STYLE";
1226
1227pub const TRUNK_BRANCH_TOKEN: &[u8] = b"RK_TRUNK_BRANCH";
1231
1232pub const LINE_PREFIX_TOKEN: &[u8] = b"RK_LINE_PREFIX";
1235
1236pub const LINE_PREFIX_RE_TOKEN: &[u8] = b"RK_LINE_PREFIX_RE";
1242
1243pub const PUSH_GUARD_TOKEN: &[u8] = b" # RK_PUSH_GUARD";
1253
1254pub const REQUIRED_CHECK_TOKEN: &[u8] = b"RK_REQUIRED_CHECK";
1256
1257pub const REQUIRED_WORKFLOW_TOKEN: &[u8] = b"RK_REQUIRED_WORKFLOW";
1259
1260pub const BRANCH_SHAPE_TOKEN: &[u8] = b"RK_BRANCH_SHAPE";
1262
1263pub const REQUEST_JOB_TOKEN: &[u8] = b"RK_REQUEST_JOB";
1265
1266pub const RELEASE_GATE_TRIGGER: &str = "blocks/release-gate-trigger.yaml.in";
1268
1269pub const RELEASE_GATE_JOB: &str = "blocks/release-gate-job.yaml.in";
1271
1272#[must_use]
1279pub fn release_driver_shape(driver: Option<&str>) -> Option<(&'static str, &'static str)> {
1280 match driver {
1281 Some("rust") => Some(("release-plz-", "release-plz-pr")),
1284 Some("bash") => Some(("chore/release-v", "release-request")),
1286 Some("python") => Some((
1290 "release-please--branches--RK_TRUNK_BRANCH",
1291 "release-please-pr",
1292 )),
1293 _ => None,
1294 }
1295}
1296
1297#[must_use]
1308pub fn release_gate_spans(driver: Option<&str>) -> [(&'static [u8], &'static [u8]); 3] {
1309 let arm: (&[u8], &[u8]) = if driver == Some("bash") {
1313 (
1314 b"\n # RK_GATE_ARM_BEGIN",
1315 b"\n # RK_GATE_ARM_END",
1316 )
1317 } else {
1318 (b"\n# RK_GATE_ARM_BEGIN", b"\n# RK_GATE_ARM_END")
1319 };
1320 [
1321 (b"\n# RK_GATE_TRIGGER_BEGIN", b"\n# RK_GATE_TRIGGER_END"),
1322 arm,
1323 (b"\n# RK_GATE_JOB_BEGIN", b"\n# RK_GATE_JOB_END"),
1324 ]
1325}
1326
1327#[must_use]
1335fn release_gates(params: &Params) -> bool {
1336 params.forge() == Some("github")
1337 && params.integration() == crate::landing::Integration::Local
1338 && params.release_mode() == ReleaseMode::Automatic
1339 && params.style() == Some(crate::landing::Style::Trunk)
1340 && !params.required_check().is_empty()
1341 && !params.required_workflow().is_empty()
1342 && release_driver_shape(params.driver()).is_some()
1343}
1344
1345fn release_gate_replacements(params: &Params) -> Result<[Option<Vec<u8>>; 3], RkError> {
1357 if !release_gates(params) {
1358 return Ok([None, None, None]);
1359 }
1360 let opened = |block: &str| -> Result<Vec<u8>, RkError> {
1363 let text = embedded_block(block)?;
1364 Ok(substitute_tokens(
1365 format!("\n{}", text.trim_end_matches('\n')).as_bytes(),
1366 params,
1367 ))
1368 };
1369 Ok([
1370 Some(opened(RELEASE_GATE_TRIGGER)?),
1371 Some(Vec::new()),
1374 Some(opened(RELEASE_GATE_JOB)?),
1375 ])
1376}
1377
1378pub const DEV_RUN_TOKEN: &[u8] = b"RK_DEV_RUN ";
1385
1386pub const DEV_SETUP_STEPS_TOKEN: &[u8] = b"RK_DEV_SETUP_STEPS";
1389
1390pub const DEV_GIT_USER_TOKEN: &[u8] = b"RK_DEV_GIT_USER";
1392
1393pub const DEV_ENVIRONMENT_RELEASE: &str = "blocks/dev-environment-release.yaml.in";
1395
1396pub const DEV_ENVIRONMENT_REQUEST: &str = "blocks/dev-environment-request.yaml.in";
1398
1399pub const DEV_ENVIRONMENT_GIT_USER: &str = "blocks/dev-environment-git-user.yaml.in";
1402
1403pub const DEV_ENVIRONMENT_SPANS: [(&[u8], &[u8]); 4] = [
1413 (
1414 b"\n# RK_DEV_ENV_RELEASE_BEGIN",
1415 b"\n# RK_DEV_ENV_RELEASE_END",
1416 ),
1417 (
1418 b"\n# RK_DEV_ENV_REQUEST_BEGIN",
1419 b"\n# RK_DEV_ENV_REQUEST_END",
1420 ),
1421 (b"\n# RK_DEV_ENV_IMAGE_BEGIN", b"\n# RK_DEV_ENV_IMAGE_END"),
1422 (b"\n# RK_DEV_ENV_SCRIPT_BEGIN", b"\n# RK_DEV_ENV_SCRIPT_END"),
1423];
1424
1425fn action_step_lines(step: &crate::config::dev_environment::ActionStep) -> String {
1428 let mut lines = format!(" - uses: {}", step.uses);
1429 if !step.tag.is_empty() {
1430 lines.push_str(" # ");
1431 lines.push_str(&step.tag);
1432 }
1433 lines.push('\n');
1434 if !step.inputs.is_empty() {
1435 lines.push_str(" with:\n");
1436 for (name, value) in &step.inputs {
1437 let _ = writeln!(lines, " {name}: {}", value.yaml());
1438 }
1439 }
1440 lines
1441}
1442
1443fn dev_environment_replacements(params: &Params) -> Result<[Option<Vec<u8>>; 4], RkError> {
1460 let Some(answer) = params.dev_environment() else {
1461 return Ok([None, None, None, None]);
1462 };
1463 let git_user = embedded_block(DEV_ENVIRONMENT_GIT_USER)?.trim_end_matches('\n');
1464 let half = |block: &str, steps: &[&crate::config::dev_environment::ActionStep]| {
1465 let text = embedded_block(block)?.trim_end_matches('\n');
1466 let text = substitute(text.as_bytes(), DEV_GIT_USER_TOKEN, git_user.as_bytes());
1467 let text = substitute_tokens(&text, params);
1468 let lines: String = steps.iter().map(|step| action_step_lines(step)).collect();
1469 let mut out = b"\n".to_vec();
1470 out.extend(substitute(&text, DEV_SETUP_STEPS_TOKEN, lines.as_bytes()));
1471 Ok::<_, RkError>(out)
1472 };
1473 let release: Vec<_> = answer.github.setup.iter().collect();
1474 let request: Vec<_> = answer
1475 .github
1476 .setup
1477 .iter()
1478 .chain(&answer.github.cache)
1479 .collect();
1480 let image = (!answer.gitlab.image.is_empty())
1481 .then(|| format!("\n image: {}", answer.gitlab.image).into_bytes());
1482 let script = if answer.gitlab.before_script.is_empty() {
1483 Vec::new()
1484 } else {
1485 let mut lines = "\n before_script:".to_owned();
1486 for line in &answer.gitlab.before_script {
1487 lines.push_str("\n - ");
1488 lines.push_str(&serde_json::Value::String(line.clone()).to_string());
1489 }
1490 lines.into_bytes()
1491 };
1492 Ok([
1493 Some(half(DEV_ENVIRONMENT_RELEASE, &release)?),
1494 Some(half(DEV_ENVIRONMENT_REQUEST, &request)?),
1495 image,
1496 Some(script),
1497 ])
1498}
1499
1500pub const SECURITY_SPANS: [(&[u8], &[u8]); 3] = [
1511 (
1512 b"<!--RK_SECURITY_CONTACT_BEGIN-->",
1513 b"<!--RK_SECURITY_CONTACT_END-->",
1514 ),
1515 (
1516 b"<!--RK_SECURITY_RESPONSE_BEGIN-->",
1517 b"<!--RK_SECURITY_RESPONSE_END-->",
1518 ),
1519 (
1520 b"<!--RK_SECURITY_DEADLINE_BEGIN-->",
1521 b"<!--RK_SECURITY_DEADLINE_END-->",
1522 ),
1523];
1524
1525fn acknowledgment(response: &str) -> String {
1528 format!("Maintainers acknowledge a report within {response}.")
1529}
1530
1531const DISCLOSURE_ONLY: &[u8] = b"This policy commits to no disclosure deadline.";
1535
1536fn security_replacements(params: &Params) -> [Option<Vec<u8>>; 3] {
1539 let contact = (!params.security_contact().is_empty())
1540 .then(|| params.security_contact().as_bytes().to_vec());
1541 let promised = params.security_response() != crate::config::RESPONSE_DEFAULT;
1542 [
1543 contact,
1544 promised.then(|| acknowledgment(params.security_response()).into_bytes()),
1545 promised.then(|| DISCLOSURE_ONLY.to_vec()),
1546 ]
1547}
1548
1549fn replace_span(baseline: &[u8], begin: &[u8], end: &[u8], value: Option<&[u8]>) -> Vec<u8> {
1555 let ordered = find(baseline, begin)
1556 .zip(find(baseline, end))
1557 .filter(|(start, stop)| stop > start);
1558 let Some((start, stop)) = ordered else {
1559 return baseline.to_vec();
1560 };
1561 let mut out = Vec::with_capacity(baseline.len());
1562 out.extend_from_slice(&baseline[..start]);
1563 out.extend_from_slice(value.unwrap_or_else(|| &baseline[start + begin.len()..stop]));
1564 out.extend_from_slice(&baseline[stop + end.len()..]);
1565 out
1566}
1567
1568#[must_use]
1586pub fn render(baseline: &[u8], params: &Params) -> Vec<u8> {
1587 let mut out = substitute_tokens(baseline, params);
1588 if let Ok(values) = release_gate_replacements(params) {
1594 for ((begin, end), value) in release_gate_spans(params.driver()).iter().zip(values) {
1595 out = replace_span(&out, begin, end, value.as_deref());
1596 }
1597 }
1598 let environment = dev_environment_replacements(params).unwrap_or_default();
1601 for ((begin, end), value) in DEV_ENVIRONMENT_SPANS.iter().zip(environment) {
1602 out = replace_span(&out, begin, end, value.as_deref());
1603 }
1604 for ((begin, end), value) in SECURITY_SPANS.iter().zip(security_replacements(params)) {
1605 out = replace_span(&out, begin, end, value.as_deref());
1606 }
1607 out
1608}
1609
1610#[must_use]
1615fn substitute_tokens(baseline: &[u8], params: &Params) -> Vec<u8> {
1616 let repo = params.repo();
1617 let owner = repo.split('/').next().unwrap_or(repo);
1618 let release = params.release_mode() != ReleaseMode::None;
1622 let mut out = release_lines(baseline, release);
1623 let (scoped, open, close): (&[u8], &[u8], &[u8]) = if release {
1624 (b"scoped ", b"", b"")
1625 } else {
1626 (b"", b"(", b")?")
1627 };
1628 out = substitute(&out, SCOPED_TOKEN, scoped);
1629 out = substitute(&out, SCOPE_OPEN_TOKEN, open);
1630 out = substitute(&out, SCOPE_CLOSE_TOKEN, close);
1631 out = substitute(&out, OWNER_TOKEN, owner.as_bytes());
1632 if let Some(style) = params.style() {
1633 out = substitute(&out, STYLE_TOKEN, style.as_str().as_bytes());
1634 }
1635 out = substitute(&out, SCOPE_SHAPE_TOKEN, SCOPE_SHAPE.as_bytes());
1636 let (fragment, flag, squash): (&str, &[u8], &[u8]) = match params.merge_form() {
1640 crate::landing::MergeForm::Squash => (GITLAB_TITLE_FRAGMENT, b"--squash", b"true"),
1641 crate::landing::MergeForm::Rebase => (GITLAB_COMMIT_FRAGMENT, b"--rebase", b"false"),
1642 };
1643 out = substitute(&out, GITLAB_GATE_FRAGMENT_TOKEN, fragment.as_bytes());
1644 out = substitute(&out, MERGE_FLAG_TOKEN, flag);
1645 out = substitute(&out, SQUASH_ON_MERGE_TOKEN, squash);
1646 let (shape, job) = release_driver_shape(params.driver()).unwrap_or_default();
1651 out = substitute(&out, BRANCH_SHAPE_TOKEN, shape.as_bytes());
1652 out = substitute(&out, REQUEST_JOB_TOKEN, job.as_bytes());
1653 out = substitute(&out, TRUNK_BRANCH_TOKEN, params.trunk().as_bytes());
1654 let escaped = params.line_prefix().replace('/', "\\/");
1655 out = substitute(&out, LINE_PREFIX_RE_TOKEN, escaped.as_bytes());
1656 out = substitute(&out, LINE_PREFIX_TOKEN, params.line_prefix().as_bytes());
1657 let required_check = serde_json::Value::String(params.required_check().to_owned()).to_string();
1661 let required_workflow =
1662 serde_json::Value::String(params.required_workflow().to_owned()).to_string();
1663 out = substitute(&out, REQUIRED_CHECK_TOKEN, required_check.as_bytes());
1664 out = substitute(&out, REQUIRED_WORKFLOW_TOKEN, required_workflow.as_bytes());
1665 let guard: &[u8] = if release_gates(params) {
1668 b" && github.event_name == 'push'"
1669 } else {
1670 b""
1671 };
1672 out = substitute(&out, PUSH_GUARD_TOKEN, guard);
1673 out = substitute(&out, REPO_TOKEN, repo.as_bytes());
1674 let run = params
1677 .dev_environment()
1678 .map(|answer| format!("{} ", answer.run))
1679 .unwrap_or_default();
1680 out = substitute(&out, DEV_RUN_TOKEN, run.as_bytes());
1681 out
1682}
1683
1684#[must_use]
1686pub fn substitute(baseline: &[u8], token: &[u8], value: &[u8]) -> Vec<u8> {
1687 let mut out = Vec::with_capacity(baseline.len());
1688 let mut rest = baseline;
1689 while let Some(at) = find(rest, token) {
1690 out.extend_from_slice(&rest[..at]);
1691 out.extend_from_slice(value);
1692 rest = &rest[at + token.len()..];
1693 }
1694 out.extend_from_slice(rest);
1695 out
1696}
1697
1698fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
1700 haystack
1701 .windows(needle.len())
1702 .position(|window| window == needle)
1703}
1704
1705pub const AGENTS_DESTINATION: &str = "AGENTS.md";
1707
1708pub const BLOCK_BEGIN: &str = "<!-- BEGIN release-kit -->";
1710
1711pub const BLOCK_END: &str = "<!-- END release-kit -->";
1713
1714pub const GLOSSARY_DESTINATION: &str = "GLOSSARY.md";
1719
1720pub const HOOKS_DESTINATION: &str = ".pre-commit-config.yaml";
1722
1723pub const BLOCK_DESTINATIONS: [&str; 3] =
1729 [AGENTS_DESTINATION, GLOSSARY_DESTINATION, HOOKS_DESTINATION];
1730
1731pub const HOOKS_BEGIN: &str = "# BEGIN release-kit";
1733
1734pub const HOOKS_END: &str = "# END release-kit";
1736
1737pub const HOOK_TYPES_LINE: &str = "default_install_hook_types: [pre-commit, commit-msg, pre-push]";
1741
1742pub const AGENTS_BLOCK: &str = "blocks/agents-block.md.in";
1744
1745pub const GLOSSARY_BLOCK: &str = "blocks/glossary.md.in";
1747
1748pub const AGENTS_LINE_AGENT_POLICY: &str = "blocks/agents-line-agent-policy.md.in";
1750
1751pub const AGENTS_LINE_COMMIT_POLICY: &str = "blocks/agents-line-commit-policy.md.in";
1755
1756pub const AGENTS_BLOCK_WORKFLOW: &str = "blocks/agents-block-workflow.md.in";
1759
1760pub const AGENTS_LINE_AGENT_POLICY_WORKFLOW: &str =
1763 "blocks/agents-line-agent-policy-workflow.md.in";
1764
1765pub const AGENTS_LINE_COMMIT_SOURCE_SQUASH: &str = "blocks/agents-line-commit-source-squash.md.in";
1768
1769pub const AGENTS_LINE_COMMIT_SOURCE_REBASE: &str = "blocks/agents-line-commit-source-rebase.md.in";
1772
1773pub const AGENTS_LINE_COMMIT_SCOPE_REQUIRED: &str =
1776 "blocks/agents-line-commit-scope-required.md.in";
1777
1778pub const AGENTS_LINE_COMMIT_SCOPE_PREFERRED: &str =
1780 "blocks/agents-line-commit-scope-preferred.md.in";
1781
1782pub const GLOSSARY_BLOCK_WORKFLOW: &str = "blocks/glossary-workflow.md.in";
1784
1785pub const PRE_COMMIT_BLOCK: &str = "blocks/pre-commit-block.yaml.in";
1787
1788pub const PRE_COMMIT_MESSAGE_GUARDS: &str = "blocks/pre-commit-message-guards.yaml.in";
1790
1791pub const PRE_COMMIT_BRANCH_NAME_GUARD: &str = "blocks/pre-commit-branch-name-guard.yaml.in";
1793
1794pub const PRE_COMMIT_WORKTREE_GUARD: &str = "blocks/pre-commit-worktree-guard.yaml.in";
1796
1797pub const PRE_COMMIT_TRUNK_COMMIT_GUARD: &str = "blocks/pre-commit-trunk-commit-guard.yaml.in";
1799
1800pub const PRE_COMMIT_TRUNK_PUSH_GUARD: &str = "blocks/pre-commit-trunk-push-guard.yaml.in";
1802
1803pub const PRE_COMMIT_SWEEP_SKIP: &str = "blocks/pre-commit-sweep-skip.yaml.in";
1805
1806pub const PRE_COMMIT_SWEEP_NONE: &str = "blocks/pre-commit-sweep-none.yaml.in";
1808
1809pub const PRE_COMMIT_INTEGRATE_NOTE: &str = "blocks/pre-commit-integrate-note.yaml.in";
1811
1812pub const PRE_COMMIT_TAG_GUARD: &str = "blocks/pre-commit-tag-guard.yaml.in";
1814
1815pub const PRE_COMMIT_STATUS_RELEASE_FILES: &str = "blocks/pre-commit-status-release-files.yaml.in";
1821
1822pub const PRE_COMMIT_LOCAL_TRUNK_COMMIT_GUARD: &str =
1825 "blocks/pre-commit-local-trunk-commit-guard.yaml.in";
1826
1827pub const PRE_COMMIT_LOCAL_TRUNK_PUSH_GUARDS: &str =
1830 "blocks/pre-commit-local-trunk-push-guards.yaml.in";
1831
1832pub const FORCE_SCOPE_ARG: &str = ", --force-scope";
1838
1839pub fn embedded_block(path: &str) -> Result<&'static str, RkError> {
1846 let name = path.strip_prefix("blocks/").unwrap_or(path);
1847 let file = embedded::BLOCKS
1848 .get_file(name)
1849 .ok_or_else(|| anyhow::anyhow!("{path}: this binary embeds no such block"))?;
1850 std::str::from_utf8(file.contents())
1851 .map_err(|_| anyhow::anyhow!("{path}: a block is UTF-8").into())
1852}
1853
1854#[must_use]
1858pub fn authored(text: &str) -> &str {
1859 text.strip_suffix('\n').unwrap_or(text)
1860}
1861
1862pub 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[-/].+)$";
1870
1871pub const SCOPE_SHAPE: &str = "[a-z0-9._/-]+";
1881
1882#[must_use]
1889pub fn scope_is_shaped(scope: &str) -> bool {
1890 !scope.is_empty()
1891 && scope.chars().all(|c| {
1892 c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '/' | '-')
1893 })
1894}
1895
1896pub struct RoutingGuards<'a> {
1898 pub agent_policy: &'a str,
1900 pub commit_policy: &'a str,
1903 pub commit_source: &'a str,
1905 pub commit_scope: &'a str,
1907}
1908
1909#[must_use]
1916pub fn compose_routing(template: &str, guards: Option<&RoutingGuards<'_>>) -> String {
1917 let fragment = |text: &str| format!("{}\n", authored(text));
1918 let (agent, commit) = guards.map_or_else(
1919 || (String::new(), String::new()),
1920 |guards| {
1921 (
1922 fragment(guards.agent_policy),
1923 fragment(
1924 &authored(guards.commit_policy)
1925 .replacen("RK_COMMIT_SOURCE", &fragment(guards.commit_source), 1)
1926 .replacen("RK_COMMIT_SCOPE", &fragment(guards.commit_scope), 1),
1927 ),
1928 )
1929 },
1930 );
1931 authored(template)
1932 .replacen("RK_AGENT_POLICY", &agent, 1)
1933 .replacen("RK_COMMIT_POLICY", &commit, 1)
1934}
1935
1936#[must_use]
1940pub fn compose_glossary(template: &str) -> String {
1941 authored(template).to_owned()
1942}
1943
1944pub struct WorkflowHooks<'a> {
1946 pub message: &'a str,
1948 pub branch_name: &'a str,
1950 pub worktree: Option<&'a str>,
1955}
1956
1957#[must_use]
1977pub fn compose_hooks(
1978 template: &str,
1979 workflow: Option<&WorkflowHooks<'_>>,
1980 parts: &TrunkGuards,
1981) -> String {
1982 let fragment = |text: &str| format!("{}\n", authored(text));
1983 let worktree = workflow
1984 .and_then(|hooks| hooks.worktree)
1985 .map_or_else(String::new, fragment);
1986 let trunk_guards = workflow.is_some();
1987 let mut skips: Vec<&str> = Vec::new();
1988 if trunk_guards {
1989 skips.push("no-commit-to-branch");
1990 }
1991 if !worktree.is_empty() {
1992 skips.push("rk-worktree-location");
1993 }
1994 let sweep = if skips.is_empty() {
1995 fragment(parts.sweep_none)
1996 } else {
1997 fragment(&authored(parts.sweep_skip).replacen("RK_SWEEP_SKIP", &skips.join(","), 1))
1998 };
1999 let (commit_guard, push_guard) = match (trunk_guards, parts.integration) {
2000 (false, _) => (String::new(), String::new()),
2001 (true, Integration::Forge) => (fragment(parts.trunk_commit), fragment(parts.trunk_push)),
2002 (true, Integration::Local) => (
2003 fragment(parts.local_trunk_commit),
2004 fragment(parts.local_trunk_push),
2005 ),
2006 };
2007 let (tag_guard, release_files) = if parts.release {
2008 (fragment(parts.tag_guard), authored(parts.release_files))
2009 } else {
2010 (String::new(), "")
2011 };
2012 let note = match parts.integration {
2013 Integration::Forge => String::new(),
2014 Integration::Local => fragment(parts.integrate_note),
2015 };
2016 let (message, branch_name) = workflow.map_or_else(
2017 || (String::new(), String::new()),
2018 |hooks| {
2019 let message = fragment(hooks.message);
2020 (
2021 if parts.release {
2022 message
2023 } else {
2024 message.replacen(FORCE_SCOPE_ARG, "", 1)
2025 },
2026 fragment(hooks.branch_name),
2027 )
2028 },
2029 );
2030 authored(template)
2031 .replacen("RK_SWEEP_NOTE", &format!("{sweep}{note}"), 1)
2032 .replacen("RK_MESSAGE_GUARDS", &message, 1)
2033 .replacen("RK_TRUNK_COMMIT_GUARD", &commit_guard, 1)
2034 .replacen("RK_BRANCH_NAME_GUARD", &branch_name, 1)
2035 .replacen("RK_WORKTREE_GUARD", &worktree, 1)
2036 .replacen("RK_TRUNK_PUSH_GUARD", &push_guard, 1)
2037 .replacen("RK_TAG_GUARD", &tag_guard, 1)
2038 .replacen("RK_STATUS_RELEASE_FILES", release_files, 1)
2039 .replacen("RK_BRANCH_GRAMMAR", BRANCH_GRAMMAR, 1)
2040}
2041
2042pub struct TrunkGuards<'a> {
2045 pub integration: Integration,
2047 pub release: bool,
2050 pub trunk_commit: &'a str,
2053 pub trunk_push: &'a str,
2056 pub local_trunk_commit: &'a str,
2059 pub local_trunk_push: &'a str,
2062 pub tag_guard: &'a str,
2064 pub release_files: &'a str,
2067 pub sweep_skip: &'a str,
2069 pub sweep_none: &'a str,
2071 pub integrate_note: &'a str,
2073}
2074
2075#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2078pub struct BlockShape {
2079 pub checkout_mode: CheckoutMode,
2081 pub integration: Integration,
2083 pub merge_form: MergeForm,
2085 pub workflow_guards: bool,
2087 pub release: bool,
2089}
2090
2091impl BlockShape {
2092 #[must_use]
2094 pub fn of(params: &Params) -> Self {
2095 Self {
2096 checkout_mode: params.checkout_mode(),
2097 integration: params.integration(),
2098 merge_form: params.merge_form(),
2099 workflow_guards: params.workflow_guards(),
2100 release: params.release_mode() != ReleaseMode::None,
2101 }
2102 }
2103
2104 #[must_use]
2108 pub const fn releasing(
2109 checkout_mode: CheckoutMode,
2110 integration: Integration,
2111 workflow_guards: bool,
2112 ) -> Self {
2113 Self {
2114 checkout_mode,
2115 integration,
2116 merge_form: MergeForm::Squash,
2117 workflow_guards,
2118 release: true,
2119 }
2120 }
2121
2122 const fn routing_sources(self) -> [&'static str; 4] {
2125 let (template, agent) = if self.release {
2126 (AGENTS_BLOCK, AGENTS_LINE_AGENT_POLICY)
2127 } else {
2128 (AGENTS_BLOCK_WORKFLOW, AGENTS_LINE_AGENT_POLICY_WORKFLOW)
2129 };
2130 let source = match self.merge_form {
2131 MergeForm::Squash => AGENTS_LINE_COMMIT_SOURCE_SQUASH,
2132 MergeForm::Rebase => AGENTS_LINE_COMMIT_SOURCE_REBASE,
2133 };
2134 let scope = if self.release {
2135 AGENTS_LINE_COMMIT_SCOPE_REQUIRED
2136 } else {
2137 AGENTS_LINE_COMMIT_SCOPE_PREFERRED
2138 };
2139 [template, agent, source, scope]
2140 }
2141
2142 const fn glossary_source(self) -> &'static str {
2144 if self.release {
2145 GLOSSARY_BLOCK
2146 } else {
2147 GLOSSARY_BLOCK_WORKFLOW
2148 }
2149 }
2150}
2151
2152pub fn routing_block(workflow_guards: bool) -> Result<String, RkError> {
2159 routing_block_of(&BlockShape::releasing(
2160 CheckoutMode::LinkedWorktree,
2161 Integration::Local,
2162 workflow_guards,
2163 ))
2164}
2165
2166pub fn routing_block_of(shape: &BlockShape) -> Result<String, RkError> {
2176 let [template, agent, source, scope] = shape.routing_sources();
2177 let guards = if shape.workflow_guards {
2178 Some(RoutingGuards {
2179 agent_policy: embedded_block(agent)?,
2180 commit_policy: embedded_block(AGENTS_LINE_COMMIT_POLICY)?,
2181 commit_source: embedded_block(source)?,
2182 commit_scope: embedded_block(scope)?,
2183 })
2184 } else {
2185 None
2186 };
2187 Ok(compose_routing(embedded_block(template)?, guards.as_ref()))
2188}
2189
2190pub fn glossary_block() -> Result<String, RkError> {
2197 Ok(compose_glossary(embedded_block(GLOSSARY_BLOCK)?))
2198}
2199
2200pub fn glossary_block_of(shape: &BlockShape) -> Result<String, RkError> {
2207 Ok(compose_glossary(embedded_block(shape.glossary_source())?))
2208}
2209
2210pub fn hooks_block(
2218 mode: CheckoutMode,
2219 integration: Integration,
2220 workflow_guards: bool,
2221) -> Result<String, RkError> {
2222 hooks_block_of(&BlockShape::releasing(mode, integration, workflow_guards))
2223}
2224
2225pub fn hooks_block_of(shape: &BlockShape) -> Result<String, RkError> {
2231 let workflow = if shape.workflow_guards {
2232 Some(WorkflowHooks {
2233 message: embedded_block(PRE_COMMIT_MESSAGE_GUARDS)?,
2234 branch_name: embedded_block(PRE_COMMIT_BRANCH_NAME_GUARD)?,
2235 worktree: match shape.checkout_mode {
2236 CheckoutMode::LinkedWorktree => Some(embedded_block(PRE_COMMIT_WORKTREE_GUARD)?),
2237 CheckoutMode::MainWorktree => None,
2238 },
2239 })
2240 } else {
2241 None
2242 };
2243 let parts = TrunkGuards {
2244 integration: shape.integration,
2245 release: shape.release,
2246 trunk_commit: embedded_block(PRE_COMMIT_TRUNK_COMMIT_GUARD)?,
2247 trunk_push: embedded_block(PRE_COMMIT_TRUNK_PUSH_GUARD)?,
2248 local_trunk_commit: embedded_block(PRE_COMMIT_LOCAL_TRUNK_COMMIT_GUARD)?,
2249 local_trunk_push: embedded_block(PRE_COMMIT_LOCAL_TRUNK_PUSH_GUARDS)?,
2250 tag_guard: embedded_block(PRE_COMMIT_TAG_GUARD)?,
2251 release_files: embedded_block(PRE_COMMIT_STATUS_RELEASE_FILES)?,
2252 sweep_skip: embedded_block(PRE_COMMIT_SWEEP_SKIP)?,
2253 sweep_none: embedded_block(PRE_COMMIT_SWEEP_NONE)?,
2254 integrate_note: embedded_block(PRE_COMMIT_INTEGRATE_NOTE)?,
2255 };
2256 Ok(compose_hooks(
2257 embedded_block(PRE_COMMIT_BLOCK)?,
2258 workflow.as_ref(),
2259 &parts,
2260 ))
2261}
2262
2263type BlockTemplate = (String, Vec<String>, &'static str);
2266
2267fn block_template(destination: &str, params: &Params) -> Result<Option<BlockTemplate>, RkError> {
2270 let shape = BlockShape::of(params);
2271 let guards = shape.workflow_guards;
2272 match destination {
2273 AGENTS_DESTINATION => {
2274 let [template, agent, source, scope] = shape.routing_sources();
2275 let mut sources = vec![template.to_owned()];
2276 if guards {
2277 sources.push(agent.to_owned());
2278 sources.push(AGENTS_LINE_COMMIT_POLICY.to_owned());
2279 sources.push(source.to_owned());
2280 sources.push(scope.to_owned());
2281 }
2282 Ok(Some((routing_block_of(&shape)?, sources, catalog::GUARDS)))
2283 }
2284 GLOSSARY_DESTINATION => Ok(guards
2285 .then(|| -> Result<_, RkError> {
2286 Ok((
2287 glossary_block_of(&shape)?,
2288 vec![shape.glossary_source().to_owned()],
2289 catalog::WORKFLOW_GUARDS,
2290 ))
2291 })
2292 .transpose()?),
2293 HOOKS_DESTINATION => {
2294 let mut sources = vec![PRE_COMMIT_BLOCK.to_owned()];
2295 if guards {
2296 sources.push(PRE_COMMIT_MESSAGE_GUARDS.to_owned());
2297 sources.push(PRE_COMMIT_BRANCH_NAME_GUARD.to_owned());
2298 if shape.checkout_mode == CheckoutMode::LinkedWorktree {
2299 sources.push(PRE_COMMIT_WORKTREE_GUARD.to_owned());
2300 }
2301 match shape.integration {
2302 Integration::Forge => {
2303 sources.push(PRE_COMMIT_TRUNK_COMMIT_GUARD.to_owned());
2304 sources.push(PRE_COMMIT_TRUNK_PUSH_GUARD.to_owned());
2305 }
2306 Integration::Local => {
2307 sources.push(PRE_COMMIT_LOCAL_TRUNK_COMMIT_GUARD.to_owned());
2308 sources.push(PRE_COMMIT_LOCAL_TRUNK_PUSH_GUARDS.to_owned());
2309 }
2310 }
2311 }
2312 if shape.release {
2313 sources.push(PRE_COMMIT_TAG_GUARD.to_owned());
2314 sources.push(PRE_COMMIT_STATUS_RELEASE_FILES.to_owned());
2315 }
2316 sources.push(if guards {
2317 PRE_COMMIT_SWEEP_SKIP.to_owned()
2318 } else {
2319 PRE_COMMIT_SWEEP_NONE.to_owned()
2320 });
2321 if shape.integration == Integration::Local {
2322 sources.push(PRE_COMMIT_INTEGRATE_NOTE.to_owned());
2323 }
2324 Ok(Some((hooks_block_of(&shape)?, sources, catalog::GUARDS)))
2325 }
2326 other => Err(anyhow::anyhow!("{other} is not a block destination").into()),
2327 }
2328}
2329
2330#[must_use]
2332pub fn block_markers(destination: &str) -> Option<(&'static str, &'static str)> {
2333 match destination {
2334 AGENTS_DESTINATION | GLOSSARY_DESTINATION => Some((BLOCK_BEGIN, BLOCK_END)),
2335 HOOKS_DESTINATION => Some((HOOKS_BEGIN, HOOKS_END)),
2336 _ => None,
2337 }
2338}
2339
2340#[must_use]
2343pub fn extract_block<'a>(text: &'a str, begin: &str, end: &str) -> Option<&'a str> {
2344 let start = text.find(begin)?;
2345 let stop = text[start..].find(end)? + start + end.len();
2346 Some(&text[start..stop])
2347}
2348
2349#[must_use]
2356pub fn splice_marked_block(existing: Option<&[u8]>, block: &str) -> Vec<u8> {
2357 let block = block.as_bytes();
2358 let Some(text) = existing else {
2359 return [block, b"\n"].concat();
2360 };
2361 if let Some(start) = find(text, BLOCK_BEGIN.as_bytes())
2365 && let Some(offset) = find(&text[start..], BLOCK_END.as_bytes())
2366 {
2367 let stop = start + offset + BLOCK_END.len();
2368 return [&text[..start], block, &text[stop..]].concat();
2369 }
2370 let mut out = Vec::with_capacity(text.len() + block.len() + 3);
2374 out.extend_from_slice(text);
2375 if !text.ends_with(b"\n") {
2376 out.push(b'\n');
2377 }
2378 out.push(b'\n');
2379 out.extend_from_slice(block);
2380 out.push(b'\n');
2381 out
2382}
2383
2384pub fn splice_hooks_block(existing: Option<&str>, block: &str) -> Result<String, String> {
2397 let Some(text) = existing else {
2398 return Ok(format!("{HOOK_TYPES_LINE}\n\nrepos:\n{block}\n"));
2399 };
2400 if let Some(defect) = hooks_marker_defect(text) {
2401 return Err(defect);
2402 }
2403 if let Some(found) = extract_block(text, HOOKS_BEGIN, HOOKS_END) {
2404 return Ok(text.replacen(found, block, 1));
2405 }
2406 let mut out = String::with_capacity(text.len() + block.len() + 1);
2407 let mut placed = false;
2408 for line in text.split_inclusive('\n') {
2409 out.push_str(line);
2410 if !placed && line.trim_end() == "repos:" {
2411 if !out.ends_with('\n') {
2412 out.push('\n');
2413 }
2414 out.push_str(block);
2415 out.push('\n');
2416 placed = true;
2417 }
2418 }
2419 if placed {
2420 Ok(out)
2421 } else {
2422 Err(format!(
2423 "{HOOKS_DESTINATION} exists with no repos: line, so the hook block has nowhere to land"
2424 ))
2425 }
2426}
2427
2428#[must_use]
2438pub fn marker_defect(destination: &str, text: &str) -> Option<String> {
2439 let (begin, end) = block_markers(destination)?;
2440 let begins = text.matches(begin).count();
2441 let ends = text.matches(end).count();
2442 if begins > 1 || ends > 1 {
2443 return Some(format!(
2444 "{destination} carries more than one release-kit marker pair; release-kit owns exactly one block"
2445 ));
2446 }
2447 match (text.find(begin), text.find(end)) {
2448 (Some(begin), Some(end)) if end > begin => None,
2449 (None, None) => None,
2450 _ => Some(format!(
2451 "{destination} carries an unmatched or misordered release-kit marker, so the block's extent is ambiguous"
2452 )),
2453 }
2454}
2455
2456#[must_use]
2459pub fn hooks_marker_defect(text: &str) -> Option<String> {
2460 marker_defect(HOOKS_DESTINATION, text)
2461}
2462
2463fn propose_document(
2467 destination: &str,
2468 existing: Option<&[u8]>,
2469 region: &[u8],
2470) -> Result<Vec<u8>, String> {
2471 let block = String::from_utf8_lossy(region).into_owned();
2476 if let Some(text) = existing
2477 && let Some(defect) = marker_defect(destination, &String::from_utf8_lossy(text))
2478 {
2479 return Err(defect);
2480 }
2481 if destination == HOOKS_DESTINATION {
2482 let text = match existing {
2486 None => None,
2487 Some(bytes) => Some(std::str::from_utf8(bytes).map_err(|_| {
2488 format!(
2489 "{destination} is not UTF-8, so the hook block has nowhere to land without rewriting the target's bytes"
2490 )
2491 })?),
2492 };
2493 return splice_hooks_block(text, &block).map(String::into_bytes);
2494 }
2495 Ok(splice_marked_block(existing, &block))
2496}
2497
2498#[must_use]
2510pub fn nix_unsupported_shape(shape: &CrateShape) -> Option<String> {
2511 let Some(text) = shape.cargo_toml.as_deref() else {
2512 return Some(
2513 "the target has no readable Cargo.toml, which the seeded package expression reads; no Nix file lands".to_owned(),
2514 );
2515 };
2516 let Ok(table) = text.parse::<toml::Table>() else {
2517 return Some(
2518 "the target's Cargo.toml does not parse, and the seeded package expression reads it; no Nix file lands".to_owned(),
2519 );
2520 };
2521 if !table.contains_key("package") {
2522 return Some(
2523 "the target's Cargo.toml has no [package] table; the seed supports a single crate, so no Nix file lands".to_owned(),
2524 );
2525 }
2526 if !shape.cargo_lock {
2527 return Some(
2528 "the target has no Cargo.lock, which the seeded package expression builds from; commit one, then opt in".to_owned(),
2529 );
2530 }
2531 let implicit_bin = shape.main_rs
2532 && table
2533 .get("package")
2534 .and_then(toml::Value::as_table)
2535 .and_then(|package| package.get("autobins"))
2536 .and_then(toml::Value::as_bool)
2537 != Some(false);
2538 let explicit_bins = table.get("bin").and_then(toml::Value::as_array);
2539 if explicit_bins.is_none() && !implicit_bin {
2540 return Some(
2541 "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(),
2542 );
2543 }
2544 if let Some(bins) = explicit_bins {
2550 let required = bins
2551 .first()
2552 .and_then(toml::Value::as_table)
2553 .and_then(|bin| bin.get("required-features"))
2554 .and_then(toml::Value::as_array);
2555 if let Some(required) = required {
2556 let enabled = default_features(&table);
2557 let missing = required
2558 .iter()
2559 .filter_map(toml::Value::as_str)
2560 .any(|feature| !enabled.contains(feature));
2561 if missing {
2562 return Some(
2563 "the target's first [[bin]] entry requires features a default build does not enable; no Nix file lands".to_owned(),
2564 );
2565 }
2566 }
2567 }
2568 None
2569}
2570
2571fn dep_edge_suppresses(features: &toml::Table, name: &str) -> bool {
2574 let edge = format!("dep:{name}");
2575 features.values().any(|list| {
2576 list.as_array().is_some_and(|entries| {
2577 entries
2578 .iter()
2579 .filter_map(toml::Value::as_str)
2580 .any(|entry| entry == edge)
2581 })
2582 })
2583}
2584
2585fn is_optional_dependency(table: &toml::Table, name: &str) -> bool {
2588 ["dependencies", "build-dependencies"]
2589 .iter()
2590 .any(|section| {
2591 table
2592 .get(*section)
2593 .and_then(toml::Value::as_table)
2594 .and_then(|dependencies| dependencies.get(name))
2595 .and_then(toml::Value::as_table)
2596 .and_then(|dependency| dependency.get("optional"))
2597 .and_then(toml::Value::as_bool)
2598 == Some(true)
2599 })
2600}
2601
2602fn default_features(table: &toml::Table) -> std::collections::BTreeSet<String> {
2609 let Some(features) = table.get("features").and_then(toml::Value::as_table) else {
2610 return std::collections::BTreeSet::new();
2611 };
2612 let mut enabled = std::collections::BTreeSet::new();
2613 let mut queue = vec!["default".to_owned()];
2614 while let Some(name) = queue.pop() {
2615 if !enabled.insert(name.clone()) {
2616 continue;
2617 }
2618 if let Some(implies) = features.get(&name).and_then(toml::Value::as_array) {
2619 for implied in implies.iter().filter_map(toml::Value::as_str) {
2620 if implied.starts_with("dep:") || implied.contains("?/") {
2621 continue;
2625 }
2626 if let Some((package, _)) = implied.split_once('/') {
2627 let feature_exists =
2635 features.contains_key(package) || !dep_edge_suppresses(features, package);
2636 if is_optional_dependency(table, package) && feature_exists {
2637 queue.push(package.to_owned());
2638 }
2639 } else {
2640 queue.push(implied.to_owned());
2641 }
2642 }
2643 }
2644 }
2645 enabled
2646}
2647
2648#[must_use]
2656pub fn flake_pair_withheld(
2657 flake_recorded: bool,
2658 flake_nix_present: bool,
2659 flake_lock_present: bool,
2660) -> Option<String> {
2661 if flake_recorded {
2662 return None;
2663 }
2664 let present: Vec<&str> = [
2665 ("flake.nix", flake_nix_present),
2666 ("flake.lock", flake_lock_present),
2667 ]
2668 .into_iter()
2669 .filter_map(|(name, present)| present.then_some(name))
2670 .collect();
2671 if present.is_empty() {
2672 return None;
2673 }
2674 Some(format!(
2675 "the target already carries {}; its flake pair stays its own",
2676 present.join(" and ")
2677 ))
2678}
2679
2680#[must_use]
2690pub fn nix_withholding(
2691 nix: bool,
2692 evidence: &TargetEvidence,
2693) -> Option<(&'static [&'static str], String)> {
2694 if !nix {
2695 return None;
2696 }
2697 if let Some(reason) = nix_unsupported_shape(&evidence.crate_shape) {
2698 return Some((&NIX_DESTINATIONS[..], reason));
2699 }
2700 flake_pair_withheld(
2701 evidence.flake_recorded,
2702 evidence.flake_nix_present,
2703 evidence.flake_lock_present,
2704 )
2705 .map(|reason| (&NIX_WITHHOLDABLE[..], reason))
2706}
2707
2708#[cfg(test)]
2709mod tests {
2710 use super::{
2711 AGENTS_DESTINATION, BLOCK_BEGIN, BLOCK_DESTINATIONS, BLOCK_END, Candidate, Collision,
2712 CrateShape, GLOSSARY_DESTINATION, HOOK_TYPES_LINE, HOOKS_BEGIN, HOOKS_DESTINATION,
2713 HOOKS_END, Placement, Projection, ProjectionInput, TargetEvidence, extract_block,
2714 };
2715 use crate::landing::{Params, Style};
2716
2717 fn supported_shape() -> CrateShape {
2719 CrateShape {
2720 cargo_toml: Some("[package]\nname = \"widget\"\nversion = \"0.1.0\"\n".to_owned()),
2721 cargo_lock: true,
2722 main_rs: true,
2723 }
2724 }
2725
2726 fn input(evidence: TargetEvidence) -> ProjectionInput {
2727 let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
2728 params.set_nix_for_test(true);
2729 ProjectionInput { params, evidence }
2730 }
2731
2732 fn compute(evidence: TargetEvidence) -> Projection {
2733 Projection::compute(&input(evidence)).expect("the embedded pair projects")
2734 }
2735
2736 fn candidate<'a>(projection: &'a Projection, destination: &str) -> &'a Candidate {
2737 projection
2738 .candidates
2739 .iter()
2740 .find(|candidate| candidate.destination == destination)
2741 .expect("the destination projects")
2742 }
2743
2744 #[test]
2749 fn the_workflow_guards_land_only_where_requested() {
2750 let mut plain = input(TargetEvidence {
2751 crate_shape: supported_shape(),
2752 ..TargetEvidence::default()
2753 });
2754 plain.params.set_workflow_guards_for_test(false);
2755 let plain = Projection::compute(&plain).expect("the embedded pair projects");
2756 assert!(
2757 !plain
2758 .candidates
2759 .iter()
2760 .any(|candidate| candidate.destination == GLOSSARY_DESTINATION),
2761 "a target without the guards takes no glossary"
2762 );
2763 let agents =
2764 String::from_utf8(candidate(&plain, AGENTS_DESTINATION).bytes.clone()).expect("text");
2765 assert!(!agents.contains("guides and never drives"), "{agents}");
2766 let hooks =
2767 String::from_utf8(candidate(&plain, HOOKS_DESTINATION).bytes.clone()).expect("text");
2768 assert!(!hooks.contains("conventional-pre-commit"), "{hooks}");
2769 assert!(hooks.contains("rk-no-hand-authored-tag"), "{hooks}");
2770
2771 let guarded = compute(TargetEvidence {
2772 crate_shape: supported_shape(),
2773 ..TargetEvidence::default()
2774 });
2775 let glossary = candidate(&guarded, GLOSSARY_DESTINATION);
2776 assert_eq!(
2777 glossary.capability,
2778 crate::profile::catalog::WORKFLOW_GUARDS
2779 );
2780 assert_eq!(
2781 candidate(&guarded, AGENTS_DESTINATION).capability,
2782 crate::profile::catalog::GUARDS
2783 );
2784 let hooks =
2785 String::from_utf8(candidate(&guarded, HOOKS_DESTINATION).bytes.clone()).expect("text");
2786 for hook in ["conventional-pre-commit", "rk-message", "rk-branch-name"] {
2787 assert!(hooks.contains(hook), "{hook}: {hooks}");
2788 }
2789 }
2790
2791 fn outside(bytes: &[u8], begin: &str, end: &str) -> (Vec<u8>, Vec<u8>) {
2793 let text = String::from_utf8_lossy(bytes);
2794 let start = text.find(begin).expect("the begin marker is present");
2795 let stop = text[start..].find(end).expect("the end marker is present") + start + end.len();
2796 (bytes[..start].to_vec(), bytes[stop..].to_vec())
2797 }
2798
2799 #[test]
2800 fn equal_projection_inputs_yield_byte_identical_projections() {
2801 let mut documents = std::collections::BTreeMap::new();
2802 documents.insert(
2803 AGENTS_DESTINATION.to_owned(),
2804 b"# Widget\n\nOwn rules.\n".to_vec(),
2805 );
2806 let evidence = TargetEvidence {
2807 documents,
2808 crate_shape: supported_shape(),
2809 ..TargetEvidence::default()
2810 };
2811 let first = input(evidence.clone());
2812 let second = input(evidence);
2813 assert_eq!(first, second, "the inputs are values and compare equal");
2814 let a = Projection::compute(&first).expect("the pair projects");
2815 let b = Projection::compute(&second).expect("the pair projects");
2816 assert_eq!(a.candidates.len(), b.candidates.len());
2817 for (x, y) in a.candidates.iter().zip(&b.candidates) {
2818 assert_eq!(x.destination, y.destination);
2819 assert_eq!(x.kind, y.kind);
2820 assert_eq!(x.placement, y.placement);
2821 assert_eq!(x.bytes, y.bytes, "{}", x.destination);
2822 assert_eq!(x.region, y.region, "{}", x.destination);
2823 assert_eq!(x.sources, y.sources, "{}", x.destination);
2824 }
2825 assert_eq!(a, b);
2826 let destinations: Vec<&str> = a
2827 .candidates
2828 .iter()
2829 .map(|candidate| candidate.destination.as_str())
2830 .collect();
2831 let mut sorted = destinations.clone();
2832 sorted.sort_unstable();
2833 assert_eq!(destinations, sorted, "candidates sort by destination");
2834 assert!(a.omissions.is_empty(), "{:?}", a.omissions);
2835 assert!(a.collisions.is_empty(), "{:?}", a.collisions);
2836 }
2837
2838 #[test]
2842 fn the_scorecard_destination_projects_only_under_the_opt_in() {
2843 use super::{Kind, SCORECARD_DESTINATIONS, kind_of};
2844 let destination = SCORECARD_DESTINATIONS[0];
2845 assert_eq!(kind_of(destination), Some(Kind::Rendered));
2846
2847 let project = |scorecard: bool| {
2848 let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
2849 params.set_scorecard_for_test(scorecard);
2850 Projection::compute(&ProjectionInput {
2851 params,
2852 evidence: TargetEvidence::default(),
2853 })
2854 .expect("the embedded pair projects")
2855 };
2856
2857 let off = project(false);
2858 assert!(
2859 !off.candidates
2860 .iter()
2861 .any(|candidate| candidate.destination == destination),
2862 "off by default, and an absent candidate is no omission"
2863 );
2864 assert!(off.omissions.is_empty(), "{:?}", off.omissions);
2865
2866 let on = project(true);
2867 let candidate = candidate(&on, destination);
2868 assert_eq!(candidate.kind, Kind::Rendered);
2869 assert_eq!(candidate.placement, Placement::Whole);
2870 let text = String::from_utf8_lossy(&candidate.bytes);
2871 assert!(!text.contains("RK_"), "a token survived: {text}");
2872 }
2873
2874 #[test]
2879 fn the_licence_judgment_reads_what_every_reader_obtains() {
2880 use super::licence_states_an_open_source_codebase as approved;
2881 for expression in [
2882 "MIT",
2883 "Apache-2.0",
2884 "MIT OR Apache-2.0",
2885 "MIT AND Apache-2.0",
2886 "(MIT OR Apache-2.0) AND ISC",
2887 "Apache-2.0 WITH LLVM-exception OR MIT",
2888 "GPL-3.0+",
2889 ] {
2890 assert!(approved(expression), "{expression} is OSI-approved");
2891 }
2892 for expression in [
2897 "MIT AND CC-BY-4.0",
2898 "CC-BY-4.0 AND MIT",
2899 "MIT AND (CC-BY-4.0 OR CC-BY-SA-4.0)",
2900 "(MIT OR CC-BY-4.0) AND MIT",
2901 "MIT OR Apache-2.0 AND CC-BY-4.0",
2902 "Apache-2.0 WITH LLVM-exception AND CC-BY-SA-3.0",
2903 ] {
2904 assert!(approved(expression), "{expression} states open source");
2905 }
2906 for expression in [
2907 "",
2908 " ",
2909 "LicenseRef-proprietary",
2910 "MIT AND LicenseRef-proprietary",
2911 "SEE LICENSE IN COPYING",
2912 "CC-BY-4.0",
2913 "DocumentRef-spdx:LicenseRef-proprietary",
2914 ] {
2915 assert!(!approved(expression), "{expression} is not recognized");
2916 }
2917 for expression in [
2922 "MIT OR CC-BY-4.0",
2923 "CC-BY-4.0 AND CC-BY-SA-4.0",
2924 "CC-BY-4.0 AND LicenseRef-proprietary",
2925 "CC0-1.0",
2926 "MIT AND CC-BY-4.0 AND LicenseRef-proprietary",
2927 ] {
2928 assert!(!approved(expression), "{expression} grants no code terms");
2929 }
2930 assert!(
2935 !approved("CC-BY-4.0 OR CC-BY-SA-4.0 AND MIT"),
2936 "AND binds tighter than OR"
2937 );
2938 }
2939
2940 #[test]
2946 fn the_licence_judgment_holds_the_expression_to_the_spdx_grammar() {
2947 use super::licence_states_an_open_source_codebase as approved;
2948 for expression in [
2953 "MIT OR",
2954 "OR MIT",
2955 "MIT AND",
2956 "MIT WITH",
2957 "WITH LLVM-exception",
2958 "(MIT",
2959 "MIT)",
2960 "MIT Apache-2.0",
2961 "()",
2962 "(MIT OR Apache-2.0",
2963 "MIT OR (Apache-2.0",
2964 "MIT OR ()",
2965 "AND",
2966 "(",
2967 ")",
2968 "MIT WITH AND ISC",
2969 "MIT OR OR ISC",
2970 "MIT @ Apache-2.0",
2971 ] {
2972 assert!(!approved(expression), "{expression} is malformed");
2973 }
2974 for expression in [
2976 "MIT AND (Apache-2.0 OR ISC)",
2977 "((MIT))",
2978 "Apache-2.0 WITH LLVM-exception AND ISC",
2979 "(Apache-2.0 WITH LLVM-exception)",
2980 ] {
2981 assert!(approved(expression), "{expression} is well formed");
2982 }
2983 for expression in [
2988 "mit",
2989 "MiT",
2990 "apache-2.0 OR mit",
2991 "APACHE-2.0 WITH llvm-exception",
2992 ] {
2993 assert!(
2994 approved(expression),
2995 "{expression} names an approved licence"
2996 );
2997 }
2998 for expression in [
3002 "MIT or Apache-2.0",
3003 "MIT and Apache-2.0",
3004 "MIT with LLVM-exception",
3005 ] {
3006 assert!(!approved(expression), "{expression} carries no operator");
3007 }
3008 for expression in [
3012 "MIT WITH definitely-not-an-spdx-exception",
3013 "MIT WITH MIT",
3014 "MIT WITH Apache-2.0",
3015 ] {
3016 assert!(!approved(expression), "{expression} names no exception");
3017 }
3018 for expression in [
3022 "GPL-2.0-only WITH GCC-exception-2.0",
3023 "GPL-2.0-or-later WITH Classpath-exception-2.0",
3024 "Apache-2.0 WITH Swift-exception",
3025 "GPL-3.0-only WITH Autoconf-exception-generic",
3026 ] {
3027 assert!(approved(expression), "{expression} names a real exception");
3028 }
3029 }
3030
3031 #[test]
3037 fn a_recorded_provider_its_pair_cannot_run_is_named() {
3038 use super::{Provider, code_scanning_incompatibility};
3039
3040 assert!(code_scanning_incompatibility(None, Some("bash"), Some("gitlab")).is_none());
3041 assert!(
3042 code_scanning_incompatibility(Some(Provider::Semgrep), Some("rust"), Some("gitlab"))
3043 .is_none()
3044 );
3045 assert!(
3046 code_scanning_incompatibility(Some(Provider::CodeQl), Some("rust"), Some("github"))
3047 .is_none()
3048 );
3049
3050 let bash =
3051 code_scanning_incompatibility(Some(Provider::Semgrep), Some("bash"), Some("github"))
3052 .expect("a binding with no scanner is named");
3053 assert!(bash.contains("bash binding"), "{bash}");
3054 let gitlab =
3055 code_scanning_incompatibility(Some(Provider::CodeQl), Some("rust"), Some("gitlab"))
3056 .expect("codeql on gitlab is named");
3057 assert!(gitlab.contains("codeql"), "{gitlab}");
3058 let release_less =
3059 code_scanning_incompatibility(Some(Provider::Semgrep), None, Some("github"))
3060 .expect("no driver is named");
3061 assert!(
3062 release_less.contains("no automatic release driver"),
3063 "{release_less}"
3064 );
3065
3066 let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
3069 params.set_code_scanning_for_test(Some(Provider::Semgrep));
3070 let clean = Projection::compute(&ProjectionInput {
3071 params: params.clone(),
3072 evidence: TargetEvidence::default(),
3073 })
3074 .expect("the pair projects");
3075 assert!(
3076 clean.record_defects.is_empty(),
3077 "{:?}",
3078 clean.record_defects
3079 );
3080 }
3081
3082 #[test]
3086 fn the_code_scanning_destinations_follow_the_recorded_provider() {
3087 use super::{
3088 CODE_SCANNING_DESTINATIONS, Kind, Provider, code_scanning_licence_refusal, kind_of,
3089 };
3090 for (destination, _) in CODE_SCANNING_DESTINATIONS {
3091 assert_eq!(kind_of(destination), Some(Kind::Rendered), "{destination}");
3092 }
3093
3094 let project = |provider: Option<Provider>| {
3095 let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
3096 params.set_code_scanning_for_test(provider);
3097 Projection::compute(&ProjectionInput {
3098 params,
3099 evidence: TargetEvidence {
3100 crate_shape: CrateShape {
3101 cargo_toml: Some(
3102 "[package]\nname = \"widget\"\nlicense = \"MIT\"\n".to_owned(),
3103 ),
3104 ..CrateShape::default()
3105 },
3106 ..TargetEvidence::default()
3107 },
3108 })
3109 .expect("the embedded pair projects")
3110 };
3111 let landed = |projection: &Projection, destination: &str| {
3112 projection
3113 .candidates
3114 .iter()
3115 .any(|candidate| candidate.destination == destination)
3116 };
3117
3118 let off = project(None);
3119 for (destination, _) in CODE_SCANNING_DESTINATIONS {
3120 assert!(!landed(&off, destination), "{destination}");
3121 }
3122 assert!(off.licence_refusal.is_none());
3123
3124 let codeql = project(Some(Provider::CodeQl));
3125 assert!(landed(
3126 &codeql,
3127 ".github/workflows/code-scanning-codeql.yml"
3128 ));
3129 assert!(!landed(
3130 &codeql,
3131 ".github/workflows/code-scanning-semgrep.yml"
3132 ));
3133 assert!(codeql.licence_refusal.is_none(), "MIT satisfies the terms");
3134
3135 let semgrep = project(Some(Provider::Semgrep));
3136 assert!(landed(
3137 &semgrep,
3138 ".github/workflows/code-scanning-semgrep.yml"
3139 ));
3140 assert!(!landed(
3141 &semgrep,
3142 ".github/workflows/code-scanning-codeql.yml"
3143 ));
3144
3145 let proprietary = CrateShape {
3147 cargo_toml: Some("[package]\nlicense = \"LicenseRef-proprietary\"\n".to_owned()),
3148 ..CrateShape::default()
3149 };
3150 assert!(
3151 code_scanning_licence_refusal(Some(Provider::Semgrep), Some("rust"), &proprietary)
3152 .is_none()
3153 );
3154 let refusal =
3155 code_scanning_licence_refusal(Some(Provider::CodeQl), Some("rust"), &proprietary)
3156 .expect("codeql refuses a licence its terms do not cover");
3157 assert!(refusal.contains("LicenseRef-proprietary"), "{refusal}");
3158 assert!(refusal.contains("semgrep"), "{refusal}");
3159 let bash =
3162 code_scanning_licence_refusal(Some(Provider::CodeQl), Some("bash"), &proprietary)
3163 .expect("an unread binding refuses");
3164 assert!(bash.contains("bash binding"), "{bash}");
3165 }
3166
3167 #[test]
3173 fn the_projection_performs_no_filesystem_git_environment_clock_registry_or_network_read() {
3174 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/projection.rs");
3175 let text = std::fs::read_to_string(&path).expect("the source reads");
3176 let production = text.split("#[cfg(test)]").next().unwrap_or("");
3177 let needles = [
3178 "std::fs",
3179 "std::env",
3180 "std::process",
3181 "std::time",
3182 "SystemTime",
3183 "Instant",
3184 "std::net",
3185 "Command::new",
3186 "registry::",
3187 "curl",
3188 "reqwest",
3189 "blob(",
3190 ];
3191 let mut hits = Vec::new();
3192 for (index, line) in production.lines().enumerate() {
3193 if line.trim_start().starts_with("//") {
3194 continue;
3195 }
3196 for needle in needles {
3197 if line.contains(needle) {
3198 hits.push(format!("src/projection.rs:{}: {needle}", index + 1));
3199 }
3200 }
3201 }
3202 assert!(
3203 hits.is_empty(),
3204 "the projection reads beyond its inputs: {hits:?}"
3205 );
3206 }
3207
3208 #[test]
3209 #[allow(
3210 clippy::too_many_lines,
3211 reason = "one test walks the three marked destinations and the three unmarked shapes"
3212 )]
3213 fn marked_region_projection_preserves_every_target_byte_outside_the_markers() {
3214 let agents_before = "# Widget\n\nOperator prose above.\n\n";
3215 let agents_after = "\n\n## Our rules\n\nOperator prose below. \n";
3216 let glossary_before = "# Glossary\n\n- `spike` is a throwaway branch.\n\n";
3217 let glossary_after = "\n\n## More terms\n\n- `own` is ours.";
3218 let hooks_before = "default_install_hook_types: [pre-commit]\n\nrepos:\n";
3219 let hooks_after =
3220 "\n - repo: https://example.com/own\n rev: v1\n hooks:\n - id: own\n";
3221 let stale = |begin: &str, end: &str| format!("{begin}\nstale block\n{end}");
3222 let mut documents = std::collections::BTreeMap::new();
3223 documents.insert(
3224 AGENTS_DESTINATION.to_owned(),
3225 format!(
3226 "{agents_before}{}{agents_after}",
3227 stale(BLOCK_BEGIN, BLOCK_END)
3228 )
3229 .into_bytes(),
3230 );
3231 documents.insert(
3232 GLOSSARY_DESTINATION.to_owned(),
3233 format!(
3234 "{glossary_before}{}{glossary_after}",
3235 stale(BLOCK_BEGIN, BLOCK_END)
3236 )
3237 .into_bytes(),
3238 );
3239 documents.insert(
3240 HOOKS_DESTINATION.to_owned(),
3241 format!(
3242 "{hooks_before}{}{hooks_after}",
3243 stale(HOOKS_BEGIN, HOOKS_END)
3244 )
3245 .into_bytes(),
3246 );
3247 let projection = compute(TargetEvidence {
3248 documents: documents.clone(),
3249 crate_shape: supported_shape(),
3250 ..TargetEvidence::default()
3251 });
3252 assert!(
3253 projection.collisions.is_empty(),
3254 "{:?}",
3255 projection.collisions
3256 );
3257 for (destination, before, after) in [
3258 (AGENTS_DESTINATION, agents_before, agents_after),
3259 (GLOSSARY_DESTINATION, glossary_before, glossary_after),
3260 (HOOKS_DESTINATION, hooks_before, hooks_after),
3261 ] {
3262 let candidate = candidate(&projection, destination);
3263 let Placement::Region { begin, end } = candidate.placement else {
3264 panic!("{destination} is a region");
3265 };
3266 let region = candidate
3267 .region
3268 .as_deref()
3269 .expect("a region carries its block");
3270 let (head, tail) = outside(&candidate.bytes, begin, end);
3271 assert_eq!(
3272 head,
3273 before.as_bytes(),
3274 "{destination}: bytes before the markers"
3275 );
3276 assert_eq!(
3277 tail,
3278 after.as_bytes(),
3279 "{destination}: bytes after the markers"
3280 );
3281 let inside = &candidate.bytes[head.len()..candidate.bytes.len() - tail.len()];
3282 assert_eq!(
3283 inside, region,
3284 "{destination}: the region is the rendered block"
3285 );
3286 let (existing_head, existing_tail) = outside(&documents[destination], begin, end);
3287 assert_eq!(head, existing_head);
3288 assert_eq!(tail, existing_tail);
3289 }
3290
3291 let own_hooks =
3295 "repos:\n - repo: https://example.com/own\n rev: v1\n hooks:\n - id: own\n";
3296 let own_agents = "# Widget\n\nOwn rules.";
3297 let mut documents = std::collections::BTreeMap::new();
3298 documents.insert(HOOKS_DESTINATION.to_owned(), own_hooks.as_bytes().to_vec());
3299 documents.insert(
3300 AGENTS_DESTINATION.to_owned(),
3301 own_agents.as_bytes().to_vec(),
3302 );
3303 let projection = compute(TargetEvidence {
3304 documents,
3305 crate_shape: supported_shape(),
3306 ..TargetEvidence::default()
3307 });
3308 assert!(
3309 projection.collisions.is_empty(),
3310 "{:?}",
3311 projection.collisions
3312 );
3313 let hooks = candidate(&projection, HOOKS_DESTINATION);
3314 let hooks_text = String::from_utf8_lossy(&hooks.bytes);
3315 let region = String::from_utf8_lossy(hooks.region.as_deref().expect("a region"));
3316 assert!(
3317 hooks_text.starts_with(&format!(
3318 "repos:\n{region}\n - repo: https://example.com/own"
3319 )),
3320 "{hooks_text}"
3321 );
3322 assert!(!hooks_text.contains(HOOK_TYPES_LINE));
3323 let agents = candidate(&projection, AGENTS_DESTINATION);
3324 assert!(agents.bytes.starts_with(own_agents.as_bytes()));
3325 assert_eq!(
3326 extract_block(
3327 &String::from_utf8_lossy(&agents.bytes),
3328 BLOCK_BEGIN,
3329 BLOCK_END
3330 )
3331 .map(str::as_bytes),
3332 agents.region.as_deref()
3333 );
3334 let glossary = candidate(&projection, GLOSSARY_DESTINATION);
3335 let region = glossary.region.as_deref().expect("a region");
3336 assert_eq!(
3337 glossary.bytes,
3338 [region, b"\n"].concat(),
3339 "an absent file is fresh"
3340 );
3341 }
3342
3343 #[test]
3349 fn a_hook_document_that_is_not_utf8_collides_instead_of_being_rewritten() {
3350 let mut documents = std::collections::BTreeMap::new();
3351 let mut invalid = b"repos:\n# own \xff above\n".to_vec();
3352 invalid.extend_from_slice(format!("{HOOKS_BEGIN}\nstale\n{HOOKS_END}\n").as_bytes());
3353 invalid.extend_from_slice(b" - repo: local \xff below\n");
3354 documents.insert(HOOKS_DESTINATION.to_owned(), invalid);
3355 let mut agents = b"# Widget r\xe9sum\xe9\n\n".to_vec();
3356 agents.extend_from_slice(format!("{BLOCK_BEGIN}\nstale\n{BLOCK_END}\n\n").as_bytes());
3357 agents.extend_from_slice(b"r\xe9sum\xe9\n");
3358 documents.insert(AGENTS_DESTINATION.to_owned(), agents.clone());
3359 let projection = compute(TargetEvidence {
3360 documents,
3361 crate_shape: supported_shape(),
3362 ..TargetEvidence::default()
3363 });
3364 let collided: Vec<&str> = projection
3365 .collisions
3366 .iter()
3367 .map(|c| c.destination.as_str())
3368 .collect();
3369 assert_eq!(collided, [HOOKS_DESTINATION]);
3370 assert!(
3371 projection.collisions[0].reason.contains("not UTF-8"),
3372 "{}",
3373 projection.collisions[0].reason
3374 );
3375 assert!(
3376 !projection
3377 .candidates
3378 .iter()
3379 .any(|c| c.destination == HOOKS_DESTINATION),
3380 "a colliding destination projects no candidate"
3381 );
3382 let agents = candidate(&projection, AGENTS_DESTINATION);
3383 assert!(
3384 agents.bytes.starts_with(b"# Widget r\xe9sum\xe9\n\n"),
3385 "{:?}",
3386 agents.bytes
3387 );
3388 assert!(
3389 agents.bytes.ends_with(b"\n\nr\xe9sum\xe9\n"),
3390 "{:?}",
3391 agents.bytes
3392 );
3393 assert!(
3394 !agents.bytes.contains(&0xEF),
3395 "a replacement character landed"
3396 );
3397
3398 let mut documents = std::collections::BTreeMap::new();
3399 let valid =
3400 format!("repos:\n# own above\n{HOOKS_BEGIN}\nstale\n{HOOKS_END}\n - repo: local\n");
3401 documents.insert(HOOKS_DESTINATION.to_owned(), valid.into_bytes());
3402 let projection = compute(TargetEvidence {
3403 documents,
3404 crate_shape: supported_shape(),
3405 ..TargetEvidence::default()
3406 });
3407 assert!(
3408 projection.collisions.is_empty(),
3409 "{:?}",
3410 projection.collisions
3411 );
3412 let hooks = candidate(&projection, HOOKS_DESTINATION);
3413 let text = String::from_utf8(hooks.bytes.clone()).expect("a valid document stays text");
3414 assert!(text.starts_with("repos:\n# own above\n"), "{text}");
3415 assert!(text.ends_with("\n - repo: local\n"), "{text}");
3416 assert!(!text.contains("stale"), "the region is replaced: {text}");
3417 }
3418
3419 #[test]
3423 fn a_whole_file_colliding_with_a_marked_region_names_both_source_paths() {
3424 let files: Vec<(String, &[u8])> = vec![
3425 ("snippets/_shared/github/SECURITY.md".to_owned(), b"policy"),
3426 ("snippets/rust/github/AGENTS.md".to_owned(), b"whole"),
3427 ];
3428 let err = Projection::compute_over(&files, &input(TargetEvidence::default()))
3429 .expect_err("a whole file at a block destination refuses");
3430 let text = err.to_string();
3431 assert!(text.contains("snippets/rust/github/AGENTS.md"), "{text}");
3432 assert!(text.contains(super::AGENTS_BLOCK), "{text}");
3433 assert!(text.contains(super::AGENTS_LINE_AGENT_POLICY), "{text}");
3434 assert!(text.contains("embedded sources are defective"), "{text}");
3435 }
3436
3437 #[test]
3438 fn duplicate_whole_file_destinations_and_overlapping_marked_regions_refuse_with_the_conflicting_source_names()
3439 {
3440 let files: Vec<(String, &[u8])> = vec![
3443 ("snippets/_shared/github/SECURITY.md".to_owned(), b"shared"),
3444 ("snippets/rust/github/SECURITY.md".to_owned(), b"pair"),
3445 ("snippets/rust/github/release-plz.toml".to_owned(), b"seed"),
3446 ];
3447 let err = Projection::compute_over(&files, &input(TargetEvidence::default()))
3448 .expect_err("a doubled destination refuses");
3449 let text = err.to_string();
3450 assert!(
3451 text.contains("snippets/_shared/github/SECURITY.md"),
3452 "{text}"
3453 );
3454 assert!(text.contains("snippets/rust/github/SECURITY.md"), "{text}");
3455 assert!(text.contains("embedded sources are defective"), "{text}");
3456
3457 let clean: Vec<(String, &[u8])> = vec![
3458 ("snippets/_shared/github/SECURITY.md".to_owned(), b"shared"),
3459 ("snippets/rust/github/release-plz.toml".to_owned(), b"seed"),
3460 ];
3461 let projection = Projection::compute_over(&clean, &input(TargetEvidence::default()))
3462 .expect("a clean list projects");
3463 let whole: Vec<&str> = projection
3464 .candidates
3465 .iter()
3466 .filter(|c| c.placement == Placement::Whole)
3467 .map(|c| c.destination.as_str())
3468 .collect();
3469 assert_eq!(whole, ["SECURITY.md", "release-plz.toml"]);
3470 assert_eq!(
3471 projection
3472 .candidates
3473 .iter()
3474 .find(|c| c.destination == "SECURITY.md")
3475 .map(|c| c.sources.clone()),
3476 Some(vec!["snippets/_shared/github/SECURITY.md".to_owned()])
3477 );
3478
3479 let doubled = format!("{BLOCK_BEGIN}\na\n{BLOCK_END}\n{BLOCK_BEGIN}\nb\n{BLOCK_END}\n");
3480 let unmatched = format!("repos:\n{HOOKS_BEGIN}\n - repo: local\n");
3481 let misordered = format!("# G\n{BLOCK_END}\n{BLOCK_BEGIN}\n");
3482 let mut documents = std::collections::BTreeMap::new();
3483 documents.insert(AGENTS_DESTINATION.to_owned(), doubled.into_bytes());
3484 documents.insert(HOOKS_DESTINATION.to_owned(), unmatched.into_bytes());
3485 documents.insert(GLOSSARY_DESTINATION.to_owned(), misordered.into_bytes());
3486 let projection = compute(TargetEvidence {
3487 documents,
3488 crate_shape: supported_shape(),
3489 ..TargetEvidence::default()
3490 });
3491 let mut collided: Vec<&str> = projection
3492 .collisions
3493 .iter()
3494 .map(|Collision { destination, .. }| destination.as_str())
3495 .collect();
3496 collided.sort_unstable();
3497 let mut expected = BLOCK_DESTINATIONS.to_vec();
3498 expected.sort_unstable();
3499 assert_eq!(collided, expected);
3500 for collision in &projection.collisions {
3501 assert!(
3502 collision.reason.contains(&collision.destination),
3503 "{collision:?}"
3504 );
3505 assert!(
3506 !projection
3507 .candidates
3508 .iter()
3509 .any(|candidate| candidate.destination == collision.destination),
3510 "{} collided and still projects",
3511 collision.destination
3512 );
3513 }
3514 let agents = projection
3515 .collisions
3516 .iter()
3517 .find(|c| c.destination == AGENTS_DESTINATION)
3518 .expect("the doubled document collides");
3519 assert!(agents.reason.contains("more than one"), "{}", agents.reason);
3520 let hooks = projection
3521 .collisions
3522 .iter()
3523 .find(|c| c.destination == HOOKS_DESTINATION)
3524 .expect("the unmatched document collides");
3525 assert!(hooks.reason.contains("unmatched"), "{}", hooks.reason);
3526 }
3527}