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 let mut collisions = Vec::new();
385 for destination in BLOCK_DESTINATIONS {
386 let Some((template, sources, capability)) = block_template(destination, params)? else {
387 continue;
388 };
389 if let Some(whole) = candidates
390 .iter()
391 .find(|candidate| candidate.destination == destination)
392 {
393 return Err(anyhow::anyhow!(
394 "{destination} is both a whole file from {} and a marked region from {}; the embedded sources are defective",
395 whole.sources.join(", "),
396 sources.join(", ")
397 )
398 .into());
399 }
400 let region = render(template.as_bytes(), params);
401 let (begin, end) = block_markers(destination).ok_or_else(|| {
402 anyhow::anyhow!("{destination} is a block destination with no markers")
403 })?;
404 match propose_document(
405 destination,
406 evidence.document(destination),
407 ®ion,
408 params.integration(),
409 ) {
410 Ok(bytes) => candidates.push(Candidate {
411 capability,
412 destination: destination.to_owned(),
413 kind: Kind::Rendered,
414 placement: Placement::Region { begin, end },
415 bytes,
416 region: Some(region),
417 sources,
418 }),
419 Err(reason) => collisions.push(Collision {
420 destination: destination.to_owned(),
421 reason,
422 }),
423 }
424 }
425 let mut omissions = Vec::new();
426 if let Some((set, reason)) = nix_withholding(params.nix_packaging(), evidence)
427 && candidates
428 .iter()
429 .any(|candidate| candidate.capability == catalog::PACKAGING_NIX)
430 {
431 candidates.retain(|candidate| {
432 if set.contains(&candidate.destination.as_str()) {
433 omissions.push(Omission {
434 destination: candidate.destination.clone(),
435 reason: reason.clone(),
436 action: None,
437 });
438 false
439 } else {
440 true
441 }
442 });
443 withhold(&mut capabilities, catalog::PACKAGING_NIX, &reason, None);
444 }
445 let gate_root = candidates.iter().position(|candidate| {
451 (candidate.capability == catalog::TITLE_CHECK
452 || candidate.capability == catalog::COMMIT_CHECK)
453 && candidate.destination == GITLAB_ROOT_PIPELINE
454 });
455 if let Some(index) = gate_root
456 && evidence.root_pipeline_present
457 && !evidence.root_pipeline_recorded
458 {
459 let candidate = candidates.remove(index);
460 let (gate, fragment) = if candidate.capability == catalog::COMMIT_CHECK {
461 ("commit gate", GITLAB_COMMIT_FRAGMENT)
462 } else {
463 ("title gate", GITLAB_TITLE_FRAGMENT)
464 };
465 let reason = format!(
466 "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"
467 );
468 let action = format!(
469 "add the include to the target's own {GITLAB_ROOT_PIPELINE}: include:\n - local: {fragment}"
470 );
471 omissions.push(Omission {
472 destination: candidate.destination,
473 reason: reason.clone(),
474 action: Some(action.clone()),
475 });
476 withhold(
477 &mut capabilities,
478 candidate.capability,
479 &reason,
480 Some(action),
481 );
482 }
483 candidates.sort_by(|a, b| a.destination.cmp(&b.destination));
484 omissions.sort_by(|a, b| a.destination.cmp(&b.destination));
485 let scanning_selected = capabilities.iter().any(|selection| {
491 selection.id == catalog::CODE_SCANNING && selection.status == Status::Selected
492 });
493 let licence_refusal = scanning_selected
494 .then(|| {
495 code_scanning_licence_refusal(
496 params.code_scanning(),
497 params.driver(),
498 &evidence.crate_shape,
499 )
500 })
501 .flatten();
502 let mut record_defects: Vec<String> = Vec::new();
507 if params.release_mode() == ReleaseMode::Automatic
511 && let Some(selection) = capabilities
512 .iter()
513 .find(|selection| selection.id == catalog::RELEASE_AUTOMATION)
514 && matches!(selection.status, Status::Unavailable | Status::Unknown)
515 && let Some(reason) = &selection.reason
516 {
517 record_defects.push(reason.clone());
518 }
519 let driver_refusal = (params.release_mode() == ReleaseMode::Automatic
523 && params.driver() == Some("python")
524 && capabilities.iter().any(|selection| {
525 selection.id == catalog::RELEASE_AUTOMATION && selection.status == Status::Selected
526 }))
527 .then(|| evidence.python_shape.refusal())
528 .flatten();
529 Ok(Self {
530 capabilities,
531 candidates,
532 omissions,
533 collisions,
534 licence_refusal,
535 driver_refusal,
536 record_defects,
537 })
538 }
539
540 #[must_use]
544 pub fn release_unavailable(&self) -> Option<&str> {
545 self.capabilities
546 .iter()
547 .find(|selection| {
548 selection.id == catalog::RELEASE_AUTOMATION
549 && matches!(selection.status, Status::Unavailable | Status::Unknown)
550 })
551 .and_then(|selection| selection.reason.as_deref())
552 }
553
554 #[must_use]
556 pub fn capability(&self, id: &str) -> Option<&Selection> {
557 self.capabilities
558 .iter()
559 .find(|selection| selection.id == id)
560 }
561}
562
563fn withhold(capabilities: &mut [Selection], id: &str, reason: &str, action: Option<String>) {
565 if let Some(selection) = capabilities.iter_mut().find(|selection| selection.id == id) {
566 selection.status = Status::Withheld;
567 selection.reason = Some(reason.to_owned());
568 selection.action = action;
569 }
570}
571
572pub const GITLAB_ROOT_PIPELINE: &str = ".gitlab-ci.yml";
575
576pub const GITLAB_TITLE_FRAGMENT: &str = ".gitlab/ci/mr-title.yml";
578
579pub const GITLAB_COMMIT_FRAGMENT: &str = ".gitlab/ci/mr-commits.yml";
581
582fn embedded_snippets() -> Vec<(String, &'static [u8])> {
585 embedded::walk(&embedded::SNIPPETS)
586 .into_iter()
587 .map(|(path, bytes)| (format!("snippets/{path}"), bytes))
588 .collect()
589}
590
591#[must_use]
594pub fn supported_pairs() -> Vec<(String, String)> {
595 Availability::embedded()
596 .automation_tuples()
597 .into_iter()
598 .filter_map(|entry| {
599 entry
600 .split_once(", ")
601 .map(|(driver, forge)| (driver.to_owned(), forge.to_owned()))
602 })
603 .collect()
604}
605
606#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
608#[serde(rename_all = "lowercase")]
609pub enum Kind {
610 Rendered,
613 Seeded,
616 State,
619}
620
621impl Kind {
622 #[must_use]
624 pub const fn as_str(self) -> &'static str {
625 match self {
626 Self::Rendered => "rendered",
627 Self::Seeded => "seeded",
628 Self::State => "state",
629 }
630 }
631}
632
633const KINDS: [(&str, Kind); 19] = [
639 (".github/workflows/release-plz.yml", Kind::Rendered),
640 (".github/workflows/release.yml", Kind::Rendered),
641 (".github/workflows/pr-title.yml", Kind::Rendered),
642 (".github/workflows/pr-commits.yml", Kind::Rendered),
643 (".github/workflows/scorecard.yml", Kind::Rendered),
644 (".github/workflows/code-scanning-codeql.yml", Kind::Rendered),
645 (
646 ".github/workflows/code-scanning-semgrep.yml",
647 Kind::Rendered,
648 ),
649 (".gitlab/ci/code-scanning-semgrep.yml", Kind::Rendered),
650 (".gitlab-ci.yml", Kind::Rendered),
651 ("SECURITY.md", Kind::Rendered),
652 (".gitlab/ci/mr-title.yml", Kind::Rendered),
653 (".gitlab/ci/mr-commits.yml", Kind::Rendered),
654 ("release-plz.toml", Kind::Seeded),
655 ("dist-workspace.toml", Kind::Seeded),
656 ("cliff.toml", Kind::Seeded),
657 ("nix/package.nix", Kind::Seeded),
658 ("flake.nix", Kind::Seeded),
659 ("VERSION", Kind::State),
660 ("flake.lock", Kind::State),
661];
662
663pub const NIX_DESTINATIONS: [&str; 3] = ["nix/package.nix", "flake.nix", "flake.lock"];
677
678pub const NIX_WITHHOLDABLE: [&str; 2] = ["flake.nix", "flake.lock"];
684
685pub const SCORECARD_DESTINATIONS: [&str; 1] = [".github/workflows/scorecard.yml"];
700
701pub const CODE_SCANNING_DESTINATIONS: [(&str, Provider); 3] = [
715 (
716 ".github/workflows/code-scanning-codeql.yml",
717 Provider::CodeQl,
718 ),
719 (
720 ".github/workflows/code-scanning-semgrep.yml",
721 Provider::Semgrep,
722 ),
723 (".gitlab/ci/code-scanning-semgrep.yml", Provider::Semgrep),
724];
725
726pub const CODE_SCANNING_TECHS: [&str; 1] = ["rust"];
734
735#[must_use]
752pub fn code_scanning_incompatibility(
753 provider: Option<Provider>,
754 driver: Option<&str>,
755 forge: Option<&str>,
756) -> Option<String> {
757 let named = provider?;
758 let Some(driver) = driver else {
759 return Some(format!(
760 "a scanner reads the release driver's language, and the profile names no automatic release driver; the bindings that carry one are: {}",
761 CODE_SCANNING_TECHS.join(", ")
762 ));
763 };
764 if !CODE_SCANNING_TECHS.contains(&driver) {
765 return Some(format!(
766 "the {driver} binding ships no code scanning workflow; a scanner reads one language, and the bindings that carry one are: {}",
767 CODE_SCANNING_TECHS.join(", ")
768 ));
769 }
770 let Some(forge) = forge else {
771 return Some(
772 "a code scanning workflow runs at a forge, and the profile names none".to_owned(),
773 );
774 };
775 if named == Provider::CodeQl && forge != "github" {
776 return Some(format!(
777 "codeql is GitHub's own analyzer and the {forge} pair ships no workflow for it; pass --code-scanning semgrep"
778 ));
779 }
780 None
781}
782
783const OSI_APPROVED: [&str; 18] = [
793 "0bsd",
794 "agpl-3.0",
795 "agpl-3.0-only",
796 "agpl-3.0-or-later",
797 "apache-2.0",
798 "bsd-2-clause",
799 "bsd-3-clause",
800 "bsl-1.0",
801 "epl-2.0",
802 "gpl-2.0",
803 "gpl-2.0-only",
804 "gpl-2.0-or-later",
805 "gpl-3.0",
806 "gpl-3.0-only",
807 "gpl-3.0-or-later",
808 "isc",
809 "mit",
810 "mpl-2.0",
811];
812
813const CONTENT_LICENCES: [&str; 4] = ["cc-by-3.0", "cc-by-4.0", "cc-by-sa-3.0", "cc-by-sa-4.0"];
831
832const SPDX_EXCEPTIONS: [&str; 86] = [
849 "389-exception",
850 "asterisk-exception",
851 "asterisk-linking-protocols-exception",
852 "autoconf-exception-2.0",
853 "autoconf-exception-3.0",
854 "autoconf-exception-generic",
855 "autoconf-exception-generic-3.0",
856 "autoconf-exception-macro",
857 "bison-exception-1.24",
858 "bison-exception-2.2",
859 "bootloader-exception",
860 "cgal-linking-exception",
861 "classpath-exception-2.0",
862 "classpath-exception-2.0-short",
863 "clisp-exception-2.0",
864 "cryptsetup-openssl-exception",
865 "digia-qt-lgpl-exception-1.1",
866 "digirule-foss-exception",
867 "ecos-exception-2.0",
868 "erlang-otp-linking-exception",
869 "fawkes-runtime-exception",
870 "fltk-exception",
871 "fmt-exception",
872 "font-exception-2.0",
873 "freertos-exception-2.0",
874 "gcc-exception-2.0",
875 "gcc-exception-2.0-note",
876 "gcc-exception-3.1",
877 "gmsh-exception",
878 "gnat-exception",
879 "gnome-examples-exception",
880 "gnu-compiler-exception",
881 "gnu-javamail-exception",
882 "google-patent-webm",
883 "gpl-3.0-389-ds-base-exception",
884 "gpl-3.0-interface-exception",
885 "gpl-3.0-linking-exception",
886 "gpl-3.0-linking-source-exception",
887 "gpl-cc-1.0",
888 "gstreamer-exception-2005",
889 "gstreamer-exception-2008",
890 "harbour-exception",
891 "i2p-gpl-java-exception",
892 "independent-modules-exception",
893 "kicad-libraries-exception",
894 "kvirc-openssl-exception",
895 "lgpl-3.0-linking-exception",
896 "libpri-openh323-exception",
897 "libtool-exception",
898 "linux-syscall-note",
899 "llgpl",
900 "llvm-exception",
901 "lzma-exception",
902 "mif-exception",
903 "mxml-exception",
904 "nokia-qt-exception-1.1",
905 "ocaml-lgpl-linking-exception",
906 "occt-exception-1.0",
907 "openjdk-assembly-exception-1.0",
908 "openvpn-openssl-exception",
909 "pcre2-exception",
910 "polyparse-exception",
911 "ps-or-pdf-font-exception-20170817",
912 "qpl-1.0-inria-2004-exception",
913 "qt-gpl-exception-1.0",
914 "qt-lgpl-exception-1.1",
915 "qwt-exception-1.0",
916 "romic-exception",
917 "rrdtool-floss-exception-2.0",
918 "rsync-linking-exception",
919 "sane-exception",
920 "shl-2.0",
921 "shl-2.1",
922 "simple-library-usage-exception",
923 "spelling-provider-lgpl-exception",
924 "sqlitestudio-openssl-exception",
925 "stunnel-exception",
926 "swi-exception",
927 "swift-exception",
928 "texinfo-exception",
929 "u-boot-exception-2.0",
930 "ubdl-exception",
931 "universal-foss-exception-1.0",
932 "vsftpd-openssl-exception",
933 "wxwindows-exception-3.1",
934 "x11vnc-openssl-exception",
935];
936
937fn listed(list: &[&str], identifier: &str) -> bool {
944 let lowered = identifier.to_ascii_lowercase();
945 list.contains(&lowered.as_str())
946}
947
948#[derive(Debug, Clone, Copy, PartialEq, Eq)]
950enum Token<'a> {
951 Open,
953 Close,
955 And,
957 Or,
959 With,
962 Identifier(&'a str),
965}
966
967fn tokenize(expression: &str) -> Option<Vec<Token<'_>>> {
975 let mut tokens = Vec::new();
976 let bytes = expression.as_bytes();
977 let mut at = 0;
978 while at < bytes.len() {
979 let byte = bytes[at];
980 if byte.is_ascii_whitespace() {
981 at += 1;
982 continue;
983 }
984 if byte == b'(' {
985 tokens.push(Token::Open);
986 at += 1;
987 continue;
988 }
989 if byte == b')' {
990 tokens.push(Token::Close);
991 at += 1;
992 continue;
993 }
994 let start = at;
995 while at < bytes.len() {
996 let byte = bytes[at];
997 if byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'+' | b':') {
998 at += 1;
999 } else {
1000 break;
1001 }
1002 }
1003 if at == start {
1004 return None;
1005 }
1006 let word = &expression[start..at];
1007 tokens.push(match word {
1008 "AND" => Token::And,
1009 "OR" => Token::Or,
1010 "WITH" => Token::With,
1011 other => Token::Identifier(other),
1012 });
1013 }
1014 Some(tokens)
1015}
1016
1017#[derive(Debug, Clone, Copy)]
1024struct Verdict {
1025 grants: bool,
1027 clean: bool,
1029}
1030
1031struct Spdx<'a> {
1040 tokens: &'a [Token<'a>],
1041 at: usize,
1042}
1043
1044impl<'a> Spdx<'a> {
1045 fn peek(&self) -> Option<Token<'a>> {
1047 self.tokens.get(self.at).copied()
1048 }
1049
1050 fn bump(&mut self) -> Option<Token<'a>> {
1052 let token = self.peek()?;
1053 self.at += 1;
1054 Some(token)
1055 }
1056
1057 fn expression(&mut self) -> Option<Verdict> {
1067 let mut verdict = self.conjunction()?;
1068 while self.peek() == Some(Token::Or) {
1069 self.bump();
1070 let right = self.conjunction()?;
1071 verdict = Verdict {
1072 grants: verdict.grants && right.grants,
1073 clean: verdict.clean && right.clean,
1074 };
1075 }
1076 Some(verdict)
1077 }
1078
1079 fn conjunction(&mut self) -> Option<Verdict> {
1089 let mut verdict = self.operand()?;
1090 while self.peek() == Some(Token::And) {
1091 self.bump();
1092 let right = self.operand()?;
1093 verdict = Verdict {
1094 grants: verdict.grants || right.grants,
1095 clean: verdict.clean && right.clean,
1096 };
1097 }
1098 Some(verdict)
1099 }
1100
1101 fn operand(&mut self) -> Option<Verdict> {
1111 match self.bump()? {
1112 Token::Open => {
1113 let inner = self.expression()?;
1116 (self.bump()? == Token::Close).then_some(inner)
1117 }
1118 Token::Identifier(name) => {
1119 let identifier = name.strip_suffix('+').unwrap_or(name);
1120 let approved = listed(&OSI_APPROVED, identifier);
1121 let verdict = Verdict {
1122 grants: approved,
1123 clean: approved || listed(&CONTENT_LICENCES, identifier),
1124 };
1125 if self.peek() == Some(Token::With) {
1126 self.bump();
1127 match self.bump()? {
1131 Token::Identifier(exception) if listed(&SPDX_EXCEPTIONS, exception) => {}
1132 _ => return None,
1133 }
1134 }
1135 Some(verdict)
1136 }
1137 Token::And | Token::Or | Token::With | Token::Close => None,
1138 }
1139 }
1140}
1141
1142#[must_use]
1158pub fn licence_states_an_open_source_codebase(expression: &str) -> bool {
1159 let Some(tokens) = tokenize(expression) else {
1160 return false;
1161 };
1162 let mut reader = Spdx {
1163 tokens: &tokens,
1164 at: 0,
1165 };
1166 let Some(verdict) = reader.expression() else {
1167 return false;
1168 };
1169 verdict.grants && verdict.clean && reader.at == tokens.len()
1170}
1171
1172#[must_use]
1186pub fn code_scanning_licence_refusal(
1187 provider: Option<Provider>,
1188 driver: Option<&str>,
1189 shape: &CrateShape,
1190) -> Option<String> {
1191 if provider != Some(Provider::CodeQl) {
1192 return None;
1193 }
1194 if driver != Some("rust") {
1195 return Some(format!(
1196 "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",
1197 driver.unwrap_or("release-less")
1198 ));
1199 }
1200 let fallback = "land --code-scanning semgrep, which carries no licence condition";
1201 let Some(text) = shape.cargo_toml.as_deref() else {
1202 return Some(format!(
1203 "the target has no readable Cargo.toml, so the licence codeql's terms depend on cannot be read; {fallback}"
1204 ));
1205 };
1206 let Ok(table) = text.parse::<toml::Table>() else {
1207 return Some(format!(
1208 "the target's Cargo.toml does not parse, so the licence codeql's terms depend on cannot be read; {fallback}"
1209 ));
1210 };
1211 let licence = table
1212 .get("package")
1213 .and_then(toml::Value::as_table)
1214 .and_then(|package| package.get("license"))
1215 .and_then(toml::Value::as_str);
1216 match licence {
1217 None => Some(format!(
1218 "the target's Cargo.toml declares no license field, and codeql's terms cover an open-source codebase alone; declare one, or {fallback}"
1219 )),
1220 Some(expression) if !licence_states_an_open_source_codebase(expression) => Some(format!(
1221 "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}"
1222 )),
1223 Some(_) => None,
1224 }
1225}
1226
1227#[must_use]
1230pub fn kind_of(destination: &str) -> Option<Kind> {
1231 if BLOCK_DESTINATIONS.contains(&destination) {
1232 return Some(Kind::Rendered);
1233 }
1234 KINDS
1235 .iter()
1236 .find(|(name, _)| *name == destination)
1237 .map(|(_, kind)| *kind)
1238}
1239
1240pub fn destinations() -> impl Iterator<Item = &'static str> {
1245 KINDS
1246 .iter()
1247 .map(|(name, _)| *name)
1248 .chain(BLOCK_DESTINATIONS)
1249}
1250
1251pub const OWNER_TOKEN: &[u8] = b"OWNER";
1258
1259pub const REPO_PLACEHOLDER: &str = "OWNER";
1264
1265pub const REPO_TOKEN: &[u8] = b"RK_REPO";
1267
1268pub const SCOPE_SHAPE_TOKEN: &[u8] = b"RK_SCOPE_SHAPE";
1270
1271pub const IF_RELEASE_TOKEN: &[u8] = b"RK_IF_RELEASE_";
1276
1277pub const IF_NO_RELEASE_TOKEN: &[u8] = b"RK_IF_NO_RELEASE_";
1280
1281pub const IF_REGISTRY_TOKEN: &[u8] = b" # RK_IF_REGISTRY";
1289
1290pub const IF_NO_REGISTRY_TOKEN: &[u8] = b"# RK_IF_NO_REGISTRY ";
1296
1297#[must_use]
1303pub fn registry_lines(baseline: &[u8], params: &Params) -> Vec<u8> {
1304 if find(baseline, IF_REGISTRY_TOKEN).is_none() && find(baseline, IF_NO_REGISTRY_TOKEN).is_none()
1305 {
1306 return baseline.to_vec();
1307 }
1308 let publishes = params.registry() != Some(crate::profile::RegistryAnswer::None);
1309 let mut out = Vec::with_capacity(baseline.len());
1310 for line in baseline.split_inclusive(|byte| *byte == b'\n') {
1311 let (token, wanted) = if find(line, IF_NO_REGISTRY_TOKEN).is_some() {
1312 (IF_NO_REGISTRY_TOKEN, !publishes)
1313 } else if find(line, IF_REGISTRY_TOKEN).is_some() {
1314 (IF_REGISTRY_TOKEN, publishes)
1315 } else {
1316 out.extend_from_slice(line);
1317 continue;
1318 };
1319 if wanted {
1320 out.extend(substitute(line, token, b""));
1321 }
1322 }
1323 out
1324}
1325
1326pub const SCOPED_TOKEN: &[u8] = b"RK_SCOPED";
1330
1331pub const SCOPE_OPEN_TOKEN: &[u8] = b"RK_SCOPE_OPEN";
1337
1338pub const SCOPE_CLOSE_TOKEN: &[u8] = b"RK_SCOPE_CLOSE";
1340
1341fn release_lines(baseline: &[u8], release: bool) -> Vec<u8> {
1346 if find(baseline, IF_RELEASE_TOKEN).is_none() && find(baseline, IF_NO_RELEASE_TOKEN).is_none() {
1347 return baseline.to_vec();
1348 }
1349 let mut out = Vec::with_capacity(baseline.len());
1350 for line in baseline.split_inclusive(|byte| *byte == b'\n') {
1351 let (token, wanted) = if find(line, IF_NO_RELEASE_TOKEN).is_some() {
1354 (IF_NO_RELEASE_TOKEN, !release)
1355 } else if find(line, IF_RELEASE_TOKEN).is_some() {
1356 (IF_RELEASE_TOKEN, release)
1357 } else {
1358 out.extend_from_slice(line);
1359 continue;
1360 };
1361 if wanted {
1362 out.extend(substitute(line, token, b""));
1363 }
1364 }
1365 out
1366}
1367
1368pub const GITLAB_GATE_FRAGMENT_TOKEN: &[u8] = b"RK_GITLAB_GATE_FRAGMENT";
1371
1372pub const MERGE_FLAG_TOKEN: &[u8] = b"RK_MERGE_FLAG";
1375
1376pub const SQUASH_ON_MERGE_TOKEN: &[u8] = b"RK_SQUASH_ON_MERGE";
1380
1381pub const STYLE_TOKEN: &[u8] = b"RK_STYLE";
1383
1384pub const BUILD_PACKAGES_TOKEN: &[u8] = b"RK_BUILD_PACKAGES";
1387
1388pub const IF_UV_UNPINNED_TOKEN: &[u8] = b" # RK_IF_UV_UNPINNED";
1394
1395pub const IF_UV_REQUIRED_TOKEN: &[u8] = b"# RK_IF_UV_REQUIRED ";
1398
1399pub const UV_REQUIRED_TOKEN: &[u8] = b"RK_UV_REQUIRED";
1402
1403#[must_use]
1410pub fn uv_lines(baseline: &[u8], shape: &PythonShape) -> Vec<u8> {
1411 if find(baseline, IF_UV_UNPINNED_TOKEN).is_none()
1412 && find(baseline, IF_UV_REQUIRED_TOKEN).is_none()
1413 {
1414 return baseline.to_vec();
1415 }
1416 let required = shape.uv_required();
1417 let mut out = Vec::with_capacity(baseline.len());
1418 for line in baseline.split_inclusive(|byte| *byte == b'\n') {
1419 let (token, wanted) = if find(line, IF_UV_REQUIRED_TOKEN).is_some() {
1420 (IF_UV_REQUIRED_TOKEN, required.is_some())
1421 } else if find(line, IF_UV_UNPINNED_TOKEN).is_some() {
1422 (IF_UV_UNPINNED_TOKEN, required.is_none())
1423 } else {
1424 out.extend_from_slice(line);
1425 continue;
1426 };
1427 if wanted {
1428 out.extend(substitute(line, token, b""));
1429 }
1430 }
1431 substitute(
1432 &out,
1433 UV_REQUIRED_TOKEN,
1434 required.unwrap_or_default().as_bytes(),
1435 )
1436}
1437
1438pub const TRUNK_BRANCH_TOKEN: &[u8] = b"RK_TRUNK_BRANCH";
1442
1443pub const LINE_PREFIX_TOKEN: &[u8] = b"RK_LINE_PREFIX";
1446
1447pub const LINE_PREFIX_RE_TOKEN: &[u8] = b"RK_LINE_PREFIX_RE";
1453
1454pub const REQUIRED_CHECK_TOKEN: &[u8] = b"RK_REQUIRED_CHECK";
1456
1457pub const BRANCH_SHAPE_TOKEN: &[u8] = b"RK_BRANCH_SHAPE";
1459
1460#[must_use]
1467pub fn release_driver_shape(driver: Option<&str>) -> Option<&'static str> {
1468 match driver {
1469 Some("rust") => Some("release-plz-"),
1472 Some("bash" | "python") => Some(crate::release_engine::REQUEST_PREFIX),
1474 _ => None,
1475 }
1476}
1477
1478pub const RELEASE_MERGE_GITHUB: &str = "blocks/release-merge-job.github.yaml.in";
1480
1481pub const RELEASE_MERGE_GITLAB: &str = "blocks/release-merge-job.gitlab.yaml.in";
1483
1484pub const RELEASE_MERGE_SPAN: (&[u8], &[u8]) = (b"\n# RK_MERGE_JOB_BEGIN", b"\n# RK_MERGE_JOB_END");
1492
1493fn release_merge_replacement(params: &Params) -> Result<Option<Vec<u8>>, RkError> {
1504 let block = match params.forge() {
1505 Some("github") => RELEASE_MERGE_GITHUB,
1506 Some("gitlab") => RELEASE_MERGE_GITLAB,
1507 _ => return Ok(None),
1508 };
1509 let text = embedded_block(block)?;
1513 Ok(Some(substitute_tokens(
1514 format!("\n{}", text.trim_end_matches('\n')).as_bytes(),
1515 params,
1516 )))
1517}
1518
1519pub const DEV_RUN_TOKEN: &[u8] = b"RK_DEV_RUN ";
1526
1527pub const DEV_SETUP_STEPS_TOKEN: &[u8] = b"RK_DEV_SETUP_STEPS";
1530
1531pub const DEV_GIT_USER_TOKEN: &[u8] = b"RK_DEV_GIT_USER";
1533
1534pub const DEV_ENVIRONMENT_RELEASE: &str = "blocks/dev-environment-release.yaml.in";
1536
1537pub const DEV_ENVIRONMENT_REQUEST: &str = "blocks/dev-environment-request.yaml.in";
1539
1540pub const DEV_ENVIRONMENT_CANDIDATE: &str = "blocks/dev-environment-candidate.yaml.in";
1543
1544pub const SELF_VERSION_TOKEN: &[u8] = b"RK_SELF_VERSION";
1548
1549pub const SELF_RELEASES_TOKEN: &[u8] = b"RK_SELF_RELEASES";
1553
1554pub const DEV_ENVIRONMENT_GIT_USER: &str = "blocks/dev-environment-git-user.yaml.in";
1557
1558pub const DEV_ENVIRONMENT_RK_INSTALL: &str = "blocks/dev-environment-rk-install.yaml.in";
1561
1562pub const DEV_ENVIRONMENT_RK_INSTALL_GITLAB: &str =
1568 "blocks/dev-environment-rk-install-gitlab.yaml.in";
1569
1570pub const DEV_ENVIRONMENT_ENGINE: &str = "blocks/dev-environment-engine.yaml.in";
1574
1575pub const DEV_ENVIRONMENT_ENGINE_CANDIDATE: &str =
1579 "blocks/dev-environment-engine-candidate.yaml.in";
1580
1581pub const DEV_ENVIRONMENT_SPANS: [(&[u8], &[u8]); 7] = [
1598 (
1599 b"\n# RK_DEV_ENV_RELEASE_BEGIN",
1600 b"\n# RK_DEV_ENV_RELEASE_END",
1601 ),
1602 (
1603 b"\n# RK_DEV_ENV_REQUEST_BEGIN",
1604 b"\n# RK_DEV_ENV_REQUEST_END",
1605 ),
1606 (b"\n# RK_DEV_ENV_IMAGE_BEGIN", b"\n# RK_DEV_ENV_IMAGE_END"),
1607 (b"\n# RK_DEV_ENV_SCRIPT_BEGIN", b"\n# RK_DEV_ENV_SCRIPT_END"),
1608 (
1609 b"\n# RK_DEV_ENV_CANDIDATE_BEGIN",
1610 b"\n# RK_DEV_ENV_CANDIDATE_END",
1611 ),
1612 (b"\n# RK_DEV_ENV_RK_BEGIN", b"\n# RK_DEV_ENV_RK_END"),
1613 (
1614 b"\n# RK_DEV_ENV_PACKAGES_BEGIN",
1615 b"\n# RK_DEV_ENV_PACKAGES_END",
1616 ),
1617];
1618
1619fn action_step_lines(step: &crate::config::dev_environment::ActionStep) -> String {
1622 let mut lines = format!(" - uses: {}", step.uses);
1623 if !step.tag.is_empty() {
1624 lines.push_str(" # ");
1625 lines.push_str(&step.tag);
1626 }
1627 lines.push('\n');
1628 if !step.inputs.is_empty() {
1629 lines.push_str(" with:\n");
1630 for (name, value) in &step.inputs {
1631 let _ = writeln!(lines, " {name}: {}", value.yaml());
1632 }
1633 }
1634 lines
1635}
1636
1637fn dev_environment_replacements(params: &Params) -> Result<[Option<Vec<u8>>; 7], RkError> {
1654 let Some(answer) = params.dev_environment() else {
1655 return Ok([None, None, None, None, None, None, None]);
1656 };
1657 let git_user = embedded_block(DEV_ENVIRONMENT_GIT_USER)?.trim_end_matches('\n');
1658 let installer = answer.rk == crate::config::dev_environment::RkRoute::Installer;
1662 let rk_install = if installer {
1663 substitute_tokens(
1664 embedded_block(DEV_ENVIRONMENT_RK_INSTALL)?.as_bytes(),
1665 params,
1666 )
1667 } else {
1668 Vec::new()
1669 };
1670 let half =
1671 |block: &str, steps: &[&crate::config::dev_environment::ActionStep], runs_rk: bool| {
1672 let text = embedded_block(block)?.trim_end_matches('\n');
1673 let text = substitute(text.as_bytes(), DEV_GIT_USER_TOKEN, git_user.as_bytes());
1674 let text = substitute_tokens(&text, params);
1675 let mut lines: Vec<u8> = steps
1676 .iter()
1677 .map(|step| action_step_lines(step))
1678 .collect::<String>()
1679 .into_bytes();
1680 if runs_rk {
1681 lines.extend_from_slice(&rk_install);
1682 }
1683 let mut out = b"\n".to_vec();
1684 out.extend(substitute(&text, DEV_SETUP_STEPS_TOKEN, &lines));
1685 Ok::<_, RkError>(out)
1686 };
1687 let release: Vec<_> = answer.github.setup.iter().collect();
1688 let request: Vec<_> = answer
1689 .github
1690 .setup
1691 .iter()
1692 .chain(&answer.github.cache)
1693 .collect();
1694 let image = (!answer.gitlab.image.is_empty())
1695 .then(|| format!("\n image: {}", answer.gitlab.image).into_bytes());
1696 let mut items = String::new();
1697 for line in &answer.gitlab.before_script {
1698 let _ = write!(items, "\n - {}", serde_json::Value::String(line.clone()));
1699 }
1700 if matches!(params.driver(), Some("bash" | "python")) {
1701 return Ok([
1713 Some(half(DEV_ENVIRONMENT_ENGINE, &release, true)?),
1714 Some(half(DEV_ENVIRONMENT_ENGINE, &request, true)?),
1715 image,
1716 Some(items.into_bytes()),
1717 Some(half(DEV_ENVIRONMENT_ENGINE_CANDIDATE, &release, true)?),
1718 (!installer).then(Vec::new),
1719 Some(Vec::new()),
1720 ]);
1721 }
1722 let script = if items.is_empty() {
1723 Vec::new()
1724 } else {
1725 format!("\n before_script:{items}").into_bytes()
1726 };
1727 let gitlab_rk = if installer {
1731 let text = embedded_block(DEV_ENVIRONMENT_RK_INSTALL_GITLAB)?.trim_end_matches('\n');
1732 let mut out = b"\n".to_vec();
1733 out.extend(substitute_tokens(text.as_bytes(), params));
1734 out
1735 } else {
1736 Vec::new()
1737 };
1738 Ok([
1739 Some(half(DEV_ENVIRONMENT_RELEASE, &release, false)?),
1740 Some(half(DEV_ENVIRONMENT_REQUEST, &request, true)?),
1742 image,
1743 Some(script),
1744 Some(half(DEV_ENVIRONMENT_CANDIDATE, &release, true)?),
1747 Some(gitlab_rk),
1748 None,
1749 ])
1750}
1751
1752impl Projection {
1753 #[must_use]
1761 pub fn accepted_drift_fault(&self, destination: &str) -> Option<String> {
1762 let Some(candidate) = self
1763 .candidates
1764 .iter()
1765 .find(|candidate| candidate.destination == destination)
1766 else {
1767 return Some(self.omissions.iter().find(|omission| omission.destination == destination).map_or_else(
1768 || format!("landing.accepted_drift names {destination}, which this landing does not select"),
1769 |omission| format!("landing.accepted_drift names {destination}, which this landing omits: {}", omission.reason),
1770 ));
1771 };
1772 if candidate.kind != Kind::Rendered {
1773 return Some(format!(
1774 "landing.accepted_drift names {destination}, a {} file, which may drift freely already",
1775 candidate.kind.as_str()
1776 ));
1777 }
1778 if matches!(candidate.placement, Placement::Region { .. }) {
1779 return Some(format!(
1780 "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"
1781 ));
1782 }
1783 None
1784 }
1785}
1786
1787pub const SECURITY_SPANS: [(&[u8], &[u8]); 4] = [
1802 (
1803 b"<!--RK_SECURITY_CONTACT_BEGIN-->",
1804 b"<!--RK_SECURITY_CONTACT_END-->",
1805 ),
1806 (
1807 b"<!--RK_SECURITY_RESPONSE_BEGIN-->",
1808 b"<!--RK_SECURITY_RESPONSE_END-->",
1809 ),
1810 (
1811 b"<!--RK_SECURITY_DEADLINE_BEGIN-->",
1812 b"<!--RK_SECURITY_DEADLINE_END-->",
1813 ),
1814 (
1815 b"<!--RK_SECURITY_SCOPE_BEGIN-->",
1816 b"<!--RK_SECURITY_SCOPE_END-->",
1817 ),
1818];
1819
1820fn acknowledgment(response: &str) -> String {
1823 format!("Maintainers acknowledge a report within {response}.")
1824}
1825
1826const DISCLOSURE_ONLY: &[u8] = b"This policy commits to no disclosure deadline.";
1830
1831fn security_replacements(params: &Params) -> [Option<Vec<u8>>; 4] {
1834 let contact = (!params.security_contact().is_empty())
1835 .then(|| params.security_contact().as_bytes().to_vec());
1836 let promised = params.security_response() != crate::config::RESPONSE_DEFAULT;
1837 [
1838 contact,
1839 promised.then(|| acknowledgment(params.security_response()).into_bytes()),
1840 promised.then(|| DISCLOSURE_ONLY.to_vec()),
1841 (!params.security_scope().is_empty()).then(|| {
1844 ["\n".repeat(2).as_str(), params.security_scope()]
1845 .concat()
1846 .into_bytes()
1847 }),
1848 ]
1849}
1850
1851fn replace_span(baseline: &[u8], begin: &[u8], end: &[u8], value: Option<&[u8]>) -> Vec<u8> {
1857 let ordered = find(baseline, begin)
1858 .zip(find(baseline, end))
1859 .filter(|(start, stop)| stop > start);
1860 let Some((start, stop)) = ordered else {
1861 return baseline.to_vec();
1862 };
1863 let mut out = Vec::with_capacity(baseline.len());
1864 out.extend_from_slice(&baseline[..start]);
1865 out.extend_from_slice(value.unwrap_or_else(|| &baseline[start + begin.len()..stop]));
1866 out.extend_from_slice(&baseline[stop + end.len()..]);
1867 out
1868}
1869
1870#[must_use]
1888pub fn render(baseline: &[u8], params: &Params) -> Vec<u8> {
1889 let mut out = substitute_tokens(baseline, params);
1890 let (begin, end) = RELEASE_MERGE_SPAN;
1894 let merge = release_merge_replacement(params).ok().flatten();
1895 out = replace_span(&out, begin, end, merge.as_deref());
1896 let environment = dev_environment_replacements(params).unwrap_or_default();
1902 for ((begin, end), value) in DEV_ENVIRONMENT_SPANS.iter().zip(environment) {
1906 loop {
1907 let next = replace_span(&out, begin, end, value.as_deref());
1908 if next == out {
1909 break;
1910 }
1911 out = next;
1912 }
1913 }
1914 for ((begin, end), value) in SECURITY_SPANS.iter().zip(security_replacements(params)) {
1915 out = replace_span(&out, begin, end, value.as_deref());
1916 }
1917 out
1918}
1919
1920#[must_use]
1925fn substitute_tokens(baseline: &[u8], params: &Params) -> Vec<u8> {
1926 let repo = params.repo();
1927 let owner = repo.split('/').next().unwrap_or(repo);
1928 let release = params.release_mode() != ReleaseMode::None;
1932 let mut out = release_lines(baseline, release);
1933 out = registry_lines(&out, params);
1934 let (scoped, open, close): (&[u8], &[u8], &[u8]) = if release {
1935 (b"scoped ", b"", b"")
1936 } else {
1937 (b"", b"(", b")?")
1938 };
1939 out = substitute(&out, SCOPED_TOKEN, scoped);
1940 out = substitute(&out, SCOPE_OPEN_TOKEN, open);
1941 out = substitute(&out, SCOPE_CLOSE_TOKEN, close);
1942 out = substitute(&out, OWNER_TOKEN, owner.as_bytes());
1943 if let Some(style) = params.style() {
1944 out = substitute(&out, STYLE_TOKEN, style.as_str().as_bytes());
1945 }
1946 out = substitute(&out, SCOPE_SHAPE_TOKEN, SCOPE_SHAPE.as_bytes());
1947 let (fragment, flag, squash): (&str, &[u8], &[u8]) = match params.merge_form() {
1951 crate::landing::MergeForm::Squash => (GITLAB_TITLE_FRAGMENT, b"--squash", b"true"),
1952 crate::landing::MergeForm::Rebase => (GITLAB_COMMIT_FRAGMENT, b"--rebase", b"false"),
1953 };
1954 out = substitute(&out, GITLAB_GATE_FRAGMENT_TOKEN, fragment.as_bytes());
1955 out = substitute(&out, MERGE_FLAG_TOKEN, flag);
1956 out = substitute(&out, SQUASH_ON_MERGE_TOKEN, squash);
1957 let shape = release_driver_shape(params.driver()).unwrap_or_default();
1962 out = substitute(&out, BRANCH_SHAPE_TOKEN, shape.as_bytes());
1963 out = substitute(&out, TRUNK_BRANCH_TOKEN, params.trunk().as_bytes());
1964 let escaped = params.line_prefix().replace('/', "\\/");
1965 out = substitute(&out, LINE_PREFIX_RE_TOKEN, escaped.as_bytes());
1966 out = substitute(&out, LINE_PREFIX_TOKEN, params.line_prefix().as_bytes());
1967 let required_check = serde_json::Value::String(params.required_check().to_owned()).to_string();
1971 out = substitute(&out, REQUIRED_CHECK_TOKEN, required_check.as_bytes());
1972 out = substitute(
1973 &out,
1974 SELF_VERSION_TOKEN,
1975 env!("CARGO_PKG_VERSION").as_bytes(),
1976 );
1977 let releases = format!("{}/releases/download", env!("CARGO_PKG_REPOSITORY"));
1978 out = substitute(&out, SELF_RELEASES_TOKEN, releases.as_bytes());
1979 out = substitute(&out, REPO_TOKEN, repo.as_bytes());
1980 let run = params
1983 .dev_environment()
1984 .map(|answer| format!("{} ", answer.run))
1985 .unwrap_or_default();
1986 out = substitute(&out, DEV_RUN_TOKEN, run.as_bytes());
1987 out = substitute(
1991 &out,
1992 BUILD_PACKAGES_TOKEN,
1993 params.build_packages().as_bytes(),
1994 );
1995 out
1996}
1997
1998#[must_use]
2000pub fn substitute(baseline: &[u8], token: &[u8], value: &[u8]) -> Vec<u8> {
2001 let mut out = Vec::with_capacity(baseline.len());
2002 let mut rest = baseline;
2003 while let Some(at) = find(rest, token) {
2004 out.extend_from_slice(&rest[..at]);
2005 out.extend_from_slice(value);
2006 rest = &rest[at + token.len()..];
2007 }
2008 out.extend_from_slice(rest);
2009 out
2010}
2011
2012fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
2014 haystack
2015 .windows(needle.len())
2016 .position(|window| window == needle)
2017}
2018
2019pub const AGENTS_DESTINATION: &str = "AGENTS.md";
2021
2022pub const BLOCK_BEGIN: &str = "<!-- BEGIN release-kit -->";
2024
2025pub const BLOCK_END: &str = "<!-- END release-kit -->";
2027
2028pub const GLOSSARY_DESTINATION: &str = "GLOSSARY.md";
2033
2034pub const HOOKS_DESTINATION: &str = ".pre-commit-config.yaml";
2036
2037pub const BLOCK_DESTINATIONS: [&str; 3] =
2043 [AGENTS_DESTINATION, GLOSSARY_DESTINATION, HOOKS_DESTINATION];
2044
2045pub const HOOKS_BEGIN: &str = "# BEGIN release-kit";
2047
2048pub const HOOKS_END: &str = "# END release-kit";
2050
2051pub const HOOK_TYPES_LINE: &str = "default_install_hook_types: [pre-commit, commit-msg, pre-push]";
2055
2056pub const LOCAL_HOOK_TYPES_LINE: &str =
2060 "default_install_hook_types: [pre-commit, commit-msg, pre-push, pre-merge-commit]";
2061
2062#[must_use]
2064pub const fn hook_types_line(integration: Integration) -> &'static str {
2065 match integration {
2066 Integration::Forge => HOOK_TYPES_LINE,
2067 Integration::Local => LOCAL_HOOK_TYPES_LINE,
2068 }
2069}
2070
2071pub const AGENTS_BLOCK: &str = "blocks/agents-block.md.in";
2073
2074pub const GLOSSARY_BLOCK: &str = "blocks/glossary.md.in";
2076
2077pub const AGENTS_LINE_AGENT_POLICY: &str = "blocks/agents-line-agent-policy.md.in";
2079
2080pub const AGENTS_LINE_COMMIT_POLICY: &str = "blocks/agents-line-commit-policy.md.in";
2084
2085pub const AGENTS_BLOCK_WORKFLOW: &str = "blocks/agents-block-workflow.md.in";
2088
2089pub const AGENTS_LINE_AGENT_POLICY_WORKFLOW: &str =
2092 "blocks/agents-line-agent-policy-workflow.md.in";
2093
2094pub const AGENTS_LINE_COMMIT_SOURCE_SQUASH: &str = "blocks/agents-line-commit-source-squash.md.in";
2097
2098pub const AGENTS_LINE_COMMIT_SOURCE_REBASE: &str = "blocks/agents-line-commit-source-rebase.md.in";
2101
2102pub const AGENTS_LINE_COMMIT_SCOPE_REQUIRED: &str =
2105 "blocks/agents-line-commit-scope-required.md.in";
2106
2107pub const AGENTS_LINE_COMMIT_SCOPE_PREFERRED: &str =
2109 "blocks/agents-line-commit-scope-preferred.md.in";
2110
2111pub const GLOSSARY_BLOCK_WORKFLOW: &str = "blocks/glossary-workflow.md.in";
2113
2114pub const PRE_COMMIT_BLOCK: &str = "blocks/pre-commit-block.yaml.in";
2116
2117pub const PRE_COMMIT_MESSAGE_GUARDS: &str = "blocks/pre-commit-message-guards.yaml.in";
2119
2120pub const PRE_COMMIT_BRANCH_NAME_GUARD: &str = "blocks/pre-commit-branch-name-guard.yaml.in";
2122
2123pub const PRE_COMMIT_WORKTREE_GUARD: &str = "blocks/pre-commit-worktree-guard.yaml.in";
2125
2126pub const PRE_COMMIT_TRUNK_COMMIT_GUARD: &str = "blocks/pre-commit-trunk-commit-guard.yaml.in";
2128
2129pub const PRE_COMMIT_TRUNK_PUSH_GUARD: &str = "blocks/pre-commit-trunk-push-guard.yaml.in";
2131
2132pub const PRE_COMMIT_SWEEP_SKIP: &str = "blocks/pre-commit-sweep-skip.yaml.in";
2134
2135pub const PRE_COMMIT_SWEEP_NONE: &str = "blocks/pre-commit-sweep-none.yaml.in";
2137
2138pub const PRE_COMMIT_INTEGRATE_NOTE: &str = "blocks/pre-commit-integrate-note.yaml.in";
2140
2141pub const PRE_COMMIT_TAG_GUARD: &str = "blocks/pre-commit-tag-guard.yaml.in";
2143
2144pub const PRE_COMMIT_STATUS_RELEASE_FILES: &str = "blocks/pre-commit-status-release-files.yaml.in";
2150
2151pub const PRE_COMMIT_LOCAL_TRUNK_COMMIT_GUARD: &str =
2154 "blocks/pre-commit-local-trunk-commit-guard.yaml.in";
2155
2156pub const PRE_COMMIT_LOCAL_TRUNK_PUSH_GUARDS: &str =
2159 "blocks/pre-commit-local-trunk-push-guards.yaml.in";
2160
2161pub const FORCE_SCOPE_ARG: &str = ", --force-scope";
2167
2168pub fn embedded_block(path: &str) -> Result<&'static str, RkError> {
2175 let name = path.strip_prefix("blocks/").unwrap_or(path);
2176 let file = embedded::BLOCKS
2177 .get_file(name)
2178 .ok_or_else(|| anyhow::anyhow!("{path}: this binary embeds no such block"))?;
2179 std::str::from_utf8(file.contents())
2180 .map_err(|_| anyhow::anyhow!("{path}: a block is UTF-8").into())
2181}
2182
2183#[must_use]
2187pub fn authored(text: &str) -> &str {
2188 text.strip_suffix('\n').unwrap_or(text)
2189}
2190
2191pub 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/.+)$";
2199
2200pub const SCOPE_SHAPE: &str = "[a-z0-9._/-]+";
2210
2211#[must_use]
2218pub fn scope_is_shaped(scope: &str) -> bool {
2219 !scope.is_empty()
2220 && scope.chars().all(|c| {
2221 c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '/' | '-')
2222 })
2223}
2224
2225pub struct RoutingGuards<'a> {
2227 pub agent_policy: &'a str,
2229 pub commit_policy: &'a str,
2232 pub commit_source: &'a str,
2234 pub commit_scope: &'a str,
2236}
2237
2238#[must_use]
2245pub fn compose_routing(template: &str, guards: Option<&RoutingGuards<'_>>) -> String {
2246 let fragment = |text: &str| format!("{}\n", authored(text));
2247 let (agent, commit) = guards.map_or_else(
2248 || (String::new(), String::new()),
2249 |guards| {
2250 (
2251 fragment(guards.agent_policy),
2252 fragment(
2253 &authored(guards.commit_policy)
2254 .replacen("RK_COMMIT_SOURCE", &fragment(guards.commit_source), 1)
2255 .replacen("RK_COMMIT_SCOPE", &fragment(guards.commit_scope), 1),
2256 ),
2257 )
2258 },
2259 );
2260 authored(template)
2261 .replacen("RK_AGENT_POLICY", &agent, 1)
2262 .replacen("RK_COMMIT_POLICY", &commit, 1)
2263}
2264
2265#[must_use]
2269pub fn compose_glossary(template: &str) -> String {
2270 authored(template).to_owned()
2271}
2272
2273pub struct WorkflowHooks<'a> {
2275 pub message: &'a str,
2277 pub branch_name: &'a str,
2279 pub worktree: Option<&'a str>,
2284}
2285
2286#[must_use]
2306pub fn compose_hooks(
2307 template: &str,
2308 workflow: Option<&WorkflowHooks<'_>>,
2309 parts: &TrunkGuards,
2310) -> String {
2311 let fragment = |text: &str| format!("{}\n", authored(text));
2312 let worktree = workflow
2313 .and_then(|hooks| hooks.worktree)
2314 .map_or_else(String::new, fragment);
2315 let trunk_guards = workflow.is_some();
2316 let mut skips: Vec<&str> = Vec::new();
2317 if trunk_guards {
2318 skips.push("no-commit-to-branch");
2319 }
2320 if !worktree.is_empty() {
2321 skips.push("rk-worktree-location");
2322 }
2323 let sweep = if skips.is_empty() {
2324 fragment(parts.sweep_none)
2325 } else {
2326 fragment(&authored(parts.sweep_skip).replacen("RK_SWEEP_SKIP", &skips.join(","), 1))
2327 };
2328 let (commit_guard, push_guard) = match (trunk_guards, parts.integration) {
2329 (false, _) => (String::new(), String::new()),
2330 (true, Integration::Forge) => (fragment(parts.trunk_commit), fragment(parts.trunk_push)),
2331 (true, Integration::Local) => (
2332 fragment(parts.local_trunk_commit),
2333 fragment(parts.local_trunk_push),
2334 ),
2335 };
2336 let (tag_guard, release_files) = if parts.release {
2337 (fragment(parts.tag_guard), authored(parts.release_files))
2338 } else {
2339 (String::new(), "")
2340 };
2341 let note = match parts.integration {
2342 Integration::Forge => String::new(),
2343 Integration::Local => fragment(parts.integrate_note),
2344 };
2345 let (message, branch_name) = workflow.map_or_else(
2346 || (String::new(), String::new()),
2347 |hooks| {
2348 let message = fragment(hooks.message);
2349 (
2350 if parts.release {
2351 message
2352 } else {
2353 message.replacen(FORCE_SCOPE_ARG, "", 1)
2354 },
2355 fragment(hooks.branch_name),
2356 )
2357 },
2358 );
2359 authored(template)
2360 .replacen("RK_SWEEP_NOTE", &format!("{sweep}{note}"), 1)
2361 .replacen("RK_MESSAGE_GUARDS", &message, 1)
2362 .replacen("RK_TRUNK_COMMIT_GUARD", &commit_guard, 1)
2363 .replacen("RK_BRANCH_NAME_GUARD", &branch_name, 1)
2364 .replacen("RK_WORKTREE_GUARD", &worktree, 1)
2365 .replacen("RK_TRUNK_PUSH_GUARD", &push_guard, 1)
2366 .replacen("RK_TAG_GUARD", &tag_guard, 1)
2367 .replacen("RK_STATUS_RELEASE_FILES", release_files, 1)
2368 .replacen("RK_BRANCH_GRAMMAR", BRANCH_GRAMMAR, 1)
2369}
2370
2371pub struct TrunkGuards<'a> {
2374 pub integration: Integration,
2376 pub release: bool,
2379 pub trunk_commit: &'a str,
2382 pub trunk_push: &'a str,
2385 pub local_trunk_commit: &'a str,
2388 pub local_trunk_push: &'a str,
2391 pub tag_guard: &'a str,
2393 pub release_files: &'a str,
2396 pub sweep_skip: &'a str,
2398 pub sweep_none: &'a str,
2400 pub integrate_note: &'a str,
2402}
2403
2404#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2407pub struct BlockShape {
2408 pub checkout_mode: CheckoutMode,
2410 pub integration: Integration,
2412 pub merge_form: MergeForm,
2414 pub workflow_guards: bool,
2416 pub release: bool,
2418}
2419
2420impl BlockShape {
2421 #[must_use]
2423 pub fn of(params: &Params) -> Self {
2424 Self {
2425 checkout_mode: params.checkout_mode(),
2426 integration: params.integration(),
2427 merge_form: params.merge_form(),
2428 workflow_guards: params.workflow_guards(),
2429 release: params.release_mode() != ReleaseMode::None,
2430 }
2431 }
2432
2433 #[must_use]
2437 pub const fn releasing(
2438 checkout_mode: CheckoutMode,
2439 integration: Integration,
2440 workflow_guards: bool,
2441 ) -> Self {
2442 Self {
2443 checkout_mode,
2444 integration,
2445 merge_form: MergeForm::Squash,
2446 workflow_guards,
2447 release: true,
2448 }
2449 }
2450
2451 const fn routing_sources(self) -> [&'static str; 4] {
2454 let (template, agent) = if self.release {
2455 (AGENTS_BLOCK, AGENTS_LINE_AGENT_POLICY)
2456 } else {
2457 (AGENTS_BLOCK_WORKFLOW, AGENTS_LINE_AGENT_POLICY_WORKFLOW)
2458 };
2459 let source = match self.merge_form {
2460 MergeForm::Squash => AGENTS_LINE_COMMIT_SOURCE_SQUASH,
2461 MergeForm::Rebase => AGENTS_LINE_COMMIT_SOURCE_REBASE,
2462 };
2463 let scope = if self.release {
2464 AGENTS_LINE_COMMIT_SCOPE_REQUIRED
2465 } else {
2466 AGENTS_LINE_COMMIT_SCOPE_PREFERRED
2467 };
2468 [template, agent, source, scope]
2469 }
2470
2471 const fn glossary_source(self) -> &'static str {
2473 if self.release {
2474 GLOSSARY_BLOCK
2475 } else {
2476 GLOSSARY_BLOCK_WORKFLOW
2477 }
2478 }
2479}
2480
2481pub fn routing_block(workflow_guards: bool) -> Result<String, RkError> {
2488 routing_block_of(&BlockShape::releasing(
2489 CheckoutMode::LinkedWorktree,
2490 Integration::Local,
2491 workflow_guards,
2492 ))
2493}
2494
2495pub fn routing_block_of(shape: &BlockShape) -> Result<String, RkError> {
2505 let [template, agent, source, scope] = shape.routing_sources();
2506 let guards = if shape.workflow_guards {
2507 Some(RoutingGuards {
2508 agent_policy: embedded_block(agent)?,
2509 commit_policy: embedded_block(AGENTS_LINE_COMMIT_POLICY)?,
2510 commit_source: embedded_block(source)?,
2511 commit_scope: embedded_block(scope)?,
2512 })
2513 } else {
2514 None
2515 };
2516 Ok(compose_routing(embedded_block(template)?, guards.as_ref()))
2517}
2518
2519pub fn glossary_block() -> Result<String, RkError> {
2526 Ok(compose_glossary(embedded_block(GLOSSARY_BLOCK)?))
2527}
2528
2529pub fn glossary_block_of(shape: &BlockShape) -> Result<String, RkError> {
2536 Ok(compose_glossary(embedded_block(shape.glossary_source())?))
2537}
2538
2539pub fn hooks_block(
2547 mode: CheckoutMode,
2548 integration: Integration,
2549 workflow_guards: bool,
2550) -> Result<String, RkError> {
2551 hooks_block_of(&BlockShape::releasing(mode, integration, workflow_guards))
2552}
2553
2554pub fn hooks_block_of(shape: &BlockShape) -> Result<String, RkError> {
2560 let workflow = if shape.workflow_guards {
2561 Some(WorkflowHooks {
2562 message: embedded_block(PRE_COMMIT_MESSAGE_GUARDS)?,
2563 branch_name: embedded_block(PRE_COMMIT_BRANCH_NAME_GUARD)?,
2564 worktree: match shape.checkout_mode {
2565 CheckoutMode::LinkedWorktree => Some(embedded_block(PRE_COMMIT_WORKTREE_GUARD)?),
2566 CheckoutMode::MainWorktree => None,
2567 },
2568 })
2569 } else {
2570 None
2571 };
2572 let parts = TrunkGuards {
2573 integration: shape.integration,
2574 release: shape.release,
2575 trunk_commit: embedded_block(PRE_COMMIT_TRUNK_COMMIT_GUARD)?,
2576 trunk_push: embedded_block(PRE_COMMIT_TRUNK_PUSH_GUARD)?,
2577 local_trunk_commit: embedded_block(PRE_COMMIT_LOCAL_TRUNK_COMMIT_GUARD)?,
2578 local_trunk_push: embedded_block(PRE_COMMIT_LOCAL_TRUNK_PUSH_GUARDS)?,
2579 tag_guard: embedded_block(PRE_COMMIT_TAG_GUARD)?,
2580 release_files: embedded_block(PRE_COMMIT_STATUS_RELEASE_FILES)?,
2581 sweep_skip: embedded_block(PRE_COMMIT_SWEEP_SKIP)?,
2582 sweep_none: embedded_block(PRE_COMMIT_SWEEP_NONE)?,
2583 integrate_note: embedded_block(PRE_COMMIT_INTEGRATE_NOTE)?,
2584 };
2585 Ok(compose_hooks(
2586 embedded_block(PRE_COMMIT_BLOCK)?,
2587 workflow.as_ref(),
2588 &parts,
2589 ))
2590}
2591
2592type BlockTemplate = (String, Vec<String>, &'static str);
2595
2596fn block_template(destination: &str, params: &Params) -> Result<Option<BlockTemplate>, RkError> {
2599 let shape = BlockShape::of(params);
2600 let guards = shape.workflow_guards;
2601 match destination {
2602 AGENTS_DESTINATION => {
2603 let [template, agent, source, scope] = shape.routing_sources();
2604 let mut sources = vec![template.to_owned()];
2605 if guards {
2606 sources.push(agent.to_owned());
2607 sources.push(AGENTS_LINE_COMMIT_POLICY.to_owned());
2608 sources.push(source.to_owned());
2609 sources.push(scope.to_owned());
2610 }
2611 Ok(Some((routing_block_of(&shape)?, sources, catalog::GUARDS)))
2612 }
2613 GLOSSARY_DESTINATION => Ok(guards
2614 .then(|| -> Result<_, RkError> {
2615 Ok((
2616 glossary_block_of(&shape)?,
2617 vec![shape.glossary_source().to_owned()],
2618 catalog::WORKFLOW_GUARDS,
2619 ))
2620 })
2621 .transpose()?),
2622 HOOKS_DESTINATION => {
2623 let mut sources = vec![PRE_COMMIT_BLOCK.to_owned()];
2624 if guards {
2625 sources.push(PRE_COMMIT_MESSAGE_GUARDS.to_owned());
2626 sources.push(PRE_COMMIT_BRANCH_NAME_GUARD.to_owned());
2627 if shape.checkout_mode == CheckoutMode::LinkedWorktree {
2628 sources.push(PRE_COMMIT_WORKTREE_GUARD.to_owned());
2629 }
2630 match shape.integration {
2631 Integration::Forge => {
2632 sources.push(PRE_COMMIT_TRUNK_COMMIT_GUARD.to_owned());
2633 sources.push(PRE_COMMIT_TRUNK_PUSH_GUARD.to_owned());
2634 }
2635 Integration::Local => {
2636 sources.push(PRE_COMMIT_LOCAL_TRUNK_COMMIT_GUARD.to_owned());
2637 sources.push(PRE_COMMIT_LOCAL_TRUNK_PUSH_GUARDS.to_owned());
2638 }
2639 }
2640 }
2641 if shape.release {
2642 sources.push(PRE_COMMIT_TAG_GUARD.to_owned());
2643 sources.push(PRE_COMMIT_STATUS_RELEASE_FILES.to_owned());
2644 }
2645 sources.push(if guards {
2646 PRE_COMMIT_SWEEP_SKIP.to_owned()
2647 } else {
2648 PRE_COMMIT_SWEEP_NONE.to_owned()
2649 });
2650 if shape.integration == Integration::Local {
2651 sources.push(PRE_COMMIT_INTEGRATE_NOTE.to_owned());
2652 }
2653 Ok(Some((hooks_block_of(&shape)?, sources, catalog::GUARDS)))
2654 }
2655 other => Err(anyhow::anyhow!("{other} is not a block destination").into()),
2656 }
2657}
2658
2659#[must_use]
2661pub fn block_markers(destination: &str) -> Option<(&'static str, &'static str)> {
2662 match destination {
2663 AGENTS_DESTINATION | GLOSSARY_DESTINATION => Some((BLOCK_BEGIN, BLOCK_END)),
2664 HOOKS_DESTINATION => Some((HOOKS_BEGIN, HOOKS_END)),
2665 _ => None,
2666 }
2667}
2668
2669#[must_use]
2672pub fn extract_block<'a>(text: &'a str, begin: &str, end: &str) -> Option<&'a str> {
2673 let start = text.find(begin)?;
2674 let stop = text[start..].find(end)? + start + end.len();
2675 Some(&text[start..stop])
2676}
2677
2678#[must_use]
2685pub fn splice_marked_block(existing: Option<&[u8]>, block: &str) -> Vec<u8> {
2686 let block = block.as_bytes();
2687 let Some(text) = existing else {
2688 return [block, b"\n"].concat();
2689 };
2690 if let Some(start) = find(text, BLOCK_BEGIN.as_bytes())
2694 && let Some(offset) = find(&text[start..], BLOCK_END.as_bytes())
2695 {
2696 let stop = start + offset + BLOCK_END.len();
2697 return [&text[..start], block, &text[stop..]].concat();
2698 }
2699 let mut out = Vec::with_capacity(text.len() + block.len() + 3);
2703 out.extend_from_slice(text);
2704 if !text.ends_with(b"\n") {
2705 out.push(b'\n');
2706 }
2707 out.push(b'\n');
2708 out.extend_from_slice(block);
2709 out.push(b'\n');
2710 out
2711}
2712
2713pub fn splice_hooks_block(
2726 existing: Option<&str>,
2727 block: &str,
2728 integration: Integration,
2729) -> Result<String, String> {
2730 let Some(text) = existing else {
2731 let types = hook_types_line(integration);
2732 return Ok(format!("{types}\n\nrepos:\n{block}\n"));
2733 };
2734 if let Some(defect) = hooks_marker_defect(text) {
2735 return Err(defect);
2736 }
2737 if let Some(found) = extract_block(text, HOOKS_BEGIN, HOOKS_END) {
2738 return Ok(text.replacen(found, block, 1));
2739 }
2740 let mut out = String::with_capacity(text.len() + block.len() + 1);
2741 let mut placed = false;
2742 for line in text.split_inclusive('\n') {
2743 out.push_str(line);
2744 if !placed && line.trim_end() == "repos:" {
2745 if !out.ends_with('\n') {
2746 out.push('\n');
2747 }
2748 out.push_str(block);
2749 out.push('\n');
2750 placed = true;
2751 }
2752 }
2753 if placed {
2754 Ok(out)
2755 } else {
2756 Err(format!(
2757 "{HOOKS_DESTINATION} exists with no repos: line, so the hook block has nowhere to land"
2758 ))
2759 }
2760}
2761
2762#[must_use]
2772pub fn marker_defect(destination: &str, text: &str) -> Option<String> {
2773 let (begin, end) = block_markers(destination)?;
2774 let begins = text.matches(begin).count();
2775 let ends = text.matches(end).count();
2776 if begins > 1 || ends > 1 {
2777 return Some(format!(
2778 "{destination} carries more than one release-kit marker pair; release-kit owns exactly one block"
2779 ));
2780 }
2781 match (text.find(begin), text.find(end)) {
2782 (Some(begin), Some(end)) if end > begin => None,
2783 (None, None) => None,
2784 _ => Some(format!(
2785 "{destination} carries an unmatched or misordered release-kit marker, so the block's extent is ambiguous"
2786 )),
2787 }
2788}
2789
2790#[must_use]
2793pub fn hooks_marker_defect(text: &str) -> Option<String> {
2794 marker_defect(HOOKS_DESTINATION, text)
2795}
2796
2797fn propose_document(
2801 destination: &str,
2802 existing: Option<&[u8]>,
2803 region: &[u8],
2804 integration: Integration,
2805) -> Result<Vec<u8>, String> {
2806 let block = String::from_utf8_lossy(region).into_owned();
2811 if let Some(text) = existing
2812 && let Some(defect) = marker_defect(destination, &String::from_utf8_lossy(text))
2813 {
2814 return Err(defect);
2815 }
2816 if destination == HOOKS_DESTINATION {
2817 let text = match existing {
2821 None => None,
2822 Some(bytes) => Some(std::str::from_utf8(bytes).map_err(|_| {
2823 format!(
2824 "{destination} is not UTF-8, so the hook block has nowhere to land without rewriting the target's bytes"
2825 )
2826 })?),
2827 };
2828 return splice_hooks_block(text, &block, integration).map(String::into_bytes);
2829 }
2830 Ok(splice_marked_block(existing, &block))
2831}
2832
2833#[must_use]
2845pub fn nix_unsupported_shape(shape: &CrateShape) -> Option<String> {
2846 let Some(text) = shape.cargo_toml.as_deref() else {
2847 return Some(
2848 "the target has no readable Cargo.toml, which the seeded package expression reads; no Nix file lands".to_owned(),
2849 );
2850 };
2851 let Ok(table) = text.parse::<toml::Table>() else {
2852 return Some(
2853 "the target's Cargo.toml does not parse, and the seeded package expression reads it; no Nix file lands".to_owned(),
2854 );
2855 };
2856 if !table.contains_key("package") {
2857 return Some(
2858 "the target's Cargo.toml has no [package] table; the seed supports a single crate, so no Nix file lands".to_owned(),
2859 );
2860 }
2861 if !shape.cargo_lock {
2862 return Some(
2863 "the target has no Cargo.lock, which the seeded package expression builds from; commit one, then opt in".to_owned(),
2864 );
2865 }
2866 let implicit_bin = shape.main_rs
2867 && table
2868 .get("package")
2869 .and_then(toml::Value::as_table)
2870 .and_then(|package| package.get("autobins"))
2871 .and_then(toml::Value::as_bool)
2872 != Some(false);
2873 let explicit_bins = table.get("bin").and_then(toml::Value::as_array);
2874 if explicit_bins.is_none() && !implicit_bin {
2875 return Some(
2876 "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(),
2877 );
2878 }
2879 if let Some(bins) = explicit_bins {
2885 let required = bins
2886 .first()
2887 .and_then(toml::Value::as_table)
2888 .and_then(|bin| bin.get("required-features"))
2889 .and_then(toml::Value::as_array);
2890 if let Some(required) = required {
2891 let enabled = default_features(&table);
2892 let missing = required
2893 .iter()
2894 .filter_map(toml::Value::as_str)
2895 .any(|feature| !enabled.contains(feature));
2896 if missing {
2897 return Some(
2898 "the target's first [[bin]] entry requires features a default build does not enable; no Nix file lands".to_owned(),
2899 );
2900 }
2901 }
2902 }
2903 None
2904}
2905
2906fn dep_edge_suppresses(features: &toml::Table, name: &str) -> bool {
2909 let edge = format!("dep:{name}");
2910 features.values().any(|list| {
2911 list.as_array().is_some_and(|entries| {
2912 entries
2913 .iter()
2914 .filter_map(toml::Value::as_str)
2915 .any(|entry| entry == edge)
2916 })
2917 })
2918}
2919
2920fn is_optional_dependency(table: &toml::Table, name: &str) -> bool {
2923 ["dependencies", "build-dependencies"]
2924 .iter()
2925 .any(|section| {
2926 table
2927 .get(*section)
2928 .and_then(toml::Value::as_table)
2929 .and_then(|dependencies| dependencies.get(name))
2930 .and_then(toml::Value::as_table)
2931 .and_then(|dependency| dependency.get("optional"))
2932 .and_then(toml::Value::as_bool)
2933 == Some(true)
2934 })
2935}
2936
2937fn default_features(table: &toml::Table) -> std::collections::BTreeSet<String> {
2944 let Some(features) = table.get("features").and_then(toml::Value::as_table) else {
2945 return std::collections::BTreeSet::new();
2946 };
2947 let mut enabled = std::collections::BTreeSet::new();
2948 let mut queue = vec!["default".to_owned()];
2949 while let Some(name) = queue.pop() {
2950 if !enabled.insert(name.clone()) {
2951 continue;
2952 }
2953 if let Some(implies) = features.get(&name).and_then(toml::Value::as_array) {
2954 for implied in implies.iter().filter_map(toml::Value::as_str) {
2955 if implied.starts_with("dep:") || implied.contains("?/") {
2956 continue;
2960 }
2961 if let Some((package, _)) = implied.split_once('/') {
2962 let feature_exists =
2970 features.contains_key(package) || !dep_edge_suppresses(features, package);
2971 if is_optional_dependency(table, package) && feature_exists {
2972 queue.push(package.to_owned());
2973 }
2974 } else {
2975 queue.push(implied.to_owned());
2976 }
2977 }
2978 }
2979 }
2980 enabled
2981}
2982
2983#[must_use]
2991pub fn flake_pair_withheld(
2992 flake_recorded: bool,
2993 flake_nix_present: bool,
2994 flake_lock_present: bool,
2995) -> Option<String> {
2996 if flake_recorded {
2997 return None;
2998 }
2999 let present: Vec<&str> = [
3000 ("flake.nix", flake_nix_present),
3001 ("flake.lock", flake_lock_present),
3002 ]
3003 .into_iter()
3004 .filter_map(|(name, present)| present.then_some(name))
3005 .collect();
3006 if present.is_empty() {
3007 return None;
3008 }
3009 Some(format!(
3010 "the target already carries {}; its flake pair stays its own",
3011 present.join(" and ")
3012 ))
3013}
3014
3015#[must_use]
3025pub fn nix_withholding(
3026 nix: bool,
3027 evidence: &TargetEvidence,
3028) -> Option<(&'static [&'static str], String)> {
3029 if !nix {
3030 return None;
3031 }
3032 if let Some(reason) = nix_unsupported_shape(&evidence.crate_shape) {
3033 return Some((&NIX_DESTINATIONS[..], reason));
3034 }
3035 flake_pair_withheld(
3036 evidence.flake_recorded,
3037 evidence.flake_nix_present,
3038 evidence.flake_lock_present,
3039 )
3040 .map(|reason| (&NIX_WITHHOLDABLE[..], reason))
3041}
3042
3043#[cfg(test)]
3044mod tests {
3045 use super::{
3046 AGENTS_DESTINATION, BLOCK_BEGIN, BLOCK_DESTINATIONS, BLOCK_END, Candidate, Collision,
3047 CrateShape, GLOSSARY_DESTINATION, HOOK_TYPES_LINE, HOOKS_BEGIN, HOOKS_DESTINATION,
3048 HOOKS_END, Placement, Projection, ProjectionInput, TargetEvidence, extract_block,
3049 };
3050 use crate::landing::{Params, Style};
3051
3052 fn supported_shape() -> CrateShape {
3054 CrateShape {
3055 cargo_toml: Some("[package]\nname = \"widget\"\nversion = \"0.1.0\"\n".to_owned()),
3056 cargo_lock: true,
3057 main_rs: true,
3058 }
3059 }
3060
3061 fn input(evidence: TargetEvidence) -> ProjectionInput {
3062 let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
3063 params.set_nix_for_test(true);
3064 ProjectionInput { params, evidence }
3065 }
3066
3067 fn compute(evidence: TargetEvidence) -> Projection {
3068 Projection::compute(&input(evidence)).expect("the embedded pair projects")
3069 }
3070
3071 fn candidate<'a>(projection: &'a Projection, destination: &str) -> &'a Candidate {
3072 projection
3073 .candidates
3074 .iter()
3075 .find(|candidate| candidate.destination == destination)
3076 .expect("the destination projects")
3077 }
3078
3079 #[test]
3084 fn the_workflow_guards_land_only_where_requested() {
3085 let mut plain = input(TargetEvidence {
3086 crate_shape: supported_shape(),
3087 ..TargetEvidence::default()
3088 });
3089 plain.params.set_workflow_guards_for_test(false);
3090 let plain = Projection::compute(&plain).expect("the embedded pair projects");
3091 assert!(
3092 !plain
3093 .candidates
3094 .iter()
3095 .any(|candidate| candidate.destination == GLOSSARY_DESTINATION),
3096 "a target without the guards takes no glossary"
3097 );
3098 let agents =
3099 String::from_utf8(candidate(&plain, AGENTS_DESTINATION).bytes.clone()).expect("text");
3100 assert!(!agents.contains("guides and never drives"), "{agents}");
3101 let hooks =
3102 String::from_utf8(candidate(&plain, HOOKS_DESTINATION).bytes.clone()).expect("text");
3103 assert!(!hooks.contains("conventional-pre-commit"), "{hooks}");
3104 assert!(hooks.contains("rk-no-hand-authored-tag"), "{hooks}");
3105
3106 let guarded = compute(TargetEvidence {
3107 crate_shape: supported_shape(),
3108 ..TargetEvidence::default()
3109 });
3110 let glossary = candidate(&guarded, GLOSSARY_DESTINATION);
3111 assert_eq!(
3112 glossary.capability,
3113 crate::profile::catalog::WORKFLOW_GUARDS
3114 );
3115 assert_eq!(
3116 candidate(&guarded, AGENTS_DESTINATION).capability,
3117 crate::profile::catalog::GUARDS
3118 );
3119 let hooks =
3120 String::from_utf8(candidate(&guarded, HOOKS_DESTINATION).bytes.clone()).expect("text");
3121 for hook in ["conventional-pre-commit", "rk-message", "rk-branch-name"] {
3122 assert!(hooks.contains(hook), "{hook}: {hooks}");
3123 }
3124 }
3125
3126 fn outside(bytes: &[u8], begin: &str, end: &str) -> (Vec<u8>, Vec<u8>) {
3128 let text = String::from_utf8_lossy(bytes);
3129 let start = text.find(begin).expect("the begin marker is present");
3130 let stop = text[start..].find(end).expect("the end marker is present") + start + end.len();
3131 (bytes[..start].to_vec(), bytes[stop..].to_vec())
3132 }
3133
3134 #[test]
3135 fn equal_projection_inputs_yield_byte_identical_projections() {
3136 let mut documents = std::collections::BTreeMap::new();
3137 documents.insert(
3138 AGENTS_DESTINATION.to_owned(),
3139 b"# Widget\n\nOwn rules.\n".to_vec(),
3140 );
3141 let evidence = TargetEvidence {
3142 documents,
3143 crate_shape: supported_shape(),
3144 ..TargetEvidence::default()
3145 };
3146 let first = input(evidence.clone());
3147 let second = input(evidence);
3148 assert_eq!(first, second, "the inputs are values and compare equal");
3149 let a = Projection::compute(&first).expect("the pair projects");
3150 let b = Projection::compute(&second).expect("the pair projects");
3151 assert_eq!(a.candidates.len(), b.candidates.len());
3152 for (x, y) in a.candidates.iter().zip(&b.candidates) {
3153 assert_eq!(x.destination, y.destination);
3154 assert_eq!(x.kind, y.kind);
3155 assert_eq!(x.placement, y.placement);
3156 assert_eq!(x.bytes, y.bytes, "{}", x.destination);
3157 assert_eq!(x.region, y.region, "{}", x.destination);
3158 assert_eq!(x.sources, y.sources, "{}", x.destination);
3159 }
3160 assert_eq!(a, b);
3161 let destinations: Vec<&str> = a
3162 .candidates
3163 .iter()
3164 .map(|candidate| candidate.destination.as_str())
3165 .collect();
3166 let mut sorted = destinations.clone();
3167 sorted.sort_unstable();
3168 assert_eq!(destinations, sorted, "candidates sort by destination");
3169 assert!(a.omissions.is_empty(), "{:?}", a.omissions);
3170 assert!(a.collisions.is_empty(), "{:?}", a.collisions);
3171 }
3172
3173 #[test]
3177 fn the_scorecard_destination_projects_only_under_the_opt_in() {
3178 use super::{Kind, SCORECARD_DESTINATIONS, kind_of};
3179 let destination = SCORECARD_DESTINATIONS[0];
3180 assert_eq!(kind_of(destination), Some(Kind::Rendered));
3181
3182 let project = |scorecard: bool| {
3183 let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
3184 params.set_scorecard_for_test(scorecard);
3185 Projection::compute(&ProjectionInput {
3186 params,
3187 evidence: TargetEvidence::default(),
3188 })
3189 .expect("the embedded pair projects")
3190 };
3191
3192 let off = project(false);
3193 assert!(
3194 !off.candidates
3195 .iter()
3196 .any(|candidate| candidate.destination == destination),
3197 "off by default, and an absent candidate is no omission"
3198 );
3199 assert!(off.omissions.is_empty(), "{:?}", off.omissions);
3200
3201 let on = project(true);
3202 let candidate = candidate(&on, destination);
3203 assert_eq!(candidate.kind, Kind::Rendered);
3204 assert_eq!(candidate.placement, Placement::Whole);
3205 let text = String::from_utf8_lossy(&candidate.bytes);
3206 assert!(!text.contains("RK_"), "a token survived: {text}");
3207 }
3208
3209 #[test]
3214 fn the_licence_judgment_reads_what_every_reader_obtains() {
3215 use super::licence_states_an_open_source_codebase as approved;
3216 for expression in [
3217 "MIT",
3218 "Apache-2.0",
3219 "MIT OR Apache-2.0",
3220 "MIT AND Apache-2.0",
3221 "(MIT OR Apache-2.0) AND ISC",
3222 "Apache-2.0 WITH LLVM-exception OR MIT",
3223 "GPL-3.0+",
3224 ] {
3225 assert!(approved(expression), "{expression} is OSI-approved");
3226 }
3227 for expression in [
3232 "MIT AND CC-BY-4.0",
3233 "CC-BY-4.0 AND MIT",
3234 "MIT AND (CC-BY-4.0 OR CC-BY-SA-4.0)",
3235 "(MIT OR CC-BY-4.0) AND MIT",
3236 "MIT OR Apache-2.0 AND CC-BY-4.0",
3237 "Apache-2.0 WITH LLVM-exception AND CC-BY-SA-3.0",
3238 ] {
3239 assert!(approved(expression), "{expression} states open source");
3240 }
3241 for expression in [
3242 "",
3243 " ",
3244 "LicenseRef-proprietary",
3245 "MIT AND LicenseRef-proprietary",
3246 "SEE LICENSE IN COPYING",
3247 "CC-BY-4.0",
3248 "DocumentRef-spdx:LicenseRef-proprietary",
3249 ] {
3250 assert!(!approved(expression), "{expression} is not recognized");
3251 }
3252 for expression in [
3257 "MIT OR CC-BY-4.0",
3258 "CC-BY-4.0 AND CC-BY-SA-4.0",
3259 "CC-BY-4.0 AND LicenseRef-proprietary",
3260 "CC0-1.0",
3261 "MIT AND CC-BY-4.0 AND LicenseRef-proprietary",
3262 ] {
3263 assert!(!approved(expression), "{expression} grants no code terms");
3264 }
3265 assert!(
3270 !approved("CC-BY-4.0 OR CC-BY-SA-4.0 AND MIT"),
3271 "AND binds tighter than OR"
3272 );
3273 }
3274
3275 #[test]
3281 fn the_licence_judgment_holds_the_expression_to_the_spdx_grammar() {
3282 use super::licence_states_an_open_source_codebase as approved;
3283 for expression in [
3288 "MIT OR",
3289 "OR MIT",
3290 "MIT AND",
3291 "MIT WITH",
3292 "WITH LLVM-exception",
3293 "(MIT",
3294 "MIT)",
3295 "MIT Apache-2.0",
3296 "()",
3297 "(MIT OR Apache-2.0",
3298 "MIT OR (Apache-2.0",
3299 "MIT OR ()",
3300 "AND",
3301 "(",
3302 ")",
3303 "MIT WITH AND ISC",
3304 "MIT OR OR ISC",
3305 "MIT @ Apache-2.0",
3306 ] {
3307 assert!(!approved(expression), "{expression} is malformed");
3308 }
3309 for expression in [
3311 "MIT AND (Apache-2.0 OR ISC)",
3312 "((MIT))",
3313 "Apache-2.0 WITH LLVM-exception AND ISC",
3314 "(Apache-2.0 WITH LLVM-exception)",
3315 ] {
3316 assert!(approved(expression), "{expression} is well formed");
3317 }
3318 for expression in [
3323 "mit",
3324 "MiT",
3325 "apache-2.0 OR mit",
3326 "APACHE-2.0 WITH llvm-exception",
3327 ] {
3328 assert!(
3329 approved(expression),
3330 "{expression} names an approved licence"
3331 );
3332 }
3333 for expression in [
3337 "MIT or Apache-2.0",
3338 "MIT and Apache-2.0",
3339 "MIT with LLVM-exception",
3340 ] {
3341 assert!(!approved(expression), "{expression} carries no operator");
3342 }
3343 for expression in [
3347 "MIT WITH definitely-not-an-spdx-exception",
3348 "MIT WITH MIT",
3349 "MIT WITH Apache-2.0",
3350 ] {
3351 assert!(!approved(expression), "{expression} names no exception");
3352 }
3353 for expression in [
3357 "GPL-2.0-only WITH GCC-exception-2.0",
3358 "GPL-2.0-or-later WITH Classpath-exception-2.0",
3359 "Apache-2.0 WITH Swift-exception",
3360 "GPL-3.0-only WITH Autoconf-exception-generic",
3361 ] {
3362 assert!(approved(expression), "{expression} names a real exception");
3363 }
3364 }
3365
3366 #[test]
3372 fn a_recorded_provider_its_pair_cannot_run_is_named() {
3373 use super::{Provider, code_scanning_incompatibility};
3374
3375 assert!(code_scanning_incompatibility(None, Some("bash"), Some("gitlab")).is_none());
3376 assert!(
3377 code_scanning_incompatibility(Some(Provider::Semgrep), Some("rust"), Some("gitlab"))
3378 .is_none()
3379 );
3380 assert!(
3381 code_scanning_incompatibility(Some(Provider::CodeQl), Some("rust"), Some("github"))
3382 .is_none()
3383 );
3384
3385 let bash =
3386 code_scanning_incompatibility(Some(Provider::Semgrep), Some("bash"), Some("github"))
3387 .expect("a binding with no scanner is named");
3388 assert!(bash.contains("bash binding"), "{bash}");
3389 let gitlab =
3390 code_scanning_incompatibility(Some(Provider::CodeQl), Some("rust"), Some("gitlab"))
3391 .expect("codeql on gitlab is named");
3392 assert!(gitlab.contains("codeql"), "{gitlab}");
3393 let release_less =
3394 code_scanning_incompatibility(Some(Provider::Semgrep), None, Some("github"))
3395 .expect("no driver is named");
3396 assert!(
3397 release_less.contains("no automatic release driver"),
3398 "{release_less}"
3399 );
3400
3401 let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
3404 params.set_code_scanning_for_test(Some(Provider::Semgrep));
3405 let clean = Projection::compute(&ProjectionInput {
3406 params: params.clone(),
3407 evidence: TargetEvidence::default(),
3408 })
3409 .expect("the pair projects");
3410 assert!(
3411 clean.record_defects.is_empty(),
3412 "{:?}",
3413 clean.record_defects
3414 );
3415 }
3416
3417 #[test]
3421 fn the_code_scanning_destinations_follow_the_recorded_provider() {
3422 use super::{
3423 CODE_SCANNING_DESTINATIONS, Kind, Provider, code_scanning_licence_refusal, kind_of,
3424 };
3425 for (destination, _) in CODE_SCANNING_DESTINATIONS {
3426 assert_eq!(kind_of(destination), Some(Kind::Rendered), "{destination}");
3427 }
3428
3429 let project = |provider: Option<Provider>| {
3430 let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
3431 params.set_code_scanning_for_test(provider);
3432 Projection::compute(&ProjectionInput {
3433 params,
3434 evidence: TargetEvidence {
3435 crate_shape: CrateShape {
3436 cargo_toml: Some(
3437 "[package]\nname = \"widget\"\nlicense = \"MIT\"\n".to_owned(),
3438 ),
3439 ..CrateShape::default()
3440 },
3441 ..TargetEvidence::default()
3442 },
3443 })
3444 .expect("the embedded pair projects")
3445 };
3446 let landed = |projection: &Projection, destination: &str| {
3447 projection
3448 .candidates
3449 .iter()
3450 .any(|candidate| candidate.destination == destination)
3451 };
3452
3453 let off = project(None);
3454 for (destination, _) in CODE_SCANNING_DESTINATIONS {
3455 assert!(!landed(&off, destination), "{destination}");
3456 }
3457 assert!(off.licence_refusal.is_none());
3458
3459 let codeql = project(Some(Provider::CodeQl));
3460 assert!(landed(
3461 &codeql,
3462 ".github/workflows/code-scanning-codeql.yml"
3463 ));
3464 assert!(!landed(
3465 &codeql,
3466 ".github/workflows/code-scanning-semgrep.yml"
3467 ));
3468 assert!(codeql.licence_refusal.is_none(), "MIT satisfies the terms");
3469
3470 let semgrep = project(Some(Provider::Semgrep));
3471 assert!(landed(
3472 &semgrep,
3473 ".github/workflows/code-scanning-semgrep.yml"
3474 ));
3475 assert!(!landed(
3476 &semgrep,
3477 ".github/workflows/code-scanning-codeql.yml"
3478 ));
3479
3480 let proprietary = CrateShape {
3482 cargo_toml: Some("[package]\nlicense = \"LicenseRef-proprietary\"\n".to_owned()),
3483 ..CrateShape::default()
3484 };
3485 assert!(
3486 code_scanning_licence_refusal(Some(Provider::Semgrep), Some("rust"), &proprietary)
3487 .is_none()
3488 );
3489 let refusal =
3490 code_scanning_licence_refusal(Some(Provider::CodeQl), Some("rust"), &proprietary)
3491 .expect("codeql refuses a licence its terms do not cover");
3492 assert!(refusal.contains("LicenseRef-proprietary"), "{refusal}");
3493 assert!(refusal.contains("semgrep"), "{refusal}");
3494 let bash =
3497 code_scanning_licence_refusal(Some(Provider::CodeQl), Some("bash"), &proprietary)
3498 .expect("an unread binding refuses");
3499 assert!(bash.contains("bash binding"), "{bash}");
3500 }
3501
3502 #[test]
3508 fn the_projection_performs_no_filesystem_git_environment_clock_registry_or_network_read() {
3509 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/projection.rs");
3510 let text = std::fs::read_to_string(&path).expect("the source reads");
3511 let production = text.split("#[cfg(test)]").next().unwrap_or("");
3512 let needles = [
3513 "std::fs",
3514 "std::env",
3515 "std::process",
3516 "std::time",
3517 "SystemTime",
3518 "Instant",
3519 "std::net",
3520 "Command::new",
3521 "registry::",
3522 "ureq",
3523 "http::",
3524 "blob(",
3525 ];
3526 let mut hits = Vec::new();
3527 for (index, line) in production.lines().enumerate() {
3528 if line.trim_start().starts_with("//") {
3529 continue;
3530 }
3531 for needle in needles {
3532 if line.contains(needle) {
3533 hits.push(format!("src/projection.rs:{}: {needle}", index + 1));
3534 }
3535 }
3536 }
3537 assert!(
3538 hits.is_empty(),
3539 "the projection reads beyond its inputs: {hits:?}"
3540 );
3541 }
3542
3543 #[test]
3544 #[allow(
3545 clippy::too_many_lines,
3546 reason = "one test walks the three marked destinations and the three unmarked shapes"
3547 )]
3548 fn marked_region_projection_preserves_every_target_byte_outside_the_markers() {
3549 let agents_before = "# Widget\n\nOperator prose above.\n\n";
3550 let agents_after = "\n\n## Our rules\n\nOperator prose below. \n";
3551 let glossary_before = "# Glossary\n\n- `spike` is a throwaway branch.\n\n";
3552 let glossary_after = "\n\n## More terms\n\n- `own` is ours.";
3553 let hooks_before = "default_install_hook_types: [pre-commit]\n\nrepos:\n";
3554 let hooks_after =
3555 "\n - repo: https://example.com/own\n rev: v1\n hooks:\n - id: own\n";
3556 let stale = |begin: &str, end: &str| format!("{begin}\nstale block\n{end}");
3557 let mut documents = std::collections::BTreeMap::new();
3558 documents.insert(
3559 AGENTS_DESTINATION.to_owned(),
3560 format!(
3561 "{agents_before}{}{agents_after}",
3562 stale(BLOCK_BEGIN, BLOCK_END)
3563 )
3564 .into_bytes(),
3565 );
3566 documents.insert(
3567 GLOSSARY_DESTINATION.to_owned(),
3568 format!(
3569 "{glossary_before}{}{glossary_after}",
3570 stale(BLOCK_BEGIN, BLOCK_END)
3571 )
3572 .into_bytes(),
3573 );
3574 documents.insert(
3575 HOOKS_DESTINATION.to_owned(),
3576 format!(
3577 "{hooks_before}{}{hooks_after}",
3578 stale(HOOKS_BEGIN, HOOKS_END)
3579 )
3580 .into_bytes(),
3581 );
3582 let projection = compute(TargetEvidence {
3583 documents: documents.clone(),
3584 crate_shape: supported_shape(),
3585 ..TargetEvidence::default()
3586 });
3587 assert!(
3588 projection.collisions.is_empty(),
3589 "{:?}",
3590 projection.collisions
3591 );
3592 for (destination, before, after) in [
3593 (AGENTS_DESTINATION, agents_before, agents_after),
3594 (GLOSSARY_DESTINATION, glossary_before, glossary_after),
3595 (HOOKS_DESTINATION, hooks_before, hooks_after),
3596 ] {
3597 let candidate = candidate(&projection, destination);
3598 let Placement::Region { begin, end } = candidate.placement else {
3599 panic!("{destination} is a region");
3600 };
3601 let region = candidate
3602 .region
3603 .as_deref()
3604 .expect("a region carries its block");
3605 let (head, tail) = outside(&candidate.bytes, begin, end);
3606 assert_eq!(
3607 head,
3608 before.as_bytes(),
3609 "{destination}: bytes before the markers"
3610 );
3611 assert_eq!(
3612 tail,
3613 after.as_bytes(),
3614 "{destination}: bytes after the markers"
3615 );
3616 let inside = &candidate.bytes[head.len()..candidate.bytes.len() - tail.len()];
3617 assert_eq!(
3618 inside, region,
3619 "{destination}: the region is the rendered block"
3620 );
3621 let (existing_head, existing_tail) = outside(&documents[destination], begin, end);
3622 assert_eq!(head, existing_head);
3623 assert_eq!(tail, existing_tail);
3624 }
3625
3626 let own_hooks =
3630 "repos:\n - repo: https://example.com/own\n rev: v1\n hooks:\n - id: own\n";
3631 let own_agents = "# Widget\n\nOwn rules.";
3632 let mut documents = std::collections::BTreeMap::new();
3633 documents.insert(HOOKS_DESTINATION.to_owned(), own_hooks.as_bytes().to_vec());
3634 documents.insert(
3635 AGENTS_DESTINATION.to_owned(),
3636 own_agents.as_bytes().to_vec(),
3637 );
3638 let projection = compute(TargetEvidence {
3639 documents,
3640 crate_shape: supported_shape(),
3641 ..TargetEvidence::default()
3642 });
3643 assert!(
3644 projection.collisions.is_empty(),
3645 "{:?}",
3646 projection.collisions
3647 );
3648 let hooks = candidate(&projection, HOOKS_DESTINATION);
3649 let hooks_text = String::from_utf8_lossy(&hooks.bytes);
3650 let region = String::from_utf8_lossy(hooks.region.as_deref().expect("a region"));
3651 assert!(
3652 hooks_text.starts_with(&format!(
3653 "repos:\n{region}\n - repo: https://example.com/own"
3654 )),
3655 "{hooks_text}"
3656 );
3657 assert!(!hooks_text.contains(HOOK_TYPES_LINE));
3658 let agents = candidate(&projection, AGENTS_DESTINATION);
3659 assert!(agents.bytes.starts_with(own_agents.as_bytes()));
3660 assert_eq!(
3661 extract_block(
3662 &String::from_utf8_lossy(&agents.bytes),
3663 BLOCK_BEGIN,
3664 BLOCK_END
3665 )
3666 .map(str::as_bytes),
3667 agents.region.as_deref()
3668 );
3669 let glossary = candidate(&projection, GLOSSARY_DESTINATION);
3670 let region = glossary.region.as_deref().expect("a region");
3671 assert_eq!(
3672 glossary.bytes,
3673 [region, b"\n"].concat(),
3674 "an absent file is fresh"
3675 );
3676 }
3677
3678 #[test]
3684 fn a_hook_document_that_is_not_utf8_collides_instead_of_being_rewritten() {
3685 let mut documents = std::collections::BTreeMap::new();
3686 let mut invalid = b"repos:\n# own \xff above\n".to_vec();
3687 invalid.extend_from_slice(format!("{HOOKS_BEGIN}\nstale\n{HOOKS_END}\n").as_bytes());
3688 invalid.extend_from_slice(b" - repo: local \xff below\n");
3689 documents.insert(HOOKS_DESTINATION.to_owned(), invalid);
3690 let mut agents = b"# Widget r\xe9sum\xe9\n\n".to_vec();
3691 agents.extend_from_slice(format!("{BLOCK_BEGIN}\nstale\n{BLOCK_END}\n\n").as_bytes());
3692 agents.extend_from_slice(b"r\xe9sum\xe9\n");
3693 documents.insert(AGENTS_DESTINATION.to_owned(), agents.clone());
3694 let projection = compute(TargetEvidence {
3695 documents,
3696 crate_shape: supported_shape(),
3697 ..TargetEvidence::default()
3698 });
3699 let collided: Vec<&str> = projection
3700 .collisions
3701 .iter()
3702 .map(|c| c.destination.as_str())
3703 .collect();
3704 assert_eq!(collided, [HOOKS_DESTINATION]);
3705 assert!(
3706 projection.collisions[0].reason.contains("not UTF-8"),
3707 "{}",
3708 projection.collisions[0].reason
3709 );
3710 assert!(
3711 !projection
3712 .candidates
3713 .iter()
3714 .any(|c| c.destination == HOOKS_DESTINATION),
3715 "a colliding destination projects no candidate"
3716 );
3717 let agents = candidate(&projection, AGENTS_DESTINATION);
3718 assert!(
3719 agents.bytes.starts_with(b"# Widget r\xe9sum\xe9\n\n"),
3720 "{:?}",
3721 agents.bytes
3722 );
3723 assert!(
3724 agents.bytes.ends_with(b"\n\nr\xe9sum\xe9\n"),
3725 "{:?}",
3726 agents.bytes
3727 );
3728 assert!(
3729 !agents.bytes.contains(&0xEF),
3730 "a replacement character landed"
3731 );
3732
3733 let mut documents = std::collections::BTreeMap::new();
3734 let valid =
3735 format!("repos:\n# own above\n{HOOKS_BEGIN}\nstale\n{HOOKS_END}\n - repo: local\n");
3736 documents.insert(HOOKS_DESTINATION.to_owned(), valid.into_bytes());
3737 let projection = compute(TargetEvidence {
3738 documents,
3739 crate_shape: supported_shape(),
3740 ..TargetEvidence::default()
3741 });
3742 assert!(
3743 projection.collisions.is_empty(),
3744 "{:?}",
3745 projection.collisions
3746 );
3747 let hooks = candidate(&projection, HOOKS_DESTINATION);
3748 let text = String::from_utf8(hooks.bytes.clone()).expect("a valid document stays text");
3749 assert!(text.starts_with("repos:\n# own above\n"), "{text}");
3750 assert!(text.ends_with("\n - repo: local\n"), "{text}");
3751 assert!(!text.contains("stale"), "the region is replaced: {text}");
3752 }
3753
3754 #[test]
3758 fn a_whole_file_colliding_with_a_marked_region_names_both_source_paths() {
3759 let files: Vec<(String, &[u8])> = vec![
3760 ("snippets/_shared/github/SECURITY.md".to_owned(), b"policy"),
3761 ("snippets/rust/github/AGENTS.md".to_owned(), b"whole"),
3762 ];
3763 let err = Projection::compute_over(&files, &input(TargetEvidence::default()))
3764 .expect_err("a whole file at a block destination refuses");
3765 let text = err.to_string();
3766 assert!(text.contains("snippets/rust/github/AGENTS.md"), "{text}");
3767 assert!(text.contains(super::AGENTS_BLOCK), "{text}");
3768 assert!(text.contains(super::AGENTS_LINE_AGENT_POLICY), "{text}");
3769 assert!(text.contains("embedded sources are defective"), "{text}");
3770 }
3771
3772 #[test]
3773 fn duplicate_whole_file_destinations_and_overlapping_marked_regions_refuse_with_the_conflicting_source_names()
3774 {
3775 let files: Vec<(String, &[u8])> = vec![
3778 ("snippets/_shared/github/SECURITY.md".to_owned(), b"shared"),
3779 ("snippets/rust/github/SECURITY.md".to_owned(), b"pair"),
3780 ("snippets/rust/github/release-plz.toml".to_owned(), b"seed"),
3781 ];
3782 let err = Projection::compute_over(&files, &input(TargetEvidence::default()))
3783 .expect_err("a doubled destination refuses");
3784 let text = err.to_string();
3785 assert!(
3786 text.contains("snippets/_shared/github/SECURITY.md"),
3787 "{text}"
3788 );
3789 assert!(text.contains("snippets/rust/github/SECURITY.md"), "{text}");
3790 assert!(text.contains("embedded sources are defective"), "{text}");
3791
3792 let clean: Vec<(String, &[u8])> = vec![
3793 ("snippets/_shared/github/SECURITY.md".to_owned(), b"shared"),
3794 ("snippets/rust/github/release-plz.toml".to_owned(), b"seed"),
3795 ];
3796 let projection = Projection::compute_over(&clean, &input(TargetEvidence::default()))
3797 .expect("a clean list projects");
3798 let whole: Vec<&str> = projection
3799 .candidates
3800 .iter()
3801 .filter(|c| c.placement == Placement::Whole)
3802 .map(|c| c.destination.as_str())
3803 .collect();
3804 assert_eq!(whole, ["SECURITY.md", "release-plz.toml"]);
3805 assert_eq!(
3806 projection
3807 .candidates
3808 .iter()
3809 .find(|c| c.destination == "SECURITY.md")
3810 .map(|c| c.sources.clone()),
3811 Some(vec!["snippets/_shared/github/SECURITY.md".to_owned()])
3812 );
3813
3814 let doubled = format!("{BLOCK_BEGIN}\na\n{BLOCK_END}\n{BLOCK_BEGIN}\nb\n{BLOCK_END}\n");
3815 let unmatched = format!("repos:\n{HOOKS_BEGIN}\n - repo: local\n");
3816 let misordered = format!("# G\n{BLOCK_END}\n{BLOCK_BEGIN}\n");
3817 let mut documents = std::collections::BTreeMap::new();
3818 documents.insert(AGENTS_DESTINATION.to_owned(), doubled.into_bytes());
3819 documents.insert(HOOKS_DESTINATION.to_owned(), unmatched.into_bytes());
3820 documents.insert(GLOSSARY_DESTINATION.to_owned(), misordered.into_bytes());
3821 let projection = compute(TargetEvidence {
3822 documents,
3823 crate_shape: supported_shape(),
3824 ..TargetEvidence::default()
3825 });
3826 let mut collided: Vec<&str> = projection
3827 .collisions
3828 .iter()
3829 .map(|Collision { destination, .. }| destination.as_str())
3830 .collect();
3831 collided.sort_unstable();
3832 let mut expected = BLOCK_DESTINATIONS.to_vec();
3833 expected.sort_unstable();
3834 assert_eq!(collided, expected);
3835 for collision in &projection.collisions {
3836 assert!(
3837 collision.reason.contains(&collision.destination),
3838 "{collision:?}"
3839 );
3840 assert!(
3841 !projection
3842 .candidates
3843 .iter()
3844 .any(|candidate| candidate.destination == collision.destination),
3845 "{} collided and still projects",
3846 collision.destination
3847 );
3848 }
3849 let agents = projection
3850 .collisions
3851 .iter()
3852 .find(|c| c.destination == AGENTS_DESTINATION)
3853 .expect("the doubled document collides");
3854 assert!(agents.reason.contains("more than one"), "{}", agents.reason);
3855 let hooks = projection
3856 .collisions
3857 .iter()
3858 .find(|c| c.destination == HOOKS_DESTINATION)
3859 .expect("the unmatched document collides");
3860 assert!(hooks.reason.contains("unmatched"), "{}", hooks.reason);
3861 }
3862}