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 {
272 Kind::Rendered => render(bytes, params),
273 Kind::Seeded => registry_lines(bytes, params),
274 Kind::State => bytes.to_vec(),
275 };
276 candidates.push(Candidate {
277 capability: selection.id,
278 destination,
279 kind,
280 placement: Placement::Whole,
281 bytes: rendered,
282 region: None,
283 sources: vec![path],
284 });
285 }
286 }
287 let mut collisions = Vec::new();
288 for destination in BLOCK_DESTINATIONS {
289 let Some((template, sources, capability)) = block_template(destination, params)? else {
290 continue;
291 };
292 if let Some(whole) = candidates
293 .iter()
294 .find(|candidate| candidate.destination == destination)
295 {
296 return Err(anyhow::anyhow!(
297 "{destination} is both a whole file from {} and a marked region from {}; the embedded sources are defective",
298 whole.sources.join(", "),
299 sources.join(", ")
300 )
301 .into());
302 }
303 let region = render(template.as_bytes(), params);
304 let (begin, end) = block_markers(destination).ok_or_else(|| {
305 anyhow::anyhow!("{destination} is a block destination with no markers")
306 })?;
307 match propose_document(destination, evidence.document(destination), ®ion) {
308 Ok(bytes) => candidates.push(Candidate {
309 capability,
310 destination: destination.to_owned(),
311 kind: Kind::Rendered,
312 placement: Placement::Region { begin, end },
313 bytes,
314 region: Some(region),
315 sources,
316 }),
317 Err(reason) => collisions.push(Collision {
318 destination: destination.to_owned(),
319 reason,
320 }),
321 }
322 }
323 let mut omissions = Vec::new();
324 if let Some((set, reason)) = nix_withholding(params.nix_packaging(), evidence)
325 && candidates
326 .iter()
327 .any(|candidate| candidate.capability == catalog::PACKAGING_NIX)
328 {
329 candidates.retain(|candidate| {
330 if set.contains(&candidate.destination.as_str()) {
331 omissions.push(Omission {
332 destination: candidate.destination.clone(),
333 reason: reason.clone(),
334 action: None,
335 });
336 false
337 } else {
338 true
339 }
340 });
341 withhold(&mut capabilities, catalog::PACKAGING_NIX, &reason, None);
342 }
343 let gate_root = candidates.iter().position(|candidate| {
349 (candidate.capability == catalog::TITLE_CHECK
350 || candidate.capability == catalog::COMMIT_CHECK)
351 && candidate.destination == GITLAB_ROOT_PIPELINE
352 });
353 if let Some(index) = gate_root
354 && evidence.root_pipeline_present
355 && !evidence.root_pipeline_recorded
356 {
357 let candidate = candidates.remove(index);
358 let (gate, fragment) = if candidate.capability == catalog::COMMIT_CHECK {
359 ("commit gate", GITLAB_COMMIT_FRAGMENT)
360 } else {
361 ("title gate", GITLAB_TITLE_FRAGMENT)
362 };
363 let reason = format!(
364 "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"
365 );
366 let action = format!(
367 "add the include to the target's own {GITLAB_ROOT_PIPELINE}: include:\n - local: {fragment}"
368 );
369 omissions.push(Omission {
370 destination: candidate.destination,
371 reason: reason.clone(),
372 action: Some(action.clone()),
373 });
374 withhold(
375 &mut capabilities,
376 candidate.capability,
377 &reason,
378 Some(action),
379 );
380 }
381 candidates.sort_by(|a, b| a.destination.cmp(&b.destination));
382 omissions.sort_by(|a, b| a.destination.cmp(&b.destination));
383 let scanning_selected = capabilities.iter().any(|selection| {
389 selection.id == catalog::CODE_SCANNING && selection.status == Status::Selected
390 });
391 let licence_refusal = scanning_selected
392 .then(|| {
393 code_scanning_licence_refusal(
394 params.code_scanning(),
395 params.driver(),
396 &evidence.crate_shape,
397 )
398 })
399 .flatten();
400 let mut record_defects: Vec<String> = Vec::new();
405 if params.release_mode() == ReleaseMode::Automatic
409 && let Some(selection) = capabilities
410 .iter()
411 .find(|selection| selection.id == catalog::RELEASE_AUTOMATION)
412 && matches!(selection.status, Status::Unavailable | Status::Unknown)
413 && let Some(reason) = &selection.reason
414 {
415 record_defects.push(reason.clone());
416 }
417 Ok(Self {
418 capabilities,
419 candidates,
420 omissions,
421 collisions,
422 licence_refusal,
423 record_defects,
424 })
425 }
426
427 #[must_use]
431 pub fn release_unavailable(&self) -> Option<&str> {
432 self.capabilities
433 .iter()
434 .find(|selection| {
435 selection.id == catalog::RELEASE_AUTOMATION
436 && matches!(selection.status, Status::Unavailable | Status::Unknown)
437 })
438 .and_then(|selection| selection.reason.as_deref())
439 }
440
441 #[must_use]
443 pub fn capability(&self, id: &str) -> Option<&Selection> {
444 self.capabilities
445 .iter()
446 .find(|selection| selection.id == id)
447 }
448}
449
450fn withhold(capabilities: &mut [Selection], id: &str, reason: &str, action: Option<String>) {
452 if let Some(selection) = capabilities.iter_mut().find(|selection| selection.id == id) {
453 selection.status = Status::Withheld;
454 selection.reason = Some(reason.to_owned());
455 selection.action = action;
456 }
457}
458
459pub const GITLAB_ROOT_PIPELINE: &str = ".gitlab-ci.yml";
462
463pub const GITLAB_TITLE_FRAGMENT: &str = ".gitlab/ci/mr-title.yml";
465
466pub const GITLAB_COMMIT_FRAGMENT: &str = ".gitlab/ci/mr-commits.yml";
468
469fn embedded_snippets() -> Vec<(String, &'static [u8])> {
472 embedded::walk(&embedded::SNIPPETS)
473 .into_iter()
474 .map(|(path, bytes)| (format!("snippets/{path}"), bytes))
475 .collect()
476}
477
478#[must_use]
481pub fn supported_pairs() -> Vec<(String, String)> {
482 Availability::embedded()
483 .automation_tuples()
484 .into_iter()
485 .filter_map(|entry| {
486 entry
487 .split_once(", ")
488 .map(|(driver, forge)| (driver.to_owned(), forge.to_owned()))
489 })
490 .collect()
491}
492
493#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
495#[serde(rename_all = "lowercase")]
496pub enum Kind {
497 Rendered,
500 Seeded,
503 State,
506}
507
508impl Kind {
509 #[must_use]
511 pub const fn as_str(self) -> &'static str {
512 match self {
513 Self::Rendered => "rendered",
514 Self::Seeded => "seeded",
515 Self::State => "state",
516 }
517 }
518}
519
520const KINDS: [(&str, Kind); 22] = [
526 (".github/workflows/release-plz.yml", Kind::Rendered),
527 (".github/workflows/release-please.yml", Kind::Rendered),
528 (".github/workflows/release.yml", Kind::Rendered),
529 (".github/workflows/pr-title.yml", Kind::Rendered),
530 (".github/workflows/pr-commits.yml", Kind::Rendered),
531 (".github/workflows/scorecard.yml", Kind::Rendered),
532 (".github/workflows/code-scanning-codeql.yml", Kind::Rendered),
533 (
534 ".github/workflows/code-scanning-semgrep.yml",
535 Kind::Rendered,
536 ),
537 (".gitlab/ci/code-scanning-semgrep.yml", Kind::Rendered),
538 (".gitlab-ci.yml", Kind::Rendered),
539 ("SECURITY.md", Kind::Rendered),
540 (".gitlab/ci/mr-title.yml", Kind::Rendered),
541 (".gitlab/ci/mr-commits.yml", Kind::Rendered),
542 ("release-plz.toml", Kind::Seeded),
543 ("dist-workspace.toml", Kind::Seeded),
544 ("release-please-config.json", Kind::Seeded),
545 ("cliff.toml", Kind::Seeded),
546 ("nix/package.nix", Kind::Seeded),
547 ("flake.nix", Kind::Seeded),
548 (".release-please-manifest.json", Kind::State),
549 ("VERSION", Kind::State),
550 ("flake.lock", Kind::State),
551];
552
553pub const NIX_DESTINATIONS: [&str; 3] = ["nix/package.nix", "flake.nix", "flake.lock"];
567
568pub const NIX_WITHHOLDABLE: [&str; 2] = ["flake.nix", "flake.lock"];
574
575pub const SCORECARD_DESTINATIONS: [&str; 1] = [".github/workflows/scorecard.yml"];
590
591pub const CODE_SCANNING_DESTINATIONS: [(&str, Provider); 3] = [
605 (
606 ".github/workflows/code-scanning-codeql.yml",
607 Provider::CodeQl,
608 ),
609 (
610 ".github/workflows/code-scanning-semgrep.yml",
611 Provider::Semgrep,
612 ),
613 (".gitlab/ci/code-scanning-semgrep.yml", Provider::Semgrep),
614];
615
616pub const CODE_SCANNING_TECHS: [&str; 1] = ["rust"];
624
625#[must_use]
642pub fn code_scanning_incompatibility(
643 provider: Option<Provider>,
644 driver: Option<&str>,
645 forge: Option<&str>,
646) -> Option<String> {
647 let named = provider?;
648 let Some(driver) = driver else {
649 return Some(format!(
650 "a scanner reads the release driver's language, and the profile names no automatic release driver; the bindings that carry one are: {}",
651 CODE_SCANNING_TECHS.join(", ")
652 ));
653 };
654 if !CODE_SCANNING_TECHS.contains(&driver) {
655 return Some(format!(
656 "the {driver} binding ships no code scanning workflow; a scanner reads one language, and the bindings that carry one are: {}",
657 CODE_SCANNING_TECHS.join(", ")
658 ));
659 }
660 let Some(forge) = forge else {
661 return Some(
662 "a code scanning workflow runs at a forge, and the profile names none".to_owned(),
663 );
664 };
665 if named == Provider::CodeQl && forge != "github" {
666 return Some(format!(
667 "codeql is GitHub's own analyzer and the {forge} pair ships no workflow for it; pass --code-scanning semgrep"
668 ));
669 }
670 None
671}
672
673const OSI_APPROVED: [&str; 18] = [
683 "0bsd",
684 "agpl-3.0",
685 "agpl-3.0-only",
686 "agpl-3.0-or-later",
687 "apache-2.0",
688 "bsd-2-clause",
689 "bsd-3-clause",
690 "bsl-1.0",
691 "epl-2.0",
692 "gpl-2.0",
693 "gpl-2.0-only",
694 "gpl-2.0-or-later",
695 "gpl-3.0",
696 "gpl-3.0-only",
697 "gpl-3.0-or-later",
698 "isc",
699 "mit",
700 "mpl-2.0",
701];
702
703const CONTENT_LICENCES: [&str; 4] = ["cc-by-3.0", "cc-by-4.0", "cc-by-sa-3.0", "cc-by-sa-4.0"];
721
722const SPDX_EXCEPTIONS: [&str; 86] = [
739 "389-exception",
740 "asterisk-exception",
741 "asterisk-linking-protocols-exception",
742 "autoconf-exception-2.0",
743 "autoconf-exception-3.0",
744 "autoconf-exception-generic",
745 "autoconf-exception-generic-3.0",
746 "autoconf-exception-macro",
747 "bison-exception-1.24",
748 "bison-exception-2.2",
749 "bootloader-exception",
750 "cgal-linking-exception",
751 "classpath-exception-2.0",
752 "classpath-exception-2.0-short",
753 "clisp-exception-2.0",
754 "cryptsetup-openssl-exception",
755 "digia-qt-lgpl-exception-1.1",
756 "digirule-foss-exception",
757 "ecos-exception-2.0",
758 "erlang-otp-linking-exception",
759 "fawkes-runtime-exception",
760 "fltk-exception",
761 "fmt-exception",
762 "font-exception-2.0",
763 "freertos-exception-2.0",
764 "gcc-exception-2.0",
765 "gcc-exception-2.0-note",
766 "gcc-exception-3.1",
767 "gmsh-exception",
768 "gnat-exception",
769 "gnome-examples-exception",
770 "gnu-compiler-exception",
771 "gnu-javamail-exception",
772 "google-patent-webm",
773 "gpl-3.0-389-ds-base-exception",
774 "gpl-3.0-interface-exception",
775 "gpl-3.0-linking-exception",
776 "gpl-3.0-linking-source-exception",
777 "gpl-cc-1.0",
778 "gstreamer-exception-2005",
779 "gstreamer-exception-2008",
780 "harbour-exception",
781 "i2p-gpl-java-exception",
782 "independent-modules-exception",
783 "kicad-libraries-exception",
784 "kvirc-openssl-exception",
785 "lgpl-3.0-linking-exception",
786 "libpri-openh323-exception",
787 "libtool-exception",
788 "linux-syscall-note",
789 "llgpl",
790 "llvm-exception",
791 "lzma-exception",
792 "mif-exception",
793 "mxml-exception",
794 "nokia-qt-exception-1.1",
795 "ocaml-lgpl-linking-exception",
796 "occt-exception-1.0",
797 "openjdk-assembly-exception-1.0",
798 "openvpn-openssl-exception",
799 "pcre2-exception",
800 "polyparse-exception",
801 "ps-or-pdf-font-exception-20170817",
802 "qpl-1.0-inria-2004-exception",
803 "qt-gpl-exception-1.0",
804 "qt-lgpl-exception-1.1",
805 "qwt-exception-1.0",
806 "romic-exception",
807 "rrdtool-floss-exception-2.0",
808 "rsync-linking-exception",
809 "sane-exception",
810 "shl-2.0",
811 "shl-2.1",
812 "simple-library-usage-exception",
813 "spelling-provider-lgpl-exception",
814 "sqlitestudio-openssl-exception",
815 "stunnel-exception",
816 "swi-exception",
817 "swift-exception",
818 "texinfo-exception",
819 "u-boot-exception-2.0",
820 "ubdl-exception",
821 "universal-foss-exception-1.0",
822 "vsftpd-openssl-exception",
823 "wxwindows-exception-3.1",
824 "x11vnc-openssl-exception",
825];
826
827fn listed(list: &[&str], identifier: &str) -> bool {
834 let lowered = identifier.to_ascii_lowercase();
835 list.contains(&lowered.as_str())
836}
837
838#[derive(Debug, Clone, Copy, PartialEq, Eq)]
840enum Token<'a> {
841 Open,
843 Close,
845 And,
847 Or,
849 With,
852 Identifier(&'a str),
855}
856
857fn tokenize(expression: &str) -> Option<Vec<Token<'_>>> {
865 let mut tokens = Vec::new();
866 let bytes = expression.as_bytes();
867 let mut at = 0;
868 while at < bytes.len() {
869 let byte = bytes[at];
870 if byte.is_ascii_whitespace() {
871 at += 1;
872 continue;
873 }
874 if byte == b'(' {
875 tokens.push(Token::Open);
876 at += 1;
877 continue;
878 }
879 if byte == b')' {
880 tokens.push(Token::Close);
881 at += 1;
882 continue;
883 }
884 let start = at;
885 while at < bytes.len() {
886 let byte = bytes[at];
887 if byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'+' | b':') {
888 at += 1;
889 } else {
890 break;
891 }
892 }
893 if at == start {
894 return None;
895 }
896 let word = &expression[start..at];
897 tokens.push(match word {
898 "AND" => Token::And,
899 "OR" => Token::Or,
900 "WITH" => Token::With,
901 other => Token::Identifier(other),
902 });
903 }
904 Some(tokens)
905}
906
907#[derive(Debug, Clone, Copy)]
914struct Verdict {
915 grants: bool,
917 clean: bool,
919}
920
921struct Spdx<'a> {
930 tokens: &'a [Token<'a>],
931 at: usize,
932}
933
934impl<'a> Spdx<'a> {
935 fn peek(&self) -> Option<Token<'a>> {
937 self.tokens.get(self.at).copied()
938 }
939
940 fn bump(&mut self) -> Option<Token<'a>> {
942 let token = self.peek()?;
943 self.at += 1;
944 Some(token)
945 }
946
947 fn expression(&mut self) -> Option<Verdict> {
957 let mut verdict = self.conjunction()?;
958 while self.peek() == Some(Token::Or) {
959 self.bump();
960 let right = self.conjunction()?;
961 verdict = Verdict {
962 grants: verdict.grants && right.grants,
963 clean: verdict.clean && right.clean,
964 };
965 }
966 Some(verdict)
967 }
968
969 fn conjunction(&mut self) -> Option<Verdict> {
979 let mut verdict = self.operand()?;
980 while self.peek() == Some(Token::And) {
981 self.bump();
982 let right = self.operand()?;
983 verdict = Verdict {
984 grants: verdict.grants || right.grants,
985 clean: verdict.clean && right.clean,
986 };
987 }
988 Some(verdict)
989 }
990
991 fn operand(&mut self) -> Option<Verdict> {
1001 match self.bump()? {
1002 Token::Open => {
1003 let inner = self.expression()?;
1006 (self.bump()? == Token::Close).then_some(inner)
1007 }
1008 Token::Identifier(name) => {
1009 let identifier = name.strip_suffix('+').unwrap_or(name);
1010 let approved = listed(&OSI_APPROVED, identifier);
1011 let verdict = Verdict {
1012 grants: approved,
1013 clean: approved || listed(&CONTENT_LICENCES, identifier),
1014 };
1015 if self.peek() == Some(Token::With) {
1016 self.bump();
1017 match self.bump()? {
1021 Token::Identifier(exception) if listed(&SPDX_EXCEPTIONS, exception) => {}
1022 _ => return None,
1023 }
1024 }
1025 Some(verdict)
1026 }
1027 Token::And | Token::Or | Token::With | Token::Close => None,
1028 }
1029 }
1030}
1031
1032#[must_use]
1048pub fn licence_states_an_open_source_codebase(expression: &str) -> bool {
1049 let Some(tokens) = tokenize(expression) else {
1050 return false;
1051 };
1052 let mut reader = Spdx {
1053 tokens: &tokens,
1054 at: 0,
1055 };
1056 let Some(verdict) = reader.expression() else {
1057 return false;
1058 };
1059 verdict.grants && verdict.clean && reader.at == tokens.len()
1060}
1061
1062#[must_use]
1076pub fn code_scanning_licence_refusal(
1077 provider: Option<Provider>,
1078 driver: Option<&str>,
1079 shape: &CrateShape,
1080) -> Option<String> {
1081 if provider != Some(Provider::CodeQl) {
1082 return None;
1083 }
1084 if driver != Some("rust") {
1085 return Some(format!(
1086 "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",
1087 driver.unwrap_or("release-less")
1088 ));
1089 }
1090 let fallback = "land --code-scanning semgrep, which carries no licence condition";
1091 let Some(text) = shape.cargo_toml.as_deref() else {
1092 return Some(format!(
1093 "the target has no readable Cargo.toml, so the licence codeql's terms depend on cannot be read; {fallback}"
1094 ));
1095 };
1096 let Ok(table) = text.parse::<toml::Table>() else {
1097 return Some(format!(
1098 "the target's Cargo.toml does not parse, so the licence codeql's terms depend on cannot be read; {fallback}"
1099 ));
1100 };
1101 let licence = table
1102 .get("package")
1103 .and_then(toml::Value::as_table)
1104 .and_then(|package| package.get("license"))
1105 .and_then(toml::Value::as_str);
1106 match licence {
1107 None => Some(format!(
1108 "the target's Cargo.toml declares no license field, and codeql's terms cover an open-source codebase alone; declare one, or {fallback}"
1109 )),
1110 Some(expression) if !licence_states_an_open_source_codebase(expression) => Some(format!(
1111 "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}"
1112 )),
1113 Some(_) => None,
1114 }
1115}
1116
1117#[must_use]
1120pub fn kind_of(destination: &str) -> Option<Kind> {
1121 if BLOCK_DESTINATIONS.contains(&destination) {
1122 return Some(Kind::Rendered);
1123 }
1124 KINDS
1125 .iter()
1126 .find(|(name, _)| *name == destination)
1127 .map(|(_, kind)| *kind)
1128}
1129
1130pub fn destinations() -> impl Iterator<Item = &'static str> {
1135 KINDS
1136 .iter()
1137 .map(|(name, _)| *name)
1138 .chain(BLOCK_DESTINATIONS)
1139}
1140
1141pub const OWNER_TOKEN: &[u8] = b"OWNER";
1148
1149pub const REPO_PLACEHOLDER: &str = "OWNER";
1154
1155pub const REPO_TOKEN: &[u8] = b"RK_REPO";
1157
1158pub const SCOPE_SHAPE_TOKEN: &[u8] = b"RK_SCOPE_SHAPE";
1160
1161pub const IF_RELEASE_TOKEN: &[u8] = b"RK_IF_RELEASE_";
1166
1167pub const IF_NO_RELEASE_TOKEN: &[u8] = b"RK_IF_NO_RELEASE_";
1170
1171pub const IF_REGISTRY_TOKEN: &[u8] = b" # RK_IF_REGISTRY";
1179
1180pub const IF_NO_REGISTRY_TOKEN: &[u8] = b"# RK_IF_NO_REGISTRY ";
1186
1187#[must_use]
1193pub fn registry_lines(baseline: &[u8], params: &Params) -> Vec<u8> {
1194 if find(baseline, IF_REGISTRY_TOKEN).is_none() && find(baseline, IF_NO_REGISTRY_TOKEN).is_none()
1195 {
1196 return baseline.to_vec();
1197 }
1198 let publishes = params.registry() != Some(crate::profile::RegistryAnswer::None);
1199 let mut out = Vec::with_capacity(baseline.len());
1200 for line in baseline.split_inclusive(|byte| *byte == b'\n') {
1201 let (token, wanted) = if find(line, IF_NO_REGISTRY_TOKEN).is_some() {
1202 (IF_NO_REGISTRY_TOKEN, !publishes)
1203 } else if find(line, IF_REGISTRY_TOKEN).is_some() {
1204 (IF_REGISTRY_TOKEN, publishes)
1205 } else {
1206 out.extend_from_slice(line);
1207 continue;
1208 };
1209 if wanted {
1210 out.extend(substitute(line, token, b""));
1211 }
1212 }
1213 out
1214}
1215
1216pub const SCOPED_TOKEN: &[u8] = b"RK_SCOPED";
1220
1221pub const SCOPE_OPEN_TOKEN: &[u8] = b"RK_SCOPE_OPEN";
1227
1228pub const SCOPE_CLOSE_TOKEN: &[u8] = b"RK_SCOPE_CLOSE";
1230
1231fn release_lines(baseline: &[u8], release: bool) -> Vec<u8> {
1236 if find(baseline, IF_RELEASE_TOKEN).is_none() && find(baseline, IF_NO_RELEASE_TOKEN).is_none() {
1237 return baseline.to_vec();
1238 }
1239 let mut out = Vec::with_capacity(baseline.len());
1240 for line in baseline.split_inclusive(|byte| *byte == b'\n') {
1241 let (token, wanted) = if find(line, IF_NO_RELEASE_TOKEN).is_some() {
1244 (IF_NO_RELEASE_TOKEN, !release)
1245 } else if find(line, IF_RELEASE_TOKEN).is_some() {
1246 (IF_RELEASE_TOKEN, release)
1247 } else {
1248 out.extend_from_slice(line);
1249 continue;
1250 };
1251 if wanted {
1252 out.extend(substitute(line, token, b""));
1253 }
1254 }
1255 out
1256}
1257
1258pub const GITLAB_GATE_FRAGMENT_TOKEN: &[u8] = b"RK_GITLAB_GATE_FRAGMENT";
1261
1262pub const MERGE_FLAG_TOKEN: &[u8] = b"RK_MERGE_FLAG";
1265
1266pub const SQUASH_ON_MERGE_TOKEN: &[u8] = b"RK_SQUASH_ON_MERGE";
1270
1271pub const STYLE_TOKEN: &[u8] = b"RK_STYLE";
1274
1275pub const BUILD_COMMAND_TOKEN: &[u8] = b"RK_BUILD_COMMAND";
1278
1279pub const BUILD_PACKAGES_TOKEN: &[u8] = b"RK_BUILD_PACKAGES";
1282
1283pub const TRUNK_BRANCH_TOKEN: &[u8] = b"RK_TRUNK_BRANCH";
1287
1288pub const LINE_PREFIX_TOKEN: &[u8] = b"RK_LINE_PREFIX";
1291
1292pub const LINE_PREFIX_RE_TOKEN: &[u8] = b"RK_LINE_PREFIX_RE";
1298
1299pub const PUSH_GUARD_TOKEN: &[u8] = b" # RK_PUSH_GUARD";
1309
1310pub const REQUIRED_CHECK_TOKEN: &[u8] = b"RK_REQUIRED_CHECK";
1312
1313pub const REQUIRED_WORKFLOW_TOKEN: &[u8] = b"RK_REQUIRED_WORKFLOW";
1315
1316pub const BRANCH_SHAPE_TOKEN: &[u8] = b"RK_BRANCH_SHAPE";
1318
1319pub const REQUEST_JOB_TOKEN: &[u8] = b"RK_REQUEST_JOB";
1321
1322pub const RELEASE_GATE_TRIGGER: &str = "blocks/release-gate-trigger.yaml.in";
1324
1325pub const RELEASE_GATE_JOB: &str = "blocks/release-gate-job.yaml.in";
1327
1328#[must_use]
1335pub fn release_driver_shape(driver: Option<&str>) -> Option<(&'static str, &'static str)> {
1336 match driver {
1337 Some("rust") => Some(("release-plz-", "release-plz-pr")),
1340 Some("bash") => Some(("chore/release-v", "release-request")),
1342 Some("python") => Some((
1346 "release-please--branches--RK_TRUNK_BRANCH",
1347 "release-please-pr",
1348 )),
1349 _ => None,
1350 }
1351}
1352
1353#[must_use]
1364pub fn release_gate_spans(driver: Option<&str>) -> [(&'static [u8], &'static [u8]); 3] {
1365 let arm: (&[u8], &[u8]) = if driver == Some("bash") {
1369 (
1370 b"\n # RK_GATE_ARM_BEGIN",
1371 b"\n # RK_GATE_ARM_END",
1372 )
1373 } else {
1374 (b"\n# RK_GATE_ARM_BEGIN", b"\n# RK_GATE_ARM_END")
1375 };
1376 [
1377 (b"\n# RK_GATE_TRIGGER_BEGIN", b"\n# RK_GATE_TRIGGER_END"),
1378 arm,
1379 (b"\n# RK_GATE_JOB_BEGIN", b"\n# RK_GATE_JOB_END"),
1380 ]
1381}
1382
1383#[must_use]
1391fn release_gates(params: &Params) -> bool {
1392 params.forge() == Some("github")
1393 && params.integration() == crate::landing::Integration::Local
1394 && params.release_mode() == ReleaseMode::Automatic
1395 && params.style() == Some(crate::landing::Style::Trunk)
1396 && !params.required_check().is_empty()
1397 && !params.required_workflow().is_empty()
1398 && release_driver_shape(params.driver()).is_some()
1399}
1400
1401fn release_gate_replacements(params: &Params) -> Result<[Option<Vec<u8>>; 3], RkError> {
1413 if !release_gates(params) {
1414 return Ok([None, None, None]);
1415 }
1416 let opened = |block: &str| -> Result<Vec<u8>, RkError> {
1419 let text = embedded_block(block)?;
1420 Ok(substitute_tokens(
1421 format!("\n{}", text.trim_end_matches('\n')).as_bytes(),
1422 params,
1423 ))
1424 };
1425 Ok([
1426 Some(opened(RELEASE_GATE_TRIGGER)?),
1427 Some(Vec::new()),
1430 Some(opened(RELEASE_GATE_JOB)?),
1431 ])
1432}
1433
1434pub const DEV_RUN_TOKEN: &[u8] = b"RK_DEV_RUN ";
1441
1442pub const DEV_SETUP_STEPS_TOKEN: &[u8] = b"RK_DEV_SETUP_STEPS";
1445
1446pub const DEV_GIT_USER_TOKEN: &[u8] = b"RK_DEV_GIT_USER";
1448
1449pub const DEV_ENVIRONMENT_RELEASE: &str = "blocks/dev-environment-release.yaml.in";
1451
1452pub const DEV_ENVIRONMENT_REQUEST: &str = "blocks/dev-environment-request.yaml.in";
1454
1455pub const DEV_ENVIRONMENT_CANDIDATE: &str = "blocks/dev-environment-candidate.yaml.in";
1458
1459pub const SELF_VERSION_TOKEN: &[u8] = b"RK_SELF_VERSION";
1463
1464pub const SELF_RELEASES_TOKEN: &[u8] = b"RK_SELF_RELEASES";
1468
1469pub const DEV_ENVIRONMENT_GIT_USER: &str = "blocks/dev-environment-git-user.yaml.in";
1472
1473pub const DEV_ENVIRONMENT_SPANS: [(&[u8], &[u8]); 6] = [
1486 (
1487 b"\n# RK_DEV_ENV_RELEASE_BEGIN",
1488 b"\n# RK_DEV_ENV_RELEASE_END",
1489 ),
1490 (
1491 b"\n# RK_DEV_ENV_REQUEST_BEGIN",
1492 b"\n# RK_DEV_ENV_REQUEST_END",
1493 ),
1494 (b"\n# RK_DEV_ENV_IMAGE_BEGIN", b"\n# RK_DEV_ENV_IMAGE_END"),
1495 (b"\n# RK_DEV_ENV_SCRIPT_BEGIN", b"\n# RK_DEV_ENV_SCRIPT_END"),
1496 (
1497 b"\n# RK_DEV_ENV_CANDIDATE_BEGIN",
1498 b"\n# RK_DEV_ENV_CANDIDATE_END",
1499 ),
1500 (b"\n# RK_DEV_ENV_RK_BEGIN", b"\n# RK_DEV_ENV_RK_END"),
1501];
1502
1503fn action_step_lines(step: &crate::config::dev_environment::ActionStep) -> String {
1506 let mut lines = format!(" - uses: {}", step.uses);
1507 if !step.tag.is_empty() {
1508 lines.push_str(" # ");
1509 lines.push_str(&step.tag);
1510 }
1511 lines.push('\n');
1512 if !step.inputs.is_empty() {
1513 lines.push_str(" with:\n");
1514 for (name, value) in &step.inputs {
1515 let _ = writeln!(lines, " {name}: {}", value.yaml());
1516 }
1517 }
1518 lines
1519}
1520
1521fn dev_environment_replacements(params: &Params) -> Result<[Option<Vec<u8>>; 6], RkError> {
1538 let Some(answer) = params.dev_environment() else {
1539 return Ok([None, None, None, None, None, None]);
1540 };
1541 let git_user = embedded_block(DEV_ENVIRONMENT_GIT_USER)?.trim_end_matches('\n');
1542 let half = |block: &str, steps: &[&crate::config::dev_environment::ActionStep]| {
1543 let text = embedded_block(block)?.trim_end_matches('\n');
1544 let text = substitute(text.as_bytes(), DEV_GIT_USER_TOKEN, git_user.as_bytes());
1545 let text = substitute_tokens(&text, params);
1546 let lines: String = steps.iter().map(|step| action_step_lines(step)).collect();
1547 let mut out = b"\n".to_vec();
1548 out.extend(substitute(&text, DEV_SETUP_STEPS_TOKEN, lines.as_bytes()));
1549 Ok::<_, RkError>(out)
1550 };
1551 let release: Vec<_> = answer.github.setup.iter().collect();
1552 let request: Vec<_> = answer
1553 .github
1554 .setup
1555 .iter()
1556 .chain(&answer.github.cache)
1557 .collect();
1558 let image = (!answer.gitlab.image.is_empty())
1559 .then(|| format!("\n image: {}", answer.gitlab.image).into_bytes());
1560 let script = if answer.gitlab.before_script.is_empty() {
1561 Vec::new()
1562 } else {
1563 let mut lines = "\n before_script:".to_owned();
1564 for line in &answer.gitlab.before_script {
1565 lines.push_str("\n - ");
1566 lines.push_str(&serde_json::Value::String(line.clone()).to_string());
1567 }
1568 lines.into_bytes()
1569 };
1570 Ok([
1571 Some(half(DEV_ENVIRONMENT_RELEASE, &release)?),
1572 Some(half(DEV_ENVIRONMENT_REQUEST, &request)?),
1573 image,
1574 Some(script),
1575 Some(half(DEV_ENVIRONMENT_CANDIDATE, &release)?),
1578 Some(Vec::new()),
1579 ])
1580}
1581
1582impl Projection {
1583 #[must_use]
1591 pub fn accepted_drift_fault(&self, destination: &str) -> Option<String> {
1592 let Some(candidate) = self
1593 .candidates
1594 .iter()
1595 .find(|candidate| candidate.destination == destination)
1596 else {
1597 return Some(self.omissions.iter().find(|omission| omission.destination == destination).map_or_else(
1598 || format!("landing.accepted_drift names {destination}, which this landing does not select"),
1599 |omission| format!("landing.accepted_drift names {destination}, which this landing omits: {}", omission.reason),
1600 ));
1601 };
1602 if candidate.kind != Kind::Rendered {
1603 return Some(format!(
1604 "landing.accepted_drift names {destination}, a {} file, which may drift freely already",
1605 candidate.kind.as_str()
1606 ));
1607 }
1608 if matches!(candidate.placement, Placement::Region { .. }) {
1609 return Some(format!(
1610 "landing.accepted_drift names {destination}, a marked block: only its marked lines are release-kit's, and every line outside them is already the project's"
1611 ));
1612 }
1613 None
1614 }
1615}
1616
1617pub const SECURITY_SPANS: [(&[u8], &[u8]); 4] = [
1632 (
1633 b"<!--RK_SECURITY_CONTACT_BEGIN-->",
1634 b"<!--RK_SECURITY_CONTACT_END-->",
1635 ),
1636 (
1637 b"<!--RK_SECURITY_RESPONSE_BEGIN-->",
1638 b"<!--RK_SECURITY_RESPONSE_END-->",
1639 ),
1640 (
1641 b"<!--RK_SECURITY_DEADLINE_BEGIN-->",
1642 b"<!--RK_SECURITY_DEADLINE_END-->",
1643 ),
1644 (
1645 b"<!--RK_SECURITY_SCOPE_BEGIN-->",
1646 b"<!--RK_SECURITY_SCOPE_END-->",
1647 ),
1648];
1649
1650fn acknowledgment(response: &str) -> String {
1653 format!("Maintainers acknowledge a report within {response}.")
1654}
1655
1656const DISCLOSURE_ONLY: &[u8] = b"This policy commits to no disclosure deadline.";
1660
1661fn security_replacements(params: &Params) -> [Option<Vec<u8>>; 4] {
1664 let contact = (!params.security_contact().is_empty())
1665 .then(|| params.security_contact().as_bytes().to_vec());
1666 let promised = params.security_response() != crate::config::RESPONSE_DEFAULT;
1667 [
1668 contact,
1669 promised.then(|| acknowledgment(params.security_response()).into_bytes()),
1670 promised.then(|| DISCLOSURE_ONLY.to_vec()),
1671 (!params.security_scope().is_empty()).then(|| {
1674 ["\n".repeat(2).as_str(), params.security_scope()]
1675 .concat()
1676 .into_bytes()
1677 }),
1678 ]
1679}
1680
1681fn replace_span(baseline: &[u8], begin: &[u8], end: &[u8], value: Option<&[u8]>) -> Vec<u8> {
1687 let ordered = find(baseline, begin)
1688 .zip(find(baseline, end))
1689 .filter(|(start, stop)| stop > start);
1690 let Some((start, stop)) = ordered else {
1691 return baseline.to_vec();
1692 };
1693 let mut out = Vec::with_capacity(baseline.len());
1694 out.extend_from_slice(&baseline[..start]);
1695 out.extend_from_slice(value.unwrap_or_else(|| &baseline[start + begin.len()..stop]));
1696 out.extend_from_slice(&baseline[stop + end.len()..]);
1697 out
1698}
1699
1700#[must_use]
1718pub fn render(baseline: &[u8], params: &Params) -> Vec<u8> {
1719 let mut out = substitute_tokens(baseline, params);
1720 if let Ok(values) = release_gate_replacements(params) {
1726 for ((begin, end), value) in release_gate_spans(params.driver()).iter().zip(values) {
1727 out = replace_span(&out, begin, end, value.as_deref());
1728 }
1729 }
1730 let environment = dev_environment_replacements(params).unwrap_or_default();
1733 for ((begin, end), value) in DEV_ENVIRONMENT_SPANS.iter().zip(environment) {
1734 out = replace_span(&out, begin, end, value.as_deref());
1735 }
1736 for ((begin, end), value) in SECURITY_SPANS.iter().zip(security_replacements(params)) {
1737 out = replace_span(&out, begin, end, value.as_deref());
1738 }
1739 out
1740}
1741
1742#[must_use]
1747fn substitute_tokens(baseline: &[u8], params: &Params) -> Vec<u8> {
1748 let repo = params.repo();
1749 let owner = repo.split('/').next().unwrap_or(repo);
1750 let release = params.release_mode() != ReleaseMode::None;
1754 let mut out = release_lines(baseline, release);
1755 out = registry_lines(&out, params);
1756 let (scoped, open, close): (&[u8], &[u8], &[u8]) = if release {
1757 (b"scoped ", b"", b"")
1758 } else {
1759 (b"", b"(", b")?")
1760 };
1761 out = substitute(&out, SCOPED_TOKEN, scoped);
1762 out = substitute(&out, SCOPE_OPEN_TOKEN, open);
1763 out = substitute(&out, SCOPE_CLOSE_TOKEN, close);
1764 out = substitute(&out, OWNER_TOKEN, owner.as_bytes());
1765 if let Some(style) = params.style() {
1766 out = substitute(&out, STYLE_TOKEN, style.as_str().as_bytes());
1767 }
1768 out = substitute(&out, SCOPE_SHAPE_TOKEN, SCOPE_SHAPE.as_bytes());
1769 let (fragment, flag, squash): (&str, &[u8], &[u8]) = match params.merge_form() {
1773 crate::landing::MergeForm::Squash => (GITLAB_TITLE_FRAGMENT, b"--squash", b"true"),
1774 crate::landing::MergeForm::Rebase => (GITLAB_COMMIT_FRAGMENT, b"--rebase", b"false"),
1775 };
1776 out = substitute(&out, GITLAB_GATE_FRAGMENT_TOKEN, fragment.as_bytes());
1777 out = substitute(&out, MERGE_FLAG_TOKEN, flag);
1778 out = substitute(&out, SQUASH_ON_MERGE_TOKEN, squash);
1779 let (shape, job) = release_driver_shape(params.driver()).unwrap_or_default();
1784 out = substitute(&out, BRANCH_SHAPE_TOKEN, shape.as_bytes());
1785 out = substitute(&out, REQUEST_JOB_TOKEN, job.as_bytes());
1786 out = substitute(&out, TRUNK_BRANCH_TOKEN, params.trunk().as_bytes());
1787 let escaped = params.line_prefix().replace('/', "\\/");
1788 out = substitute(&out, LINE_PREFIX_RE_TOKEN, escaped.as_bytes());
1789 out = substitute(&out, LINE_PREFIX_TOKEN, params.line_prefix().as_bytes());
1790 let required_check = serde_json::Value::String(params.required_check().to_owned()).to_string();
1794 let required_workflow =
1795 serde_json::Value::String(params.required_workflow().to_owned()).to_string();
1796 out = substitute(&out, REQUIRED_CHECK_TOKEN, required_check.as_bytes());
1797 out = substitute(&out, REQUIRED_WORKFLOW_TOKEN, required_workflow.as_bytes());
1798 let guard: &[u8] = if release_gates(params) {
1801 b" && github.event_name == 'push'"
1802 } else {
1803 b""
1804 };
1805 out = substitute(&out, PUSH_GUARD_TOKEN, guard);
1806 out = substitute(
1807 &out,
1808 SELF_VERSION_TOKEN,
1809 env!("CARGO_PKG_VERSION").as_bytes(),
1810 );
1811 let releases = format!("{}/releases/download", env!("CARGO_PKG_REPOSITORY"));
1812 out = substitute(&out, SELF_RELEASES_TOKEN, releases.as_bytes());
1813 out = substitute(&out, REPO_TOKEN, repo.as_bytes());
1814 let run = params
1817 .dev_environment()
1818 .map(|answer| format!("{} ", answer.run))
1819 .unwrap_or_default();
1820 out = substitute(&out, DEV_RUN_TOKEN, run.as_bytes());
1821 out = substitute(
1824 &out,
1825 BUILD_PACKAGES_TOKEN,
1826 params.build_packages().as_bytes(),
1827 );
1828 out = substitute(&out, BUILD_COMMAND_TOKEN, params.build_command().as_bytes());
1829 out
1830}
1831
1832#[must_use]
1834pub fn substitute(baseline: &[u8], token: &[u8], value: &[u8]) -> Vec<u8> {
1835 let mut out = Vec::with_capacity(baseline.len());
1836 let mut rest = baseline;
1837 while let Some(at) = find(rest, token) {
1838 out.extend_from_slice(&rest[..at]);
1839 out.extend_from_slice(value);
1840 rest = &rest[at + token.len()..];
1841 }
1842 out.extend_from_slice(rest);
1843 out
1844}
1845
1846fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
1848 haystack
1849 .windows(needle.len())
1850 .position(|window| window == needle)
1851}
1852
1853pub const AGENTS_DESTINATION: &str = "AGENTS.md";
1855
1856pub const BLOCK_BEGIN: &str = "<!-- BEGIN release-kit -->";
1858
1859pub const BLOCK_END: &str = "<!-- END release-kit -->";
1861
1862pub const GLOSSARY_DESTINATION: &str = "GLOSSARY.md";
1867
1868pub const HOOKS_DESTINATION: &str = ".pre-commit-config.yaml";
1870
1871pub const BLOCK_DESTINATIONS: [&str; 3] =
1877 [AGENTS_DESTINATION, GLOSSARY_DESTINATION, HOOKS_DESTINATION];
1878
1879pub const HOOKS_BEGIN: &str = "# BEGIN release-kit";
1881
1882pub const HOOKS_END: &str = "# END release-kit";
1884
1885pub const HOOK_TYPES_LINE: &str = "default_install_hook_types: [pre-commit, commit-msg, pre-push]";
1889
1890pub const AGENTS_BLOCK: &str = "blocks/agents-block.md.in";
1892
1893pub const GLOSSARY_BLOCK: &str = "blocks/glossary.md.in";
1895
1896pub const AGENTS_LINE_AGENT_POLICY: &str = "blocks/agents-line-agent-policy.md.in";
1898
1899pub const AGENTS_LINE_COMMIT_POLICY: &str = "blocks/agents-line-commit-policy.md.in";
1903
1904pub const AGENTS_BLOCK_WORKFLOW: &str = "blocks/agents-block-workflow.md.in";
1907
1908pub const AGENTS_LINE_AGENT_POLICY_WORKFLOW: &str =
1911 "blocks/agents-line-agent-policy-workflow.md.in";
1912
1913pub const AGENTS_LINE_COMMIT_SOURCE_SQUASH: &str = "blocks/agents-line-commit-source-squash.md.in";
1916
1917pub const AGENTS_LINE_COMMIT_SOURCE_REBASE: &str = "blocks/agents-line-commit-source-rebase.md.in";
1920
1921pub const AGENTS_LINE_COMMIT_SCOPE_REQUIRED: &str =
1924 "blocks/agents-line-commit-scope-required.md.in";
1925
1926pub const AGENTS_LINE_COMMIT_SCOPE_PREFERRED: &str =
1928 "blocks/agents-line-commit-scope-preferred.md.in";
1929
1930pub const GLOSSARY_BLOCK_WORKFLOW: &str = "blocks/glossary-workflow.md.in";
1932
1933pub const PRE_COMMIT_BLOCK: &str = "blocks/pre-commit-block.yaml.in";
1935
1936pub const PRE_COMMIT_MESSAGE_GUARDS: &str = "blocks/pre-commit-message-guards.yaml.in";
1938
1939pub const PRE_COMMIT_BRANCH_NAME_GUARD: &str = "blocks/pre-commit-branch-name-guard.yaml.in";
1941
1942pub const PRE_COMMIT_WORKTREE_GUARD: &str = "blocks/pre-commit-worktree-guard.yaml.in";
1944
1945pub const PRE_COMMIT_TRUNK_COMMIT_GUARD: &str = "blocks/pre-commit-trunk-commit-guard.yaml.in";
1947
1948pub const PRE_COMMIT_TRUNK_PUSH_GUARD: &str = "blocks/pre-commit-trunk-push-guard.yaml.in";
1950
1951pub const PRE_COMMIT_SWEEP_SKIP: &str = "blocks/pre-commit-sweep-skip.yaml.in";
1953
1954pub const PRE_COMMIT_SWEEP_NONE: &str = "blocks/pre-commit-sweep-none.yaml.in";
1956
1957pub const PRE_COMMIT_INTEGRATE_NOTE: &str = "blocks/pre-commit-integrate-note.yaml.in";
1959
1960pub const PRE_COMMIT_TAG_GUARD: &str = "blocks/pre-commit-tag-guard.yaml.in";
1962
1963pub const PRE_COMMIT_STATUS_RELEASE_FILES: &str = "blocks/pre-commit-status-release-files.yaml.in";
1969
1970pub const PRE_COMMIT_LOCAL_TRUNK_COMMIT_GUARD: &str =
1973 "blocks/pre-commit-local-trunk-commit-guard.yaml.in";
1974
1975pub const PRE_COMMIT_LOCAL_TRUNK_PUSH_GUARDS: &str =
1978 "blocks/pre-commit-local-trunk-push-guards.yaml.in";
1979
1980pub const FORCE_SCOPE_ARG: &str = ", --force-scope";
1986
1987pub fn embedded_block(path: &str) -> Result<&'static str, RkError> {
1994 let name = path.strip_prefix("blocks/").unwrap_or(path);
1995 let file = embedded::BLOCKS
1996 .get_file(name)
1997 .ok_or_else(|| anyhow::anyhow!("{path}: this binary embeds no such block"))?;
1998 std::str::from_utf8(file.contents())
1999 .map_err(|_| anyhow::anyhow!("{path}: a block is UTF-8").into())
2000}
2001
2002#[must_use]
2006pub fn authored(text: &str) -> &str {
2007 text.strip_suffix('\n').unwrap_or(text)
2008}
2009
2010pub 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[-/].+)$";
2018
2019pub const SCOPE_SHAPE: &str = "[a-z0-9._/-]+";
2029
2030#[must_use]
2037pub fn scope_is_shaped(scope: &str) -> bool {
2038 !scope.is_empty()
2039 && scope.chars().all(|c| {
2040 c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '/' | '-')
2041 })
2042}
2043
2044pub struct RoutingGuards<'a> {
2046 pub agent_policy: &'a str,
2048 pub commit_policy: &'a str,
2051 pub commit_source: &'a str,
2053 pub commit_scope: &'a str,
2055}
2056
2057#[must_use]
2064pub fn compose_routing(template: &str, guards: Option<&RoutingGuards<'_>>) -> String {
2065 let fragment = |text: &str| format!("{}\n", authored(text));
2066 let (agent, commit) = guards.map_or_else(
2067 || (String::new(), String::new()),
2068 |guards| {
2069 (
2070 fragment(guards.agent_policy),
2071 fragment(
2072 &authored(guards.commit_policy)
2073 .replacen("RK_COMMIT_SOURCE", &fragment(guards.commit_source), 1)
2074 .replacen("RK_COMMIT_SCOPE", &fragment(guards.commit_scope), 1),
2075 ),
2076 )
2077 },
2078 );
2079 authored(template)
2080 .replacen("RK_AGENT_POLICY", &agent, 1)
2081 .replacen("RK_COMMIT_POLICY", &commit, 1)
2082}
2083
2084#[must_use]
2088pub fn compose_glossary(template: &str) -> String {
2089 authored(template).to_owned()
2090}
2091
2092pub struct WorkflowHooks<'a> {
2094 pub message: &'a str,
2096 pub branch_name: &'a str,
2098 pub worktree: Option<&'a str>,
2103}
2104
2105#[must_use]
2125pub fn compose_hooks(
2126 template: &str,
2127 workflow: Option<&WorkflowHooks<'_>>,
2128 parts: &TrunkGuards,
2129) -> String {
2130 let fragment = |text: &str| format!("{}\n", authored(text));
2131 let worktree = workflow
2132 .and_then(|hooks| hooks.worktree)
2133 .map_or_else(String::new, fragment);
2134 let trunk_guards = workflow.is_some();
2135 let mut skips: Vec<&str> = Vec::new();
2136 if trunk_guards {
2137 skips.push("no-commit-to-branch");
2138 }
2139 if !worktree.is_empty() {
2140 skips.push("rk-worktree-location");
2141 }
2142 let sweep = if skips.is_empty() {
2143 fragment(parts.sweep_none)
2144 } else {
2145 fragment(&authored(parts.sweep_skip).replacen("RK_SWEEP_SKIP", &skips.join(","), 1))
2146 };
2147 let (commit_guard, push_guard) = match (trunk_guards, parts.integration) {
2148 (false, _) => (String::new(), String::new()),
2149 (true, Integration::Forge) => (fragment(parts.trunk_commit), fragment(parts.trunk_push)),
2150 (true, Integration::Local) => (
2151 fragment(parts.local_trunk_commit),
2152 fragment(parts.local_trunk_push),
2153 ),
2154 };
2155 let (tag_guard, release_files) = if parts.release {
2156 (fragment(parts.tag_guard), authored(parts.release_files))
2157 } else {
2158 (String::new(), "")
2159 };
2160 let note = match parts.integration {
2161 Integration::Forge => String::new(),
2162 Integration::Local => fragment(parts.integrate_note),
2163 };
2164 let (message, branch_name) = workflow.map_or_else(
2165 || (String::new(), String::new()),
2166 |hooks| {
2167 let message = fragment(hooks.message);
2168 (
2169 if parts.release {
2170 message
2171 } else {
2172 message.replacen(FORCE_SCOPE_ARG, "", 1)
2173 },
2174 fragment(hooks.branch_name),
2175 )
2176 },
2177 );
2178 authored(template)
2179 .replacen("RK_SWEEP_NOTE", &format!("{sweep}{note}"), 1)
2180 .replacen("RK_MESSAGE_GUARDS", &message, 1)
2181 .replacen("RK_TRUNK_COMMIT_GUARD", &commit_guard, 1)
2182 .replacen("RK_BRANCH_NAME_GUARD", &branch_name, 1)
2183 .replacen("RK_WORKTREE_GUARD", &worktree, 1)
2184 .replacen("RK_TRUNK_PUSH_GUARD", &push_guard, 1)
2185 .replacen("RK_TAG_GUARD", &tag_guard, 1)
2186 .replacen("RK_STATUS_RELEASE_FILES", release_files, 1)
2187 .replacen("RK_BRANCH_GRAMMAR", BRANCH_GRAMMAR, 1)
2188}
2189
2190pub struct TrunkGuards<'a> {
2193 pub integration: Integration,
2195 pub release: bool,
2198 pub trunk_commit: &'a str,
2201 pub trunk_push: &'a str,
2204 pub local_trunk_commit: &'a str,
2207 pub local_trunk_push: &'a str,
2210 pub tag_guard: &'a str,
2212 pub release_files: &'a str,
2215 pub sweep_skip: &'a str,
2217 pub sweep_none: &'a str,
2219 pub integrate_note: &'a str,
2221}
2222
2223#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2226pub struct BlockShape {
2227 pub checkout_mode: CheckoutMode,
2229 pub integration: Integration,
2231 pub merge_form: MergeForm,
2233 pub workflow_guards: bool,
2235 pub release: bool,
2237}
2238
2239impl BlockShape {
2240 #[must_use]
2242 pub fn of(params: &Params) -> Self {
2243 Self {
2244 checkout_mode: params.checkout_mode(),
2245 integration: params.integration(),
2246 merge_form: params.merge_form(),
2247 workflow_guards: params.workflow_guards(),
2248 release: params.release_mode() != ReleaseMode::None,
2249 }
2250 }
2251
2252 #[must_use]
2256 pub const fn releasing(
2257 checkout_mode: CheckoutMode,
2258 integration: Integration,
2259 workflow_guards: bool,
2260 ) -> Self {
2261 Self {
2262 checkout_mode,
2263 integration,
2264 merge_form: MergeForm::Squash,
2265 workflow_guards,
2266 release: true,
2267 }
2268 }
2269
2270 const fn routing_sources(self) -> [&'static str; 4] {
2273 let (template, agent) = if self.release {
2274 (AGENTS_BLOCK, AGENTS_LINE_AGENT_POLICY)
2275 } else {
2276 (AGENTS_BLOCK_WORKFLOW, AGENTS_LINE_AGENT_POLICY_WORKFLOW)
2277 };
2278 let source = match self.merge_form {
2279 MergeForm::Squash => AGENTS_LINE_COMMIT_SOURCE_SQUASH,
2280 MergeForm::Rebase => AGENTS_LINE_COMMIT_SOURCE_REBASE,
2281 };
2282 let scope = if self.release {
2283 AGENTS_LINE_COMMIT_SCOPE_REQUIRED
2284 } else {
2285 AGENTS_LINE_COMMIT_SCOPE_PREFERRED
2286 };
2287 [template, agent, source, scope]
2288 }
2289
2290 const fn glossary_source(self) -> &'static str {
2292 if self.release {
2293 GLOSSARY_BLOCK
2294 } else {
2295 GLOSSARY_BLOCK_WORKFLOW
2296 }
2297 }
2298}
2299
2300pub fn routing_block(workflow_guards: bool) -> Result<String, RkError> {
2307 routing_block_of(&BlockShape::releasing(
2308 CheckoutMode::LinkedWorktree,
2309 Integration::Local,
2310 workflow_guards,
2311 ))
2312}
2313
2314pub fn routing_block_of(shape: &BlockShape) -> Result<String, RkError> {
2324 let [template, agent, source, scope] = shape.routing_sources();
2325 let guards = if shape.workflow_guards {
2326 Some(RoutingGuards {
2327 agent_policy: embedded_block(agent)?,
2328 commit_policy: embedded_block(AGENTS_LINE_COMMIT_POLICY)?,
2329 commit_source: embedded_block(source)?,
2330 commit_scope: embedded_block(scope)?,
2331 })
2332 } else {
2333 None
2334 };
2335 Ok(compose_routing(embedded_block(template)?, guards.as_ref()))
2336}
2337
2338pub fn glossary_block() -> Result<String, RkError> {
2345 Ok(compose_glossary(embedded_block(GLOSSARY_BLOCK)?))
2346}
2347
2348pub fn glossary_block_of(shape: &BlockShape) -> Result<String, RkError> {
2355 Ok(compose_glossary(embedded_block(shape.glossary_source())?))
2356}
2357
2358pub fn hooks_block(
2366 mode: CheckoutMode,
2367 integration: Integration,
2368 workflow_guards: bool,
2369) -> Result<String, RkError> {
2370 hooks_block_of(&BlockShape::releasing(mode, integration, workflow_guards))
2371}
2372
2373pub fn hooks_block_of(shape: &BlockShape) -> Result<String, RkError> {
2379 let workflow = if shape.workflow_guards {
2380 Some(WorkflowHooks {
2381 message: embedded_block(PRE_COMMIT_MESSAGE_GUARDS)?,
2382 branch_name: embedded_block(PRE_COMMIT_BRANCH_NAME_GUARD)?,
2383 worktree: match shape.checkout_mode {
2384 CheckoutMode::LinkedWorktree => Some(embedded_block(PRE_COMMIT_WORKTREE_GUARD)?),
2385 CheckoutMode::MainWorktree => None,
2386 },
2387 })
2388 } else {
2389 None
2390 };
2391 let parts = TrunkGuards {
2392 integration: shape.integration,
2393 release: shape.release,
2394 trunk_commit: embedded_block(PRE_COMMIT_TRUNK_COMMIT_GUARD)?,
2395 trunk_push: embedded_block(PRE_COMMIT_TRUNK_PUSH_GUARD)?,
2396 local_trunk_commit: embedded_block(PRE_COMMIT_LOCAL_TRUNK_COMMIT_GUARD)?,
2397 local_trunk_push: embedded_block(PRE_COMMIT_LOCAL_TRUNK_PUSH_GUARDS)?,
2398 tag_guard: embedded_block(PRE_COMMIT_TAG_GUARD)?,
2399 release_files: embedded_block(PRE_COMMIT_STATUS_RELEASE_FILES)?,
2400 sweep_skip: embedded_block(PRE_COMMIT_SWEEP_SKIP)?,
2401 sweep_none: embedded_block(PRE_COMMIT_SWEEP_NONE)?,
2402 integrate_note: embedded_block(PRE_COMMIT_INTEGRATE_NOTE)?,
2403 };
2404 Ok(compose_hooks(
2405 embedded_block(PRE_COMMIT_BLOCK)?,
2406 workflow.as_ref(),
2407 &parts,
2408 ))
2409}
2410
2411type BlockTemplate = (String, Vec<String>, &'static str);
2414
2415fn block_template(destination: &str, params: &Params) -> Result<Option<BlockTemplate>, RkError> {
2418 let shape = BlockShape::of(params);
2419 let guards = shape.workflow_guards;
2420 match destination {
2421 AGENTS_DESTINATION => {
2422 let [template, agent, source, scope] = shape.routing_sources();
2423 let mut sources = vec![template.to_owned()];
2424 if guards {
2425 sources.push(agent.to_owned());
2426 sources.push(AGENTS_LINE_COMMIT_POLICY.to_owned());
2427 sources.push(source.to_owned());
2428 sources.push(scope.to_owned());
2429 }
2430 Ok(Some((routing_block_of(&shape)?, sources, catalog::GUARDS)))
2431 }
2432 GLOSSARY_DESTINATION => Ok(guards
2433 .then(|| -> Result<_, RkError> {
2434 Ok((
2435 glossary_block_of(&shape)?,
2436 vec![shape.glossary_source().to_owned()],
2437 catalog::WORKFLOW_GUARDS,
2438 ))
2439 })
2440 .transpose()?),
2441 HOOKS_DESTINATION => {
2442 let mut sources = vec![PRE_COMMIT_BLOCK.to_owned()];
2443 if guards {
2444 sources.push(PRE_COMMIT_MESSAGE_GUARDS.to_owned());
2445 sources.push(PRE_COMMIT_BRANCH_NAME_GUARD.to_owned());
2446 if shape.checkout_mode == CheckoutMode::LinkedWorktree {
2447 sources.push(PRE_COMMIT_WORKTREE_GUARD.to_owned());
2448 }
2449 match shape.integration {
2450 Integration::Forge => {
2451 sources.push(PRE_COMMIT_TRUNK_COMMIT_GUARD.to_owned());
2452 sources.push(PRE_COMMIT_TRUNK_PUSH_GUARD.to_owned());
2453 }
2454 Integration::Local => {
2455 sources.push(PRE_COMMIT_LOCAL_TRUNK_COMMIT_GUARD.to_owned());
2456 sources.push(PRE_COMMIT_LOCAL_TRUNK_PUSH_GUARDS.to_owned());
2457 }
2458 }
2459 }
2460 if shape.release {
2461 sources.push(PRE_COMMIT_TAG_GUARD.to_owned());
2462 sources.push(PRE_COMMIT_STATUS_RELEASE_FILES.to_owned());
2463 }
2464 sources.push(if guards {
2465 PRE_COMMIT_SWEEP_SKIP.to_owned()
2466 } else {
2467 PRE_COMMIT_SWEEP_NONE.to_owned()
2468 });
2469 if shape.integration == Integration::Local {
2470 sources.push(PRE_COMMIT_INTEGRATE_NOTE.to_owned());
2471 }
2472 Ok(Some((hooks_block_of(&shape)?, sources, catalog::GUARDS)))
2473 }
2474 other => Err(anyhow::anyhow!("{other} is not a block destination").into()),
2475 }
2476}
2477
2478#[must_use]
2480pub fn block_markers(destination: &str) -> Option<(&'static str, &'static str)> {
2481 match destination {
2482 AGENTS_DESTINATION | GLOSSARY_DESTINATION => Some((BLOCK_BEGIN, BLOCK_END)),
2483 HOOKS_DESTINATION => Some((HOOKS_BEGIN, HOOKS_END)),
2484 _ => None,
2485 }
2486}
2487
2488#[must_use]
2491pub fn extract_block<'a>(text: &'a str, begin: &str, end: &str) -> Option<&'a str> {
2492 let start = text.find(begin)?;
2493 let stop = text[start..].find(end)? + start + end.len();
2494 Some(&text[start..stop])
2495}
2496
2497#[must_use]
2504pub fn splice_marked_block(existing: Option<&[u8]>, block: &str) -> Vec<u8> {
2505 let block = block.as_bytes();
2506 let Some(text) = existing else {
2507 return [block, b"\n"].concat();
2508 };
2509 if let Some(start) = find(text, BLOCK_BEGIN.as_bytes())
2513 && let Some(offset) = find(&text[start..], BLOCK_END.as_bytes())
2514 {
2515 let stop = start + offset + BLOCK_END.len();
2516 return [&text[..start], block, &text[stop..]].concat();
2517 }
2518 let mut out = Vec::with_capacity(text.len() + block.len() + 3);
2522 out.extend_from_slice(text);
2523 if !text.ends_with(b"\n") {
2524 out.push(b'\n');
2525 }
2526 out.push(b'\n');
2527 out.extend_from_slice(block);
2528 out.push(b'\n');
2529 out
2530}
2531
2532pub fn splice_hooks_block(existing: Option<&str>, block: &str) -> Result<String, String> {
2545 let Some(text) = existing else {
2546 return Ok(format!("{HOOK_TYPES_LINE}\n\nrepos:\n{block}\n"));
2547 };
2548 if let Some(defect) = hooks_marker_defect(text) {
2549 return Err(defect);
2550 }
2551 if let Some(found) = extract_block(text, HOOKS_BEGIN, HOOKS_END) {
2552 return Ok(text.replacen(found, block, 1));
2553 }
2554 let mut out = String::with_capacity(text.len() + block.len() + 1);
2555 let mut placed = false;
2556 for line in text.split_inclusive('\n') {
2557 out.push_str(line);
2558 if !placed && line.trim_end() == "repos:" {
2559 if !out.ends_with('\n') {
2560 out.push('\n');
2561 }
2562 out.push_str(block);
2563 out.push('\n');
2564 placed = true;
2565 }
2566 }
2567 if placed {
2568 Ok(out)
2569 } else {
2570 Err(format!(
2571 "{HOOKS_DESTINATION} exists with no repos: line, so the hook block has nowhere to land"
2572 ))
2573 }
2574}
2575
2576#[must_use]
2586pub fn marker_defect(destination: &str, text: &str) -> Option<String> {
2587 let (begin, end) = block_markers(destination)?;
2588 let begins = text.matches(begin).count();
2589 let ends = text.matches(end).count();
2590 if begins > 1 || ends > 1 {
2591 return Some(format!(
2592 "{destination} carries more than one release-kit marker pair; release-kit owns exactly one block"
2593 ));
2594 }
2595 match (text.find(begin), text.find(end)) {
2596 (Some(begin), Some(end)) if end > begin => None,
2597 (None, None) => None,
2598 _ => Some(format!(
2599 "{destination} carries an unmatched or misordered release-kit marker, so the block's extent is ambiguous"
2600 )),
2601 }
2602}
2603
2604#[must_use]
2607pub fn hooks_marker_defect(text: &str) -> Option<String> {
2608 marker_defect(HOOKS_DESTINATION, text)
2609}
2610
2611fn propose_document(
2615 destination: &str,
2616 existing: Option<&[u8]>,
2617 region: &[u8],
2618) -> Result<Vec<u8>, String> {
2619 let block = String::from_utf8_lossy(region).into_owned();
2624 if let Some(text) = existing
2625 && let Some(defect) = marker_defect(destination, &String::from_utf8_lossy(text))
2626 {
2627 return Err(defect);
2628 }
2629 if destination == HOOKS_DESTINATION {
2630 let text = match existing {
2634 None => None,
2635 Some(bytes) => Some(std::str::from_utf8(bytes).map_err(|_| {
2636 format!(
2637 "{destination} is not UTF-8, so the hook block has nowhere to land without rewriting the target's bytes"
2638 )
2639 })?),
2640 };
2641 return splice_hooks_block(text, &block).map(String::into_bytes);
2642 }
2643 Ok(splice_marked_block(existing, &block))
2644}
2645
2646#[must_use]
2658pub fn nix_unsupported_shape(shape: &CrateShape) -> Option<String> {
2659 let Some(text) = shape.cargo_toml.as_deref() else {
2660 return Some(
2661 "the target has no readable Cargo.toml, which the seeded package expression reads; no Nix file lands".to_owned(),
2662 );
2663 };
2664 let Ok(table) = text.parse::<toml::Table>() else {
2665 return Some(
2666 "the target's Cargo.toml does not parse, and the seeded package expression reads it; no Nix file lands".to_owned(),
2667 );
2668 };
2669 if !table.contains_key("package") {
2670 return Some(
2671 "the target's Cargo.toml has no [package] table; the seed supports a single crate, so no Nix file lands".to_owned(),
2672 );
2673 }
2674 if !shape.cargo_lock {
2675 return Some(
2676 "the target has no Cargo.lock, which the seeded package expression builds from; commit one, then opt in".to_owned(),
2677 );
2678 }
2679 let implicit_bin = shape.main_rs
2680 && table
2681 .get("package")
2682 .and_then(toml::Value::as_table)
2683 .and_then(|package| package.get("autobins"))
2684 .and_then(toml::Value::as_bool)
2685 != Some(false);
2686 let explicit_bins = table.get("bin").and_then(toml::Value::as_array);
2687 if explicit_bins.is_none() && !implicit_bin {
2688 return Some(
2689 "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(),
2690 );
2691 }
2692 if let Some(bins) = explicit_bins {
2698 let required = bins
2699 .first()
2700 .and_then(toml::Value::as_table)
2701 .and_then(|bin| bin.get("required-features"))
2702 .and_then(toml::Value::as_array);
2703 if let Some(required) = required {
2704 let enabled = default_features(&table);
2705 let missing = required
2706 .iter()
2707 .filter_map(toml::Value::as_str)
2708 .any(|feature| !enabled.contains(feature));
2709 if missing {
2710 return Some(
2711 "the target's first [[bin]] entry requires features a default build does not enable; no Nix file lands".to_owned(),
2712 );
2713 }
2714 }
2715 }
2716 None
2717}
2718
2719fn dep_edge_suppresses(features: &toml::Table, name: &str) -> bool {
2722 let edge = format!("dep:{name}");
2723 features.values().any(|list| {
2724 list.as_array().is_some_and(|entries| {
2725 entries
2726 .iter()
2727 .filter_map(toml::Value::as_str)
2728 .any(|entry| entry == edge)
2729 })
2730 })
2731}
2732
2733fn is_optional_dependency(table: &toml::Table, name: &str) -> bool {
2736 ["dependencies", "build-dependencies"]
2737 .iter()
2738 .any(|section| {
2739 table
2740 .get(*section)
2741 .and_then(toml::Value::as_table)
2742 .and_then(|dependencies| dependencies.get(name))
2743 .and_then(toml::Value::as_table)
2744 .and_then(|dependency| dependency.get("optional"))
2745 .and_then(toml::Value::as_bool)
2746 == Some(true)
2747 })
2748}
2749
2750fn default_features(table: &toml::Table) -> std::collections::BTreeSet<String> {
2757 let Some(features) = table.get("features").and_then(toml::Value::as_table) else {
2758 return std::collections::BTreeSet::new();
2759 };
2760 let mut enabled = std::collections::BTreeSet::new();
2761 let mut queue = vec!["default".to_owned()];
2762 while let Some(name) = queue.pop() {
2763 if !enabled.insert(name.clone()) {
2764 continue;
2765 }
2766 if let Some(implies) = features.get(&name).and_then(toml::Value::as_array) {
2767 for implied in implies.iter().filter_map(toml::Value::as_str) {
2768 if implied.starts_with("dep:") || implied.contains("?/") {
2769 continue;
2773 }
2774 if let Some((package, _)) = implied.split_once('/') {
2775 let feature_exists =
2783 features.contains_key(package) || !dep_edge_suppresses(features, package);
2784 if is_optional_dependency(table, package) && feature_exists {
2785 queue.push(package.to_owned());
2786 }
2787 } else {
2788 queue.push(implied.to_owned());
2789 }
2790 }
2791 }
2792 }
2793 enabled
2794}
2795
2796#[must_use]
2804pub fn flake_pair_withheld(
2805 flake_recorded: bool,
2806 flake_nix_present: bool,
2807 flake_lock_present: bool,
2808) -> Option<String> {
2809 if flake_recorded {
2810 return None;
2811 }
2812 let present: Vec<&str> = [
2813 ("flake.nix", flake_nix_present),
2814 ("flake.lock", flake_lock_present),
2815 ]
2816 .into_iter()
2817 .filter_map(|(name, present)| present.then_some(name))
2818 .collect();
2819 if present.is_empty() {
2820 return None;
2821 }
2822 Some(format!(
2823 "the target already carries {}; its flake pair stays its own",
2824 present.join(" and ")
2825 ))
2826}
2827
2828#[must_use]
2838pub fn nix_withholding(
2839 nix: bool,
2840 evidence: &TargetEvidence,
2841) -> Option<(&'static [&'static str], String)> {
2842 if !nix {
2843 return None;
2844 }
2845 if let Some(reason) = nix_unsupported_shape(&evidence.crate_shape) {
2846 return Some((&NIX_DESTINATIONS[..], reason));
2847 }
2848 flake_pair_withheld(
2849 evidence.flake_recorded,
2850 evidence.flake_nix_present,
2851 evidence.flake_lock_present,
2852 )
2853 .map(|reason| (&NIX_WITHHOLDABLE[..], reason))
2854}
2855
2856#[cfg(test)]
2857mod tests {
2858 use super::{
2859 AGENTS_DESTINATION, BLOCK_BEGIN, BLOCK_DESTINATIONS, BLOCK_END, Candidate, Collision,
2860 CrateShape, GLOSSARY_DESTINATION, HOOK_TYPES_LINE, HOOKS_BEGIN, HOOKS_DESTINATION,
2861 HOOKS_END, Placement, Projection, ProjectionInput, TargetEvidence, extract_block,
2862 };
2863 use crate::landing::{Params, Style};
2864
2865 fn supported_shape() -> CrateShape {
2867 CrateShape {
2868 cargo_toml: Some("[package]\nname = \"widget\"\nversion = \"0.1.0\"\n".to_owned()),
2869 cargo_lock: true,
2870 main_rs: true,
2871 }
2872 }
2873
2874 fn input(evidence: TargetEvidence) -> ProjectionInput {
2875 let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
2876 params.set_nix_for_test(true);
2877 ProjectionInput { params, evidence }
2878 }
2879
2880 fn compute(evidence: TargetEvidence) -> Projection {
2881 Projection::compute(&input(evidence)).expect("the embedded pair projects")
2882 }
2883
2884 fn candidate<'a>(projection: &'a Projection, destination: &str) -> &'a Candidate {
2885 projection
2886 .candidates
2887 .iter()
2888 .find(|candidate| candidate.destination == destination)
2889 .expect("the destination projects")
2890 }
2891
2892 #[test]
2897 fn the_workflow_guards_land_only_where_requested() {
2898 let mut plain = input(TargetEvidence {
2899 crate_shape: supported_shape(),
2900 ..TargetEvidence::default()
2901 });
2902 plain.params.set_workflow_guards_for_test(false);
2903 let plain = Projection::compute(&plain).expect("the embedded pair projects");
2904 assert!(
2905 !plain
2906 .candidates
2907 .iter()
2908 .any(|candidate| candidate.destination == GLOSSARY_DESTINATION),
2909 "a target without the guards takes no glossary"
2910 );
2911 let agents =
2912 String::from_utf8(candidate(&plain, AGENTS_DESTINATION).bytes.clone()).expect("text");
2913 assert!(!agents.contains("guides and never drives"), "{agents}");
2914 let hooks =
2915 String::from_utf8(candidate(&plain, HOOKS_DESTINATION).bytes.clone()).expect("text");
2916 assert!(!hooks.contains("conventional-pre-commit"), "{hooks}");
2917 assert!(hooks.contains("rk-no-hand-authored-tag"), "{hooks}");
2918
2919 let guarded = compute(TargetEvidence {
2920 crate_shape: supported_shape(),
2921 ..TargetEvidence::default()
2922 });
2923 let glossary = candidate(&guarded, GLOSSARY_DESTINATION);
2924 assert_eq!(
2925 glossary.capability,
2926 crate::profile::catalog::WORKFLOW_GUARDS
2927 );
2928 assert_eq!(
2929 candidate(&guarded, AGENTS_DESTINATION).capability,
2930 crate::profile::catalog::GUARDS
2931 );
2932 let hooks =
2933 String::from_utf8(candidate(&guarded, HOOKS_DESTINATION).bytes.clone()).expect("text");
2934 for hook in ["conventional-pre-commit", "rk-message", "rk-branch-name"] {
2935 assert!(hooks.contains(hook), "{hook}: {hooks}");
2936 }
2937 }
2938
2939 fn outside(bytes: &[u8], begin: &str, end: &str) -> (Vec<u8>, Vec<u8>) {
2941 let text = String::from_utf8_lossy(bytes);
2942 let start = text.find(begin).expect("the begin marker is present");
2943 let stop = text[start..].find(end).expect("the end marker is present") + start + end.len();
2944 (bytes[..start].to_vec(), bytes[stop..].to_vec())
2945 }
2946
2947 #[test]
2948 fn equal_projection_inputs_yield_byte_identical_projections() {
2949 let mut documents = std::collections::BTreeMap::new();
2950 documents.insert(
2951 AGENTS_DESTINATION.to_owned(),
2952 b"# Widget\n\nOwn rules.\n".to_vec(),
2953 );
2954 let evidence = TargetEvidence {
2955 documents,
2956 crate_shape: supported_shape(),
2957 ..TargetEvidence::default()
2958 };
2959 let first = input(evidence.clone());
2960 let second = input(evidence);
2961 assert_eq!(first, second, "the inputs are values and compare equal");
2962 let a = Projection::compute(&first).expect("the pair projects");
2963 let b = Projection::compute(&second).expect("the pair projects");
2964 assert_eq!(a.candidates.len(), b.candidates.len());
2965 for (x, y) in a.candidates.iter().zip(&b.candidates) {
2966 assert_eq!(x.destination, y.destination);
2967 assert_eq!(x.kind, y.kind);
2968 assert_eq!(x.placement, y.placement);
2969 assert_eq!(x.bytes, y.bytes, "{}", x.destination);
2970 assert_eq!(x.region, y.region, "{}", x.destination);
2971 assert_eq!(x.sources, y.sources, "{}", x.destination);
2972 }
2973 assert_eq!(a, b);
2974 let destinations: Vec<&str> = a
2975 .candidates
2976 .iter()
2977 .map(|candidate| candidate.destination.as_str())
2978 .collect();
2979 let mut sorted = destinations.clone();
2980 sorted.sort_unstable();
2981 assert_eq!(destinations, sorted, "candidates sort by destination");
2982 assert!(a.omissions.is_empty(), "{:?}", a.omissions);
2983 assert!(a.collisions.is_empty(), "{:?}", a.collisions);
2984 }
2985
2986 #[test]
2990 fn the_scorecard_destination_projects_only_under_the_opt_in() {
2991 use super::{Kind, SCORECARD_DESTINATIONS, kind_of};
2992 let destination = SCORECARD_DESTINATIONS[0];
2993 assert_eq!(kind_of(destination), Some(Kind::Rendered));
2994
2995 let project = |scorecard: bool| {
2996 let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
2997 params.set_scorecard_for_test(scorecard);
2998 Projection::compute(&ProjectionInput {
2999 params,
3000 evidence: TargetEvidence::default(),
3001 })
3002 .expect("the embedded pair projects")
3003 };
3004
3005 let off = project(false);
3006 assert!(
3007 !off.candidates
3008 .iter()
3009 .any(|candidate| candidate.destination == destination),
3010 "off by default, and an absent candidate is no omission"
3011 );
3012 assert!(off.omissions.is_empty(), "{:?}", off.omissions);
3013
3014 let on = project(true);
3015 let candidate = candidate(&on, destination);
3016 assert_eq!(candidate.kind, Kind::Rendered);
3017 assert_eq!(candidate.placement, Placement::Whole);
3018 let text = String::from_utf8_lossy(&candidate.bytes);
3019 assert!(!text.contains("RK_"), "a token survived: {text}");
3020 }
3021
3022 #[test]
3027 fn the_licence_judgment_reads_what_every_reader_obtains() {
3028 use super::licence_states_an_open_source_codebase as approved;
3029 for expression in [
3030 "MIT",
3031 "Apache-2.0",
3032 "MIT OR Apache-2.0",
3033 "MIT AND Apache-2.0",
3034 "(MIT OR Apache-2.0) AND ISC",
3035 "Apache-2.0 WITH LLVM-exception OR MIT",
3036 "GPL-3.0+",
3037 ] {
3038 assert!(approved(expression), "{expression} is OSI-approved");
3039 }
3040 for expression in [
3045 "MIT AND CC-BY-4.0",
3046 "CC-BY-4.0 AND MIT",
3047 "MIT AND (CC-BY-4.0 OR CC-BY-SA-4.0)",
3048 "(MIT OR CC-BY-4.0) AND MIT",
3049 "MIT OR Apache-2.0 AND CC-BY-4.0",
3050 "Apache-2.0 WITH LLVM-exception AND CC-BY-SA-3.0",
3051 ] {
3052 assert!(approved(expression), "{expression} states open source");
3053 }
3054 for expression in [
3055 "",
3056 " ",
3057 "LicenseRef-proprietary",
3058 "MIT AND LicenseRef-proprietary",
3059 "SEE LICENSE IN COPYING",
3060 "CC-BY-4.0",
3061 "DocumentRef-spdx:LicenseRef-proprietary",
3062 ] {
3063 assert!(!approved(expression), "{expression} is not recognized");
3064 }
3065 for expression in [
3070 "MIT OR CC-BY-4.0",
3071 "CC-BY-4.0 AND CC-BY-SA-4.0",
3072 "CC-BY-4.0 AND LicenseRef-proprietary",
3073 "CC0-1.0",
3074 "MIT AND CC-BY-4.0 AND LicenseRef-proprietary",
3075 ] {
3076 assert!(!approved(expression), "{expression} grants no code terms");
3077 }
3078 assert!(
3083 !approved("CC-BY-4.0 OR CC-BY-SA-4.0 AND MIT"),
3084 "AND binds tighter than OR"
3085 );
3086 }
3087
3088 #[test]
3094 fn the_licence_judgment_holds_the_expression_to_the_spdx_grammar() {
3095 use super::licence_states_an_open_source_codebase as approved;
3096 for expression in [
3101 "MIT OR",
3102 "OR MIT",
3103 "MIT AND",
3104 "MIT WITH",
3105 "WITH LLVM-exception",
3106 "(MIT",
3107 "MIT)",
3108 "MIT Apache-2.0",
3109 "()",
3110 "(MIT OR Apache-2.0",
3111 "MIT OR (Apache-2.0",
3112 "MIT OR ()",
3113 "AND",
3114 "(",
3115 ")",
3116 "MIT WITH AND ISC",
3117 "MIT OR OR ISC",
3118 "MIT @ Apache-2.0",
3119 ] {
3120 assert!(!approved(expression), "{expression} is malformed");
3121 }
3122 for expression in [
3124 "MIT AND (Apache-2.0 OR ISC)",
3125 "((MIT))",
3126 "Apache-2.0 WITH LLVM-exception AND ISC",
3127 "(Apache-2.0 WITH LLVM-exception)",
3128 ] {
3129 assert!(approved(expression), "{expression} is well formed");
3130 }
3131 for expression in [
3136 "mit",
3137 "MiT",
3138 "apache-2.0 OR mit",
3139 "APACHE-2.0 WITH llvm-exception",
3140 ] {
3141 assert!(
3142 approved(expression),
3143 "{expression} names an approved licence"
3144 );
3145 }
3146 for expression in [
3150 "MIT or Apache-2.0",
3151 "MIT and Apache-2.0",
3152 "MIT with LLVM-exception",
3153 ] {
3154 assert!(!approved(expression), "{expression} carries no operator");
3155 }
3156 for expression in [
3160 "MIT WITH definitely-not-an-spdx-exception",
3161 "MIT WITH MIT",
3162 "MIT WITH Apache-2.0",
3163 ] {
3164 assert!(!approved(expression), "{expression} names no exception");
3165 }
3166 for expression in [
3170 "GPL-2.0-only WITH GCC-exception-2.0",
3171 "GPL-2.0-or-later WITH Classpath-exception-2.0",
3172 "Apache-2.0 WITH Swift-exception",
3173 "GPL-3.0-only WITH Autoconf-exception-generic",
3174 ] {
3175 assert!(approved(expression), "{expression} names a real exception");
3176 }
3177 }
3178
3179 #[test]
3185 fn a_recorded_provider_its_pair_cannot_run_is_named() {
3186 use super::{Provider, code_scanning_incompatibility};
3187
3188 assert!(code_scanning_incompatibility(None, Some("bash"), Some("gitlab")).is_none());
3189 assert!(
3190 code_scanning_incompatibility(Some(Provider::Semgrep), Some("rust"), Some("gitlab"))
3191 .is_none()
3192 );
3193 assert!(
3194 code_scanning_incompatibility(Some(Provider::CodeQl), Some("rust"), Some("github"))
3195 .is_none()
3196 );
3197
3198 let bash =
3199 code_scanning_incompatibility(Some(Provider::Semgrep), Some("bash"), Some("github"))
3200 .expect("a binding with no scanner is named");
3201 assert!(bash.contains("bash binding"), "{bash}");
3202 let gitlab =
3203 code_scanning_incompatibility(Some(Provider::CodeQl), Some("rust"), Some("gitlab"))
3204 .expect("codeql on gitlab is named");
3205 assert!(gitlab.contains("codeql"), "{gitlab}");
3206 let release_less =
3207 code_scanning_incompatibility(Some(Provider::Semgrep), None, Some("github"))
3208 .expect("no driver is named");
3209 assert!(
3210 release_less.contains("no automatic release driver"),
3211 "{release_less}"
3212 );
3213
3214 let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
3217 params.set_code_scanning_for_test(Some(Provider::Semgrep));
3218 let clean = Projection::compute(&ProjectionInput {
3219 params: params.clone(),
3220 evidence: TargetEvidence::default(),
3221 })
3222 .expect("the pair projects");
3223 assert!(
3224 clean.record_defects.is_empty(),
3225 "{:?}",
3226 clean.record_defects
3227 );
3228 }
3229
3230 #[test]
3234 fn the_code_scanning_destinations_follow_the_recorded_provider() {
3235 use super::{
3236 CODE_SCANNING_DESTINATIONS, Kind, Provider, code_scanning_licence_refusal, kind_of,
3237 };
3238 for (destination, _) in CODE_SCANNING_DESTINATIONS {
3239 assert_eq!(kind_of(destination), Some(Kind::Rendered), "{destination}");
3240 }
3241
3242 let project = |provider: Option<Provider>| {
3243 let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
3244 params.set_code_scanning_for_test(provider);
3245 Projection::compute(&ProjectionInput {
3246 params,
3247 evidence: TargetEvidence {
3248 crate_shape: CrateShape {
3249 cargo_toml: Some(
3250 "[package]\nname = \"widget\"\nlicense = \"MIT\"\n".to_owned(),
3251 ),
3252 ..CrateShape::default()
3253 },
3254 ..TargetEvidence::default()
3255 },
3256 })
3257 .expect("the embedded pair projects")
3258 };
3259 let landed = |projection: &Projection, destination: &str| {
3260 projection
3261 .candidates
3262 .iter()
3263 .any(|candidate| candidate.destination == destination)
3264 };
3265
3266 let off = project(None);
3267 for (destination, _) in CODE_SCANNING_DESTINATIONS {
3268 assert!(!landed(&off, destination), "{destination}");
3269 }
3270 assert!(off.licence_refusal.is_none());
3271
3272 let codeql = project(Some(Provider::CodeQl));
3273 assert!(landed(
3274 &codeql,
3275 ".github/workflows/code-scanning-codeql.yml"
3276 ));
3277 assert!(!landed(
3278 &codeql,
3279 ".github/workflows/code-scanning-semgrep.yml"
3280 ));
3281 assert!(codeql.licence_refusal.is_none(), "MIT satisfies the terms");
3282
3283 let semgrep = project(Some(Provider::Semgrep));
3284 assert!(landed(
3285 &semgrep,
3286 ".github/workflows/code-scanning-semgrep.yml"
3287 ));
3288 assert!(!landed(
3289 &semgrep,
3290 ".github/workflows/code-scanning-codeql.yml"
3291 ));
3292
3293 let proprietary = CrateShape {
3295 cargo_toml: Some("[package]\nlicense = \"LicenseRef-proprietary\"\n".to_owned()),
3296 ..CrateShape::default()
3297 };
3298 assert!(
3299 code_scanning_licence_refusal(Some(Provider::Semgrep), Some("rust"), &proprietary)
3300 .is_none()
3301 );
3302 let refusal =
3303 code_scanning_licence_refusal(Some(Provider::CodeQl), Some("rust"), &proprietary)
3304 .expect("codeql refuses a licence its terms do not cover");
3305 assert!(refusal.contains("LicenseRef-proprietary"), "{refusal}");
3306 assert!(refusal.contains("semgrep"), "{refusal}");
3307 let bash =
3310 code_scanning_licence_refusal(Some(Provider::CodeQl), Some("bash"), &proprietary)
3311 .expect("an unread binding refuses");
3312 assert!(bash.contains("bash binding"), "{bash}");
3313 }
3314
3315 #[test]
3321 fn the_projection_performs_no_filesystem_git_environment_clock_registry_or_network_read() {
3322 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/projection.rs");
3323 let text = std::fs::read_to_string(&path).expect("the source reads");
3324 let production = text.split("#[cfg(test)]").next().unwrap_or("");
3325 let needles = [
3326 "std::fs",
3327 "std::env",
3328 "std::process",
3329 "std::time",
3330 "SystemTime",
3331 "Instant",
3332 "std::net",
3333 "Command::new",
3334 "registry::",
3335 "curl",
3336 "reqwest",
3337 "blob(",
3338 ];
3339 let mut hits = Vec::new();
3340 for (index, line) in production.lines().enumerate() {
3341 if line.trim_start().starts_with("//") {
3342 continue;
3343 }
3344 for needle in needles {
3345 if line.contains(needle) {
3346 hits.push(format!("src/projection.rs:{}: {needle}", index + 1));
3347 }
3348 }
3349 }
3350 assert!(
3351 hits.is_empty(),
3352 "the projection reads beyond its inputs: {hits:?}"
3353 );
3354 }
3355
3356 #[test]
3357 #[allow(
3358 clippy::too_many_lines,
3359 reason = "one test walks the three marked destinations and the three unmarked shapes"
3360 )]
3361 fn marked_region_projection_preserves_every_target_byte_outside_the_markers() {
3362 let agents_before = "# Widget\n\nOperator prose above.\n\n";
3363 let agents_after = "\n\n## Our rules\n\nOperator prose below. \n";
3364 let glossary_before = "# Glossary\n\n- `spike` is a throwaway branch.\n\n";
3365 let glossary_after = "\n\n## More terms\n\n- `own` is ours.";
3366 let hooks_before = "default_install_hook_types: [pre-commit]\n\nrepos:\n";
3367 let hooks_after =
3368 "\n - repo: https://example.com/own\n rev: v1\n hooks:\n - id: own\n";
3369 let stale = |begin: &str, end: &str| format!("{begin}\nstale block\n{end}");
3370 let mut documents = std::collections::BTreeMap::new();
3371 documents.insert(
3372 AGENTS_DESTINATION.to_owned(),
3373 format!(
3374 "{agents_before}{}{agents_after}",
3375 stale(BLOCK_BEGIN, BLOCK_END)
3376 )
3377 .into_bytes(),
3378 );
3379 documents.insert(
3380 GLOSSARY_DESTINATION.to_owned(),
3381 format!(
3382 "{glossary_before}{}{glossary_after}",
3383 stale(BLOCK_BEGIN, BLOCK_END)
3384 )
3385 .into_bytes(),
3386 );
3387 documents.insert(
3388 HOOKS_DESTINATION.to_owned(),
3389 format!(
3390 "{hooks_before}{}{hooks_after}",
3391 stale(HOOKS_BEGIN, HOOKS_END)
3392 )
3393 .into_bytes(),
3394 );
3395 let projection = compute(TargetEvidence {
3396 documents: documents.clone(),
3397 crate_shape: supported_shape(),
3398 ..TargetEvidence::default()
3399 });
3400 assert!(
3401 projection.collisions.is_empty(),
3402 "{:?}",
3403 projection.collisions
3404 );
3405 for (destination, before, after) in [
3406 (AGENTS_DESTINATION, agents_before, agents_after),
3407 (GLOSSARY_DESTINATION, glossary_before, glossary_after),
3408 (HOOKS_DESTINATION, hooks_before, hooks_after),
3409 ] {
3410 let candidate = candidate(&projection, destination);
3411 let Placement::Region { begin, end } = candidate.placement else {
3412 panic!("{destination} is a region");
3413 };
3414 let region = candidate
3415 .region
3416 .as_deref()
3417 .expect("a region carries its block");
3418 let (head, tail) = outside(&candidate.bytes, begin, end);
3419 assert_eq!(
3420 head,
3421 before.as_bytes(),
3422 "{destination}: bytes before the markers"
3423 );
3424 assert_eq!(
3425 tail,
3426 after.as_bytes(),
3427 "{destination}: bytes after the markers"
3428 );
3429 let inside = &candidate.bytes[head.len()..candidate.bytes.len() - tail.len()];
3430 assert_eq!(
3431 inside, region,
3432 "{destination}: the region is the rendered block"
3433 );
3434 let (existing_head, existing_tail) = outside(&documents[destination], begin, end);
3435 assert_eq!(head, existing_head);
3436 assert_eq!(tail, existing_tail);
3437 }
3438
3439 let own_hooks =
3443 "repos:\n - repo: https://example.com/own\n rev: v1\n hooks:\n - id: own\n";
3444 let own_agents = "# Widget\n\nOwn rules.";
3445 let mut documents = std::collections::BTreeMap::new();
3446 documents.insert(HOOKS_DESTINATION.to_owned(), own_hooks.as_bytes().to_vec());
3447 documents.insert(
3448 AGENTS_DESTINATION.to_owned(),
3449 own_agents.as_bytes().to_vec(),
3450 );
3451 let projection = compute(TargetEvidence {
3452 documents,
3453 crate_shape: supported_shape(),
3454 ..TargetEvidence::default()
3455 });
3456 assert!(
3457 projection.collisions.is_empty(),
3458 "{:?}",
3459 projection.collisions
3460 );
3461 let hooks = candidate(&projection, HOOKS_DESTINATION);
3462 let hooks_text = String::from_utf8_lossy(&hooks.bytes);
3463 let region = String::from_utf8_lossy(hooks.region.as_deref().expect("a region"));
3464 assert!(
3465 hooks_text.starts_with(&format!(
3466 "repos:\n{region}\n - repo: https://example.com/own"
3467 )),
3468 "{hooks_text}"
3469 );
3470 assert!(!hooks_text.contains(HOOK_TYPES_LINE));
3471 let agents = candidate(&projection, AGENTS_DESTINATION);
3472 assert!(agents.bytes.starts_with(own_agents.as_bytes()));
3473 assert_eq!(
3474 extract_block(
3475 &String::from_utf8_lossy(&agents.bytes),
3476 BLOCK_BEGIN,
3477 BLOCK_END
3478 )
3479 .map(str::as_bytes),
3480 agents.region.as_deref()
3481 );
3482 let glossary = candidate(&projection, GLOSSARY_DESTINATION);
3483 let region = glossary.region.as_deref().expect("a region");
3484 assert_eq!(
3485 glossary.bytes,
3486 [region, b"\n"].concat(),
3487 "an absent file is fresh"
3488 );
3489 }
3490
3491 #[test]
3497 fn a_hook_document_that_is_not_utf8_collides_instead_of_being_rewritten() {
3498 let mut documents = std::collections::BTreeMap::new();
3499 let mut invalid = b"repos:\n# own \xff above\n".to_vec();
3500 invalid.extend_from_slice(format!("{HOOKS_BEGIN}\nstale\n{HOOKS_END}\n").as_bytes());
3501 invalid.extend_from_slice(b" - repo: local \xff below\n");
3502 documents.insert(HOOKS_DESTINATION.to_owned(), invalid);
3503 let mut agents = b"# Widget r\xe9sum\xe9\n\n".to_vec();
3504 agents.extend_from_slice(format!("{BLOCK_BEGIN}\nstale\n{BLOCK_END}\n\n").as_bytes());
3505 agents.extend_from_slice(b"r\xe9sum\xe9\n");
3506 documents.insert(AGENTS_DESTINATION.to_owned(), agents.clone());
3507 let projection = compute(TargetEvidence {
3508 documents,
3509 crate_shape: supported_shape(),
3510 ..TargetEvidence::default()
3511 });
3512 let collided: Vec<&str> = projection
3513 .collisions
3514 .iter()
3515 .map(|c| c.destination.as_str())
3516 .collect();
3517 assert_eq!(collided, [HOOKS_DESTINATION]);
3518 assert!(
3519 projection.collisions[0].reason.contains("not UTF-8"),
3520 "{}",
3521 projection.collisions[0].reason
3522 );
3523 assert!(
3524 !projection
3525 .candidates
3526 .iter()
3527 .any(|c| c.destination == HOOKS_DESTINATION),
3528 "a colliding destination projects no candidate"
3529 );
3530 let agents = candidate(&projection, AGENTS_DESTINATION);
3531 assert!(
3532 agents.bytes.starts_with(b"# Widget r\xe9sum\xe9\n\n"),
3533 "{:?}",
3534 agents.bytes
3535 );
3536 assert!(
3537 agents.bytes.ends_with(b"\n\nr\xe9sum\xe9\n"),
3538 "{:?}",
3539 agents.bytes
3540 );
3541 assert!(
3542 !agents.bytes.contains(&0xEF),
3543 "a replacement character landed"
3544 );
3545
3546 let mut documents = std::collections::BTreeMap::new();
3547 let valid =
3548 format!("repos:\n# own above\n{HOOKS_BEGIN}\nstale\n{HOOKS_END}\n - repo: local\n");
3549 documents.insert(HOOKS_DESTINATION.to_owned(), valid.into_bytes());
3550 let projection = compute(TargetEvidence {
3551 documents,
3552 crate_shape: supported_shape(),
3553 ..TargetEvidence::default()
3554 });
3555 assert!(
3556 projection.collisions.is_empty(),
3557 "{:?}",
3558 projection.collisions
3559 );
3560 let hooks = candidate(&projection, HOOKS_DESTINATION);
3561 let text = String::from_utf8(hooks.bytes.clone()).expect("a valid document stays text");
3562 assert!(text.starts_with("repos:\n# own above\n"), "{text}");
3563 assert!(text.ends_with("\n - repo: local\n"), "{text}");
3564 assert!(!text.contains("stale"), "the region is replaced: {text}");
3565 }
3566
3567 #[test]
3571 fn a_whole_file_colliding_with_a_marked_region_names_both_source_paths() {
3572 let files: Vec<(String, &[u8])> = vec![
3573 ("snippets/_shared/github/SECURITY.md".to_owned(), b"policy"),
3574 ("snippets/rust/github/AGENTS.md".to_owned(), b"whole"),
3575 ];
3576 let err = Projection::compute_over(&files, &input(TargetEvidence::default()))
3577 .expect_err("a whole file at a block destination refuses");
3578 let text = err.to_string();
3579 assert!(text.contains("snippets/rust/github/AGENTS.md"), "{text}");
3580 assert!(text.contains(super::AGENTS_BLOCK), "{text}");
3581 assert!(text.contains(super::AGENTS_LINE_AGENT_POLICY), "{text}");
3582 assert!(text.contains("embedded sources are defective"), "{text}");
3583 }
3584
3585 #[test]
3586 fn duplicate_whole_file_destinations_and_overlapping_marked_regions_refuse_with_the_conflicting_source_names()
3587 {
3588 let files: Vec<(String, &[u8])> = vec![
3591 ("snippets/_shared/github/SECURITY.md".to_owned(), b"shared"),
3592 ("snippets/rust/github/SECURITY.md".to_owned(), b"pair"),
3593 ("snippets/rust/github/release-plz.toml".to_owned(), b"seed"),
3594 ];
3595 let err = Projection::compute_over(&files, &input(TargetEvidence::default()))
3596 .expect_err("a doubled destination refuses");
3597 let text = err.to_string();
3598 assert!(
3599 text.contains("snippets/_shared/github/SECURITY.md"),
3600 "{text}"
3601 );
3602 assert!(text.contains("snippets/rust/github/SECURITY.md"), "{text}");
3603 assert!(text.contains("embedded sources are defective"), "{text}");
3604
3605 let clean: Vec<(String, &[u8])> = vec![
3606 ("snippets/_shared/github/SECURITY.md".to_owned(), b"shared"),
3607 ("snippets/rust/github/release-plz.toml".to_owned(), b"seed"),
3608 ];
3609 let projection = Projection::compute_over(&clean, &input(TargetEvidence::default()))
3610 .expect("a clean list projects");
3611 let whole: Vec<&str> = projection
3612 .candidates
3613 .iter()
3614 .filter(|c| c.placement == Placement::Whole)
3615 .map(|c| c.destination.as_str())
3616 .collect();
3617 assert_eq!(whole, ["SECURITY.md", "release-plz.toml"]);
3618 assert_eq!(
3619 projection
3620 .candidates
3621 .iter()
3622 .find(|c| c.destination == "SECURITY.md")
3623 .map(|c| c.sources.clone()),
3624 Some(vec!["snippets/_shared/github/SECURITY.md".to_owned()])
3625 );
3626
3627 let doubled = format!("{BLOCK_BEGIN}\na\n{BLOCK_END}\n{BLOCK_BEGIN}\nb\n{BLOCK_END}\n");
3628 let unmatched = format!("repos:\n{HOOKS_BEGIN}\n - repo: local\n");
3629 let misordered = format!("# G\n{BLOCK_END}\n{BLOCK_BEGIN}\n");
3630 let mut documents = std::collections::BTreeMap::new();
3631 documents.insert(AGENTS_DESTINATION.to_owned(), doubled.into_bytes());
3632 documents.insert(HOOKS_DESTINATION.to_owned(), unmatched.into_bytes());
3633 documents.insert(GLOSSARY_DESTINATION.to_owned(), misordered.into_bytes());
3634 let projection = compute(TargetEvidence {
3635 documents,
3636 crate_shape: supported_shape(),
3637 ..TargetEvidence::default()
3638 });
3639 let mut collided: Vec<&str> = projection
3640 .collisions
3641 .iter()
3642 .map(|Collision { destination, .. }| destination.as_str())
3643 .collect();
3644 collided.sort_unstable();
3645 let mut expected = BLOCK_DESTINATIONS.to_vec();
3646 expected.sort_unstable();
3647 assert_eq!(collided, expected);
3648 for collision in &projection.collisions {
3649 assert!(
3650 collision.reason.contains(&collision.destination),
3651 "{collision:?}"
3652 );
3653 assert!(
3654 !projection
3655 .candidates
3656 .iter()
3657 .any(|candidate| candidate.destination == collision.destination),
3658 "{} collided and still projects",
3659 collision.destination
3660 );
3661 }
3662 let agents = projection
3663 .collisions
3664 .iter()
3665 .find(|c| c.destination == AGENTS_DESTINATION)
3666 .expect("the doubled document collides");
3667 assert!(agents.reason.contains("more than one"), "{}", agents.reason);
3668 let hooks = projection
3669 .collisions
3670 .iter()
3671 .find(|c| c.destination == HOOKS_DESTINATION)
3672 .expect("the unmatched document collides");
3673 assert!(hooks.reason.contains("unmatched"), "{}", hooks.reason);
3674 }
3675}