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 pub python_shape: PythonShape,
83}
84
85impl TargetEvidence {
86 #[must_use]
88 pub fn document(&self, destination: &str) -> Option<&[u8]> {
89 self.documents.get(destination).map(Vec::as_slice)
90 }
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Default)]
96pub struct CrateShape {
97 pub cargo_toml: Option<String>,
99 pub cargo_lock: bool,
101 pub main_rs: bool,
103}
104
105#[derive(Debug, Clone, PartialEq, Eq, Default)]
109pub struct PythonShape {
110 pub pyproject_toml: Option<String>,
113 pub uv_toml: Option<String>,
115 pub uv_lock: bool,
117}
118
119impl PythonShape {
120 #[must_use]
129 pub fn uv_required(&self) -> Option<String> {
130 let from_uv = |text: &str| {
131 text.parse::<toml::Table>()
132 .ok()
133 .and_then(|table| table.get("required-version")?.as_str().map(str::to_owned))
134 };
135 let from_project = || {
136 self.pyproject_toml
137 .as_deref()
138 .and_then(|text| text.parse::<toml::Table>().ok())
139 .and_then(|table| {
140 table
141 .get("tool")?
142 .get("uv")?
143 .get("required-version")?
144 .as_str()
145 .map(str::to_owned)
146 })
147 };
148 let stated = self.uv_toml.as_deref().map_or_else(from_project, from_uv)?;
149 let exact = stated.trim();
150 let exact = exact.strip_prefix("==").map_or(exact, str::trim);
151 let parts: Vec<&str> = exact.split('.').collect();
152 (parts.len() == 3
153 && parts
154 .iter()
155 .all(|part| !part.is_empty() && part.bytes().all(|b| b.is_ascii_digit())))
156 .then(|| exact.to_owned())
157 }
158
159 #[must_use]
162 pub fn refusal(&self) -> Option<String> {
163 let Some(text) = self.pyproject_toml.as_deref() else {
164 return Some(
165 "the target carries no pyproject.toml; the python release writes [project] version through uv version; run uv init, then uv lock".to_owned(),
166 );
167 };
168 let Ok(table) = text.parse::<toml::Table>() else {
169 return Some(
170 "the target's pyproject.toml does not parse, so its version cannot be read; repair it, then uv lock".to_owned(),
171 );
172 };
173 let project = table.get("project");
174 let dynamic = project
175 .and_then(|project| project.get("dynamic"))
176 .and_then(toml::Value::as_array)
177 .is_some_and(|list| list.iter().any(|item| item.as_str() == Some("version")));
178 let literal = project
179 .and_then(|project| project.get("version"))
180 .is_some_and(toml::Value::is_str);
181 if dynamic || !literal {
182 return Some(
183 "the target's pyproject.toml carries no static [project] version, and the committed version must lead the tag; write a static version = \"<version>\" in [project] and drop version from dynamic, then uv lock".to_owned(),
184 );
185 }
186 if !self.uv_lock {
187 return Some(
188 "the target carries no uv.lock, which the python release keeps in step with every version write; run uv lock and commit uv.lock".to_owned(),
189 );
190 }
191 None
192 }
193}
194
195#[derive(Debug, Clone, PartialEq, Eq)]
201pub struct Projection {
202 pub capabilities: Vec<Selection>,
205 pub candidates: Vec<Candidate>,
207 pub omissions: Vec<Omission>,
212 pub collisions: Vec<Collision>,
215 pub licence_refusal: Option<String>,
221 pub driver_refusal: Option<String>,
226 pub record_defects: Vec<String>,
231}
232
233#[derive(Debug, Clone, PartialEq, Eq)]
235pub struct Candidate {
236 pub capability: &'static str,
238 pub destination: String,
240 pub kind: Kind,
242 pub placement: Placement,
244 pub bytes: Vec<u8>,
248 pub region: Option<Vec<u8>>,
251 pub sources: Vec<String>,
254}
255
256#[derive(Debug, Clone, Copy, PartialEq, Eq)]
258pub enum Placement {
259 Whole,
261 Region {
264 begin: &'static str,
266 end: &'static str,
268 },
269}
270
271#[derive(Debug, Clone, PartialEq, Eq)]
273pub struct Omission {
274 pub destination: String,
276 pub reason: String,
278 pub action: Option<String>,
282}
283
284#[derive(Debug, Clone, PartialEq, Eq)]
286pub struct Collision {
287 pub destination: String,
289 pub reason: String,
291}
292
293impl Projection {
294 pub fn compute(input: &ProjectionInput) -> Result<Self, RkError> {
304 Self::compute_over(&embedded_snippets(), input)
305 }
306
307 #[allow(
311 clippy::too_many_lines,
312 reason = "one pass selects, renders, splices, and withholds; splitting it would separate a withheld destination from the selection that offered it"
313 )]
314 fn compute_over(files: &[(String, &[u8])], input: &ProjectionInput) -> Result<Self, RkError> {
315 let params = &input.params;
316 let evidence = &input.evidence;
317 let availability = Availability::over(
318 files
319 .iter()
320 .filter_map(|(path, _)| path.strip_prefix("snippets/").map(str::to_owned))
321 .collect(),
322 );
323 let mut capabilities = catalog::select(params, &availability);
324 let mut candidates: Vec<Candidate> = Vec::new();
325 for selection in &capabilities {
326 if !selection.lands() {
327 continue;
328 }
329 for source in &selection.sources {
330 let destination = source
331 .splitn(3, '/')
332 .nth(2)
333 .ok_or_else(|| {
334 anyhow::anyhow!(
335 "{source}: a snippet path has a zone, a forge, and a destination"
336 )
337 })?
338 .to_owned();
339 let path = format!("snippets/{source}");
340 let bytes = files
341 .iter()
342 .find(|(candidate, _)| *candidate == path)
343 .map(|(_, bytes)| *bytes)
344 .ok_or_else(|| {
345 anyhow::anyhow!(
346 "{path}: the catalog selected a source the tree does not carry"
347 )
348 })?;
349 if let Some(existing) = candidates
350 .iter()
351 .find(|candidate| candidate.destination == destination)
352 {
353 return Err(anyhow::anyhow!(
354 "{} and {} both ship {destination}: {} and {path}; the embedded sources are defective",
355 existing.capability,
356 selection.id,
357 existing.sources.join(", ")
358 )
359 .into());
360 }
361 let kind = kind_of(&destination).ok_or_else(|| {
362 anyhow::anyhow!(
363 "the embedded sources do not classify {destination}; the kind table is stale"
364 )
365 })?;
366 let rendered = match kind {
369 Kind::Rendered => uv_lines(&render(bytes, params), &evidence.python_shape),
370 Kind::Seeded => registry_lines(bytes, params),
371 Kind::State => bytes.to_vec(),
372 };
373 candidates.push(Candidate {
374 capability: selection.id,
375 destination,
376 kind,
377 placement: Placement::Whole,
378 bytes: rendered,
379 region: None,
380 sources: vec![path],
381 });
382 }
383 }
384 for candidate in skill_candidates()? {
390 if let Some(existing) = candidates
391 .iter()
392 .find(|existing| existing.destination == candidate.destination)
393 {
394 return Err(anyhow::anyhow!(
395 "{} and the skill packages both ship {}; the embedded sources are defective",
396 existing.capability,
397 candidate.destination
398 )
399 .into());
400 }
401 candidates.push(candidate);
402 }
403 let mut collisions = Vec::new();
404 for destination in BLOCK_DESTINATIONS {
405 let Some((template, sources, capability)) = block_template(destination, params)? else {
406 continue;
407 };
408 if let Some(whole) = candidates
409 .iter()
410 .find(|candidate| candidate.destination == destination)
411 {
412 return Err(anyhow::anyhow!(
413 "{destination} is both a whole file from {} and a marked region from {}; the embedded sources are defective",
414 whole.sources.join(", "),
415 sources.join(", ")
416 )
417 .into());
418 }
419 let region = render(template.as_bytes(), params);
420 let (begin, end) = block_markers(destination).ok_or_else(|| {
421 anyhow::anyhow!("{destination} is a block destination with no markers")
422 })?;
423 match propose_document(
424 destination,
425 evidence.document(destination),
426 ®ion,
427 params.integration(),
428 ) {
429 Ok(bytes) => candidates.push(Candidate {
430 capability,
431 destination: destination.to_owned(),
432 kind: Kind::Rendered,
433 placement: Placement::Region { begin, end },
434 bytes,
435 region: Some(region),
436 sources,
437 }),
438 Err(reason) => collisions.push(Collision {
439 destination: destination.to_owned(),
440 reason,
441 }),
442 }
443 }
444 let mut omissions = Vec::new();
445 if let Some((set, reason)) = nix_withholding(params.nix_packaging(), evidence)
446 && candidates
447 .iter()
448 .any(|candidate| candidate.capability == catalog::PACKAGING_NIX)
449 {
450 candidates.retain(|candidate| {
451 if set.contains(&candidate.destination.as_str()) {
452 omissions.push(Omission {
453 destination: candidate.destination.clone(),
454 reason: reason.clone(),
455 action: None,
456 });
457 false
458 } else {
459 true
460 }
461 });
462 withhold(&mut capabilities, catalog::PACKAGING_NIX, &reason, None);
463 }
464 let gate_root = candidates.iter().position(|candidate| {
470 (candidate.capability == catalog::TITLE_CHECK
471 || candidate.capability == catalog::COMMIT_CHECK)
472 && candidate.destination == GITLAB_ROOT_PIPELINE
473 });
474 if let Some(index) = gate_root
475 && evidence.root_pipeline_present
476 && !evidence.root_pipeline_recorded
477 {
478 let candidate = candidates.remove(index);
479 let (gate, fragment) = if candidate.capability == catalog::COMMIT_CHECK {
480 ("commit gate", GITLAB_COMMIT_FRAGMENT)
481 } else {
482 ("title gate", GITLAB_TITLE_FRAGMENT)
483 };
484 let reason = format!(
485 "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"
486 );
487 let action = format!(
488 "add the include to the target's own {GITLAB_ROOT_PIPELINE}: include:\n - local: {fragment}"
489 );
490 omissions.push(Omission {
491 destination: candidate.destination,
492 reason: reason.clone(),
493 action: Some(action.clone()),
494 });
495 withhold(
496 &mut capabilities,
497 candidate.capability,
498 &reason,
499 Some(action),
500 );
501 }
502 candidates.sort_by(|a, b| a.destination.cmp(&b.destination));
503 omissions.sort_by(|a, b| a.destination.cmp(&b.destination));
504 let scanning_selected = capabilities.iter().any(|selection| {
510 selection.id == catalog::CODE_SCANNING && selection.status == Status::Selected
511 });
512 let licence_refusal = scanning_selected
513 .then(|| {
514 code_scanning_licence_refusal(
515 params.code_scanning(),
516 params.driver(),
517 &evidence.crate_shape,
518 )
519 })
520 .flatten();
521 let mut record_defects: Vec<String> = Vec::new();
526 if params.release_mode() == ReleaseMode::Automatic
530 && let Some(selection) = capabilities
531 .iter()
532 .find(|selection| selection.id == catalog::RELEASE_AUTOMATION)
533 && matches!(selection.status, Status::Unavailable | Status::Unknown)
534 && let Some(reason) = &selection.reason
535 {
536 record_defects.push(reason.clone());
537 }
538 let driver_refusal = (params.release_mode() == ReleaseMode::Automatic
542 && params.driver() == Some("python")
543 && capabilities.iter().any(|selection| {
544 selection.id == catalog::RELEASE_AUTOMATION && selection.status == Status::Selected
545 }))
546 .then(|| evidence.python_shape.refusal())
547 .flatten();
548 Ok(Self {
549 capabilities,
550 candidates,
551 omissions,
552 collisions,
553 licence_refusal,
554 driver_refusal,
555 record_defects,
556 })
557 }
558
559 #[must_use]
563 pub fn release_unavailable(&self) -> Option<&str> {
564 self.capabilities
565 .iter()
566 .find(|selection| {
567 selection.id == catalog::RELEASE_AUTOMATION
568 && matches!(selection.status, Status::Unavailable | Status::Unknown)
569 })
570 .and_then(|selection| selection.reason.as_deref())
571 }
572
573 #[must_use]
575 pub fn capability(&self, id: &str) -> Option<&Selection> {
576 self.capabilities
577 .iter()
578 .find(|selection| selection.id == id)
579 }
580}
581
582fn withhold(capabilities: &mut [Selection], id: &str, reason: &str, action: Option<String>) {
584 if let Some(selection) = capabilities.iter_mut().find(|selection| selection.id == id) {
585 selection.status = Status::Withheld;
586 selection.reason = Some(reason.to_owned());
587 selection.action = action;
588 }
589}
590
591pub const GITLAB_ROOT_PIPELINE: &str = ".gitlab-ci.yml";
594
595pub const GITLAB_TITLE_FRAGMENT: &str = ".gitlab/ci/mr-title.yml";
597
598pub const GITLAB_COMMIT_FRAGMENT: &str = ".gitlab/ci/mr-commits.yml";
600
601fn embedded_snippets() -> Vec<(String, &'static [u8])> {
604 embedded::walk(&embedded::SNIPPETS)
605 .into_iter()
606 .map(|(path, bytes)| (format!("snippets/{path}"), bytes))
607 .collect()
608}
609
610#[must_use]
613pub fn supported_pairs() -> Vec<(String, String)> {
614 Availability::embedded()
615 .automation_tuples()
616 .into_iter()
617 .filter_map(|entry| {
618 entry
619 .split_once(", ")
620 .map(|(driver, forge)| (driver.to_owned(), forge.to_owned()))
621 })
622 .collect()
623}
624
625#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
627#[serde(rename_all = "lowercase")]
628pub enum Kind {
629 Rendered,
632 Seeded,
635 State,
638}
639
640impl Kind {
641 #[must_use]
643 pub const fn as_str(self) -> &'static str {
644 match self {
645 Self::Rendered => "rendered",
646 Self::Seeded => "seeded",
647 Self::State => "state",
648 }
649 }
650}
651
652const KINDS: [(&str, Kind); 19] = [
658 (".github/workflows/release-plz.yml", Kind::Rendered),
659 (".github/workflows/release.yml", Kind::Rendered),
660 (".github/workflows/pr-title.yml", Kind::Rendered),
661 (".github/workflows/pr-commits.yml", Kind::Rendered),
662 (".github/workflows/scorecard.yml", Kind::Rendered),
663 (".github/workflows/code-scanning-codeql.yml", Kind::Rendered),
664 (
665 ".github/workflows/code-scanning-semgrep.yml",
666 Kind::Rendered,
667 ),
668 (".gitlab/ci/code-scanning-semgrep.yml", Kind::Rendered),
669 (".gitlab-ci.yml", Kind::Rendered),
670 ("SECURITY.md", Kind::Rendered),
671 (".gitlab/ci/mr-title.yml", Kind::Rendered),
672 (".gitlab/ci/mr-commits.yml", Kind::Rendered),
673 ("release-plz.toml", Kind::Seeded),
674 ("dist-workspace.toml", Kind::Seeded),
675 ("cliff.toml", Kind::Seeded),
676 ("nix/package.nix", Kind::Seeded),
677 ("flake.nix", Kind::Seeded),
678 ("VERSION", Kind::State),
679 ("flake.lock", Kind::State),
680];
681
682pub const NIX_DESTINATIONS: [&str; 3] = ["nix/package.nix", "flake.nix", "flake.lock"];
696
697pub const NIX_WITHHOLDABLE: [&str; 2] = ["flake.nix", "flake.lock"];
703
704pub const SCORECARD_DESTINATIONS: [&str; 1] = [".github/workflows/scorecard.yml"];
719
720pub const CODE_SCANNING_DESTINATIONS: [(&str, Provider); 3] = [
734 (
735 ".github/workflows/code-scanning-codeql.yml",
736 Provider::CodeQl,
737 ),
738 (
739 ".github/workflows/code-scanning-semgrep.yml",
740 Provider::Semgrep,
741 ),
742 (".gitlab/ci/code-scanning-semgrep.yml", Provider::Semgrep),
743];
744
745pub const CODE_SCANNING_TECHS: [&str; 1] = ["rust"];
753
754#[must_use]
771pub fn code_scanning_incompatibility(
772 provider: Option<Provider>,
773 driver: Option<&str>,
774 forge: Option<&str>,
775) -> Option<String> {
776 let named = provider?;
777 let Some(driver) = driver else {
778 return Some(format!(
779 "a scanner reads the release driver's language, and the profile names no automatic release driver; the bindings that carry one are: {}",
780 CODE_SCANNING_TECHS.join(", ")
781 ));
782 };
783 if !CODE_SCANNING_TECHS.contains(&driver) {
784 return Some(format!(
785 "the {driver} binding ships no code scanning workflow; a scanner reads one language, and the bindings that carry one are: {}",
786 CODE_SCANNING_TECHS.join(", ")
787 ));
788 }
789 let Some(forge) = forge else {
790 return Some(
791 "a code scanning workflow runs at a forge, and the profile names none".to_owned(),
792 );
793 };
794 if named == Provider::CodeQl && forge != "github" {
795 return Some(format!(
796 "codeql is GitHub's own analyzer and the {forge} pair ships no workflow for it; pass --code-scanning semgrep"
797 ));
798 }
799 None
800}
801
802const OSI_APPROVED: [&str; 18] = [
812 "0bsd",
813 "agpl-3.0",
814 "agpl-3.0-only",
815 "agpl-3.0-or-later",
816 "apache-2.0",
817 "bsd-2-clause",
818 "bsd-3-clause",
819 "bsl-1.0",
820 "epl-2.0",
821 "gpl-2.0",
822 "gpl-2.0-only",
823 "gpl-2.0-or-later",
824 "gpl-3.0",
825 "gpl-3.0-only",
826 "gpl-3.0-or-later",
827 "isc",
828 "mit",
829 "mpl-2.0",
830];
831
832const CONTENT_LICENCES: [&str; 4] = ["cc-by-3.0", "cc-by-4.0", "cc-by-sa-3.0", "cc-by-sa-4.0"];
850
851const SPDX_EXCEPTIONS: [&str; 86] = [
868 "389-exception",
869 "asterisk-exception",
870 "asterisk-linking-protocols-exception",
871 "autoconf-exception-2.0",
872 "autoconf-exception-3.0",
873 "autoconf-exception-generic",
874 "autoconf-exception-generic-3.0",
875 "autoconf-exception-macro",
876 "bison-exception-1.24",
877 "bison-exception-2.2",
878 "bootloader-exception",
879 "cgal-linking-exception",
880 "classpath-exception-2.0",
881 "classpath-exception-2.0-short",
882 "clisp-exception-2.0",
883 "cryptsetup-openssl-exception",
884 "digia-qt-lgpl-exception-1.1",
885 "digirule-foss-exception",
886 "ecos-exception-2.0",
887 "erlang-otp-linking-exception",
888 "fawkes-runtime-exception",
889 "fltk-exception",
890 "fmt-exception",
891 "font-exception-2.0",
892 "freertos-exception-2.0",
893 "gcc-exception-2.0",
894 "gcc-exception-2.0-note",
895 "gcc-exception-3.1",
896 "gmsh-exception",
897 "gnat-exception",
898 "gnome-examples-exception",
899 "gnu-compiler-exception",
900 "gnu-javamail-exception",
901 "google-patent-webm",
902 "gpl-3.0-389-ds-base-exception",
903 "gpl-3.0-interface-exception",
904 "gpl-3.0-linking-exception",
905 "gpl-3.0-linking-source-exception",
906 "gpl-cc-1.0",
907 "gstreamer-exception-2005",
908 "gstreamer-exception-2008",
909 "harbour-exception",
910 "i2p-gpl-java-exception",
911 "independent-modules-exception",
912 "kicad-libraries-exception",
913 "kvirc-openssl-exception",
914 "lgpl-3.0-linking-exception",
915 "libpri-openh323-exception",
916 "libtool-exception",
917 "linux-syscall-note",
918 "llgpl",
919 "llvm-exception",
920 "lzma-exception",
921 "mif-exception",
922 "mxml-exception",
923 "nokia-qt-exception-1.1",
924 "ocaml-lgpl-linking-exception",
925 "occt-exception-1.0",
926 "openjdk-assembly-exception-1.0",
927 "openvpn-openssl-exception",
928 "pcre2-exception",
929 "polyparse-exception",
930 "ps-or-pdf-font-exception-20170817",
931 "qpl-1.0-inria-2004-exception",
932 "qt-gpl-exception-1.0",
933 "qt-lgpl-exception-1.1",
934 "qwt-exception-1.0",
935 "romic-exception",
936 "rrdtool-floss-exception-2.0",
937 "rsync-linking-exception",
938 "sane-exception",
939 "shl-2.0",
940 "shl-2.1",
941 "simple-library-usage-exception",
942 "spelling-provider-lgpl-exception",
943 "sqlitestudio-openssl-exception",
944 "stunnel-exception",
945 "swi-exception",
946 "swift-exception",
947 "texinfo-exception",
948 "u-boot-exception-2.0",
949 "ubdl-exception",
950 "universal-foss-exception-1.0",
951 "vsftpd-openssl-exception",
952 "wxwindows-exception-3.1",
953 "x11vnc-openssl-exception",
954];
955
956fn listed(list: &[&str], identifier: &str) -> bool {
963 let lowered = identifier.to_ascii_lowercase();
964 list.contains(&lowered.as_str())
965}
966
967#[derive(Debug, Clone, Copy, PartialEq, Eq)]
969enum Token<'a> {
970 Open,
972 Close,
974 And,
976 Or,
978 With,
981 Identifier(&'a str),
984}
985
986fn tokenize(expression: &str) -> Option<Vec<Token<'_>>> {
994 let mut tokens = Vec::new();
995 let bytes = expression.as_bytes();
996 let mut at = 0;
997 while at < bytes.len() {
998 let byte = bytes[at];
999 if byte.is_ascii_whitespace() {
1000 at += 1;
1001 continue;
1002 }
1003 if byte == b'(' {
1004 tokens.push(Token::Open);
1005 at += 1;
1006 continue;
1007 }
1008 if byte == b')' {
1009 tokens.push(Token::Close);
1010 at += 1;
1011 continue;
1012 }
1013 let start = at;
1014 while at < bytes.len() {
1015 let byte = bytes[at];
1016 if byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'+' | b':') {
1017 at += 1;
1018 } else {
1019 break;
1020 }
1021 }
1022 if at == start {
1023 return None;
1024 }
1025 let word = &expression[start..at];
1026 tokens.push(match word {
1027 "AND" => Token::And,
1028 "OR" => Token::Or,
1029 "WITH" => Token::With,
1030 other => Token::Identifier(other),
1031 });
1032 }
1033 Some(tokens)
1034}
1035
1036#[derive(Debug, Clone, Copy)]
1043struct Verdict {
1044 grants: bool,
1046 clean: bool,
1048}
1049
1050struct Spdx<'a> {
1059 tokens: &'a [Token<'a>],
1060 at: usize,
1061}
1062
1063impl<'a> Spdx<'a> {
1064 fn peek(&self) -> Option<Token<'a>> {
1066 self.tokens.get(self.at).copied()
1067 }
1068
1069 fn bump(&mut self) -> Option<Token<'a>> {
1071 let token = self.peek()?;
1072 self.at += 1;
1073 Some(token)
1074 }
1075
1076 fn expression(&mut self) -> Option<Verdict> {
1086 let mut verdict = self.conjunction()?;
1087 while self.peek() == Some(Token::Or) {
1088 self.bump();
1089 let right = self.conjunction()?;
1090 verdict = Verdict {
1091 grants: verdict.grants && right.grants,
1092 clean: verdict.clean && right.clean,
1093 };
1094 }
1095 Some(verdict)
1096 }
1097
1098 fn conjunction(&mut self) -> Option<Verdict> {
1108 let mut verdict = self.operand()?;
1109 while self.peek() == Some(Token::And) {
1110 self.bump();
1111 let right = self.operand()?;
1112 verdict = Verdict {
1113 grants: verdict.grants || right.grants,
1114 clean: verdict.clean && right.clean,
1115 };
1116 }
1117 Some(verdict)
1118 }
1119
1120 fn operand(&mut self) -> Option<Verdict> {
1130 match self.bump()? {
1131 Token::Open => {
1132 let inner = self.expression()?;
1135 (self.bump()? == Token::Close).then_some(inner)
1136 }
1137 Token::Identifier(name) => {
1138 let identifier = name.strip_suffix('+').unwrap_or(name);
1139 let approved = listed(&OSI_APPROVED, identifier);
1140 let verdict = Verdict {
1141 grants: approved,
1142 clean: approved || listed(&CONTENT_LICENCES, identifier),
1143 };
1144 if self.peek() == Some(Token::With) {
1145 self.bump();
1146 match self.bump()? {
1150 Token::Identifier(exception) if listed(&SPDX_EXCEPTIONS, exception) => {}
1151 _ => return None,
1152 }
1153 }
1154 Some(verdict)
1155 }
1156 Token::And | Token::Or | Token::With | Token::Close => None,
1157 }
1158 }
1159}
1160
1161#[must_use]
1177pub fn licence_states_an_open_source_codebase(expression: &str) -> bool {
1178 let Some(tokens) = tokenize(expression) else {
1179 return false;
1180 };
1181 let mut reader = Spdx {
1182 tokens: &tokens,
1183 at: 0,
1184 };
1185 let Some(verdict) = reader.expression() else {
1186 return false;
1187 };
1188 verdict.grants && verdict.clean && reader.at == tokens.len()
1189}
1190
1191#[must_use]
1205pub fn code_scanning_licence_refusal(
1206 provider: Option<Provider>,
1207 driver: Option<&str>,
1208 shape: &CrateShape,
1209) -> Option<String> {
1210 if provider != Some(Provider::CodeQl) {
1211 return None;
1212 }
1213 if driver != Some("rust") {
1214 return Some(format!(
1215 "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",
1216 driver.unwrap_or("release-less")
1217 ));
1218 }
1219 let fallback = "land --code-scanning semgrep, which carries no licence condition";
1220 let Some(text) = shape.cargo_toml.as_deref() else {
1221 return Some(format!(
1222 "the target has no readable Cargo.toml, so the licence codeql's terms depend on cannot be read; {fallback}"
1223 ));
1224 };
1225 let Ok(table) = text.parse::<toml::Table>() else {
1226 return Some(format!(
1227 "the target's Cargo.toml does not parse, so the licence codeql's terms depend on cannot be read; {fallback}"
1228 ));
1229 };
1230 let licence = table
1231 .get("package")
1232 .and_then(toml::Value::as_table)
1233 .and_then(|package| package.get("license"))
1234 .and_then(toml::Value::as_str);
1235 match licence {
1236 None => Some(format!(
1237 "the target's Cargo.toml declares no license field, and codeql's terms cover an open-source codebase alone; declare one, or {fallback}"
1238 )),
1239 Some(expression) if !licence_states_an_open_source_codebase(expression) => Some(format!(
1240 "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}"
1241 )),
1242 Some(_) => None,
1243 }
1244}
1245
1246pub const SKILLS_SOURCE: &str = "agent.skills";
1249
1250pub const SKILL_ROOTS: [&str; 2] = [crate::skills::CLAUDE_ROOT, crate::skills::AGENTS_ROOT];
1254
1255#[must_use]
1258pub fn skill_folder(destination: &str) -> Option<String> {
1259 SKILL_ROOTS.iter().find_map(|root| {
1260 let rest = destination.strip_prefix(root)?.strip_prefix('/')?;
1261 let (name, path) = rest.split_once('/')?;
1262 (!name.is_empty() && !path.is_empty()).then(|| format!("{root}/{name}"))
1263 })
1264}
1265
1266#[must_use]
1268pub fn is_skill_folder(path: &str) -> bool {
1269 SKILL_ROOTS.iter().any(|root| {
1270 path.strip_prefix(root)
1271 .and_then(|rest| rest.strip_prefix('/'))
1272 .is_some_and(|name| !name.is_empty() && !name.contains('/'))
1273 })
1274}
1275
1276fn skill_candidates() -> Result<Vec<Candidate>, RkError> {
1279 let mut out = Vec::new();
1280 for package in crate::skills::packages()? {
1281 for file in &package.files {
1282 let source = if file.shared {
1283 format!(
1284 "skill-shared/{}",
1285 file.path
1286 .strip_prefix(&format!("{}/", crate::skills::REFERENCES_DIR))
1287 .unwrap_or(&file.path)
1288 )
1289 } else {
1290 format!("skills/{}/{}", package.name, file.path)
1291 };
1292 for root in SKILL_ROOTS {
1293 out.push(Candidate {
1294 capability: SKILLS_SOURCE,
1295 destination: format!("{root}/{}/{}", package.name, file.path),
1296 kind: Kind::Rendered,
1297 placement: Placement::Whole,
1298 bytes: file.bytes.to_vec(),
1299 region: None,
1300 sources: vec![source.clone()],
1301 });
1302 }
1303 }
1304 }
1305 Ok(out)
1306}
1307
1308#[must_use]
1311pub fn kind_of(destination: &str) -> Option<Kind> {
1312 if BLOCK_DESTINATIONS.contains(&destination) || skill_folder(destination).is_some() {
1313 return Some(Kind::Rendered);
1314 }
1315 KINDS
1316 .iter()
1317 .find(|(name, _)| *name == destination)
1318 .map(|(_, kind)| *kind)
1319}
1320
1321pub fn destinations() -> impl Iterator<Item = &'static str> {
1326 KINDS
1327 .iter()
1328 .map(|(name, _)| *name)
1329 .chain(BLOCK_DESTINATIONS)
1330}
1331
1332pub const OWNER_TOKEN: &[u8] = b"OWNER";
1339
1340pub const REPO_PLACEHOLDER: &str = "OWNER";
1345
1346pub const REPO_TOKEN: &[u8] = b"RK_REPO";
1348
1349pub const SCOPE_SHAPE_TOKEN: &[u8] = b"RK_SCOPE_SHAPE";
1351
1352pub const IF_RELEASE_TOKEN: &[u8] = b"RK_IF_RELEASE_";
1357
1358pub const IF_NO_RELEASE_TOKEN: &[u8] = b"RK_IF_NO_RELEASE_";
1361
1362pub const IF_REGISTRY_TOKEN: &[u8] = b" # RK_IF_REGISTRY";
1370
1371pub const IF_NO_REGISTRY_TOKEN: &[u8] = b"# RK_IF_NO_REGISTRY ";
1377
1378#[must_use]
1384pub fn registry_lines(baseline: &[u8], params: &Params) -> Vec<u8> {
1385 if find(baseline, IF_REGISTRY_TOKEN).is_none() && find(baseline, IF_NO_REGISTRY_TOKEN).is_none()
1386 {
1387 return baseline.to_vec();
1388 }
1389 let publishes = params.registry() != Some(crate::profile::RegistryAnswer::None);
1390 let mut out = Vec::with_capacity(baseline.len());
1391 for line in baseline.split_inclusive(|byte| *byte == b'\n') {
1392 let (token, wanted) = if find(line, IF_NO_REGISTRY_TOKEN).is_some() {
1393 (IF_NO_REGISTRY_TOKEN, !publishes)
1394 } else if find(line, IF_REGISTRY_TOKEN).is_some() {
1395 (IF_REGISTRY_TOKEN, publishes)
1396 } else {
1397 out.extend_from_slice(line);
1398 continue;
1399 };
1400 if wanted {
1401 out.extend(substitute(line, token, b""));
1402 }
1403 }
1404 out
1405}
1406
1407pub const SCOPED_TOKEN: &[u8] = b"RK_SCOPED";
1411
1412pub const SCOPE_OPEN_TOKEN: &[u8] = b"RK_SCOPE_OPEN";
1418
1419pub const SCOPE_CLOSE_TOKEN: &[u8] = b"RK_SCOPE_CLOSE";
1421
1422fn release_lines(baseline: &[u8], release: bool) -> Vec<u8> {
1427 if find(baseline, IF_RELEASE_TOKEN).is_none() && find(baseline, IF_NO_RELEASE_TOKEN).is_none() {
1428 return baseline.to_vec();
1429 }
1430 let mut out = Vec::with_capacity(baseline.len());
1431 for line in baseline.split_inclusive(|byte| *byte == b'\n') {
1432 let (token, wanted) = if find(line, IF_NO_RELEASE_TOKEN).is_some() {
1435 (IF_NO_RELEASE_TOKEN, !release)
1436 } else if find(line, IF_RELEASE_TOKEN).is_some() {
1437 (IF_RELEASE_TOKEN, release)
1438 } else {
1439 out.extend_from_slice(line);
1440 continue;
1441 };
1442 if wanted {
1443 out.extend(substitute(line, token, b""));
1444 }
1445 }
1446 out
1447}
1448
1449pub const GITLAB_GATE_FRAGMENT_TOKEN: &[u8] = b"RK_GITLAB_GATE_FRAGMENT";
1452
1453pub const MERGE_FLAG_TOKEN: &[u8] = b"RK_MERGE_FLAG";
1456
1457pub const SQUASH_ON_MERGE_TOKEN: &[u8] = b"RK_SQUASH_ON_MERGE";
1461
1462pub const STYLE_TOKEN: &[u8] = b"RK_STYLE";
1464
1465pub const BUILD_PACKAGES_TOKEN: &[u8] = b"RK_BUILD_PACKAGES";
1468
1469pub const IF_UV_UNPINNED_TOKEN: &[u8] = b" # RK_IF_UV_UNPINNED";
1475
1476pub const IF_UV_REQUIRED_TOKEN: &[u8] = b"# RK_IF_UV_REQUIRED ";
1479
1480pub const UV_REQUIRED_TOKEN: &[u8] = b"RK_UV_REQUIRED";
1483
1484#[must_use]
1491pub fn uv_lines(baseline: &[u8], shape: &PythonShape) -> Vec<u8> {
1492 if find(baseline, IF_UV_UNPINNED_TOKEN).is_none()
1493 && find(baseline, IF_UV_REQUIRED_TOKEN).is_none()
1494 {
1495 return baseline.to_vec();
1496 }
1497 let required = shape.uv_required();
1498 let mut out = Vec::with_capacity(baseline.len());
1499 for line in baseline.split_inclusive(|byte| *byte == b'\n') {
1500 let (token, wanted) = if find(line, IF_UV_REQUIRED_TOKEN).is_some() {
1501 (IF_UV_REQUIRED_TOKEN, required.is_some())
1502 } else if find(line, IF_UV_UNPINNED_TOKEN).is_some() {
1503 (IF_UV_UNPINNED_TOKEN, required.is_none())
1504 } else {
1505 out.extend_from_slice(line);
1506 continue;
1507 };
1508 if wanted {
1509 out.extend(substitute(line, token, b""));
1510 }
1511 }
1512 substitute(
1513 &out,
1514 UV_REQUIRED_TOKEN,
1515 required.unwrap_or_default().as_bytes(),
1516 )
1517}
1518
1519pub const TRUNK_BRANCH_TOKEN: &[u8] = b"RK_TRUNK_BRANCH";
1523
1524pub const LINE_PREFIX_TOKEN: &[u8] = b"RK_LINE_PREFIX";
1527
1528pub const LINE_PREFIX_RE_TOKEN: &[u8] = b"RK_LINE_PREFIX_RE";
1534
1535pub const REQUIRED_CHECK_TOKEN: &[u8] = b"RK_REQUIRED_CHECK";
1537
1538pub const BRANCH_SHAPE_TOKEN: &[u8] = b"RK_BRANCH_SHAPE";
1540
1541#[must_use]
1548pub fn release_driver_shape(driver: Option<&str>) -> Option<&'static str> {
1549 match driver {
1550 Some("rust") => Some("release-plz-"),
1553 Some("bash" | "python") => Some(crate::release_engine::REQUEST_PREFIX),
1555 _ => None,
1556 }
1557}
1558
1559pub const RELEASE_MERGE_GITHUB: &str = "blocks/release-merge-job.github.yaml.in";
1561
1562pub const RELEASE_MERGE_GITLAB: &str = "blocks/release-merge-job.gitlab.yaml.in";
1564
1565pub const RELEASE_MERGE_SPAN: (&[u8], &[u8]) = (b"\n# RK_MERGE_JOB_BEGIN", b"\n# RK_MERGE_JOB_END");
1573
1574fn release_merge_replacement(params: &Params) -> Result<Option<Vec<u8>>, RkError> {
1585 let block = match params.forge() {
1586 Some("github") => RELEASE_MERGE_GITHUB,
1587 Some("gitlab") => RELEASE_MERGE_GITLAB,
1588 _ => return Ok(None),
1589 };
1590 let text = embedded_block(block)?;
1594 Ok(Some(substitute_tokens(
1595 format!("\n{}", text.trim_end_matches('\n')).as_bytes(),
1596 params,
1597 )))
1598}
1599
1600pub const DEV_RUN_TOKEN: &[u8] = b"RK_DEV_RUN ";
1607
1608pub const DEV_SETUP_STEPS_TOKEN: &[u8] = b"RK_DEV_SETUP_STEPS";
1611
1612pub const DEV_GIT_USER_TOKEN: &[u8] = b"RK_DEV_GIT_USER";
1614
1615pub const DEV_ENVIRONMENT_RELEASE: &str = "blocks/dev-environment-release.yaml.in";
1617
1618pub const DEV_ENVIRONMENT_REQUEST: &str = "blocks/dev-environment-request.yaml.in";
1620
1621pub const DEV_ENVIRONMENT_CANDIDATE: &str = "blocks/dev-environment-candidate.yaml.in";
1624
1625pub const SELF_VERSION_TOKEN: &[u8] = b"RK_SELF_VERSION";
1629
1630pub const SELF_RELEASES_TOKEN: &[u8] = b"RK_SELF_RELEASES";
1634
1635pub const DEV_ENVIRONMENT_GIT_USER: &str = "blocks/dev-environment-git-user.yaml.in";
1638
1639pub const DEV_ENVIRONMENT_RK_INSTALL: &str = "blocks/dev-environment-rk-install.yaml.in";
1642
1643pub const DEV_ENVIRONMENT_RK_INSTALL_GITLAB: &str =
1649 "blocks/dev-environment-rk-install-gitlab.yaml.in";
1650
1651pub const DEV_ENVIRONMENT_ENGINE: &str = "blocks/dev-environment-engine.yaml.in";
1655
1656pub const DEV_ENVIRONMENT_ENGINE_CANDIDATE: &str =
1660 "blocks/dev-environment-engine-candidate.yaml.in";
1661
1662pub const DEV_ENVIRONMENT_SPANS: [(&[u8], &[u8]); 7] = [
1679 (
1680 b"\n# RK_DEV_ENV_RELEASE_BEGIN",
1681 b"\n# RK_DEV_ENV_RELEASE_END",
1682 ),
1683 (
1684 b"\n# RK_DEV_ENV_REQUEST_BEGIN",
1685 b"\n# RK_DEV_ENV_REQUEST_END",
1686 ),
1687 (b"\n# RK_DEV_ENV_IMAGE_BEGIN", b"\n# RK_DEV_ENV_IMAGE_END"),
1688 (b"\n# RK_DEV_ENV_SCRIPT_BEGIN", b"\n# RK_DEV_ENV_SCRIPT_END"),
1689 (
1690 b"\n# RK_DEV_ENV_CANDIDATE_BEGIN",
1691 b"\n# RK_DEV_ENV_CANDIDATE_END",
1692 ),
1693 (b"\n# RK_DEV_ENV_RK_BEGIN", b"\n# RK_DEV_ENV_RK_END"),
1694 (
1695 b"\n# RK_DEV_ENV_PACKAGES_BEGIN",
1696 b"\n# RK_DEV_ENV_PACKAGES_END",
1697 ),
1698];
1699
1700fn action_step_lines(step: &crate::config::dev_environment::ActionStep) -> String {
1703 let mut lines = format!(" - uses: {}", step.uses);
1704 if !step.tag.is_empty() {
1705 lines.push_str(" # ");
1706 lines.push_str(&step.tag);
1707 }
1708 lines.push('\n');
1709 if !step.inputs.is_empty() {
1710 lines.push_str(" with:\n");
1711 for (name, value) in &step.inputs {
1712 let _ = writeln!(lines, " {name}: {}", value.yaml());
1713 }
1714 }
1715 lines
1716}
1717
1718fn dev_environment_replacements(params: &Params) -> Result<[Option<Vec<u8>>; 7], RkError> {
1735 let Some(answer) = params.dev_environment() else {
1736 return Ok([None, None, None, None, None, None, None]);
1737 };
1738 let git_user = embedded_block(DEV_ENVIRONMENT_GIT_USER)?.trim_end_matches('\n');
1739 let installer = answer.rk == crate::config::dev_environment::RkRoute::Installer;
1743 let rk_install = if installer {
1744 substitute_tokens(
1745 embedded_block(DEV_ENVIRONMENT_RK_INSTALL)?.as_bytes(),
1746 params,
1747 )
1748 } else {
1749 Vec::new()
1750 };
1751 let half =
1752 |block: &str, steps: &[&crate::config::dev_environment::ActionStep], runs_rk: bool| {
1753 let text = embedded_block(block)?.trim_end_matches('\n');
1754 let text = substitute(text.as_bytes(), DEV_GIT_USER_TOKEN, git_user.as_bytes());
1755 let text = substitute_tokens(&text, params);
1756 let mut lines: Vec<u8> = steps
1757 .iter()
1758 .map(|step| action_step_lines(step))
1759 .collect::<String>()
1760 .into_bytes();
1761 if runs_rk {
1762 lines.extend_from_slice(&rk_install);
1763 }
1764 let mut out = b"\n".to_vec();
1765 out.extend(substitute(&text, DEV_SETUP_STEPS_TOKEN, &lines));
1766 Ok::<_, RkError>(out)
1767 };
1768 let release: Vec<_> = answer.github.setup.iter().collect();
1769 let request: Vec<_> = answer
1770 .github
1771 .setup
1772 .iter()
1773 .chain(&answer.github.cache)
1774 .collect();
1775 let image = (!answer.gitlab.image.is_empty())
1776 .then(|| format!("\n image: {}", answer.gitlab.image).into_bytes());
1777 let mut items = String::new();
1778 for line in &answer.gitlab.before_script {
1779 let _ = write!(items, "\n - {}", serde_json::Value::String(line.clone()));
1780 }
1781 if matches!(params.driver(), Some("bash" | "python")) {
1782 return Ok([
1794 Some(half(DEV_ENVIRONMENT_ENGINE, &release, true)?),
1795 Some(half(DEV_ENVIRONMENT_ENGINE, &request, true)?),
1796 image,
1797 Some(items.into_bytes()),
1798 Some(half(DEV_ENVIRONMENT_ENGINE_CANDIDATE, &release, true)?),
1799 (!installer).then(Vec::new),
1800 Some(Vec::new()),
1801 ]);
1802 }
1803 let script = if items.is_empty() {
1804 Vec::new()
1805 } else {
1806 format!("\n before_script:{items}").into_bytes()
1807 };
1808 let gitlab_rk = if installer {
1812 let text = embedded_block(DEV_ENVIRONMENT_RK_INSTALL_GITLAB)?.trim_end_matches('\n');
1813 let mut out = b"\n".to_vec();
1814 out.extend(substitute_tokens(text.as_bytes(), params));
1815 out
1816 } else {
1817 Vec::new()
1818 };
1819 Ok([
1820 Some(half(DEV_ENVIRONMENT_RELEASE, &release, false)?),
1821 Some(half(DEV_ENVIRONMENT_REQUEST, &request, true)?),
1823 image,
1824 Some(script),
1825 Some(half(DEV_ENVIRONMENT_CANDIDATE, &release, true)?),
1828 Some(gitlab_rk),
1829 None,
1830 ])
1831}
1832
1833impl Projection {
1834 #[must_use]
1842 pub fn accepted_drift_fault(&self, destination: &str) -> Option<String> {
1843 let Some(candidate) = self
1844 .candidates
1845 .iter()
1846 .find(|candidate| candidate.destination == destination)
1847 else {
1848 return Some(self.omissions.iter().find(|omission| omission.destination == destination).map_or_else(
1849 || format!("landing.accepted_drift names {destination}, which this landing does not select"),
1850 |omission| format!("landing.accepted_drift names {destination}, which this landing omits: {}", omission.reason),
1851 ));
1852 };
1853 if candidate.kind != Kind::Rendered {
1854 return Some(format!(
1855 "landing.accepted_drift names {destination}, a {} file, which may drift freely already",
1856 candidate.kind.as_str()
1857 ));
1858 }
1859 if matches!(candidate.placement, Placement::Region { .. }) {
1860 return Some(format!(
1861 "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"
1862 ));
1863 }
1864 None
1865 }
1866}
1867
1868pub const SECURITY_SPANS: [(&[u8], &[u8]); 4] = [
1883 (
1884 b"<!--RK_SECURITY_CONTACT_BEGIN-->",
1885 b"<!--RK_SECURITY_CONTACT_END-->",
1886 ),
1887 (
1888 b"<!--RK_SECURITY_RESPONSE_BEGIN-->",
1889 b"<!--RK_SECURITY_RESPONSE_END-->",
1890 ),
1891 (
1892 b"<!--RK_SECURITY_DEADLINE_BEGIN-->",
1893 b"<!--RK_SECURITY_DEADLINE_END-->",
1894 ),
1895 (
1896 b"<!--RK_SECURITY_SCOPE_BEGIN-->",
1897 b"<!--RK_SECURITY_SCOPE_END-->",
1898 ),
1899];
1900
1901fn acknowledgment(response: &str) -> String {
1904 format!("Maintainers acknowledge a report within {response}.")
1905}
1906
1907const DISCLOSURE_ONLY: &[u8] = b"This policy commits to no disclosure deadline.";
1911
1912fn security_replacements(params: &Params) -> [Option<Vec<u8>>; 4] {
1915 let contact = (!params.security_contact().is_empty())
1916 .then(|| params.security_contact().as_bytes().to_vec());
1917 let promised = params.security_response() != crate::config::RESPONSE_DEFAULT;
1918 [
1919 contact,
1920 promised.then(|| acknowledgment(params.security_response()).into_bytes()),
1921 promised.then(|| DISCLOSURE_ONLY.to_vec()),
1922 (!params.security_scope().is_empty()).then(|| {
1925 ["\n".repeat(2).as_str(), params.security_scope()]
1926 .concat()
1927 .into_bytes()
1928 }),
1929 ]
1930}
1931
1932fn replace_span(baseline: &[u8], begin: &[u8], end: &[u8], value: Option<&[u8]>) -> Vec<u8> {
1938 let ordered = find(baseline, begin)
1939 .zip(find(baseline, end))
1940 .filter(|(start, stop)| stop > start);
1941 let Some((start, stop)) = ordered else {
1942 return baseline.to_vec();
1943 };
1944 let mut out = Vec::with_capacity(baseline.len());
1945 out.extend_from_slice(&baseline[..start]);
1946 out.extend_from_slice(value.unwrap_or_else(|| &baseline[start + begin.len()..stop]));
1947 out.extend_from_slice(&baseline[stop + end.len()..]);
1948 out
1949}
1950
1951#[must_use]
1969pub fn render(baseline: &[u8], params: &Params) -> Vec<u8> {
1970 let mut out = substitute_tokens(baseline, params);
1971 let (begin, end) = RELEASE_MERGE_SPAN;
1975 let merge = release_merge_replacement(params).ok().flatten();
1976 out = replace_span(&out, begin, end, merge.as_deref());
1977 let environment = dev_environment_replacements(params).unwrap_or_default();
1983 for ((begin, end), value) in DEV_ENVIRONMENT_SPANS.iter().zip(environment) {
1987 loop {
1988 let next = replace_span(&out, begin, end, value.as_deref());
1989 if next == out {
1990 break;
1991 }
1992 out = next;
1993 }
1994 }
1995 for ((begin, end), value) in SECURITY_SPANS.iter().zip(security_replacements(params)) {
1996 out = replace_span(&out, begin, end, value.as_deref());
1997 }
1998 out
1999}
2000
2001#[must_use]
2006fn substitute_tokens(baseline: &[u8], params: &Params) -> Vec<u8> {
2007 let repo = params.repo();
2008 let owner = repo.split('/').next().unwrap_or(repo);
2009 let release = params.release_mode() != ReleaseMode::None;
2013 let mut out = release_lines(baseline, release);
2014 out = registry_lines(&out, params);
2015 let (scoped, open, close): (&[u8], &[u8], &[u8]) = if release {
2016 (b"scoped ", b"", b"")
2017 } else {
2018 (b"", b"(", b")?")
2019 };
2020 out = substitute(&out, SCOPED_TOKEN, scoped);
2021 out = substitute(&out, SCOPE_OPEN_TOKEN, open);
2022 out = substitute(&out, SCOPE_CLOSE_TOKEN, close);
2023 out = substitute(&out, OWNER_TOKEN, owner.as_bytes());
2024 if let Some(style) = params.style() {
2025 out = substitute(&out, STYLE_TOKEN, style.as_str().as_bytes());
2026 }
2027 out = substitute(&out, SCOPE_SHAPE_TOKEN, SCOPE_SHAPE.as_bytes());
2028 let (fragment, flag, squash): (&str, &[u8], &[u8]) = match params.merge_form() {
2032 crate::landing::MergeForm::Squash => (GITLAB_TITLE_FRAGMENT, b"--squash", b"true"),
2033 crate::landing::MergeForm::Rebase => (GITLAB_COMMIT_FRAGMENT, b"--rebase", b"false"),
2034 };
2035 out = substitute(&out, GITLAB_GATE_FRAGMENT_TOKEN, fragment.as_bytes());
2036 out = substitute(&out, MERGE_FLAG_TOKEN, flag);
2037 out = substitute(&out, SQUASH_ON_MERGE_TOKEN, squash);
2038 let shape = release_driver_shape(params.driver()).unwrap_or_default();
2043 out = substitute(&out, BRANCH_SHAPE_TOKEN, shape.as_bytes());
2044 out = substitute(&out, TRUNK_BRANCH_TOKEN, params.trunk().as_bytes());
2045 let escaped = params.line_prefix().replace('/', "\\/");
2046 out = substitute(&out, LINE_PREFIX_RE_TOKEN, escaped.as_bytes());
2047 out = substitute(&out, LINE_PREFIX_TOKEN, params.line_prefix().as_bytes());
2048 let required_check = serde_json::Value::String(params.required_check().to_owned()).to_string();
2052 out = substitute(&out, REQUIRED_CHECK_TOKEN, required_check.as_bytes());
2053 out = substitute(
2054 &out,
2055 SELF_VERSION_TOKEN,
2056 env!("CARGO_PKG_VERSION").as_bytes(),
2057 );
2058 let releases = format!("{}/releases/download", env!("CARGO_PKG_REPOSITORY"));
2059 out = substitute(&out, SELF_RELEASES_TOKEN, releases.as_bytes());
2060 out = substitute(&out, REPO_TOKEN, repo.as_bytes());
2061 let run = params
2064 .dev_environment()
2065 .map(|answer| format!("{} ", answer.run))
2066 .unwrap_or_default();
2067 out = substitute(&out, DEV_RUN_TOKEN, run.as_bytes());
2068 out = substitute(
2072 &out,
2073 BUILD_PACKAGES_TOKEN,
2074 params.build_packages().as_bytes(),
2075 );
2076 out
2077}
2078
2079#[must_use]
2081pub fn substitute(baseline: &[u8], token: &[u8], value: &[u8]) -> Vec<u8> {
2082 let mut out = Vec::with_capacity(baseline.len());
2083 let mut rest = baseline;
2084 while let Some(at) = find(rest, token) {
2085 out.extend_from_slice(&rest[..at]);
2086 out.extend_from_slice(value);
2087 rest = &rest[at + token.len()..];
2088 }
2089 out.extend_from_slice(rest);
2090 out
2091}
2092
2093fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
2095 haystack
2096 .windows(needle.len())
2097 .position(|window| window == needle)
2098}
2099
2100pub const AGENTS_DESTINATION: &str = "AGENTS.md";
2102
2103pub const BLOCK_BEGIN: &str = "<!-- BEGIN release-kit -->";
2105
2106pub const BLOCK_END: &str = "<!-- END release-kit -->";
2108
2109pub const GLOSSARY_DESTINATION: &str = "GLOSSARY.md";
2114
2115pub const HOOKS_DESTINATION: &str = ".pre-commit-config.yaml";
2117
2118pub const BLOCK_DESTINATIONS: [&str; 3] =
2124 [AGENTS_DESTINATION, GLOSSARY_DESTINATION, HOOKS_DESTINATION];
2125
2126pub const HOOKS_BEGIN: &str = "# BEGIN release-kit";
2128
2129pub const HOOKS_END: &str = "# END release-kit";
2131
2132pub const HOOK_TYPES_LINE: &str = "default_install_hook_types: [pre-commit, commit-msg, pre-push]";
2136
2137pub const LOCAL_HOOK_TYPES_LINE: &str =
2141 "default_install_hook_types: [pre-commit, commit-msg, pre-push, pre-merge-commit]";
2142
2143#[must_use]
2145pub const fn hook_types_line(integration: Integration) -> &'static str {
2146 match integration {
2147 Integration::Forge => HOOK_TYPES_LINE,
2148 Integration::Local => LOCAL_HOOK_TYPES_LINE,
2149 }
2150}
2151
2152pub const AGENTS_BLOCK: &str = "blocks/agents-block.md.in";
2154
2155pub const GLOSSARY_BLOCK: &str = "blocks/glossary.md.in";
2157
2158pub const AGENTS_LINE_AGENT_POLICY: &str = "blocks/agents-line-agent-policy.md.in";
2160
2161pub const AGENTS_LINE_COMMIT_POLICY: &str = "blocks/agents-line-commit-policy.md.in";
2165
2166pub const AGENTS_BLOCK_WORKFLOW: &str = "blocks/agents-block-workflow.md.in";
2169
2170pub const AGENTS_LINE_AGENT_POLICY_WORKFLOW: &str =
2173 "blocks/agents-line-agent-policy-workflow.md.in";
2174
2175pub const AGENTS_LINE_COMMIT_SOURCE_SQUASH: &str = "blocks/agents-line-commit-source-squash.md.in";
2178
2179pub const AGENTS_LINE_COMMIT_SOURCE_REBASE: &str = "blocks/agents-line-commit-source-rebase.md.in";
2182
2183pub const AGENTS_LINE_COMMIT_SCOPE_REQUIRED: &str =
2186 "blocks/agents-line-commit-scope-required.md.in";
2187
2188pub const AGENTS_LINE_COMMIT_SCOPE_PREFERRED: &str =
2190 "blocks/agents-line-commit-scope-preferred.md.in";
2191
2192pub const GLOSSARY_BLOCK_WORKFLOW: &str = "blocks/glossary-workflow.md.in";
2194
2195pub const PRE_COMMIT_BLOCK: &str = "blocks/pre-commit-block.yaml.in";
2197
2198pub const PRE_COMMIT_MESSAGE_GUARDS: &str = "blocks/pre-commit-message-guards.yaml.in";
2200
2201pub const PRE_COMMIT_BRANCH_NAME_GUARD: &str = "blocks/pre-commit-branch-name-guard.yaml.in";
2203
2204pub const PRE_COMMIT_WORKTREE_GUARD: &str = "blocks/pre-commit-worktree-guard.yaml.in";
2206
2207pub const PRE_COMMIT_TRUNK_COMMIT_GUARD: &str = "blocks/pre-commit-trunk-commit-guard.yaml.in";
2209
2210pub const PRE_COMMIT_TRUNK_PUSH_GUARD: &str = "blocks/pre-commit-trunk-push-guard.yaml.in";
2212
2213pub const PRE_COMMIT_SWEEP_SKIP: &str = "blocks/pre-commit-sweep-skip.yaml.in";
2215
2216pub const PRE_COMMIT_SWEEP_NONE: &str = "blocks/pre-commit-sweep-none.yaml.in";
2218
2219pub const PRE_COMMIT_INTEGRATE_NOTE: &str = "blocks/pre-commit-integrate-note.yaml.in";
2221
2222pub const PRE_COMMIT_TAG_GUARD: &str = "blocks/pre-commit-tag-guard.yaml.in";
2224
2225pub const PRE_COMMIT_STATUS_RELEASE_FILES: &str = "blocks/pre-commit-status-release-files.yaml.in";
2231
2232pub const PRE_COMMIT_LOCAL_TRUNK_COMMIT_GUARD: &str =
2235 "blocks/pre-commit-local-trunk-commit-guard.yaml.in";
2236
2237pub const PRE_COMMIT_LOCAL_TRUNK_PUSH_GUARDS: &str =
2240 "blocks/pre-commit-local-trunk-push-guards.yaml.in";
2241
2242pub const FORCE_SCOPE_ARG: &str = ", --force-scope";
2248
2249pub fn embedded_block(path: &str) -> Result<&'static str, RkError> {
2256 let name = path.strip_prefix("blocks/").unwrap_or(path);
2257 let file = embedded::BLOCKS
2258 .get_file(name)
2259 .ok_or_else(|| anyhow::anyhow!("{path}: this binary embeds no such block"))?;
2260 std::str::from_utf8(file.contents())
2261 .map_err(|_| anyhow::anyhow!("{path}: a block is UTF-8").into())
2262}
2263
2264#[must_use]
2268pub fn authored(text: &str) -> &str {
2269 text.strip_suffix('\n').unwrap_or(text)
2270}
2271
2272pub 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[-/].+|rk-release/.+)$";
2280
2281pub const SCOPE_SHAPE: &str = "[a-z0-9._/-]+";
2291
2292#[must_use]
2299pub fn scope_is_shaped(scope: &str) -> bool {
2300 !scope.is_empty()
2301 && scope.chars().all(|c| {
2302 c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '/' | '-')
2303 })
2304}
2305
2306pub struct RoutingGuards<'a> {
2308 pub agent_policy: &'a str,
2310 pub commit_policy: &'a str,
2313 pub commit_source: &'a str,
2315 pub commit_scope: &'a str,
2317}
2318
2319#[must_use]
2326pub fn compose_routing(template: &str, guards: Option<&RoutingGuards<'_>>) -> String {
2327 let fragment = |text: &str| format!("{}\n", authored(text));
2328 let (agent, commit) = guards.map_or_else(
2329 || (String::new(), String::new()),
2330 |guards| {
2331 (
2332 fragment(guards.agent_policy),
2333 fragment(
2334 &authored(guards.commit_policy)
2335 .replacen("RK_COMMIT_SOURCE", &fragment(guards.commit_source), 1)
2336 .replacen("RK_COMMIT_SCOPE", &fragment(guards.commit_scope), 1),
2337 ),
2338 )
2339 },
2340 );
2341 authored(template)
2342 .replacen("RK_AGENT_POLICY", &agent, 1)
2343 .replacen("RK_COMMIT_POLICY", &commit, 1)
2344}
2345
2346#[must_use]
2350pub fn compose_glossary(template: &str) -> String {
2351 authored(template).to_owned()
2352}
2353
2354pub struct WorkflowHooks<'a> {
2356 pub message: &'a str,
2358 pub branch_name: &'a str,
2360 pub worktree: Option<&'a str>,
2365}
2366
2367#[must_use]
2387pub fn compose_hooks(
2388 template: &str,
2389 workflow: Option<&WorkflowHooks<'_>>,
2390 parts: &TrunkGuards,
2391) -> String {
2392 let fragment = |text: &str| format!("{}\n", authored(text));
2393 let worktree = workflow
2394 .and_then(|hooks| hooks.worktree)
2395 .map_or_else(String::new, fragment);
2396 let trunk_guards = workflow.is_some();
2397 let mut skips: Vec<&str> = Vec::new();
2398 if trunk_guards {
2399 skips.push("no-commit-to-branch");
2400 }
2401 if !worktree.is_empty() {
2402 skips.push("rk-worktree-location");
2403 }
2404 let sweep = if skips.is_empty() {
2405 fragment(parts.sweep_none)
2406 } else {
2407 fragment(&authored(parts.sweep_skip).replacen("RK_SWEEP_SKIP", &skips.join(","), 1))
2408 };
2409 let (commit_guard, push_guard) = match (trunk_guards, parts.integration) {
2410 (false, _) => (String::new(), String::new()),
2411 (true, Integration::Forge) => (fragment(parts.trunk_commit), fragment(parts.trunk_push)),
2412 (true, Integration::Local) => (
2413 fragment(parts.local_trunk_commit),
2414 fragment(parts.local_trunk_push),
2415 ),
2416 };
2417 let (tag_guard, release_files) = if parts.release {
2418 (fragment(parts.tag_guard), authored(parts.release_files))
2419 } else {
2420 (String::new(), "")
2421 };
2422 let note = match parts.integration {
2423 Integration::Forge => String::new(),
2424 Integration::Local => fragment(parts.integrate_note),
2425 };
2426 let (message, branch_name) = workflow.map_or_else(
2427 || (String::new(), String::new()),
2428 |hooks| {
2429 let message = fragment(hooks.message);
2430 (
2431 if parts.release {
2432 message
2433 } else {
2434 message.replacen(FORCE_SCOPE_ARG, "", 1)
2435 },
2436 fragment(hooks.branch_name),
2437 )
2438 },
2439 );
2440 authored(template)
2441 .replacen("RK_SWEEP_NOTE", &format!("{sweep}{note}"), 1)
2442 .replacen("RK_MESSAGE_GUARDS", &message, 1)
2443 .replacen("RK_TRUNK_COMMIT_GUARD", &commit_guard, 1)
2444 .replacen("RK_BRANCH_NAME_GUARD", &branch_name, 1)
2445 .replacen("RK_WORKTREE_GUARD", &worktree, 1)
2446 .replacen("RK_TRUNK_PUSH_GUARD", &push_guard, 1)
2447 .replacen("RK_TAG_GUARD", &tag_guard, 1)
2448 .replacen("RK_STATUS_RELEASE_FILES", release_files, 1)
2449 .replacen("RK_BRANCH_GRAMMAR", BRANCH_GRAMMAR, 1)
2450}
2451
2452pub struct TrunkGuards<'a> {
2455 pub integration: Integration,
2457 pub release: bool,
2460 pub trunk_commit: &'a str,
2463 pub trunk_push: &'a str,
2466 pub local_trunk_commit: &'a str,
2469 pub local_trunk_push: &'a str,
2472 pub tag_guard: &'a str,
2474 pub release_files: &'a str,
2477 pub sweep_skip: &'a str,
2479 pub sweep_none: &'a str,
2481 pub integrate_note: &'a str,
2483}
2484
2485#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2488pub struct BlockShape {
2489 pub checkout_mode: CheckoutMode,
2491 pub integration: Integration,
2493 pub merge_form: MergeForm,
2495 pub workflow_guards: bool,
2497 pub release: bool,
2499}
2500
2501impl BlockShape {
2502 #[must_use]
2504 pub fn of(params: &Params) -> Self {
2505 Self {
2506 checkout_mode: params.checkout_mode(),
2507 integration: params.integration(),
2508 merge_form: params.merge_form(),
2509 workflow_guards: params.workflow_guards(),
2510 release: params.release_mode() != ReleaseMode::None,
2511 }
2512 }
2513
2514 #[must_use]
2518 pub const fn releasing(
2519 checkout_mode: CheckoutMode,
2520 integration: Integration,
2521 workflow_guards: bool,
2522 ) -> Self {
2523 Self {
2524 checkout_mode,
2525 integration,
2526 merge_form: MergeForm::Squash,
2527 workflow_guards,
2528 release: true,
2529 }
2530 }
2531
2532 const fn routing_sources(self) -> [&'static str; 4] {
2535 let (template, agent) = if self.release {
2536 (AGENTS_BLOCK, AGENTS_LINE_AGENT_POLICY)
2537 } else {
2538 (AGENTS_BLOCK_WORKFLOW, AGENTS_LINE_AGENT_POLICY_WORKFLOW)
2539 };
2540 let source = match self.merge_form {
2541 MergeForm::Squash => AGENTS_LINE_COMMIT_SOURCE_SQUASH,
2542 MergeForm::Rebase => AGENTS_LINE_COMMIT_SOURCE_REBASE,
2543 };
2544 let scope = if self.release {
2545 AGENTS_LINE_COMMIT_SCOPE_REQUIRED
2546 } else {
2547 AGENTS_LINE_COMMIT_SCOPE_PREFERRED
2548 };
2549 [template, agent, source, scope]
2550 }
2551
2552 const fn glossary_source(self) -> &'static str {
2554 if self.release {
2555 GLOSSARY_BLOCK
2556 } else {
2557 GLOSSARY_BLOCK_WORKFLOW
2558 }
2559 }
2560}
2561
2562pub fn routing_block(workflow_guards: bool) -> Result<String, RkError> {
2569 routing_block_of(&BlockShape::releasing(
2570 CheckoutMode::LinkedWorktree,
2571 Integration::Local,
2572 workflow_guards,
2573 ))
2574}
2575
2576pub fn routing_block_of(shape: &BlockShape) -> Result<String, RkError> {
2586 let [template, agent, source, scope] = shape.routing_sources();
2587 let guards = if shape.workflow_guards {
2588 Some(RoutingGuards {
2589 agent_policy: embedded_block(agent)?,
2590 commit_policy: embedded_block(AGENTS_LINE_COMMIT_POLICY)?,
2591 commit_source: embedded_block(source)?,
2592 commit_scope: embedded_block(scope)?,
2593 })
2594 } else {
2595 None
2596 };
2597 Ok(compose_routing(embedded_block(template)?, guards.as_ref()))
2598}
2599
2600pub fn glossary_block() -> Result<String, RkError> {
2607 Ok(compose_glossary(embedded_block(GLOSSARY_BLOCK)?))
2608}
2609
2610pub fn glossary_block_of(shape: &BlockShape) -> Result<String, RkError> {
2617 Ok(compose_glossary(embedded_block(shape.glossary_source())?))
2618}
2619
2620pub fn hooks_block(
2628 mode: CheckoutMode,
2629 integration: Integration,
2630 workflow_guards: bool,
2631) -> Result<String, RkError> {
2632 hooks_block_of(&BlockShape::releasing(mode, integration, workflow_guards))
2633}
2634
2635pub fn hooks_block_of(shape: &BlockShape) -> Result<String, RkError> {
2641 let workflow = if shape.workflow_guards {
2642 Some(WorkflowHooks {
2643 message: embedded_block(PRE_COMMIT_MESSAGE_GUARDS)?,
2644 branch_name: embedded_block(PRE_COMMIT_BRANCH_NAME_GUARD)?,
2645 worktree: match shape.checkout_mode {
2646 CheckoutMode::LinkedWorktree => Some(embedded_block(PRE_COMMIT_WORKTREE_GUARD)?),
2647 CheckoutMode::MainWorktree => None,
2648 },
2649 })
2650 } else {
2651 None
2652 };
2653 let parts = TrunkGuards {
2654 integration: shape.integration,
2655 release: shape.release,
2656 trunk_commit: embedded_block(PRE_COMMIT_TRUNK_COMMIT_GUARD)?,
2657 trunk_push: embedded_block(PRE_COMMIT_TRUNK_PUSH_GUARD)?,
2658 local_trunk_commit: embedded_block(PRE_COMMIT_LOCAL_TRUNK_COMMIT_GUARD)?,
2659 local_trunk_push: embedded_block(PRE_COMMIT_LOCAL_TRUNK_PUSH_GUARDS)?,
2660 tag_guard: embedded_block(PRE_COMMIT_TAG_GUARD)?,
2661 release_files: embedded_block(PRE_COMMIT_STATUS_RELEASE_FILES)?,
2662 sweep_skip: embedded_block(PRE_COMMIT_SWEEP_SKIP)?,
2663 sweep_none: embedded_block(PRE_COMMIT_SWEEP_NONE)?,
2664 integrate_note: embedded_block(PRE_COMMIT_INTEGRATE_NOTE)?,
2665 };
2666 Ok(compose_hooks(
2667 embedded_block(PRE_COMMIT_BLOCK)?,
2668 workflow.as_ref(),
2669 &parts,
2670 ))
2671}
2672
2673type BlockTemplate = (String, Vec<String>, &'static str);
2676
2677fn block_template(destination: &str, params: &Params) -> Result<Option<BlockTemplate>, RkError> {
2680 let shape = BlockShape::of(params);
2681 let guards = shape.workflow_guards;
2682 match destination {
2683 AGENTS_DESTINATION => {
2684 let [template, agent, source, scope] = shape.routing_sources();
2685 let mut sources = vec![template.to_owned()];
2686 if guards {
2687 sources.push(agent.to_owned());
2688 sources.push(AGENTS_LINE_COMMIT_POLICY.to_owned());
2689 sources.push(source.to_owned());
2690 sources.push(scope.to_owned());
2691 }
2692 Ok(Some((routing_block_of(&shape)?, sources, catalog::GUARDS)))
2693 }
2694 GLOSSARY_DESTINATION => Ok(guards
2695 .then(|| -> Result<_, RkError> {
2696 Ok((
2697 glossary_block_of(&shape)?,
2698 vec![shape.glossary_source().to_owned()],
2699 catalog::WORKFLOW_GUARDS,
2700 ))
2701 })
2702 .transpose()?),
2703 HOOKS_DESTINATION => {
2704 let mut sources = vec![PRE_COMMIT_BLOCK.to_owned()];
2705 if guards {
2706 sources.push(PRE_COMMIT_MESSAGE_GUARDS.to_owned());
2707 sources.push(PRE_COMMIT_BRANCH_NAME_GUARD.to_owned());
2708 if shape.checkout_mode == CheckoutMode::LinkedWorktree {
2709 sources.push(PRE_COMMIT_WORKTREE_GUARD.to_owned());
2710 }
2711 match shape.integration {
2712 Integration::Forge => {
2713 sources.push(PRE_COMMIT_TRUNK_COMMIT_GUARD.to_owned());
2714 sources.push(PRE_COMMIT_TRUNK_PUSH_GUARD.to_owned());
2715 }
2716 Integration::Local => {
2717 sources.push(PRE_COMMIT_LOCAL_TRUNK_COMMIT_GUARD.to_owned());
2718 sources.push(PRE_COMMIT_LOCAL_TRUNK_PUSH_GUARDS.to_owned());
2719 }
2720 }
2721 }
2722 if shape.release {
2723 sources.push(PRE_COMMIT_TAG_GUARD.to_owned());
2724 sources.push(PRE_COMMIT_STATUS_RELEASE_FILES.to_owned());
2725 }
2726 sources.push(if guards {
2727 PRE_COMMIT_SWEEP_SKIP.to_owned()
2728 } else {
2729 PRE_COMMIT_SWEEP_NONE.to_owned()
2730 });
2731 if shape.integration == Integration::Local {
2732 sources.push(PRE_COMMIT_INTEGRATE_NOTE.to_owned());
2733 }
2734 Ok(Some((hooks_block_of(&shape)?, sources, catalog::GUARDS)))
2735 }
2736 other => Err(anyhow::anyhow!("{other} is not a block destination").into()),
2737 }
2738}
2739
2740#[must_use]
2742pub fn block_markers(destination: &str) -> Option<(&'static str, &'static str)> {
2743 match destination {
2744 AGENTS_DESTINATION | GLOSSARY_DESTINATION => Some((BLOCK_BEGIN, BLOCK_END)),
2745 HOOKS_DESTINATION => Some((HOOKS_BEGIN, HOOKS_END)),
2746 _ => None,
2747 }
2748}
2749
2750#[must_use]
2753pub fn extract_block<'a>(text: &'a str, begin: &str, end: &str) -> Option<&'a str> {
2754 let start = text.find(begin)?;
2755 let stop = text[start..].find(end)? + start + end.len();
2756 Some(&text[start..stop])
2757}
2758
2759#[must_use]
2766pub fn splice_marked_block(existing: Option<&[u8]>, block: &str) -> Vec<u8> {
2767 let block = block.as_bytes();
2768 let Some(text) = existing else {
2769 return [block, b"\n"].concat();
2770 };
2771 if let Some(start) = find(text, BLOCK_BEGIN.as_bytes())
2775 && let Some(offset) = find(&text[start..], BLOCK_END.as_bytes())
2776 {
2777 let stop = start + offset + BLOCK_END.len();
2778 return [&text[..start], block, &text[stop..]].concat();
2779 }
2780 let mut out = Vec::with_capacity(text.len() + block.len() + 3);
2784 out.extend_from_slice(text);
2785 if !text.ends_with(b"\n") {
2786 out.push(b'\n');
2787 }
2788 out.push(b'\n');
2789 out.extend_from_slice(block);
2790 out.push(b'\n');
2791 out
2792}
2793
2794pub fn splice_hooks_block(
2807 existing: Option<&str>,
2808 block: &str,
2809 integration: Integration,
2810) -> Result<String, String> {
2811 let Some(text) = existing else {
2812 let types = hook_types_line(integration);
2813 return Ok(format!("{types}\n\nrepos:\n{block}\n"));
2814 };
2815 if let Some(defect) = hooks_marker_defect(text) {
2816 return Err(defect);
2817 }
2818 if let Some(found) = extract_block(text, HOOKS_BEGIN, HOOKS_END) {
2819 return Ok(text.replacen(found, block, 1));
2820 }
2821 let mut out = String::with_capacity(text.len() + block.len() + 1);
2822 let mut placed = false;
2823 for line in text.split_inclusive('\n') {
2824 out.push_str(line);
2825 if !placed && line.trim_end() == "repos:" {
2826 if !out.ends_with('\n') {
2827 out.push('\n');
2828 }
2829 out.push_str(block);
2830 out.push('\n');
2831 placed = true;
2832 }
2833 }
2834 if placed {
2835 Ok(out)
2836 } else {
2837 Err(format!(
2838 "{HOOKS_DESTINATION} exists with no repos: line, so the hook block has nowhere to land"
2839 ))
2840 }
2841}
2842
2843#[must_use]
2853pub fn marker_defect(destination: &str, text: &str) -> Option<String> {
2854 let (begin, end) = block_markers(destination)?;
2855 let begins = text.matches(begin).count();
2856 let ends = text.matches(end).count();
2857 if begins > 1 || ends > 1 {
2858 return Some(format!(
2859 "{destination} carries more than one release-kit marker pair; release-kit owns exactly one block"
2860 ));
2861 }
2862 match (text.find(begin), text.find(end)) {
2863 (Some(begin), Some(end)) if end > begin => None,
2864 (None, None) => None,
2865 _ => Some(format!(
2866 "{destination} carries an unmatched or misordered release-kit marker, so the block's extent is ambiguous"
2867 )),
2868 }
2869}
2870
2871#[must_use]
2874pub fn hooks_marker_defect(text: &str) -> Option<String> {
2875 marker_defect(HOOKS_DESTINATION, text)
2876}
2877
2878fn propose_document(
2882 destination: &str,
2883 existing: Option<&[u8]>,
2884 region: &[u8],
2885 integration: Integration,
2886) -> Result<Vec<u8>, String> {
2887 let block = String::from_utf8_lossy(region).into_owned();
2892 if let Some(text) = existing
2893 && let Some(defect) = marker_defect(destination, &String::from_utf8_lossy(text))
2894 {
2895 return Err(defect);
2896 }
2897 if destination == HOOKS_DESTINATION {
2898 let text = match existing {
2902 None => None,
2903 Some(bytes) => Some(std::str::from_utf8(bytes).map_err(|_| {
2904 format!(
2905 "{destination} is not UTF-8, so the hook block has nowhere to land without rewriting the target's bytes"
2906 )
2907 })?),
2908 };
2909 return splice_hooks_block(text, &block, integration).map(String::into_bytes);
2910 }
2911 Ok(splice_marked_block(existing, &block))
2912}
2913
2914#[must_use]
2926pub fn nix_unsupported_shape(shape: &CrateShape) -> Option<String> {
2927 let Some(text) = shape.cargo_toml.as_deref() else {
2928 return Some(
2929 "the target has no readable Cargo.toml, which the seeded package expression reads; no Nix file lands".to_owned(),
2930 );
2931 };
2932 let Ok(table) = text.parse::<toml::Table>() else {
2933 return Some(
2934 "the target's Cargo.toml does not parse, and the seeded package expression reads it; no Nix file lands".to_owned(),
2935 );
2936 };
2937 if !table.contains_key("package") {
2938 return Some(
2939 "the target's Cargo.toml has no [package] table; the seed supports a single crate, so no Nix file lands".to_owned(),
2940 );
2941 }
2942 if !shape.cargo_lock {
2943 return Some(
2944 "the target has no Cargo.lock, which the seeded package expression builds from; commit one, then opt in".to_owned(),
2945 );
2946 }
2947 let implicit_bin = shape.main_rs
2948 && table
2949 .get("package")
2950 .and_then(toml::Value::as_table)
2951 .and_then(|package| package.get("autobins"))
2952 .and_then(toml::Value::as_bool)
2953 != Some(false);
2954 let explicit_bins = table.get("bin").and_then(toml::Value::as_array);
2955 if explicit_bins.is_none() && !implicit_bin {
2956 return Some(
2957 "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(),
2958 );
2959 }
2960 if let Some(bins) = explicit_bins {
2966 let required = bins
2967 .first()
2968 .and_then(toml::Value::as_table)
2969 .and_then(|bin| bin.get("required-features"))
2970 .and_then(toml::Value::as_array);
2971 if let Some(required) = required {
2972 let enabled = default_features(&table);
2973 let missing = required
2974 .iter()
2975 .filter_map(toml::Value::as_str)
2976 .any(|feature| !enabled.contains(feature));
2977 if missing {
2978 return Some(
2979 "the target's first [[bin]] entry requires features a default build does not enable; no Nix file lands".to_owned(),
2980 );
2981 }
2982 }
2983 }
2984 None
2985}
2986
2987fn dep_edge_suppresses(features: &toml::Table, name: &str) -> bool {
2990 let edge = format!("dep:{name}");
2991 features.values().any(|list| {
2992 list.as_array().is_some_and(|entries| {
2993 entries
2994 .iter()
2995 .filter_map(toml::Value::as_str)
2996 .any(|entry| entry == edge)
2997 })
2998 })
2999}
3000
3001fn is_optional_dependency(table: &toml::Table, name: &str) -> bool {
3004 ["dependencies", "build-dependencies"]
3005 .iter()
3006 .any(|section| {
3007 table
3008 .get(*section)
3009 .and_then(toml::Value::as_table)
3010 .and_then(|dependencies| dependencies.get(name))
3011 .and_then(toml::Value::as_table)
3012 .and_then(|dependency| dependency.get("optional"))
3013 .and_then(toml::Value::as_bool)
3014 == Some(true)
3015 })
3016}
3017
3018fn default_features(table: &toml::Table) -> std::collections::BTreeSet<String> {
3025 let Some(features) = table.get("features").and_then(toml::Value::as_table) else {
3026 return std::collections::BTreeSet::new();
3027 };
3028 let mut enabled = std::collections::BTreeSet::new();
3029 let mut queue = vec!["default".to_owned()];
3030 while let Some(name) = queue.pop() {
3031 if !enabled.insert(name.clone()) {
3032 continue;
3033 }
3034 if let Some(implies) = features.get(&name).and_then(toml::Value::as_array) {
3035 for implied in implies.iter().filter_map(toml::Value::as_str) {
3036 if implied.starts_with("dep:") || implied.contains("?/") {
3037 continue;
3041 }
3042 if let Some((package, _)) = implied.split_once('/') {
3043 let feature_exists =
3051 features.contains_key(package) || !dep_edge_suppresses(features, package);
3052 if is_optional_dependency(table, package) && feature_exists {
3053 queue.push(package.to_owned());
3054 }
3055 } else {
3056 queue.push(implied.to_owned());
3057 }
3058 }
3059 }
3060 }
3061 enabled
3062}
3063
3064#[must_use]
3072pub fn flake_pair_withheld(
3073 flake_recorded: bool,
3074 flake_nix_present: bool,
3075 flake_lock_present: bool,
3076) -> Option<String> {
3077 if flake_recorded {
3078 return None;
3079 }
3080 let present: Vec<&str> = [
3081 ("flake.nix", flake_nix_present),
3082 ("flake.lock", flake_lock_present),
3083 ]
3084 .into_iter()
3085 .filter_map(|(name, present)| present.then_some(name))
3086 .collect();
3087 if present.is_empty() {
3088 return None;
3089 }
3090 Some(format!(
3091 "the target already carries {}; its flake pair stays its own",
3092 present.join(" and ")
3093 ))
3094}
3095
3096#[must_use]
3106pub fn nix_withholding(
3107 nix: bool,
3108 evidence: &TargetEvidence,
3109) -> Option<(&'static [&'static str], String)> {
3110 if !nix {
3111 return None;
3112 }
3113 if let Some(reason) = nix_unsupported_shape(&evidence.crate_shape) {
3114 return Some((&NIX_DESTINATIONS[..], reason));
3115 }
3116 flake_pair_withheld(
3117 evidence.flake_recorded,
3118 evidence.flake_nix_present,
3119 evidence.flake_lock_present,
3120 )
3121 .map(|reason| (&NIX_WITHHOLDABLE[..], reason))
3122}
3123
3124#[cfg(test)]
3125mod tests {
3126 use super::{
3127 AGENTS_DESTINATION, BLOCK_BEGIN, BLOCK_DESTINATIONS, BLOCK_END, Candidate, Collision,
3128 CrateShape, GLOSSARY_DESTINATION, HOOK_TYPES_LINE, HOOKS_BEGIN, HOOKS_DESTINATION,
3129 HOOKS_END, Placement, Projection, ProjectionInput, TargetEvidence, extract_block,
3130 };
3131 use crate::landing::{Params, Style};
3132
3133 fn supported_shape() -> CrateShape {
3135 CrateShape {
3136 cargo_toml: Some("[package]\nname = \"widget\"\nversion = \"0.1.0\"\n".to_owned()),
3137 cargo_lock: true,
3138 main_rs: true,
3139 }
3140 }
3141
3142 fn input(evidence: TargetEvidence) -> ProjectionInput {
3143 let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
3144 params.set_nix_for_test(true);
3145 ProjectionInput { params, evidence }
3146 }
3147
3148 fn compute(evidence: TargetEvidence) -> Projection {
3149 Projection::compute(&input(evidence)).expect("the embedded pair projects")
3150 }
3151
3152 fn candidate<'a>(projection: &'a Projection, destination: &str) -> &'a Candidate {
3153 projection
3154 .candidates
3155 .iter()
3156 .find(|candidate| candidate.destination == destination)
3157 .expect("the destination projects")
3158 }
3159
3160 #[test]
3165 fn the_workflow_guards_land_only_where_requested() {
3166 let mut plain = input(TargetEvidence {
3167 crate_shape: supported_shape(),
3168 ..TargetEvidence::default()
3169 });
3170 plain.params.set_workflow_guards_for_test(false);
3171 let plain = Projection::compute(&plain).expect("the embedded pair projects");
3172 assert!(
3173 !plain
3174 .candidates
3175 .iter()
3176 .any(|candidate| candidate.destination == GLOSSARY_DESTINATION),
3177 "a target without the guards takes no glossary"
3178 );
3179 let agents =
3180 String::from_utf8(candidate(&plain, AGENTS_DESTINATION).bytes.clone()).expect("text");
3181 assert!(!agents.contains("guides and never drives"), "{agents}");
3182 let hooks =
3183 String::from_utf8(candidate(&plain, HOOKS_DESTINATION).bytes.clone()).expect("text");
3184 assert!(!hooks.contains("conventional-pre-commit"), "{hooks}");
3185 assert!(hooks.contains("rk-no-hand-authored-tag"), "{hooks}");
3186
3187 let guarded = compute(TargetEvidence {
3188 crate_shape: supported_shape(),
3189 ..TargetEvidence::default()
3190 });
3191 let glossary = candidate(&guarded, GLOSSARY_DESTINATION);
3192 assert_eq!(
3193 glossary.capability,
3194 crate::profile::catalog::WORKFLOW_GUARDS
3195 );
3196 assert_eq!(
3197 candidate(&guarded, AGENTS_DESTINATION).capability,
3198 crate::profile::catalog::GUARDS
3199 );
3200 let hooks =
3201 String::from_utf8(candidate(&guarded, HOOKS_DESTINATION).bytes.clone()).expect("text");
3202 for hook in ["conventional-pre-commit", "rk-message", "rk-branch-name"] {
3203 assert!(hooks.contains(hook), "{hook}: {hooks}");
3204 }
3205 }
3206
3207 fn outside(bytes: &[u8], begin: &str, end: &str) -> (Vec<u8>, Vec<u8>) {
3209 let text = String::from_utf8_lossy(bytes);
3210 let start = text.find(begin).expect("the begin marker is present");
3211 let stop = text[start..].find(end).expect("the end marker is present") + start + end.len();
3212 (bytes[..start].to_vec(), bytes[stop..].to_vec())
3213 }
3214
3215 #[test]
3216 fn equal_projection_inputs_yield_byte_identical_projections() {
3217 let mut documents = std::collections::BTreeMap::new();
3218 documents.insert(
3219 AGENTS_DESTINATION.to_owned(),
3220 b"# Widget\n\nOwn rules.\n".to_vec(),
3221 );
3222 let evidence = TargetEvidence {
3223 documents,
3224 crate_shape: supported_shape(),
3225 ..TargetEvidence::default()
3226 };
3227 let first = input(evidence.clone());
3228 let second = input(evidence);
3229 assert_eq!(first, second, "the inputs are values and compare equal");
3230 let a = Projection::compute(&first).expect("the pair projects");
3231 let b = Projection::compute(&second).expect("the pair projects");
3232 assert_eq!(a.candidates.len(), b.candidates.len());
3233 for (x, y) in a.candidates.iter().zip(&b.candidates) {
3234 assert_eq!(x.destination, y.destination);
3235 assert_eq!(x.kind, y.kind);
3236 assert_eq!(x.placement, y.placement);
3237 assert_eq!(x.bytes, y.bytes, "{}", x.destination);
3238 assert_eq!(x.region, y.region, "{}", x.destination);
3239 assert_eq!(x.sources, y.sources, "{}", x.destination);
3240 }
3241 assert_eq!(a, b);
3242 let destinations: Vec<&str> = a
3243 .candidates
3244 .iter()
3245 .map(|candidate| candidate.destination.as_str())
3246 .collect();
3247 let mut sorted = destinations.clone();
3248 sorted.sort_unstable();
3249 assert_eq!(destinations, sorted, "candidates sort by destination");
3250 assert!(a.omissions.is_empty(), "{:?}", a.omissions);
3251 assert!(a.collisions.is_empty(), "{:?}", a.collisions);
3252 }
3253
3254 #[test]
3258 fn the_scorecard_destination_projects_only_under_the_opt_in() {
3259 use super::{Kind, SCORECARD_DESTINATIONS, kind_of};
3260 let destination = SCORECARD_DESTINATIONS[0];
3261 assert_eq!(kind_of(destination), Some(Kind::Rendered));
3262
3263 let project = |scorecard: bool| {
3264 let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
3265 params.set_scorecard_for_test(scorecard);
3266 Projection::compute(&ProjectionInput {
3267 params,
3268 evidence: TargetEvidence::default(),
3269 })
3270 .expect("the embedded pair projects")
3271 };
3272
3273 let off = project(false);
3274 assert!(
3275 !off.candidates
3276 .iter()
3277 .any(|candidate| candidate.destination == destination),
3278 "off by default, and an absent candidate is no omission"
3279 );
3280 assert!(off.omissions.is_empty(), "{:?}", off.omissions);
3281
3282 let on = project(true);
3283 let candidate = candidate(&on, destination);
3284 assert_eq!(candidate.kind, Kind::Rendered);
3285 assert_eq!(candidate.placement, Placement::Whole);
3286 let text = String::from_utf8_lossy(&candidate.bytes);
3287 assert!(!text.contains("RK_"), "a token survived: {text}");
3288 }
3289
3290 #[test]
3295 fn the_licence_judgment_reads_what_every_reader_obtains() {
3296 use super::licence_states_an_open_source_codebase as approved;
3297 for expression in [
3298 "MIT",
3299 "Apache-2.0",
3300 "MIT OR Apache-2.0",
3301 "MIT AND Apache-2.0",
3302 "(MIT OR Apache-2.0) AND ISC",
3303 "Apache-2.0 WITH LLVM-exception OR MIT",
3304 "GPL-3.0+",
3305 ] {
3306 assert!(approved(expression), "{expression} is OSI-approved");
3307 }
3308 for expression in [
3313 "MIT AND CC-BY-4.0",
3314 "CC-BY-4.0 AND MIT",
3315 "MIT AND (CC-BY-4.0 OR CC-BY-SA-4.0)",
3316 "(MIT OR CC-BY-4.0) AND MIT",
3317 "MIT OR Apache-2.0 AND CC-BY-4.0",
3318 "Apache-2.0 WITH LLVM-exception AND CC-BY-SA-3.0",
3319 ] {
3320 assert!(approved(expression), "{expression} states open source");
3321 }
3322 for expression in [
3323 "",
3324 " ",
3325 "LicenseRef-proprietary",
3326 "MIT AND LicenseRef-proprietary",
3327 "SEE LICENSE IN COPYING",
3328 "CC-BY-4.0",
3329 "DocumentRef-spdx:LicenseRef-proprietary",
3330 ] {
3331 assert!(!approved(expression), "{expression} is not recognized");
3332 }
3333 for expression in [
3338 "MIT OR CC-BY-4.0",
3339 "CC-BY-4.0 AND CC-BY-SA-4.0",
3340 "CC-BY-4.0 AND LicenseRef-proprietary",
3341 "CC0-1.0",
3342 "MIT AND CC-BY-4.0 AND LicenseRef-proprietary",
3343 ] {
3344 assert!(!approved(expression), "{expression} grants no code terms");
3345 }
3346 assert!(
3351 !approved("CC-BY-4.0 OR CC-BY-SA-4.0 AND MIT"),
3352 "AND binds tighter than OR"
3353 );
3354 }
3355
3356 #[test]
3362 fn the_licence_judgment_holds_the_expression_to_the_spdx_grammar() {
3363 use super::licence_states_an_open_source_codebase as approved;
3364 for expression in [
3369 "MIT OR",
3370 "OR MIT",
3371 "MIT AND",
3372 "MIT WITH",
3373 "WITH LLVM-exception",
3374 "(MIT",
3375 "MIT)",
3376 "MIT Apache-2.0",
3377 "()",
3378 "(MIT OR Apache-2.0",
3379 "MIT OR (Apache-2.0",
3380 "MIT OR ()",
3381 "AND",
3382 "(",
3383 ")",
3384 "MIT WITH AND ISC",
3385 "MIT OR OR ISC",
3386 "MIT @ Apache-2.0",
3387 ] {
3388 assert!(!approved(expression), "{expression} is malformed");
3389 }
3390 for expression in [
3392 "MIT AND (Apache-2.0 OR ISC)",
3393 "((MIT))",
3394 "Apache-2.0 WITH LLVM-exception AND ISC",
3395 "(Apache-2.0 WITH LLVM-exception)",
3396 ] {
3397 assert!(approved(expression), "{expression} is well formed");
3398 }
3399 for expression in [
3404 "mit",
3405 "MiT",
3406 "apache-2.0 OR mit",
3407 "APACHE-2.0 WITH llvm-exception",
3408 ] {
3409 assert!(
3410 approved(expression),
3411 "{expression} names an approved licence"
3412 );
3413 }
3414 for expression in [
3418 "MIT or Apache-2.0",
3419 "MIT and Apache-2.0",
3420 "MIT with LLVM-exception",
3421 ] {
3422 assert!(!approved(expression), "{expression} carries no operator");
3423 }
3424 for expression in [
3428 "MIT WITH definitely-not-an-spdx-exception",
3429 "MIT WITH MIT",
3430 "MIT WITH Apache-2.0",
3431 ] {
3432 assert!(!approved(expression), "{expression} names no exception");
3433 }
3434 for expression in [
3438 "GPL-2.0-only WITH GCC-exception-2.0",
3439 "GPL-2.0-or-later WITH Classpath-exception-2.0",
3440 "Apache-2.0 WITH Swift-exception",
3441 "GPL-3.0-only WITH Autoconf-exception-generic",
3442 ] {
3443 assert!(approved(expression), "{expression} names a real exception");
3444 }
3445 }
3446
3447 #[test]
3453 fn a_recorded_provider_its_pair_cannot_run_is_named() {
3454 use super::{Provider, code_scanning_incompatibility};
3455
3456 assert!(code_scanning_incompatibility(None, Some("bash"), Some("gitlab")).is_none());
3457 assert!(
3458 code_scanning_incompatibility(Some(Provider::Semgrep), Some("rust"), Some("gitlab"))
3459 .is_none()
3460 );
3461 assert!(
3462 code_scanning_incompatibility(Some(Provider::CodeQl), Some("rust"), Some("github"))
3463 .is_none()
3464 );
3465
3466 let bash =
3467 code_scanning_incompatibility(Some(Provider::Semgrep), Some("bash"), Some("github"))
3468 .expect("a binding with no scanner is named");
3469 assert!(bash.contains("bash binding"), "{bash}");
3470 let gitlab =
3471 code_scanning_incompatibility(Some(Provider::CodeQl), Some("rust"), Some("gitlab"))
3472 .expect("codeql on gitlab is named");
3473 assert!(gitlab.contains("codeql"), "{gitlab}");
3474 let release_less =
3475 code_scanning_incompatibility(Some(Provider::Semgrep), None, Some("github"))
3476 .expect("no driver is named");
3477 assert!(
3478 release_less.contains("no automatic release driver"),
3479 "{release_less}"
3480 );
3481
3482 let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
3485 params.set_code_scanning_for_test(Some(Provider::Semgrep));
3486 let clean = Projection::compute(&ProjectionInput {
3487 params: params.clone(),
3488 evidence: TargetEvidence::default(),
3489 })
3490 .expect("the pair projects");
3491 assert!(
3492 clean.record_defects.is_empty(),
3493 "{:?}",
3494 clean.record_defects
3495 );
3496 }
3497
3498 #[test]
3502 fn the_code_scanning_destinations_follow_the_recorded_provider() {
3503 use super::{
3504 CODE_SCANNING_DESTINATIONS, Kind, Provider, code_scanning_licence_refusal, kind_of,
3505 };
3506 for (destination, _) in CODE_SCANNING_DESTINATIONS {
3507 assert_eq!(kind_of(destination), Some(Kind::Rendered), "{destination}");
3508 }
3509
3510 let project = |provider: Option<Provider>| {
3511 let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
3512 params.set_code_scanning_for_test(provider);
3513 Projection::compute(&ProjectionInput {
3514 params,
3515 evidence: TargetEvidence {
3516 crate_shape: CrateShape {
3517 cargo_toml: Some(
3518 "[package]\nname = \"widget\"\nlicense = \"MIT\"\n".to_owned(),
3519 ),
3520 ..CrateShape::default()
3521 },
3522 ..TargetEvidence::default()
3523 },
3524 })
3525 .expect("the embedded pair projects")
3526 };
3527 let landed = |projection: &Projection, destination: &str| {
3528 projection
3529 .candidates
3530 .iter()
3531 .any(|candidate| candidate.destination == destination)
3532 };
3533
3534 let off = project(None);
3535 for (destination, _) in CODE_SCANNING_DESTINATIONS {
3536 assert!(!landed(&off, destination), "{destination}");
3537 }
3538 assert!(off.licence_refusal.is_none());
3539
3540 let codeql = project(Some(Provider::CodeQl));
3541 assert!(landed(
3542 &codeql,
3543 ".github/workflows/code-scanning-codeql.yml"
3544 ));
3545 assert!(!landed(
3546 &codeql,
3547 ".github/workflows/code-scanning-semgrep.yml"
3548 ));
3549 assert!(codeql.licence_refusal.is_none(), "MIT satisfies the terms");
3550
3551 let semgrep = project(Some(Provider::Semgrep));
3552 assert!(landed(
3553 &semgrep,
3554 ".github/workflows/code-scanning-semgrep.yml"
3555 ));
3556 assert!(!landed(
3557 &semgrep,
3558 ".github/workflows/code-scanning-codeql.yml"
3559 ));
3560
3561 let proprietary = CrateShape {
3563 cargo_toml: Some("[package]\nlicense = \"LicenseRef-proprietary\"\n".to_owned()),
3564 ..CrateShape::default()
3565 };
3566 assert!(
3567 code_scanning_licence_refusal(Some(Provider::Semgrep), Some("rust"), &proprietary)
3568 .is_none()
3569 );
3570 let refusal =
3571 code_scanning_licence_refusal(Some(Provider::CodeQl), Some("rust"), &proprietary)
3572 .expect("codeql refuses a licence its terms do not cover");
3573 assert!(refusal.contains("LicenseRef-proprietary"), "{refusal}");
3574 assert!(refusal.contains("semgrep"), "{refusal}");
3575 let bash =
3578 code_scanning_licence_refusal(Some(Provider::CodeQl), Some("bash"), &proprietary)
3579 .expect("an unread binding refuses");
3580 assert!(bash.contains("bash binding"), "{bash}");
3581 }
3582
3583 #[test]
3589 fn the_projection_performs_no_filesystem_git_environment_clock_registry_or_network_read() {
3590 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/projection.rs");
3591 let text = std::fs::read_to_string(&path).expect("the source reads");
3592 let production = text.split("#[cfg(test)]").next().unwrap_or("");
3593 let needles = [
3594 "std::fs",
3595 "std::env",
3596 "std::process",
3597 "std::time",
3598 "SystemTime",
3599 "Instant",
3600 "std::net",
3601 "Command::new",
3602 "registry::",
3603 "ureq",
3604 "http::",
3605 "blob(",
3606 ];
3607 let mut hits = Vec::new();
3608 for (index, line) in production.lines().enumerate() {
3609 if line.trim_start().starts_with("//") {
3610 continue;
3611 }
3612 for needle in needles {
3613 if line.contains(needle) {
3614 hits.push(format!("src/projection.rs:{}: {needle}", index + 1));
3615 }
3616 }
3617 }
3618 assert!(
3619 hits.is_empty(),
3620 "the projection reads beyond its inputs: {hits:?}"
3621 );
3622 }
3623
3624 #[test]
3625 #[allow(
3626 clippy::too_many_lines,
3627 reason = "one test walks the three marked destinations and the three unmarked shapes"
3628 )]
3629 fn marked_region_projection_preserves_every_target_byte_outside_the_markers() {
3630 let agents_before = "# Widget\n\nOperator prose above.\n\n";
3631 let agents_after = "\n\n## Our rules\n\nOperator prose below. \n";
3632 let glossary_before = "# Glossary\n\n- `spike` is a throwaway branch.\n\n";
3633 let glossary_after = "\n\n## More terms\n\n- `own` is ours.";
3634 let hooks_before = "default_install_hook_types: [pre-commit]\n\nrepos:\n";
3635 let hooks_after =
3636 "\n - repo: https://example.com/own\n rev: v1\n hooks:\n - id: own\n";
3637 let stale = |begin: &str, end: &str| format!("{begin}\nstale block\n{end}");
3638 let mut documents = std::collections::BTreeMap::new();
3639 documents.insert(
3640 AGENTS_DESTINATION.to_owned(),
3641 format!(
3642 "{agents_before}{}{agents_after}",
3643 stale(BLOCK_BEGIN, BLOCK_END)
3644 )
3645 .into_bytes(),
3646 );
3647 documents.insert(
3648 GLOSSARY_DESTINATION.to_owned(),
3649 format!(
3650 "{glossary_before}{}{glossary_after}",
3651 stale(BLOCK_BEGIN, BLOCK_END)
3652 )
3653 .into_bytes(),
3654 );
3655 documents.insert(
3656 HOOKS_DESTINATION.to_owned(),
3657 format!(
3658 "{hooks_before}{}{hooks_after}",
3659 stale(HOOKS_BEGIN, HOOKS_END)
3660 )
3661 .into_bytes(),
3662 );
3663 let projection = compute(TargetEvidence {
3664 documents: documents.clone(),
3665 crate_shape: supported_shape(),
3666 ..TargetEvidence::default()
3667 });
3668 assert!(
3669 projection.collisions.is_empty(),
3670 "{:?}",
3671 projection.collisions
3672 );
3673 for (destination, before, after) in [
3674 (AGENTS_DESTINATION, agents_before, agents_after),
3675 (GLOSSARY_DESTINATION, glossary_before, glossary_after),
3676 (HOOKS_DESTINATION, hooks_before, hooks_after),
3677 ] {
3678 let candidate = candidate(&projection, destination);
3679 let Placement::Region { begin, end } = candidate.placement else {
3680 panic!("{destination} is a region");
3681 };
3682 let region = candidate
3683 .region
3684 .as_deref()
3685 .expect("a region carries its block");
3686 let (head, tail) = outside(&candidate.bytes, begin, end);
3687 assert_eq!(
3688 head,
3689 before.as_bytes(),
3690 "{destination}: bytes before the markers"
3691 );
3692 assert_eq!(
3693 tail,
3694 after.as_bytes(),
3695 "{destination}: bytes after the markers"
3696 );
3697 let inside = &candidate.bytes[head.len()..candidate.bytes.len() - tail.len()];
3698 assert_eq!(
3699 inside, region,
3700 "{destination}: the region is the rendered block"
3701 );
3702 let (existing_head, existing_tail) = outside(&documents[destination], begin, end);
3703 assert_eq!(head, existing_head);
3704 assert_eq!(tail, existing_tail);
3705 }
3706
3707 let own_hooks =
3711 "repos:\n - repo: https://example.com/own\n rev: v1\n hooks:\n - id: own\n";
3712 let own_agents = "# Widget\n\nOwn rules.";
3713 let mut documents = std::collections::BTreeMap::new();
3714 documents.insert(HOOKS_DESTINATION.to_owned(), own_hooks.as_bytes().to_vec());
3715 documents.insert(
3716 AGENTS_DESTINATION.to_owned(),
3717 own_agents.as_bytes().to_vec(),
3718 );
3719 let projection = compute(TargetEvidence {
3720 documents,
3721 crate_shape: supported_shape(),
3722 ..TargetEvidence::default()
3723 });
3724 assert!(
3725 projection.collisions.is_empty(),
3726 "{:?}",
3727 projection.collisions
3728 );
3729 let hooks = candidate(&projection, HOOKS_DESTINATION);
3730 let hooks_text = String::from_utf8_lossy(&hooks.bytes);
3731 let region = String::from_utf8_lossy(hooks.region.as_deref().expect("a region"));
3732 assert!(
3733 hooks_text.starts_with(&format!(
3734 "repos:\n{region}\n - repo: https://example.com/own"
3735 )),
3736 "{hooks_text}"
3737 );
3738 assert!(!hooks_text.contains(HOOK_TYPES_LINE));
3739 let agents = candidate(&projection, AGENTS_DESTINATION);
3740 assert!(agents.bytes.starts_with(own_agents.as_bytes()));
3741 assert_eq!(
3742 extract_block(
3743 &String::from_utf8_lossy(&agents.bytes),
3744 BLOCK_BEGIN,
3745 BLOCK_END
3746 )
3747 .map(str::as_bytes),
3748 agents.region.as_deref()
3749 );
3750 let glossary = candidate(&projection, GLOSSARY_DESTINATION);
3751 let region = glossary.region.as_deref().expect("a region");
3752 assert_eq!(
3753 glossary.bytes,
3754 [region, b"\n"].concat(),
3755 "an absent file is fresh"
3756 );
3757 }
3758
3759 #[test]
3765 fn a_hook_document_that_is_not_utf8_collides_instead_of_being_rewritten() {
3766 let mut documents = std::collections::BTreeMap::new();
3767 let mut invalid = b"repos:\n# own \xff above\n".to_vec();
3768 invalid.extend_from_slice(format!("{HOOKS_BEGIN}\nstale\n{HOOKS_END}\n").as_bytes());
3769 invalid.extend_from_slice(b" - repo: local \xff below\n");
3770 documents.insert(HOOKS_DESTINATION.to_owned(), invalid);
3771 let mut agents = b"# Widget r\xe9sum\xe9\n\n".to_vec();
3772 agents.extend_from_slice(format!("{BLOCK_BEGIN}\nstale\n{BLOCK_END}\n\n").as_bytes());
3773 agents.extend_from_slice(b"r\xe9sum\xe9\n");
3774 documents.insert(AGENTS_DESTINATION.to_owned(), agents.clone());
3775 let projection = compute(TargetEvidence {
3776 documents,
3777 crate_shape: supported_shape(),
3778 ..TargetEvidence::default()
3779 });
3780 let collided: Vec<&str> = projection
3781 .collisions
3782 .iter()
3783 .map(|c| c.destination.as_str())
3784 .collect();
3785 assert_eq!(collided, [HOOKS_DESTINATION]);
3786 assert!(
3787 projection.collisions[0].reason.contains("not UTF-8"),
3788 "{}",
3789 projection.collisions[0].reason
3790 );
3791 assert!(
3792 !projection
3793 .candidates
3794 .iter()
3795 .any(|c| c.destination == HOOKS_DESTINATION),
3796 "a colliding destination projects no candidate"
3797 );
3798 let agents = candidate(&projection, AGENTS_DESTINATION);
3799 assert!(
3800 agents.bytes.starts_with(b"# Widget r\xe9sum\xe9\n\n"),
3801 "{:?}",
3802 agents.bytes
3803 );
3804 assert!(
3805 agents.bytes.ends_with(b"\n\nr\xe9sum\xe9\n"),
3806 "{:?}",
3807 agents.bytes
3808 );
3809 assert!(
3810 !agents.bytes.contains(&0xEF),
3811 "a replacement character landed"
3812 );
3813
3814 let mut documents = std::collections::BTreeMap::new();
3815 let valid =
3816 format!("repos:\n# own above\n{HOOKS_BEGIN}\nstale\n{HOOKS_END}\n - repo: local\n");
3817 documents.insert(HOOKS_DESTINATION.to_owned(), valid.into_bytes());
3818 let projection = compute(TargetEvidence {
3819 documents,
3820 crate_shape: supported_shape(),
3821 ..TargetEvidence::default()
3822 });
3823 assert!(
3824 projection.collisions.is_empty(),
3825 "{:?}",
3826 projection.collisions
3827 );
3828 let hooks = candidate(&projection, HOOKS_DESTINATION);
3829 let text = String::from_utf8(hooks.bytes.clone()).expect("a valid document stays text");
3830 assert!(text.starts_with("repos:\n# own above\n"), "{text}");
3831 assert!(text.ends_with("\n - repo: local\n"), "{text}");
3832 assert!(!text.contains("stale"), "the region is replaced: {text}");
3833 }
3834
3835 #[test]
3839 fn a_whole_file_colliding_with_a_marked_region_names_both_source_paths() {
3840 let files: Vec<(String, &[u8])> = vec![
3841 ("snippets/_shared/github/SECURITY.md".to_owned(), b"policy"),
3842 ("snippets/rust/github/AGENTS.md".to_owned(), b"whole"),
3843 ];
3844 let err = Projection::compute_over(&files, &input(TargetEvidence::default()))
3845 .expect_err("a whole file at a block destination refuses");
3846 let text = err.to_string();
3847 assert!(text.contains("snippets/rust/github/AGENTS.md"), "{text}");
3848 assert!(text.contains(super::AGENTS_BLOCK), "{text}");
3849 assert!(text.contains(super::AGENTS_LINE_AGENT_POLICY), "{text}");
3850 assert!(text.contains("embedded sources are defective"), "{text}");
3851 }
3852
3853 #[test]
3854 fn duplicate_whole_file_destinations_and_overlapping_marked_regions_refuse_with_the_conflicting_source_names()
3855 {
3856 let files: Vec<(String, &[u8])> = vec![
3859 ("snippets/_shared/github/SECURITY.md".to_owned(), b"shared"),
3860 ("snippets/rust/github/SECURITY.md".to_owned(), b"pair"),
3861 ("snippets/rust/github/release-plz.toml".to_owned(), b"seed"),
3862 ];
3863 let err = Projection::compute_over(&files, &input(TargetEvidence::default()))
3864 .expect_err("a doubled destination refuses");
3865 let text = err.to_string();
3866 assert!(
3867 text.contains("snippets/_shared/github/SECURITY.md"),
3868 "{text}"
3869 );
3870 assert!(text.contains("snippets/rust/github/SECURITY.md"), "{text}");
3871 assert!(text.contains("embedded sources are defective"), "{text}");
3872
3873 let clean: Vec<(String, &[u8])> = vec![
3874 ("snippets/_shared/github/SECURITY.md".to_owned(), b"shared"),
3875 ("snippets/rust/github/release-plz.toml".to_owned(), b"seed"),
3876 ];
3877 let projection = Projection::compute_over(&clean, &input(TargetEvidence::default()))
3878 .expect("a clean list projects");
3879 let whole: Vec<&str> = projection
3880 .candidates
3881 .iter()
3882 .filter(|c| c.placement == Placement::Whole && c.capability != super::SKILLS_SOURCE)
3883 .map(|c| c.destination.as_str())
3884 .collect();
3885 assert_eq!(whole, ["SECURITY.md", "release-plz.toml"]);
3886 assert_eq!(
3887 projection
3888 .candidates
3889 .iter()
3890 .find(|c| c.destination == "SECURITY.md")
3891 .map(|c| c.sources.clone()),
3892 Some(vec!["snippets/_shared/github/SECURITY.md".to_owned()])
3893 );
3894
3895 let doubled = format!("{BLOCK_BEGIN}\na\n{BLOCK_END}\n{BLOCK_BEGIN}\nb\n{BLOCK_END}\n");
3896 let unmatched = format!("repos:\n{HOOKS_BEGIN}\n - repo: local\n");
3897 let misordered = format!("# G\n{BLOCK_END}\n{BLOCK_BEGIN}\n");
3898 let mut documents = std::collections::BTreeMap::new();
3899 documents.insert(AGENTS_DESTINATION.to_owned(), doubled.into_bytes());
3900 documents.insert(HOOKS_DESTINATION.to_owned(), unmatched.into_bytes());
3901 documents.insert(GLOSSARY_DESTINATION.to_owned(), misordered.into_bytes());
3902 let projection = compute(TargetEvidence {
3903 documents,
3904 crate_shape: supported_shape(),
3905 ..TargetEvidence::default()
3906 });
3907 let mut collided: Vec<&str> = projection
3908 .collisions
3909 .iter()
3910 .map(|Collision { destination, .. }| destination.as_str())
3911 .collect();
3912 collided.sort_unstable();
3913 let mut expected = BLOCK_DESTINATIONS.to_vec();
3914 expected.sort_unstable();
3915 assert_eq!(collided, expected);
3916 for collision in &projection.collisions {
3917 assert!(
3918 collision.reason.contains(&collision.destination),
3919 "{collision:?}"
3920 );
3921 assert!(
3922 !projection
3923 .candidates
3924 .iter()
3925 .any(|candidate| candidate.destination == collision.destination),
3926 "{} collided and still projects",
3927 collision.destination
3928 );
3929 }
3930 let agents = projection
3931 .collisions
3932 .iter()
3933 .find(|c| c.destination == AGENTS_DESTINATION)
3934 .expect("the doubled document collides");
3935 assert!(agents.reason.contains("more than one"), "{}", agents.reason);
3936 let hooks = projection
3937 .collisions
3938 .iter()
3939 .find(|c| c.destination == HOOKS_DESTINATION)
3940 .expect("the unmatched document collides");
3941 assert!(hooks.reason.contains("unmatched"), "{}", hooks.reason);
3942 }
3943}