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(destination, evidence.document(destination), ®ion) {
405 Ok(bytes) => candidates.push(Candidate {
406 capability,
407 destination: destination.to_owned(),
408 kind: Kind::Rendered,
409 placement: Placement::Region { begin, end },
410 bytes,
411 region: Some(region),
412 sources,
413 }),
414 Err(reason) => collisions.push(Collision {
415 destination: destination.to_owned(),
416 reason,
417 }),
418 }
419 }
420 let mut omissions = Vec::new();
421 if let Some((set, reason)) = nix_withholding(params.nix_packaging(), evidence)
422 && candidates
423 .iter()
424 .any(|candidate| candidate.capability == catalog::PACKAGING_NIX)
425 {
426 candidates.retain(|candidate| {
427 if set.contains(&candidate.destination.as_str()) {
428 omissions.push(Omission {
429 destination: candidate.destination.clone(),
430 reason: reason.clone(),
431 action: None,
432 });
433 false
434 } else {
435 true
436 }
437 });
438 withhold(&mut capabilities, catalog::PACKAGING_NIX, &reason, None);
439 }
440 let gate_root = candidates.iter().position(|candidate| {
446 (candidate.capability == catalog::TITLE_CHECK
447 || candidate.capability == catalog::COMMIT_CHECK)
448 && candidate.destination == GITLAB_ROOT_PIPELINE
449 });
450 if let Some(index) = gate_root
451 && evidence.root_pipeline_present
452 && !evidence.root_pipeline_recorded
453 {
454 let candidate = candidates.remove(index);
455 let (gate, fragment) = if candidate.capability == catalog::COMMIT_CHECK {
456 ("commit gate", GITLAB_COMMIT_FRAGMENT)
457 } else {
458 ("title gate", GITLAB_TITLE_FRAGMENT)
459 };
460 let reason = format!(
461 "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"
462 );
463 let action = format!(
464 "add the include to the target's own {GITLAB_ROOT_PIPELINE}: include:\n - local: {fragment}"
465 );
466 omissions.push(Omission {
467 destination: candidate.destination,
468 reason: reason.clone(),
469 action: Some(action.clone()),
470 });
471 withhold(
472 &mut capabilities,
473 candidate.capability,
474 &reason,
475 Some(action),
476 );
477 }
478 candidates.sort_by(|a, b| a.destination.cmp(&b.destination));
479 omissions.sort_by(|a, b| a.destination.cmp(&b.destination));
480 let scanning_selected = capabilities.iter().any(|selection| {
486 selection.id == catalog::CODE_SCANNING && selection.status == Status::Selected
487 });
488 let licence_refusal = scanning_selected
489 .then(|| {
490 code_scanning_licence_refusal(
491 params.code_scanning(),
492 params.driver(),
493 &evidence.crate_shape,
494 )
495 })
496 .flatten();
497 let mut record_defects: Vec<String> = Vec::new();
502 if params.release_mode() == ReleaseMode::Automatic
506 && let Some(selection) = capabilities
507 .iter()
508 .find(|selection| selection.id == catalog::RELEASE_AUTOMATION)
509 && matches!(selection.status, Status::Unavailable | Status::Unknown)
510 && let Some(reason) = &selection.reason
511 {
512 record_defects.push(reason.clone());
513 }
514 let driver_refusal = (params.release_mode() == ReleaseMode::Automatic
518 && params.driver() == Some("python")
519 && capabilities.iter().any(|selection| {
520 selection.id == catalog::RELEASE_AUTOMATION && selection.status == Status::Selected
521 }))
522 .then(|| evidence.python_shape.refusal())
523 .flatten();
524 Ok(Self {
525 capabilities,
526 candidates,
527 omissions,
528 collisions,
529 licence_refusal,
530 driver_refusal,
531 record_defects,
532 })
533 }
534
535 #[must_use]
539 pub fn release_unavailable(&self) -> Option<&str> {
540 self.capabilities
541 .iter()
542 .find(|selection| {
543 selection.id == catalog::RELEASE_AUTOMATION
544 && matches!(selection.status, Status::Unavailable | Status::Unknown)
545 })
546 .and_then(|selection| selection.reason.as_deref())
547 }
548
549 #[must_use]
551 pub fn capability(&self, id: &str) -> Option<&Selection> {
552 self.capabilities
553 .iter()
554 .find(|selection| selection.id == id)
555 }
556}
557
558fn withhold(capabilities: &mut [Selection], id: &str, reason: &str, action: Option<String>) {
560 if let Some(selection) = capabilities.iter_mut().find(|selection| selection.id == id) {
561 selection.status = Status::Withheld;
562 selection.reason = Some(reason.to_owned());
563 selection.action = action;
564 }
565}
566
567pub const GITLAB_ROOT_PIPELINE: &str = ".gitlab-ci.yml";
570
571pub const GITLAB_TITLE_FRAGMENT: &str = ".gitlab/ci/mr-title.yml";
573
574pub const GITLAB_COMMIT_FRAGMENT: &str = ".gitlab/ci/mr-commits.yml";
576
577fn embedded_snippets() -> Vec<(String, &'static [u8])> {
580 embedded::walk(&embedded::SNIPPETS)
581 .into_iter()
582 .map(|(path, bytes)| (format!("snippets/{path}"), bytes))
583 .collect()
584}
585
586#[must_use]
589pub fn supported_pairs() -> Vec<(String, String)> {
590 Availability::embedded()
591 .automation_tuples()
592 .into_iter()
593 .filter_map(|entry| {
594 entry
595 .split_once(", ")
596 .map(|(driver, forge)| (driver.to_owned(), forge.to_owned()))
597 })
598 .collect()
599}
600
601#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
603#[serde(rename_all = "lowercase")]
604pub enum Kind {
605 Rendered,
608 Seeded,
611 State,
614}
615
616impl Kind {
617 #[must_use]
619 pub const fn as_str(self) -> &'static str {
620 match self {
621 Self::Rendered => "rendered",
622 Self::Seeded => "seeded",
623 Self::State => "state",
624 }
625 }
626}
627
628const KINDS: [(&str, Kind); 19] = [
634 (".github/workflows/release-plz.yml", Kind::Rendered),
635 (".github/workflows/release.yml", Kind::Rendered),
636 (".github/workflows/pr-title.yml", Kind::Rendered),
637 (".github/workflows/pr-commits.yml", Kind::Rendered),
638 (".github/workflows/scorecard.yml", Kind::Rendered),
639 (".github/workflows/code-scanning-codeql.yml", Kind::Rendered),
640 (
641 ".github/workflows/code-scanning-semgrep.yml",
642 Kind::Rendered,
643 ),
644 (".gitlab/ci/code-scanning-semgrep.yml", Kind::Rendered),
645 (".gitlab-ci.yml", Kind::Rendered),
646 ("SECURITY.md", Kind::Rendered),
647 (".gitlab/ci/mr-title.yml", Kind::Rendered),
648 (".gitlab/ci/mr-commits.yml", Kind::Rendered),
649 ("release-plz.toml", Kind::Seeded),
650 ("dist-workspace.toml", Kind::Seeded),
651 ("cliff.toml", Kind::Seeded),
652 ("nix/package.nix", Kind::Seeded),
653 ("flake.nix", Kind::Seeded),
654 ("VERSION", Kind::State),
655 ("flake.lock", Kind::State),
656];
657
658pub const NIX_DESTINATIONS: [&str; 3] = ["nix/package.nix", "flake.nix", "flake.lock"];
672
673pub const NIX_WITHHOLDABLE: [&str; 2] = ["flake.nix", "flake.lock"];
679
680pub const SCORECARD_DESTINATIONS: [&str; 1] = [".github/workflows/scorecard.yml"];
695
696pub const CODE_SCANNING_DESTINATIONS: [(&str, Provider); 3] = [
710 (
711 ".github/workflows/code-scanning-codeql.yml",
712 Provider::CodeQl,
713 ),
714 (
715 ".github/workflows/code-scanning-semgrep.yml",
716 Provider::Semgrep,
717 ),
718 (".gitlab/ci/code-scanning-semgrep.yml", Provider::Semgrep),
719];
720
721pub const CODE_SCANNING_TECHS: [&str; 1] = ["rust"];
729
730#[must_use]
747pub fn code_scanning_incompatibility(
748 provider: Option<Provider>,
749 driver: Option<&str>,
750 forge: Option<&str>,
751) -> Option<String> {
752 let named = provider?;
753 let Some(driver) = driver else {
754 return Some(format!(
755 "a scanner reads the release driver's language, and the profile names no automatic release driver; the bindings that carry one are: {}",
756 CODE_SCANNING_TECHS.join(", ")
757 ));
758 };
759 if !CODE_SCANNING_TECHS.contains(&driver) {
760 return Some(format!(
761 "the {driver} binding ships no code scanning workflow; a scanner reads one language, and the bindings that carry one are: {}",
762 CODE_SCANNING_TECHS.join(", ")
763 ));
764 }
765 let Some(forge) = forge else {
766 return Some(
767 "a code scanning workflow runs at a forge, and the profile names none".to_owned(),
768 );
769 };
770 if named == Provider::CodeQl && forge != "github" {
771 return Some(format!(
772 "codeql is GitHub's own analyzer and the {forge} pair ships no workflow for it; pass --code-scanning semgrep"
773 ));
774 }
775 None
776}
777
778const OSI_APPROVED: [&str; 18] = [
788 "0bsd",
789 "agpl-3.0",
790 "agpl-3.0-only",
791 "agpl-3.0-or-later",
792 "apache-2.0",
793 "bsd-2-clause",
794 "bsd-3-clause",
795 "bsl-1.0",
796 "epl-2.0",
797 "gpl-2.0",
798 "gpl-2.0-only",
799 "gpl-2.0-or-later",
800 "gpl-3.0",
801 "gpl-3.0-only",
802 "gpl-3.0-or-later",
803 "isc",
804 "mit",
805 "mpl-2.0",
806];
807
808const CONTENT_LICENCES: [&str; 4] = ["cc-by-3.0", "cc-by-4.0", "cc-by-sa-3.0", "cc-by-sa-4.0"];
826
827const SPDX_EXCEPTIONS: [&str; 86] = [
844 "389-exception",
845 "asterisk-exception",
846 "asterisk-linking-protocols-exception",
847 "autoconf-exception-2.0",
848 "autoconf-exception-3.0",
849 "autoconf-exception-generic",
850 "autoconf-exception-generic-3.0",
851 "autoconf-exception-macro",
852 "bison-exception-1.24",
853 "bison-exception-2.2",
854 "bootloader-exception",
855 "cgal-linking-exception",
856 "classpath-exception-2.0",
857 "classpath-exception-2.0-short",
858 "clisp-exception-2.0",
859 "cryptsetup-openssl-exception",
860 "digia-qt-lgpl-exception-1.1",
861 "digirule-foss-exception",
862 "ecos-exception-2.0",
863 "erlang-otp-linking-exception",
864 "fawkes-runtime-exception",
865 "fltk-exception",
866 "fmt-exception",
867 "font-exception-2.0",
868 "freertos-exception-2.0",
869 "gcc-exception-2.0",
870 "gcc-exception-2.0-note",
871 "gcc-exception-3.1",
872 "gmsh-exception",
873 "gnat-exception",
874 "gnome-examples-exception",
875 "gnu-compiler-exception",
876 "gnu-javamail-exception",
877 "google-patent-webm",
878 "gpl-3.0-389-ds-base-exception",
879 "gpl-3.0-interface-exception",
880 "gpl-3.0-linking-exception",
881 "gpl-3.0-linking-source-exception",
882 "gpl-cc-1.0",
883 "gstreamer-exception-2005",
884 "gstreamer-exception-2008",
885 "harbour-exception",
886 "i2p-gpl-java-exception",
887 "independent-modules-exception",
888 "kicad-libraries-exception",
889 "kvirc-openssl-exception",
890 "lgpl-3.0-linking-exception",
891 "libpri-openh323-exception",
892 "libtool-exception",
893 "linux-syscall-note",
894 "llgpl",
895 "llvm-exception",
896 "lzma-exception",
897 "mif-exception",
898 "mxml-exception",
899 "nokia-qt-exception-1.1",
900 "ocaml-lgpl-linking-exception",
901 "occt-exception-1.0",
902 "openjdk-assembly-exception-1.0",
903 "openvpn-openssl-exception",
904 "pcre2-exception",
905 "polyparse-exception",
906 "ps-or-pdf-font-exception-20170817",
907 "qpl-1.0-inria-2004-exception",
908 "qt-gpl-exception-1.0",
909 "qt-lgpl-exception-1.1",
910 "qwt-exception-1.0",
911 "romic-exception",
912 "rrdtool-floss-exception-2.0",
913 "rsync-linking-exception",
914 "sane-exception",
915 "shl-2.0",
916 "shl-2.1",
917 "simple-library-usage-exception",
918 "spelling-provider-lgpl-exception",
919 "sqlitestudio-openssl-exception",
920 "stunnel-exception",
921 "swi-exception",
922 "swift-exception",
923 "texinfo-exception",
924 "u-boot-exception-2.0",
925 "ubdl-exception",
926 "universal-foss-exception-1.0",
927 "vsftpd-openssl-exception",
928 "wxwindows-exception-3.1",
929 "x11vnc-openssl-exception",
930];
931
932fn listed(list: &[&str], identifier: &str) -> bool {
939 let lowered = identifier.to_ascii_lowercase();
940 list.contains(&lowered.as_str())
941}
942
943#[derive(Debug, Clone, Copy, PartialEq, Eq)]
945enum Token<'a> {
946 Open,
948 Close,
950 And,
952 Or,
954 With,
957 Identifier(&'a str),
960}
961
962fn tokenize(expression: &str) -> Option<Vec<Token<'_>>> {
970 let mut tokens = Vec::new();
971 let bytes = expression.as_bytes();
972 let mut at = 0;
973 while at < bytes.len() {
974 let byte = bytes[at];
975 if byte.is_ascii_whitespace() {
976 at += 1;
977 continue;
978 }
979 if byte == b'(' {
980 tokens.push(Token::Open);
981 at += 1;
982 continue;
983 }
984 if byte == b')' {
985 tokens.push(Token::Close);
986 at += 1;
987 continue;
988 }
989 let start = at;
990 while at < bytes.len() {
991 let byte = bytes[at];
992 if byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'+' | b':') {
993 at += 1;
994 } else {
995 break;
996 }
997 }
998 if at == start {
999 return None;
1000 }
1001 let word = &expression[start..at];
1002 tokens.push(match word {
1003 "AND" => Token::And,
1004 "OR" => Token::Or,
1005 "WITH" => Token::With,
1006 other => Token::Identifier(other),
1007 });
1008 }
1009 Some(tokens)
1010}
1011
1012#[derive(Debug, Clone, Copy)]
1019struct Verdict {
1020 grants: bool,
1022 clean: bool,
1024}
1025
1026struct Spdx<'a> {
1035 tokens: &'a [Token<'a>],
1036 at: usize,
1037}
1038
1039impl<'a> Spdx<'a> {
1040 fn peek(&self) -> Option<Token<'a>> {
1042 self.tokens.get(self.at).copied()
1043 }
1044
1045 fn bump(&mut self) -> Option<Token<'a>> {
1047 let token = self.peek()?;
1048 self.at += 1;
1049 Some(token)
1050 }
1051
1052 fn expression(&mut self) -> Option<Verdict> {
1062 let mut verdict = self.conjunction()?;
1063 while self.peek() == Some(Token::Or) {
1064 self.bump();
1065 let right = self.conjunction()?;
1066 verdict = Verdict {
1067 grants: verdict.grants && right.grants,
1068 clean: verdict.clean && right.clean,
1069 };
1070 }
1071 Some(verdict)
1072 }
1073
1074 fn conjunction(&mut self) -> Option<Verdict> {
1084 let mut verdict = self.operand()?;
1085 while self.peek() == Some(Token::And) {
1086 self.bump();
1087 let right = self.operand()?;
1088 verdict = Verdict {
1089 grants: verdict.grants || right.grants,
1090 clean: verdict.clean && right.clean,
1091 };
1092 }
1093 Some(verdict)
1094 }
1095
1096 fn operand(&mut self) -> Option<Verdict> {
1106 match self.bump()? {
1107 Token::Open => {
1108 let inner = self.expression()?;
1111 (self.bump()? == Token::Close).then_some(inner)
1112 }
1113 Token::Identifier(name) => {
1114 let identifier = name.strip_suffix('+').unwrap_or(name);
1115 let approved = listed(&OSI_APPROVED, identifier);
1116 let verdict = Verdict {
1117 grants: approved,
1118 clean: approved || listed(&CONTENT_LICENCES, identifier),
1119 };
1120 if self.peek() == Some(Token::With) {
1121 self.bump();
1122 match self.bump()? {
1126 Token::Identifier(exception) if listed(&SPDX_EXCEPTIONS, exception) => {}
1127 _ => return None,
1128 }
1129 }
1130 Some(verdict)
1131 }
1132 Token::And | Token::Or | Token::With | Token::Close => None,
1133 }
1134 }
1135}
1136
1137#[must_use]
1153pub fn licence_states_an_open_source_codebase(expression: &str) -> bool {
1154 let Some(tokens) = tokenize(expression) else {
1155 return false;
1156 };
1157 let mut reader = Spdx {
1158 tokens: &tokens,
1159 at: 0,
1160 };
1161 let Some(verdict) = reader.expression() else {
1162 return false;
1163 };
1164 verdict.grants && verdict.clean && reader.at == tokens.len()
1165}
1166
1167#[must_use]
1181pub fn code_scanning_licence_refusal(
1182 provider: Option<Provider>,
1183 driver: Option<&str>,
1184 shape: &CrateShape,
1185) -> Option<String> {
1186 if provider != Some(Provider::CodeQl) {
1187 return None;
1188 }
1189 if driver != Some("rust") {
1190 return Some(format!(
1191 "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",
1192 driver.unwrap_or("release-less")
1193 ));
1194 }
1195 let fallback = "land --code-scanning semgrep, which carries no licence condition";
1196 let Some(text) = shape.cargo_toml.as_deref() else {
1197 return Some(format!(
1198 "the target has no readable Cargo.toml, so the licence codeql's terms depend on cannot be read; {fallback}"
1199 ));
1200 };
1201 let Ok(table) = text.parse::<toml::Table>() else {
1202 return Some(format!(
1203 "the target's Cargo.toml does not parse, so the licence codeql's terms depend on cannot be read; {fallback}"
1204 ));
1205 };
1206 let licence = table
1207 .get("package")
1208 .and_then(toml::Value::as_table)
1209 .and_then(|package| package.get("license"))
1210 .and_then(toml::Value::as_str);
1211 match licence {
1212 None => Some(format!(
1213 "the target's Cargo.toml declares no license field, and codeql's terms cover an open-source codebase alone; declare one, or {fallback}"
1214 )),
1215 Some(expression) if !licence_states_an_open_source_codebase(expression) => Some(format!(
1216 "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}"
1217 )),
1218 Some(_) => None,
1219 }
1220}
1221
1222#[must_use]
1225pub fn kind_of(destination: &str) -> Option<Kind> {
1226 if BLOCK_DESTINATIONS.contains(&destination) {
1227 return Some(Kind::Rendered);
1228 }
1229 KINDS
1230 .iter()
1231 .find(|(name, _)| *name == destination)
1232 .map(|(_, kind)| *kind)
1233}
1234
1235pub fn destinations() -> impl Iterator<Item = &'static str> {
1240 KINDS
1241 .iter()
1242 .map(|(name, _)| *name)
1243 .chain(BLOCK_DESTINATIONS)
1244}
1245
1246pub const OWNER_TOKEN: &[u8] = b"OWNER";
1253
1254pub const REPO_PLACEHOLDER: &str = "OWNER";
1259
1260pub const REPO_TOKEN: &[u8] = b"RK_REPO";
1262
1263pub const SCOPE_SHAPE_TOKEN: &[u8] = b"RK_SCOPE_SHAPE";
1265
1266pub const IF_RELEASE_TOKEN: &[u8] = b"RK_IF_RELEASE_";
1271
1272pub const IF_NO_RELEASE_TOKEN: &[u8] = b"RK_IF_NO_RELEASE_";
1275
1276pub const IF_REGISTRY_TOKEN: &[u8] = b" # RK_IF_REGISTRY";
1284
1285pub const IF_NO_REGISTRY_TOKEN: &[u8] = b"# RK_IF_NO_REGISTRY ";
1291
1292#[must_use]
1298pub fn registry_lines(baseline: &[u8], params: &Params) -> Vec<u8> {
1299 if find(baseline, IF_REGISTRY_TOKEN).is_none() && find(baseline, IF_NO_REGISTRY_TOKEN).is_none()
1300 {
1301 return baseline.to_vec();
1302 }
1303 let publishes = params.registry() != Some(crate::profile::RegistryAnswer::None);
1304 let mut out = Vec::with_capacity(baseline.len());
1305 for line in baseline.split_inclusive(|byte| *byte == b'\n') {
1306 let (token, wanted) = if find(line, IF_NO_REGISTRY_TOKEN).is_some() {
1307 (IF_NO_REGISTRY_TOKEN, !publishes)
1308 } else if find(line, IF_REGISTRY_TOKEN).is_some() {
1309 (IF_REGISTRY_TOKEN, publishes)
1310 } else {
1311 out.extend_from_slice(line);
1312 continue;
1313 };
1314 if wanted {
1315 out.extend(substitute(line, token, b""));
1316 }
1317 }
1318 out
1319}
1320
1321pub const SCOPED_TOKEN: &[u8] = b"RK_SCOPED";
1325
1326pub const SCOPE_OPEN_TOKEN: &[u8] = b"RK_SCOPE_OPEN";
1332
1333pub const SCOPE_CLOSE_TOKEN: &[u8] = b"RK_SCOPE_CLOSE";
1335
1336fn release_lines(baseline: &[u8], release: bool) -> Vec<u8> {
1341 if find(baseline, IF_RELEASE_TOKEN).is_none() && find(baseline, IF_NO_RELEASE_TOKEN).is_none() {
1342 return baseline.to_vec();
1343 }
1344 let mut out = Vec::with_capacity(baseline.len());
1345 for line in baseline.split_inclusive(|byte| *byte == b'\n') {
1346 let (token, wanted) = if find(line, IF_NO_RELEASE_TOKEN).is_some() {
1349 (IF_NO_RELEASE_TOKEN, !release)
1350 } else if find(line, IF_RELEASE_TOKEN).is_some() {
1351 (IF_RELEASE_TOKEN, release)
1352 } else {
1353 out.extend_from_slice(line);
1354 continue;
1355 };
1356 if wanted {
1357 out.extend(substitute(line, token, b""));
1358 }
1359 }
1360 out
1361}
1362
1363pub const GITLAB_GATE_FRAGMENT_TOKEN: &[u8] = b"RK_GITLAB_GATE_FRAGMENT";
1366
1367pub const MERGE_FLAG_TOKEN: &[u8] = b"RK_MERGE_FLAG";
1370
1371pub const SQUASH_ON_MERGE_TOKEN: &[u8] = b"RK_SQUASH_ON_MERGE";
1375
1376pub const STYLE_TOKEN: &[u8] = b"RK_STYLE";
1379
1380pub const BUILD_PACKAGES_TOKEN: &[u8] = b"RK_BUILD_PACKAGES";
1383
1384pub const IF_UV_UNPINNED_TOKEN: &[u8] = b" # RK_IF_UV_UNPINNED";
1390
1391pub const IF_UV_REQUIRED_TOKEN: &[u8] = b"# RK_IF_UV_REQUIRED ";
1394
1395pub const UV_REQUIRED_TOKEN: &[u8] = b"RK_UV_REQUIRED";
1398
1399#[must_use]
1406pub fn uv_lines(baseline: &[u8], shape: &PythonShape) -> Vec<u8> {
1407 if find(baseline, IF_UV_UNPINNED_TOKEN).is_none()
1408 && find(baseline, IF_UV_REQUIRED_TOKEN).is_none()
1409 {
1410 return baseline.to_vec();
1411 }
1412 let required = shape.uv_required();
1413 let mut out = Vec::with_capacity(baseline.len());
1414 for line in baseline.split_inclusive(|byte| *byte == b'\n') {
1415 let (token, wanted) = if find(line, IF_UV_REQUIRED_TOKEN).is_some() {
1416 (IF_UV_REQUIRED_TOKEN, required.is_some())
1417 } else if find(line, IF_UV_UNPINNED_TOKEN).is_some() {
1418 (IF_UV_UNPINNED_TOKEN, required.is_none())
1419 } else {
1420 out.extend_from_slice(line);
1421 continue;
1422 };
1423 if wanted {
1424 out.extend(substitute(line, token, b""));
1425 }
1426 }
1427 substitute(
1428 &out,
1429 UV_REQUIRED_TOKEN,
1430 required.unwrap_or_default().as_bytes(),
1431 )
1432}
1433
1434pub const TRUNK_BRANCH_TOKEN: &[u8] = b"RK_TRUNK_BRANCH";
1438
1439pub const LINE_PREFIX_TOKEN: &[u8] = b"RK_LINE_PREFIX";
1442
1443pub const LINE_PREFIX_RE_TOKEN: &[u8] = b"RK_LINE_PREFIX_RE";
1449
1450pub const REQUIRED_CHECK_TOKEN: &[u8] = b"RK_REQUIRED_CHECK";
1452
1453pub const REQUIRED_WORKFLOW_TOKEN: &[u8] = b"RK_REQUIRED_WORKFLOW";
1455
1456pub const BRANCH_SHAPE_TOKEN: &[u8] = b"RK_BRANCH_SHAPE";
1458
1459pub const REQUEST_JOB_TOKEN: &[u8] = b"RK_REQUEST_JOB";
1461
1462pub const RELEASE_GATE_TRIGGER: &str = "blocks/release-gate-trigger.yaml.in";
1464
1465pub const RELEASE_GATE_JOB: &str = "blocks/release-gate-job.yaml.in";
1467
1468#[must_use]
1475pub fn release_driver_shape(driver: Option<&str>) -> Option<(&'static str, &'static str)> {
1476 match driver {
1477 Some("rust") => Some(("release-plz-", "release-plz-pr")),
1480 Some("bash" | "python") => Some((crate::release_engine::REQUEST_PREFIX, "release-request")),
1482 _ => None,
1483 }
1484}
1485
1486#[must_use]
1497pub const fn release_gate_spans() -> [(&'static [u8], &'static [u8]); 3] {
1498 [
1501 (b"\n# RK_GATE_TRIGGER_BEGIN", b"\n# RK_GATE_TRIGGER_END"),
1502 (b"\n# RK_GATE_ARM_BEGIN", b"\n# RK_GATE_ARM_END"),
1503 (b"\n# RK_GATE_JOB_BEGIN", b"\n# RK_GATE_JOB_END"),
1504 ]
1505}
1506
1507#[must_use]
1515fn release_gates(params: &Params) -> bool {
1516 params.forge() == Some("github")
1517 && params.integration() == crate::landing::Integration::Local
1518 && params.release_mode() == ReleaseMode::Automatic
1519 && params.style() == Some(crate::landing::Style::Trunk)
1520 && !params.required_check().is_empty()
1521 && !params.required_workflow().is_empty()
1522 && release_driver_shape(params.driver()).is_some()
1523}
1524
1525fn release_gate_replacements(params: &Params) -> Result<[Option<Vec<u8>>; 3], RkError> {
1537 if !release_gates(params) {
1538 return Ok([None, None, None]);
1539 }
1540 let opened = |block: &str| -> Result<Vec<u8>, RkError> {
1543 let text = embedded_block(block)?;
1544 Ok(substitute_tokens(
1545 format!("\n{}", text.trim_end_matches('\n')).as_bytes(),
1546 params,
1547 ))
1548 };
1549 Ok([
1550 Some(opened(RELEASE_GATE_TRIGGER)?),
1551 Some(Vec::new()),
1554 Some(opened(RELEASE_GATE_JOB)?),
1555 ])
1556}
1557
1558pub const DEV_RUN_TOKEN: &[u8] = b"RK_DEV_RUN ";
1565
1566pub const DEV_SETUP_STEPS_TOKEN: &[u8] = b"RK_DEV_SETUP_STEPS";
1569
1570pub const DEV_GIT_USER_TOKEN: &[u8] = b"RK_DEV_GIT_USER";
1572
1573pub const DEV_ENVIRONMENT_RELEASE: &str = "blocks/dev-environment-release.yaml.in";
1575
1576pub const DEV_ENVIRONMENT_REQUEST: &str = "blocks/dev-environment-request.yaml.in";
1578
1579pub const DEV_ENVIRONMENT_CANDIDATE: &str = "blocks/dev-environment-candidate.yaml.in";
1582
1583pub const SELF_VERSION_TOKEN: &[u8] = b"RK_SELF_VERSION";
1587
1588pub const SELF_RELEASES_TOKEN: &[u8] = b"RK_SELF_RELEASES";
1592
1593pub const DEV_ENVIRONMENT_GIT_USER: &str = "blocks/dev-environment-git-user.yaml.in";
1596
1597pub const DEV_ENVIRONMENT_ENGINE: &str = "blocks/dev-environment-engine.yaml.in";
1601
1602pub const DEV_ENVIRONMENT_ENGINE_CANDIDATE: &str =
1606 "blocks/dev-environment-engine-candidate.yaml.in";
1607
1608pub const DEV_ENVIRONMENT_SPANS: [(&[u8], &[u8]); 6] = [
1623 (
1624 b"\n# RK_DEV_ENV_RELEASE_BEGIN",
1625 b"\n# RK_DEV_ENV_RELEASE_END",
1626 ),
1627 (
1628 b"\n# RK_DEV_ENV_REQUEST_BEGIN",
1629 b"\n# RK_DEV_ENV_REQUEST_END",
1630 ),
1631 (b"\n# RK_DEV_ENV_IMAGE_BEGIN", b"\n# RK_DEV_ENV_IMAGE_END"),
1632 (b"\n# RK_DEV_ENV_SCRIPT_BEGIN", b"\n# RK_DEV_ENV_SCRIPT_END"),
1633 (
1634 b"\n# RK_DEV_ENV_CANDIDATE_BEGIN",
1635 b"\n# RK_DEV_ENV_CANDIDATE_END",
1636 ),
1637 (b"\n# RK_DEV_ENV_RK_BEGIN", b"\n# RK_DEV_ENV_RK_END"),
1638];
1639
1640fn action_step_lines(step: &crate::config::dev_environment::ActionStep) -> String {
1643 let mut lines = format!(" - uses: {}", step.uses);
1644 if !step.tag.is_empty() {
1645 lines.push_str(" # ");
1646 lines.push_str(&step.tag);
1647 }
1648 lines.push('\n');
1649 if !step.inputs.is_empty() {
1650 lines.push_str(" with:\n");
1651 for (name, value) in &step.inputs {
1652 let _ = writeln!(lines, " {name}: {}", value.yaml());
1653 }
1654 }
1655 lines
1656}
1657
1658fn dev_environment_replacements(params: &Params) -> Result<[Option<Vec<u8>>; 6], RkError> {
1675 let Some(answer) = params.dev_environment() else {
1676 return Ok([None, None, None, None, None, None]);
1677 };
1678 let git_user = embedded_block(DEV_ENVIRONMENT_GIT_USER)?.trim_end_matches('\n');
1679 let half = |block: &str, steps: &[&crate::config::dev_environment::ActionStep]| {
1680 let text = embedded_block(block)?.trim_end_matches('\n');
1681 let text = substitute(text.as_bytes(), DEV_GIT_USER_TOKEN, git_user.as_bytes());
1682 let text = substitute_tokens(&text, params);
1683 let lines: String = steps.iter().map(|step| action_step_lines(step)).collect();
1684 let mut out = b"\n".to_vec();
1685 out.extend(substitute(&text, DEV_SETUP_STEPS_TOKEN, lines.as_bytes()));
1686 Ok::<_, RkError>(out)
1687 };
1688 let release: Vec<_> = answer.github.setup.iter().collect();
1689 let request: Vec<_> = answer
1690 .github
1691 .setup
1692 .iter()
1693 .chain(&answer.github.cache)
1694 .collect();
1695 let image = (!answer.gitlab.image.is_empty())
1696 .then(|| format!("\n image: {}", answer.gitlab.image).into_bytes());
1697 let mut items = String::new();
1698 for line in &answer.gitlab.before_script {
1699 let _ = write!(items, "\n - {}", serde_json::Value::String(line.clone()));
1700 }
1701 if matches!(params.driver(), Some("bash" | "python")) {
1702 return Ok([
1712 Some(half(DEV_ENVIRONMENT_ENGINE, &release)?),
1713 Some(half(DEV_ENVIRONMENT_ENGINE, &request)?),
1714 image,
1715 Some(items.into_bytes()),
1716 Some(half(DEV_ENVIRONMENT_ENGINE_CANDIDATE, &release)?),
1717 Some(Vec::new()),
1718 ]);
1719 }
1720 let script = if items.is_empty() {
1721 Vec::new()
1722 } else {
1723 format!("\n before_script:{items}").into_bytes()
1724 };
1725 Ok([
1726 Some(half(DEV_ENVIRONMENT_RELEASE, &release)?),
1727 Some(half(DEV_ENVIRONMENT_REQUEST, &request)?),
1728 image,
1729 Some(script),
1730 Some(half(DEV_ENVIRONMENT_CANDIDATE, &release)?),
1733 Some(Vec::new()),
1734 ])
1735}
1736
1737impl Projection {
1738 #[must_use]
1746 pub fn accepted_drift_fault(&self, destination: &str) -> Option<String> {
1747 let Some(candidate) = self
1748 .candidates
1749 .iter()
1750 .find(|candidate| candidate.destination == destination)
1751 else {
1752 return Some(self.omissions.iter().find(|omission| omission.destination == destination).map_or_else(
1753 || format!("landing.accepted_drift names {destination}, which this landing does not select"),
1754 |omission| format!("landing.accepted_drift names {destination}, which this landing omits: {}", omission.reason),
1755 ));
1756 };
1757 if candidate.kind != Kind::Rendered {
1758 return Some(format!(
1759 "landing.accepted_drift names {destination}, a {} file, which may drift freely already",
1760 candidate.kind.as_str()
1761 ));
1762 }
1763 if matches!(candidate.placement, Placement::Region { .. }) {
1764 return Some(format!(
1765 "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"
1766 ));
1767 }
1768 None
1769 }
1770}
1771
1772pub const SECURITY_SPANS: [(&[u8], &[u8]); 4] = [
1787 (
1788 b"<!--RK_SECURITY_CONTACT_BEGIN-->",
1789 b"<!--RK_SECURITY_CONTACT_END-->",
1790 ),
1791 (
1792 b"<!--RK_SECURITY_RESPONSE_BEGIN-->",
1793 b"<!--RK_SECURITY_RESPONSE_END-->",
1794 ),
1795 (
1796 b"<!--RK_SECURITY_DEADLINE_BEGIN-->",
1797 b"<!--RK_SECURITY_DEADLINE_END-->",
1798 ),
1799 (
1800 b"<!--RK_SECURITY_SCOPE_BEGIN-->",
1801 b"<!--RK_SECURITY_SCOPE_END-->",
1802 ),
1803];
1804
1805fn acknowledgment(response: &str) -> String {
1808 format!("Maintainers acknowledge a report within {response}.")
1809}
1810
1811const DISCLOSURE_ONLY: &[u8] = b"This policy commits to no disclosure deadline.";
1815
1816fn security_replacements(params: &Params) -> [Option<Vec<u8>>; 4] {
1819 let contact = (!params.security_contact().is_empty())
1820 .then(|| params.security_contact().as_bytes().to_vec());
1821 let promised = params.security_response() != crate::config::RESPONSE_DEFAULT;
1822 [
1823 contact,
1824 promised.then(|| acknowledgment(params.security_response()).into_bytes()),
1825 promised.then(|| DISCLOSURE_ONLY.to_vec()),
1826 (!params.security_scope().is_empty()).then(|| {
1829 ["\n".repeat(2).as_str(), params.security_scope()]
1830 .concat()
1831 .into_bytes()
1832 }),
1833 ]
1834}
1835
1836fn replace_span(baseline: &[u8], begin: &[u8], end: &[u8], value: Option<&[u8]>) -> Vec<u8> {
1842 let ordered = find(baseline, begin)
1843 .zip(find(baseline, end))
1844 .filter(|(start, stop)| stop > start);
1845 let Some((start, stop)) = ordered else {
1846 return baseline.to_vec();
1847 };
1848 let mut out = Vec::with_capacity(baseline.len());
1849 out.extend_from_slice(&baseline[..start]);
1850 out.extend_from_slice(value.unwrap_or_else(|| &baseline[start + begin.len()..stop]));
1851 out.extend_from_slice(&baseline[stop + end.len()..]);
1852 out
1853}
1854
1855#[must_use]
1873pub fn render(baseline: &[u8], params: &Params) -> Vec<u8> {
1874 let mut out = substitute_tokens(baseline, params);
1875 if let Ok(values) = release_gate_replacements(params) {
1881 for ((begin, end), value) in release_gate_spans().iter().zip(values) {
1882 out = replace_span(&out, begin, end, value.as_deref());
1883 }
1884 }
1885 let environment = dev_environment_replacements(params).unwrap_or_default();
1888 for ((begin, end), value) in DEV_ENVIRONMENT_SPANS.iter().zip(environment) {
1889 out = replace_span(&out, begin, end, value.as_deref());
1890 }
1891 for ((begin, end), value) in SECURITY_SPANS.iter().zip(security_replacements(params)) {
1892 out = replace_span(&out, begin, end, value.as_deref());
1893 }
1894 out
1895}
1896
1897#[must_use]
1902fn substitute_tokens(baseline: &[u8], params: &Params) -> Vec<u8> {
1903 let repo = params.repo();
1904 let owner = repo.split('/').next().unwrap_or(repo);
1905 let release = params.release_mode() != ReleaseMode::None;
1909 let mut out = release_lines(baseline, release);
1910 out = registry_lines(&out, params);
1911 let (scoped, open, close): (&[u8], &[u8], &[u8]) = if release {
1912 (b"scoped ", b"", b"")
1913 } else {
1914 (b"", b"(", b")?")
1915 };
1916 out = substitute(&out, SCOPED_TOKEN, scoped);
1917 out = substitute(&out, SCOPE_OPEN_TOKEN, open);
1918 out = substitute(&out, SCOPE_CLOSE_TOKEN, close);
1919 out = substitute(&out, OWNER_TOKEN, owner.as_bytes());
1920 if let Some(style) = params.style() {
1921 out = substitute(&out, STYLE_TOKEN, style.as_str().as_bytes());
1922 }
1923 out = substitute(&out, SCOPE_SHAPE_TOKEN, SCOPE_SHAPE.as_bytes());
1924 let (fragment, flag, squash): (&str, &[u8], &[u8]) = match params.merge_form() {
1928 crate::landing::MergeForm::Squash => (GITLAB_TITLE_FRAGMENT, b"--squash", b"true"),
1929 crate::landing::MergeForm::Rebase => (GITLAB_COMMIT_FRAGMENT, b"--rebase", b"false"),
1930 };
1931 out = substitute(&out, GITLAB_GATE_FRAGMENT_TOKEN, fragment.as_bytes());
1932 out = substitute(&out, MERGE_FLAG_TOKEN, flag);
1933 out = substitute(&out, SQUASH_ON_MERGE_TOKEN, squash);
1934 let (shape, job) = release_driver_shape(params.driver()).unwrap_or_default();
1939 out = substitute(&out, BRANCH_SHAPE_TOKEN, shape.as_bytes());
1940 out = substitute(&out, REQUEST_JOB_TOKEN, job.as_bytes());
1941 out = substitute(&out, TRUNK_BRANCH_TOKEN, params.trunk().as_bytes());
1942 let escaped = params.line_prefix().replace('/', "\\/");
1943 out = substitute(&out, LINE_PREFIX_RE_TOKEN, escaped.as_bytes());
1944 out = substitute(&out, LINE_PREFIX_TOKEN, params.line_prefix().as_bytes());
1945 let required_check = serde_json::Value::String(params.required_check().to_owned()).to_string();
1949 let required_workflow =
1950 serde_json::Value::String(params.required_workflow().to_owned()).to_string();
1951 out = substitute(&out, REQUIRED_CHECK_TOKEN, required_check.as_bytes());
1952 out = substitute(&out, REQUIRED_WORKFLOW_TOKEN, required_workflow.as_bytes());
1953 out = substitute(
1954 &out,
1955 SELF_VERSION_TOKEN,
1956 env!("CARGO_PKG_VERSION").as_bytes(),
1957 );
1958 let releases = format!("{}/releases/download", env!("CARGO_PKG_REPOSITORY"));
1959 out = substitute(&out, SELF_RELEASES_TOKEN, releases.as_bytes());
1960 out = substitute(&out, REPO_TOKEN, repo.as_bytes());
1961 let run = params
1964 .dev_environment()
1965 .map(|answer| format!("{} ", answer.run))
1966 .unwrap_or_default();
1967 out = substitute(&out, DEV_RUN_TOKEN, run.as_bytes());
1968 out = substitute(
1972 &out,
1973 BUILD_PACKAGES_TOKEN,
1974 params.build_packages().as_bytes(),
1975 );
1976 out
1977}
1978
1979#[must_use]
1981pub fn substitute(baseline: &[u8], token: &[u8], value: &[u8]) -> Vec<u8> {
1982 let mut out = Vec::with_capacity(baseline.len());
1983 let mut rest = baseline;
1984 while let Some(at) = find(rest, token) {
1985 out.extend_from_slice(&rest[..at]);
1986 out.extend_from_slice(value);
1987 rest = &rest[at + token.len()..];
1988 }
1989 out.extend_from_slice(rest);
1990 out
1991}
1992
1993fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
1995 haystack
1996 .windows(needle.len())
1997 .position(|window| window == needle)
1998}
1999
2000pub const AGENTS_DESTINATION: &str = "AGENTS.md";
2002
2003pub const BLOCK_BEGIN: &str = "<!-- BEGIN release-kit -->";
2005
2006pub const BLOCK_END: &str = "<!-- END release-kit -->";
2008
2009pub const GLOSSARY_DESTINATION: &str = "GLOSSARY.md";
2014
2015pub const HOOKS_DESTINATION: &str = ".pre-commit-config.yaml";
2017
2018pub const BLOCK_DESTINATIONS: [&str; 3] =
2024 [AGENTS_DESTINATION, GLOSSARY_DESTINATION, HOOKS_DESTINATION];
2025
2026pub const HOOKS_BEGIN: &str = "# BEGIN release-kit";
2028
2029pub const HOOKS_END: &str = "# END release-kit";
2031
2032pub const HOOK_TYPES_LINE: &str = "default_install_hook_types: [pre-commit, commit-msg, pre-push]";
2036
2037pub const AGENTS_BLOCK: &str = "blocks/agents-block.md.in";
2039
2040pub const GLOSSARY_BLOCK: &str = "blocks/glossary.md.in";
2042
2043pub const AGENTS_LINE_AGENT_POLICY: &str = "blocks/agents-line-agent-policy.md.in";
2045
2046pub const AGENTS_LINE_COMMIT_POLICY: &str = "blocks/agents-line-commit-policy.md.in";
2050
2051pub const AGENTS_BLOCK_WORKFLOW: &str = "blocks/agents-block-workflow.md.in";
2054
2055pub const AGENTS_LINE_AGENT_POLICY_WORKFLOW: &str =
2058 "blocks/agents-line-agent-policy-workflow.md.in";
2059
2060pub const AGENTS_LINE_COMMIT_SOURCE_SQUASH: &str = "blocks/agents-line-commit-source-squash.md.in";
2063
2064pub const AGENTS_LINE_COMMIT_SOURCE_REBASE: &str = "blocks/agents-line-commit-source-rebase.md.in";
2067
2068pub const AGENTS_LINE_COMMIT_SCOPE_REQUIRED: &str =
2071 "blocks/agents-line-commit-scope-required.md.in";
2072
2073pub const AGENTS_LINE_COMMIT_SCOPE_PREFERRED: &str =
2075 "blocks/agents-line-commit-scope-preferred.md.in";
2076
2077pub const GLOSSARY_BLOCK_WORKFLOW: &str = "blocks/glossary-workflow.md.in";
2079
2080pub const PRE_COMMIT_BLOCK: &str = "blocks/pre-commit-block.yaml.in";
2082
2083pub const PRE_COMMIT_MESSAGE_GUARDS: &str = "blocks/pre-commit-message-guards.yaml.in";
2085
2086pub const PRE_COMMIT_BRANCH_NAME_GUARD: &str = "blocks/pre-commit-branch-name-guard.yaml.in";
2088
2089pub const PRE_COMMIT_WORKTREE_GUARD: &str = "blocks/pre-commit-worktree-guard.yaml.in";
2091
2092pub const PRE_COMMIT_TRUNK_COMMIT_GUARD: &str = "blocks/pre-commit-trunk-commit-guard.yaml.in";
2094
2095pub const PRE_COMMIT_TRUNK_PUSH_GUARD: &str = "blocks/pre-commit-trunk-push-guard.yaml.in";
2097
2098pub const PRE_COMMIT_SWEEP_SKIP: &str = "blocks/pre-commit-sweep-skip.yaml.in";
2100
2101pub const PRE_COMMIT_SWEEP_NONE: &str = "blocks/pre-commit-sweep-none.yaml.in";
2103
2104pub const PRE_COMMIT_INTEGRATE_NOTE: &str = "blocks/pre-commit-integrate-note.yaml.in";
2106
2107pub const PRE_COMMIT_TAG_GUARD: &str = "blocks/pre-commit-tag-guard.yaml.in";
2109
2110pub const PRE_COMMIT_STATUS_RELEASE_FILES: &str = "blocks/pre-commit-status-release-files.yaml.in";
2116
2117pub const PRE_COMMIT_LOCAL_TRUNK_COMMIT_GUARD: &str =
2120 "blocks/pre-commit-local-trunk-commit-guard.yaml.in";
2121
2122pub const PRE_COMMIT_LOCAL_TRUNK_PUSH_GUARDS: &str =
2125 "blocks/pre-commit-local-trunk-push-guards.yaml.in";
2126
2127pub const FORCE_SCOPE_ARG: &str = ", --force-scope";
2133
2134pub fn embedded_block(path: &str) -> Result<&'static str, RkError> {
2141 let name = path.strip_prefix("blocks/").unwrap_or(path);
2142 let file = embedded::BLOCKS
2143 .get_file(name)
2144 .ok_or_else(|| anyhow::anyhow!("{path}: this binary embeds no such block"))?;
2145 std::str::from_utf8(file.contents())
2146 .map_err(|_| anyhow::anyhow!("{path}: a block is UTF-8").into())
2147}
2148
2149#[must_use]
2153pub fn authored(text: &str) -> &str {
2154 text.strip_suffix('\n').unwrap_or(text)
2155}
2156
2157pub 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/.+)$";
2165
2166pub const SCOPE_SHAPE: &str = "[a-z0-9._/-]+";
2176
2177#[must_use]
2184pub fn scope_is_shaped(scope: &str) -> bool {
2185 !scope.is_empty()
2186 && scope.chars().all(|c| {
2187 c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '/' | '-')
2188 })
2189}
2190
2191pub struct RoutingGuards<'a> {
2193 pub agent_policy: &'a str,
2195 pub commit_policy: &'a str,
2198 pub commit_source: &'a str,
2200 pub commit_scope: &'a str,
2202}
2203
2204#[must_use]
2211pub fn compose_routing(template: &str, guards: Option<&RoutingGuards<'_>>) -> String {
2212 let fragment = |text: &str| format!("{}\n", authored(text));
2213 let (agent, commit) = guards.map_or_else(
2214 || (String::new(), String::new()),
2215 |guards| {
2216 (
2217 fragment(guards.agent_policy),
2218 fragment(
2219 &authored(guards.commit_policy)
2220 .replacen("RK_COMMIT_SOURCE", &fragment(guards.commit_source), 1)
2221 .replacen("RK_COMMIT_SCOPE", &fragment(guards.commit_scope), 1),
2222 ),
2223 )
2224 },
2225 );
2226 authored(template)
2227 .replacen("RK_AGENT_POLICY", &agent, 1)
2228 .replacen("RK_COMMIT_POLICY", &commit, 1)
2229}
2230
2231#[must_use]
2235pub fn compose_glossary(template: &str) -> String {
2236 authored(template).to_owned()
2237}
2238
2239pub struct WorkflowHooks<'a> {
2241 pub message: &'a str,
2243 pub branch_name: &'a str,
2245 pub worktree: Option<&'a str>,
2250}
2251
2252#[must_use]
2272pub fn compose_hooks(
2273 template: &str,
2274 workflow: Option<&WorkflowHooks<'_>>,
2275 parts: &TrunkGuards,
2276) -> String {
2277 let fragment = |text: &str| format!("{}\n", authored(text));
2278 let worktree = workflow
2279 .and_then(|hooks| hooks.worktree)
2280 .map_or_else(String::new, fragment);
2281 let trunk_guards = workflow.is_some();
2282 let mut skips: Vec<&str> = Vec::new();
2283 if trunk_guards {
2284 skips.push("no-commit-to-branch");
2285 }
2286 if !worktree.is_empty() {
2287 skips.push("rk-worktree-location");
2288 }
2289 let sweep = if skips.is_empty() {
2290 fragment(parts.sweep_none)
2291 } else {
2292 fragment(&authored(parts.sweep_skip).replacen("RK_SWEEP_SKIP", &skips.join(","), 1))
2293 };
2294 let (commit_guard, push_guard) = match (trunk_guards, parts.integration) {
2295 (false, _) => (String::new(), String::new()),
2296 (true, Integration::Forge) => (fragment(parts.trunk_commit), fragment(parts.trunk_push)),
2297 (true, Integration::Local) => (
2298 fragment(parts.local_trunk_commit),
2299 fragment(parts.local_trunk_push),
2300 ),
2301 };
2302 let (tag_guard, release_files) = if parts.release {
2303 (fragment(parts.tag_guard), authored(parts.release_files))
2304 } else {
2305 (String::new(), "")
2306 };
2307 let note = match parts.integration {
2308 Integration::Forge => String::new(),
2309 Integration::Local => fragment(parts.integrate_note),
2310 };
2311 let (message, branch_name) = workflow.map_or_else(
2312 || (String::new(), String::new()),
2313 |hooks| {
2314 let message = fragment(hooks.message);
2315 (
2316 if parts.release {
2317 message
2318 } else {
2319 message.replacen(FORCE_SCOPE_ARG, "", 1)
2320 },
2321 fragment(hooks.branch_name),
2322 )
2323 },
2324 );
2325 authored(template)
2326 .replacen("RK_SWEEP_NOTE", &format!("{sweep}{note}"), 1)
2327 .replacen("RK_MESSAGE_GUARDS", &message, 1)
2328 .replacen("RK_TRUNK_COMMIT_GUARD", &commit_guard, 1)
2329 .replacen("RK_BRANCH_NAME_GUARD", &branch_name, 1)
2330 .replacen("RK_WORKTREE_GUARD", &worktree, 1)
2331 .replacen("RK_TRUNK_PUSH_GUARD", &push_guard, 1)
2332 .replacen("RK_TAG_GUARD", &tag_guard, 1)
2333 .replacen("RK_STATUS_RELEASE_FILES", release_files, 1)
2334 .replacen("RK_BRANCH_GRAMMAR", BRANCH_GRAMMAR, 1)
2335}
2336
2337pub struct TrunkGuards<'a> {
2340 pub integration: Integration,
2342 pub release: bool,
2345 pub trunk_commit: &'a str,
2348 pub trunk_push: &'a str,
2351 pub local_trunk_commit: &'a str,
2354 pub local_trunk_push: &'a str,
2357 pub tag_guard: &'a str,
2359 pub release_files: &'a str,
2362 pub sweep_skip: &'a str,
2364 pub sweep_none: &'a str,
2366 pub integrate_note: &'a str,
2368}
2369
2370#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2373pub struct BlockShape {
2374 pub checkout_mode: CheckoutMode,
2376 pub integration: Integration,
2378 pub merge_form: MergeForm,
2380 pub workflow_guards: bool,
2382 pub release: bool,
2384}
2385
2386impl BlockShape {
2387 #[must_use]
2389 pub fn of(params: &Params) -> Self {
2390 Self {
2391 checkout_mode: params.checkout_mode(),
2392 integration: params.integration(),
2393 merge_form: params.merge_form(),
2394 workflow_guards: params.workflow_guards(),
2395 release: params.release_mode() != ReleaseMode::None,
2396 }
2397 }
2398
2399 #[must_use]
2403 pub const fn releasing(
2404 checkout_mode: CheckoutMode,
2405 integration: Integration,
2406 workflow_guards: bool,
2407 ) -> Self {
2408 Self {
2409 checkout_mode,
2410 integration,
2411 merge_form: MergeForm::Squash,
2412 workflow_guards,
2413 release: true,
2414 }
2415 }
2416
2417 const fn routing_sources(self) -> [&'static str; 4] {
2420 let (template, agent) = if self.release {
2421 (AGENTS_BLOCK, AGENTS_LINE_AGENT_POLICY)
2422 } else {
2423 (AGENTS_BLOCK_WORKFLOW, AGENTS_LINE_AGENT_POLICY_WORKFLOW)
2424 };
2425 let source = match self.merge_form {
2426 MergeForm::Squash => AGENTS_LINE_COMMIT_SOURCE_SQUASH,
2427 MergeForm::Rebase => AGENTS_LINE_COMMIT_SOURCE_REBASE,
2428 };
2429 let scope = if self.release {
2430 AGENTS_LINE_COMMIT_SCOPE_REQUIRED
2431 } else {
2432 AGENTS_LINE_COMMIT_SCOPE_PREFERRED
2433 };
2434 [template, agent, source, scope]
2435 }
2436
2437 const fn glossary_source(self) -> &'static str {
2439 if self.release {
2440 GLOSSARY_BLOCK
2441 } else {
2442 GLOSSARY_BLOCK_WORKFLOW
2443 }
2444 }
2445}
2446
2447pub fn routing_block(workflow_guards: bool) -> Result<String, RkError> {
2454 routing_block_of(&BlockShape::releasing(
2455 CheckoutMode::LinkedWorktree,
2456 Integration::Local,
2457 workflow_guards,
2458 ))
2459}
2460
2461pub fn routing_block_of(shape: &BlockShape) -> Result<String, RkError> {
2471 let [template, agent, source, scope] = shape.routing_sources();
2472 let guards = if shape.workflow_guards {
2473 Some(RoutingGuards {
2474 agent_policy: embedded_block(agent)?,
2475 commit_policy: embedded_block(AGENTS_LINE_COMMIT_POLICY)?,
2476 commit_source: embedded_block(source)?,
2477 commit_scope: embedded_block(scope)?,
2478 })
2479 } else {
2480 None
2481 };
2482 Ok(compose_routing(embedded_block(template)?, guards.as_ref()))
2483}
2484
2485pub fn glossary_block() -> Result<String, RkError> {
2492 Ok(compose_glossary(embedded_block(GLOSSARY_BLOCK)?))
2493}
2494
2495pub fn glossary_block_of(shape: &BlockShape) -> Result<String, RkError> {
2502 Ok(compose_glossary(embedded_block(shape.glossary_source())?))
2503}
2504
2505pub fn hooks_block(
2513 mode: CheckoutMode,
2514 integration: Integration,
2515 workflow_guards: bool,
2516) -> Result<String, RkError> {
2517 hooks_block_of(&BlockShape::releasing(mode, integration, workflow_guards))
2518}
2519
2520pub fn hooks_block_of(shape: &BlockShape) -> Result<String, RkError> {
2526 let workflow = if shape.workflow_guards {
2527 Some(WorkflowHooks {
2528 message: embedded_block(PRE_COMMIT_MESSAGE_GUARDS)?,
2529 branch_name: embedded_block(PRE_COMMIT_BRANCH_NAME_GUARD)?,
2530 worktree: match shape.checkout_mode {
2531 CheckoutMode::LinkedWorktree => Some(embedded_block(PRE_COMMIT_WORKTREE_GUARD)?),
2532 CheckoutMode::MainWorktree => None,
2533 },
2534 })
2535 } else {
2536 None
2537 };
2538 let parts = TrunkGuards {
2539 integration: shape.integration,
2540 release: shape.release,
2541 trunk_commit: embedded_block(PRE_COMMIT_TRUNK_COMMIT_GUARD)?,
2542 trunk_push: embedded_block(PRE_COMMIT_TRUNK_PUSH_GUARD)?,
2543 local_trunk_commit: embedded_block(PRE_COMMIT_LOCAL_TRUNK_COMMIT_GUARD)?,
2544 local_trunk_push: embedded_block(PRE_COMMIT_LOCAL_TRUNK_PUSH_GUARDS)?,
2545 tag_guard: embedded_block(PRE_COMMIT_TAG_GUARD)?,
2546 release_files: embedded_block(PRE_COMMIT_STATUS_RELEASE_FILES)?,
2547 sweep_skip: embedded_block(PRE_COMMIT_SWEEP_SKIP)?,
2548 sweep_none: embedded_block(PRE_COMMIT_SWEEP_NONE)?,
2549 integrate_note: embedded_block(PRE_COMMIT_INTEGRATE_NOTE)?,
2550 };
2551 Ok(compose_hooks(
2552 embedded_block(PRE_COMMIT_BLOCK)?,
2553 workflow.as_ref(),
2554 &parts,
2555 ))
2556}
2557
2558type BlockTemplate = (String, Vec<String>, &'static str);
2561
2562fn block_template(destination: &str, params: &Params) -> Result<Option<BlockTemplate>, RkError> {
2565 let shape = BlockShape::of(params);
2566 let guards = shape.workflow_guards;
2567 match destination {
2568 AGENTS_DESTINATION => {
2569 let [template, agent, source, scope] = shape.routing_sources();
2570 let mut sources = vec![template.to_owned()];
2571 if guards {
2572 sources.push(agent.to_owned());
2573 sources.push(AGENTS_LINE_COMMIT_POLICY.to_owned());
2574 sources.push(source.to_owned());
2575 sources.push(scope.to_owned());
2576 }
2577 Ok(Some((routing_block_of(&shape)?, sources, catalog::GUARDS)))
2578 }
2579 GLOSSARY_DESTINATION => Ok(guards
2580 .then(|| -> Result<_, RkError> {
2581 Ok((
2582 glossary_block_of(&shape)?,
2583 vec![shape.glossary_source().to_owned()],
2584 catalog::WORKFLOW_GUARDS,
2585 ))
2586 })
2587 .transpose()?),
2588 HOOKS_DESTINATION => {
2589 let mut sources = vec![PRE_COMMIT_BLOCK.to_owned()];
2590 if guards {
2591 sources.push(PRE_COMMIT_MESSAGE_GUARDS.to_owned());
2592 sources.push(PRE_COMMIT_BRANCH_NAME_GUARD.to_owned());
2593 if shape.checkout_mode == CheckoutMode::LinkedWorktree {
2594 sources.push(PRE_COMMIT_WORKTREE_GUARD.to_owned());
2595 }
2596 match shape.integration {
2597 Integration::Forge => {
2598 sources.push(PRE_COMMIT_TRUNK_COMMIT_GUARD.to_owned());
2599 sources.push(PRE_COMMIT_TRUNK_PUSH_GUARD.to_owned());
2600 }
2601 Integration::Local => {
2602 sources.push(PRE_COMMIT_LOCAL_TRUNK_COMMIT_GUARD.to_owned());
2603 sources.push(PRE_COMMIT_LOCAL_TRUNK_PUSH_GUARDS.to_owned());
2604 }
2605 }
2606 }
2607 if shape.release {
2608 sources.push(PRE_COMMIT_TAG_GUARD.to_owned());
2609 sources.push(PRE_COMMIT_STATUS_RELEASE_FILES.to_owned());
2610 }
2611 sources.push(if guards {
2612 PRE_COMMIT_SWEEP_SKIP.to_owned()
2613 } else {
2614 PRE_COMMIT_SWEEP_NONE.to_owned()
2615 });
2616 if shape.integration == Integration::Local {
2617 sources.push(PRE_COMMIT_INTEGRATE_NOTE.to_owned());
2618 }
2619 Ok(Some((hooks_block_of(&shape)?, sources, catalog::GUARDS)))
2620 }
2621 other => Err(anyhow::anyhow!("{other} is not a block destination").into()),
2622 }
2623}
2624
2625#[must_use]
2627pub fn block_markers(destination: &str) -> Option<(&'static str, &'static str)> {
2628 match destination {
2629 AGENTS_DESTINATION | GLOSSARY_DESTINATION => Some((BLOCK_BEGIN, BLOCK_END)),
2630 HOOKS_DESTINATION => Some((HOOKS_BEGIN, HOOKS_END)),
2631 _ => None,
2632 }
2633}
2634
2635#[must_use]
2638pub fn extract_block<'a>(text: &'a str, begin: &str, end: &str) -> Option<&'a str> {
2639 let start = text.find(begin)?;
2640 let stop = text[start..].find(end)? + start + end.len();
2641 Some(&text[start..stop])
2642}
2643
2644#[must_use]
2651pub fn splice_marked_block(existing: Option<&[u8]>, block: &str) -> Vec<u8> {
2652 let block = block.as_bytes();
2653 let Some(text) = existing else {
2654 return [block, b"\n"].concat();
2655 };
2656 if let Some(start) = find(text, BLOCK_BEGIN.as_bytes())
2660 && let Some(offset) = find(&text[start..], BLOCK_END.as_bytes())
2661 {
2662 let stop = start + offset + BLOCK_END.len();
2663 return [&text[..start], block, &text[stop..]].concat();
2664 }
2665 let mut out = Vec::with_capacity(text.len() + block.len() + 3);
2669 out.extend_from_slice(text);
2670 if !text.ends_with(b"\n") {
2671 out.push(b'\n');
2672 }
2673 out.push(b'\n');
2674 out.extend_from_slice(block);
2675 out.push(b'\n');
2676 out
2677}
2678
2679pub fn splice_hooks_block(existing: Option<&str>, block: &str) -> Result<String, String> {
2692 let Some(text) = existing else {
2693 return Ok(format!("{HOOK_TYPES_LINE}\n\nrepos:\n{block}\n"));
2694 };
2695 if let Some(defect) = hooks_marker_defect(text) {
2696 return Err(defect);
2697 }
2698 if let Some(found) = extract_block(text, HOOKS_BEGIN, HOOKS_END) {
2699 return Ok(text.replacen(found, block, 1));
2700 }
2701 let mut out = String::with_capacity(text.len() + block.len() + 1);
2702 let mut placed = false;
2703 for line in text.split_inclusive('\n') {
2704 out.push_str(line);
2705 if !placed && line.trim_end() == "repos:" {
2706 if !out.ends_with('\n') {
2707 out.push('\n');
2708 }
2709 out.push_str(block);
2710 out.push('\n');
2711 placed = true;
2712 }
2713 }
2714 if placed {
2715 Ok(out)
2716 } else {
2717 Err(format!(
2718 "{HOOKS_DESTINATION} exists with no repos: line, so the hook block has nowhere to land"
2719 ))
2720 }
2721}
2722
2723#[must_use]
2733pub fn marker_defect(destination: &str, text: &str) -> Option<String> {
2734 let (begin, end) = block_markers(destination)?;
2735 let begins = text.matches(begin).count();
2736 let ends = text.matches(end).count();
2737 if begins > 1 || ends > 1 {
2738 return Some(format!(
2739 "{destination} carries more than one release-kit marker pair; release-kit owns exactly one block"
2740 ));
2741 }
2742 match (text.find(begin), text.find(end)) {
2743 (Some(begin), Some(end)) if end > begin => None,
2744 (None, None) => None,
2745 _ => Some(format!(
2746 "{destination} carries an unmatched or misordered release-kit marker, so the block's extent is ambiguous"
2747 )),
2748 }
2749}
2750
2751#[must_use]
2754pub fn hooks_marker_defect(text: &str) -> Option<String> {
2755 marker_defect(HOOKS_DESTINATION, text)
2756}
2757
2758fn propose_document(
2762 destination: &str,
2763 existing: Option<&[u8]>,
2764 region: &[u8],
2765) -> Result<Vec<u8>, String> {
2766 let block = String::from_utf8_lossy(region).into_owned();
2771 if let Some(text) = existing
2772 && let Some(defect) = marker_defect(destination, &String::from_utf8_lossy(text))
2773 {
2774 return Err(defect);
2775 }
2776 if destination == HOOKS_DESTINATION {
2777 let text = match existing {
2781 None => None,
2782 Some(bytes) => Some(std::str::from_utf8(bytes).map_err(|_| {
2783 format!(
2784 "{destination} is not UTF-8, so the hook block has nowhere to land without rewriting the target's bytes"
2785 )
2786 })?),
2787 };
2788 return splice_hooks_block(text, &block).map(String::into_bytes);
2789 }
2790 Ok(splice_marked_block(existing, &block))
2791}
2792
2793#[must_use]
2805pub fn nix_unsupported_shape(shape: &CrateShape) -> Option<String> {
2806 let Some(text) = shape.cargo_toml.as_deref() else {
2807 return Some(
2808 "the target has no readable Cargo.toml, which the seeded package expression reads; no Nix file lands".to_owned(),
2809 );
2810 };
2811 let Ok(table) = text.parse::<toml::Table>() else {
2812 return Some(
2813 "the target's Cargo.toml does not parse, and the seeded package expression reads it; no Nix file lands".to_owned(),
2814 );
2815 };
2816 if !table.contains_key("package") {
2817 return Some(
2818 "the target's Cargo.toml has no [package] table; the seed supports a single crate, so no Nix file lands".to_owned(),
2819 );
2820 }
2821 if !shape.cargo_lock {
2822 return Some(
2823 "the target has no Cargo.lock, which the seeded package expression builds from; commit one, then opt in".to_owned(),
2824 );
2825 }
2826 let implicit_bin = shape.main_rs
2827 && table
2828 .get("package")
2829 .and_then(toml::Value::as_table)
2830 .and_then(|package| package.get("autobins"))
2831 .and_then(toml::Value::as_bool)
2832 != Some(false);
2833 let explicit_bins = table.get("bin").and_then(toml::Value::as_array);
2834 if explicit_bins.is_none() && !implicit_bin {
2835 return Some(
2836 "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(),
2837 );
2838 }
2839 if let Some(bins) = explicit_bins {
2845 let required = bins
2846 .first()
2847 .and_then(toml::Value::as_table)
2848 .and_then(|bin| bin.get("required-features"))
2849 .and_then(toml::Value::as_array);
2850 if let Some(required) = required {
2851 let enabled = default_features(&table);
2852 let missing = required
2853 .iter()
2854 .filter_map(toml::Value::as_str)
2855 .any(|feature| !enabled.contains(feature));
2856 if missing {
2857 return Some(
2858 "the target's first [[bin]] entry requires features a default build does not enable; no Nix file lands".to_owned(),
2859 );
2860 }
2861 }
2862 }
2863 None
2864}
2865
2866fn dep_edge_suppresses(features: &toml::Table, name: &str) -> bool {
2869 let edge = format!("dep:{name}");
2870 features.values().any(|list| {
2871 list.as_array().is_some_and(|entries| {
2872 entries
2873 .iter()
2874 .filter_map(toml::Value::as_str)
2875 .any(|entry| entry == edge)
2876 })
2877 })
2878}
2879
2880fn is_optional_dependency(table: &toml::Table, name: &str) -> bool {
2883 ["dependencies", "build-dependencies"]
2884 .iter()
2885 .any(|section| {
2886 table
2887 .get(*section)
2888 .and_then(toml::Value::as_table)
2889 .and_then(|dependencies| dependencies.get(name))
2890 .and_then(toml::Value::as_table)
2891 .and_then(|dependency| dependency.get("optional"))
2892 .and_then(toml::Value::as_bool)
2893 == Some(true)
2894 })
2895}
2896
2897fn default_features(table: &toml::Table) -> std::collections::BTreeSet<String> {
2904 let Some(features) = table.get("features").and_then(toml::Value::as_table) else {
2905 return std::collections::BTreeSet::new();
2906 };
2907 let mut enabled = std::collections::BTreeSet::new();
2908 let mut queue = vec!["default".to_owned()];
2909 while let Some(name) = queue.pop() {
2910 if !enabled.insert(name.clone()) {
2911 continue;
2912 }
2913 if let Some(implies) = features.get(&name).and_then(toml::Value::as_array) {
2914 for implied in implies.iter().filter_map(toml::Value::as_str) {
2915 if implied.starts_with("dep:") || implied.contains("?/") {
2916 continue;
2920 }
2921 if let Some((package, _)) = implied.split_once('/') {
2922 let feature_exists =
2930 features.contains_key(package) || !dep_edge_suppresses(features, package);
2931 if is_optional_dependency(table, package) && feature_exists {
2932 queue.push(package.to_owned());
2933 }
2934 } else {
2935 queue.push(implied.to_owned());
2936 }
2937 }
2938 }
2939 }
2940 enabled
2941}
2942
2943#[must_use]
2951pub fn flake_pair_withheld(
2952 flake_recorded: bool,
2953 flake_nix_present: bool,
2954 flake_lock_present: bool,
2955) -> Option<String> {
2956 if flake_recorded {
2957 return None;
2958 }
2959 let present: Vec<&str> = [
2960 ("flake.nix", flake_nix_present),
2961 ("flake.lock", flake_lock_present),
2962 ]
2963 .into_iter()
2964 .filter_map(|(name, present)| present.then_some(name))
2965 .collect();
2966 if present.is_empty() {
2967 return None;
2968 }
2969 Some(format!(
2970 "the target already carries {}; its flake pair stays its own",
2971 present.join(" and ")
2972 ))
2973}
2974
2975#[must_use]
2985pub fn nix_withholding(
2986 nix: bool,
2987 evidence: &TargetEvidence,
2988) -> Option<(&'static [&'static str], String)> {
2989 if !nix {
2990 return None;
2991 }
2992 if let Some(reason) = nix_unsupported_shape(&evidence.crate_shape) {
2993 return Some((&NIX_DESTINATIONS[..], reason));
2994 }
2995 flake_pair_withheld(
2996 evidence.flake_recorded,
2997 evidence.flake_nix_present,
2998 evidence.flake_lock_present,
2999 )
3000 .map(|reason| (&NIX_WITHHOLDABLE[..], reason))
3001}
3002
3003#[cfg(test)]
3004mod tests {
3005 use super::{
3006 AGENTS_DESTINATION, BLOCK_BEGIN, BLOCK_DESTINATIONS, BLOCK_END, Candidate, Collision,
3007 CrateShape, GLOSSARY_DESTINATION, HOOK_TYPES_LINE, HOOKS_BEGIN, HOOKS_DESTINATION,
3008 HOOKS_END, Placement, Projection, ProjectionInput, TargetEvidence, extract_block,
3009 };
3010 use crate::landing::{Params, Style};
3011
3012 fn supported_shape() -> CrateShape {
3014 CrateShape {
3015 cargo_toml: Some("[package]\nname = \"widget\"\nversion = \"0.1.0\"\n".to_owned()),
3016 cargo_lock: true,
3017 main_rs: true,
3018 }
3019 }
3020
3021 fn input(evidence: TargetEvidence) -> ProjectionInput {
3022 let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
3023 params.set_nix_for_test(true);
3024 ProjectionInput { params, evidence }
3025 }
3026
3027 fn compute(evidence: TargetEvidence) -> Projection {
3028 Projection::compute(&input(evidence)).expect("the embedded pair projects")
3029 }
3030
3031 fn candidate<'a>(projection: &'a Projection, destination: &str) -> &'a Candidate {
3032 projection
3033 .candidates
3034 .iter()
3035 .find(|candidate| candidate.destination == destination)
3036 .expect("the destination projects")
3037 }
3038
3039 #[test]
3044 fn the_workflow_guards_land_only_where_requested() {
3045 let mut plain = input(TargetEvidence {
3046 crate_shape: supported_shape(),
3047 ..TargetEvidence::default()
3048 });
3049 plain.params.set_workflow_guards_for_test(false);
3050 let plain = Projection::compute(&plain).expect("the embedded pair projects");
3051 assert!(
3052 !plain
3053 .candidates
3054 .iter()
3055 .any(|candidate| candidate.destination == GLOSSARY_DESTINATION),
3056 "a target without the guards takes no glossary"
3057 );
3058 let agents =
3059 String::from_utf8(candidate(&plain, AGENTS_DESTINATION).bytes.clone()).expect("text");
3060 assert!(!agents.contains("guides and never drives"), "{agents}");
3061 let hooks =
3062 String::from_utf8(candidate(&plain, HOOKS_DESTINATION).bytes.clone()).expect("text");
3063 assert!(!hooks.contains("conventional-pre-commit"), "{hooks}");
3064 assert!(hooks.contains("rk-no-hand-authored-tag"), "{hooks}");
3065
3066 let guarded = compute(TargetEvidence {
3067 crate_shape: supported_shape(),
3068 ..TargetEvidence::default()
3069 });
3070 let glossary = candidate(&guarded, GLOSSARY_DESTINATION);
3071 assert_eq!(
3072 glossary.capability,
3073 crate::profile::catalog::WORKFLOW_GUARDS
3074 );
3075 assert_eq!(
3076 candidate(&guarded, AGENTS_DESTINATION).capability,
3077 crate::profile::catalog::GUARDS
3078 );
3079 let hooks =
3080 String::from_utf8(candidate(&guarded, HOOKS_DESTINATION).bytes.clone()).expect("text");
3081 for hook in ["conventional-pre-commit", "rk-message", "rk-branch-name"] {
3082 assert!(hooks.contains(hook), "{hook}: {hooks}");
3083 }
3084 }
3085
3086 fn outside(bytes: &[u8], begin: &str, end: &str) -> (Vec<u8>, Vec<u8>) {
3088 let text = String::from_utf8_lossy(bytes);
3089 let start = text.find(begin).expect("the begin marker is present");
3090 let stop = text[start..].find(end).expect("the end marker is present") + start + end.len();
3091 (bytes[..start].to_vec(), bytes[stop..].to_vec())
3092 }
3093
3094 #[test]
3095 fn equal_projection_inputs_yield_byte_identical_projections() {
3096 let mut documents = std::collections::BTreeMap::new();
3097 documents.insert(
3098 AGENTS_DESTINATION.to_owned(),
3099 b"# Widget\n\nOwn rules.\n".to_vec(),
3100 );
3101 let evidence = TargetEvidence {
3102 documents,
3103 crate_shape: supported_shape(),
3104 ..TargetEvidence::default()
3105 };
3106 let first = input(evidence.clone());
3107 let second = input(evidence);
3108 assert_eq!(first, second, "the inputs are values and compare equal");
3109 let a = Projection::compute(&first).expect("the pair projects");
3110 let b = Projection::compute(&second).expect("the pair projects");
3111 assert_eq!(a.candidates.len(), b.candidates.len());
3112 for (x, y) in a.candidates.iter().zip(&b.candidates) {
3113 assert_eq!(x.destination, y.destination);
3114 assert_eq!(x.kind, y.kind);
3115 assert_eq!(x.placement, y.placement);
3116 assert_eq!(x.bytes, y.bytes, "{}", x.destination);
3117 assert_eq!(x.region, y.region, "{}", x.destination);
3118 assert_eq!(x.sources, y.sources, "{}", x.destination);
3119 }
3120 assert_eq!(a, b);
3121 let destinations: Vec<&str> = a
3122 .candidates
3123 .iter()
3124 .map(|candidate| candidate.destination.as_str())
3125 .collect();
3126 let mut sorted = destinations.clone();
3127 sorted.sort_unstable();
3128 assert_eq!(destinations, sorted, "candidates sort by destination");
3129 assert!(a.omissions.is_empty(), "{:?}", a.omissions);
3130 assert!(a.collisions.is_empty(), "{:?}", a.collisions);
3131 }
3132
3133 #[test]
3137 fn the_scorecard_destination_projects_only_under_the_opt_in() {
3138 use super::{Kind, SCORECARD_DESTINATIONS, kind_of};
3139 let destination = SCORECARD_DESTINATIONS[0];
3140 assert_eq!(kind_of(destination), Some(Kind::Rendered));
3141
3142 let project = |scorecard: bool| {
3143 let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
3144 params.set_scorecard_for_test(scorecard);
3145 Projection::compute(&ProjectionInput {
3146 params,
3147 evidence: TargetEvidence::default(),
3148 })
3149 .expect("the embedded pair projects")
3150 };
3151
3152 let off = project(false);
3153 assert!(
3154 !off.candidates
3155 .iter()
3156 .any(|candidate| candidate.destination == destination),
3157 "off by default, and an absent candidate is no omission"
3158 );
3159 assert!(off.omissions.is_empty(), "{:?}", off.omissions);
3160
3161 let on = project(true);
3162 let candidate = candidate(&on, destination);
3163 assert_eq!(candidate.kind, Kind::Rendered);
3164 assert_eq!(candidate.placement, Placement::Whole);
3165 let text = String::from_utf8_lossy(&candidate.bytes);
3166 assert!(!text.contains("RK_"), "a token survived: {text}");
3167 }
3168
3169 #[test]
3174 fn the_licence_judgment_reads_what_every_reader_obtains() {
3175 use super::licence_states_an_open_source_codebase as approved;
3176 for expression in [
3177 "MIT",
3178 "Apache-2.0",
3179 "MIT OR Apache-2.0",
3180 "MIT AND Apache-2.0",
3181 "(MIT OR Apache-2.0) AND ISC",
3182 "Apache-2.0 WITH LLVM-exception OR MIT",
3183 "GPL-3.0+",
3184 ] {
3185 assert!(approved(expression), "{expression} is OSI-approved");
3186 }
3187 for expression in [
3192 "MIT AND CC-BY-4.0",
3193 "CC-BY-4.0 AND MIT",
3194 "MIT AND (CC-BY-4.0 OR CC-BY-SA-4.0)",
3195 "(MIT OR CC-BY-4.0) AND MIT",
3196 "MIT OR Apache-2.0 AND CC-BY-4.0",
3197 "Apache-2.0 WITH LLVM-exception AND CC-BY-SA-3.0",
3198 ] {
3199 assert!(approved(expression), "{expression} states open source");
3200 }
3201 for expression in [
3202 "",
3203 " ",
3204 "LicenseRef-proprietary",
3205 "MIT AND LicenseRef-proprietary",
3206 "SEE LICENSE IN COPYING",
3207 "CC-BY-4.0",
3208 "DocumentRef-spdx:LicenseRef-proprietary",
3209 ] {
3210 assert!(!approved(expression), "{expression} is not recognized");
3211 }
3212 for expression in [
3217 "MIT OR CC-BY-4.0",
3218 "CC-BY-4.0 AND CC-BY-SA-4.0",
3219 "CC-BY-4.0 AND LicenseRef-proprietary",
3220 "CC0-1.0",
3221 "MIT AND CC-BY-4.0 AND LicenseRef-proprietary",
3222 ] {
3223 assert!(!approved(expression), "{expression} grants no code terms");
3224 }
3225 assert!(
3230 !approved("CC-BY-4.0 OR CC-BY-SA-4.0 AND MIT"),
3231 "AND binds tighter than OR"
3232 );
3233 }
3234
3235 #[test]
3241 fn the_licence_judgment_holds_the_expression_to_the_spdx_grammar() {
3242 use super::licence_states_an_open_source_codebase as approved;
3243 for expression in [
3248 "MIT OR",
3249 "OR MIT",
3250 "MIT AND",
3251 "MIT WITH",
3252 "WITH LLVM-exception",
3253 "(MIT",
3254 "MIT)",
3255 "MIT Apache-2.0",
3256 "()",
3257 "(MIT OR Apache-2.0",
3258 "MIT OR (Apache-2.0",
3259 "MIT OR ()",
3260 "AND",
3261 "(",
3262 ")",
3263 "MIT WITH AND ISC",
3264 "MIT OR OR ISC",
3265 "MIT @ Apache-2.0",
3266 ] {
3267 assert!(!approved(expression), "{expression} is malformed");
3268 }
3269 for expression in [
3271 "MIT AND (Apache-2.0 OR ISC)",
3272 "((MIT))",
3273 "Apache-2.0 WITH LLVM-exception AND ISC",
3274 "(Apache-2.0 WITH LLVM-exception)",
3275 ] {
3276 assert!(approved(expression), "{expression} is well formed");
3277 }
3278 for expression in [
3283 "mit",
3284 "MiT",
3285 "apache-2.0 OR mit",
3286 "APACHE-2.0 WITH llvm-exception",
3287 ] {
3288 assert!(
3289 approved(expression),
3290 "{expression} names an approved licence"
3291 );
3292 }
3293 for expression in [
3297 "MIT or Apache-2.0",
3298 "MIT and Apache-2.0",
3299 "MIT with LLVM-exception",
3300 ] {
3301 assert!(!approved(expression), "{expression} carries no operator");
3302 }
3303 for expression in [
3307 "MIT WITH definitely-not-an-spdx-exception",
3308 "MIT WITH MIT",
3309 "MIT WITH Apache-2.0",
3310 ] {
3311 assert!(!approved(expression), "{expression} names no exception");
3312 }
3313 for expression in [
3317 "GPL-2.0-only WITH GCC-exception-2.0",
3318 "GPL-2.0-or-later WITH Classpath-exception-2.0",
3319 "Apache-2.0 WITH Swift-exception",
3320 "GPL-3.0-only WITH Autoconf-exception-generic",
3321 ] {
3322 assert!(approved(expression), "{expression} names a real exception");
3323 }
3324 }
3325
3326 #[test]
3332 fn a_recorded_provider_its_pair_cannot_run_is_named() {
3333 use super::{Provider, code_scanning_incompatibility};
3334
3335 assert!(code_scanning_incompatibility(None, Some("bash"), Some("gitlab")).is_none());
3336 assert!(
3337 code_scanning_incompatibility(Some(Provider::Semgrep), Some("rust"), Some("gitlab"))
3338 .is_none()
3339 );
3340 assert!(
3341 code_scanning_incompatibility(Some(Provider::CodeQl), Some("rust"), Some("github"))
3342 .is_none()
3343 );
3344
3345 let bash =
3346 code_scanning_incompatibility(Some(Provider::Semgrep), Some("bash"), Some("github"))
3347 .expect("a binding with no scanner is named");
3348 assert!(bash.contains("bash binding"), "{bash}");
3349 let gitlab =
3350 code_scanning_incompatibility(Some(Provider::CodeQl), Some("rust"), Some("gitlab"))
3351 .expect("codeql on gitlab is named");
3352 assert!(gitlab.contains("codeql"), "{gitlab}");
3353 let release_less =
3354 code_scanning_incompatibility(Some(Provider::Semgrep), None, Some("github"))
3355 .expect("no driver is named");
3356 assert!(
3357 release_less.contains("no automatic release driver"),
3358 "{release_less}"
3359 );
3360
3361 let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
3364 params.set_code_scanning_for_test(Some(Provider::Semgrep));
3365 let clean = Projection::compute(&ProjectionInput {
3366 params: params.clone(),
3367 evidence: TargetEvidence::default(),
3368 })
3369 .expect("the pair projects");
3370 assert!(
3371 clean.record_defects.is_empty(),
3372 "{:?}",
3373 clean.record_defects
3374 );
3375 }
3376
3377 #[test]
3381 fn the_code_scanning_destinations_follow_the_recorded_provider() {
3382 use super::{
3383 CODE_SCANNING_DESTINATIONS, Kind, Provider, code_scanning_licence_refusal, kind_of,
3384 };
3385 for (destination, _) in CODE_SCANNING_DESTINATIONS {
3386 assert_eq!(kind_of(destination), Some(Kind::Rendered), "{destination}");
3387 }
3388
3389 let project = |provider: Option<Provider>| {
3390 let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
3391 params.set_code_scanning_for_test(provider);
3392 Projection::compute(&ProjectionInput {
3393 params,
3394 evidence: TargetEvidence {
3395 crate_shape: CrateShape {
3396 cargo_toml: Some(
3397 "[package]\nname = \"widget\"\nlicense = \"MIT\"\n".to_owned(),
3398 ),
3399 ..CrateShape::default()
3400 },
3401 ..TargetEvidence::default()
3402 },
3403 })
3404 .expect("the embedded pair projects")
3405 };
3406 let landed = |projection: &Projection, destination: &str| {
3407 projection
3408 .candidates
3409 .iter()
3410 .any(|candidate| candidate.destination == destination)
3411 };
3412
3413 let off = project(None);
3414 for (destination, _) in CODE_SCANNING_DESTINATIONS {
3415 assert!(!landed(&off, destination), "{destination}");
3416 }
3417 assert!(off.licence_refusal.is_none());
3418
3419 let codeql = project(Some(Provider::CodeQl));
3420 assert!(landed(
3421 &codeql,
3422 ".github/workflows/code-scanning-codeql.yml"
3423 ));
3424 assert!(!landed(
3425 &codeql,
3426 ".github/workflows/code-scanning-semgrep.yml"
3427 ));
3428 assert!(codeql.licence_refusal.is_none(), "MIT satisfies the terms");
3429
3430 let semgrep = project(Some(Provider::Semgrep));
3431 assert!(landed(
3432 &semgrep,
3433 ".github/workflows/code-scanning-semgrep.yml"
3434 ));
3435 assert!(!landed(
3436 &semgrep,
3437 ".github/workflows/code-scanning-codeql.yml"
3438 ));
3439
3440 let proprietary = CrateShape {
3442 cargo_toml: Some("[package]\nlicense = \"LicenseRef-proprietary\"\n".to_owned()),
3443 ..CrateShape::default()
3444 };
3445 assert!(
3446 code_scanning_licence_refusal(Some(Provider::Semgrep), Some("rust"), &proprietary)
3447 .is_none()
3448 );
3449 let refusal =
3450 code_scanning_licence_refusal(Some(Provider::CodeQl), Some("rust"), &proprietary)
3451 .expect("codeql refuses a licence its terms do not cover");
3452 assert!(refusal.contains("LicenseRef-proprietary"), "{refusal}");
3453 assert!(refusal.contains("semgrep"), "{refusal}");
3454 let bash =
3457 code_scanning_licence_refusal(Some(Provider::CodeQl), Some("bash"), &proprietary)
3458 .expect("an unread binding refuses");
3459 assert!(bash.contains("bash binding"), "{bash}");
3460 }
3461
3462 #[test]
3468 fn the_projection_performs_no_filesystem_git_environment_clock_registry_or_network_read() {
3469 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/projection.rs");
3470 let text = std::fs::read_to_string(&path).expect("the source reads");
3471 let production = text.split("#[cfg(test)]").next().unwrap_or("");
3472 let needles = [
3473 "std::fs",
3474 "std::env",
3475 "std::process",
3476 "std::time",
3477 "SystemTime",
3478 "Instant",
3479 "std::net",
3480 "Command::new",
3481 "registry::",
3482 "curl",
3483 "reqwest",
3484 "blob(",
3485 ];
3486 let mut hits = Vec::new();
3487 for (index, line) in production.lines().enumerate() {
3488 if line.trim_start().starts_with("//") {
3489 continue;
3490 }
3491 for needle in needles {
3492 if line.contains(needle) {
3493 hits.push(format!("src/projection.rs:{}: {needle}", index + 1));
3494 }
3495 }
3496 }
3497 assert!(
3498 hits.is_empty(),
3499 "the projection reads beyond its inputs: {hits:?}"
3500 );
3501 }
3502
3503 #[test]
3504 #[allow(
3505 clippy::too_many_lines,
3506 reason = "one test walks the three marked destinations and the three unmarked shapes"
3507 )]
3508 fn marked_region_projection_preserves_every_target_byte_outside_the_markers() {
3509 let agents_before = "# Widget\n\nOperator prose above.\n\n";
3510 let agents_after = "\n\n## Our rules\n\nOperator prose below. \n";
3511 let glossary_before = "# Glossary\n\n- `spike` is a throwaway branch.\n\n";
3512 let glossary_after = "\n\n## More terms\n\n- `own` is ours.";
3513 let hooks_before = "default_install_hook_types: [pre-commit]\n\nrepos:\n";
3514 let hooks_after =
3515 "\n - repo: https://example.com/own\n rev: v1\n hooks:\n - id: own\n";
3516 let stale = |begin: &str, end: &str| format!("{begin}\nstale block\n{end}");
3517 let mut documents = std::collections::BTreeMap::new();
3518 documents.insert(
3519 AGENTS_DESTINATION.to_owned(),
3520 format!(
3521 "{agents_before}{}{agents_after}",
3522 stale(BLOCK_BEGIN, BLOCK_END)
3523 )
3524 .into_bytes(),
3525 );
3526 documents.insert(
3527 GLOSSARY_DESTINATION.to_owned(),
3528 format!(
3529 "{glossary_before}{}{glossary_after}",
3530 stale(BLOCK_BEGIN, BLOCK_END)
3531 )
3532 .into_bytes(),
3533 );
3534 documents.insert(
3535 HOOKS_DESTINATION.to_owned(),
3536 format!(
3537 "{hooks_before}{}{hooks_after}",
3538 stale(HOOKS_BEGIN, HOOKS_END)
3539 )
3540 .into_bytes(),
3541 );
3542 let projection = compute(TargetEvidence {
3543 documents: documents.clone(),
3544 crate_shape: supported_shape(),
3545 ..TargetEvidence::default()
3546 });
3547 assert!(
3548 projection.collisions.is_empty(),
3549 "{:?}",
3550 projection.collisions
3551 );
3552 for (destination, before, after) in [
3553 (AGENTS_DESTINATION, agents_before, agents_after),
3554 (GLOSSARY_DESTINATION, glossary_before, glossary_after),
3555 (HOOKS_DESTINATION, hooks_before, hooks_after),
3556 ] {
3557 let candidate = candidate(&projection, destination);
3558 let Placement::Region { begin, end } = candidate.placement else {
3559 panic!("{destination} is a region");
3560 };
3561 let region = candidate
3562 .region
3563 .as_deref()
3564 .expect("a region carries its block");
3565 let (head, tail) = outside(&candidate.bytes, begin, end);
3566 assert_eq!(
3567 head,
3568 before.as_bytes(),
3569 "{destination}: bytes before the markers"
3570 );
3571 assert_eq!(
3572 tail,
3573 after.as_bytes(),
3574 "{destination}: bytes after the markers"
3575 );
3576 let inside = &candidate.bytes[head.len()..candidate.bytes.len() - tail.len()];
3577 assert_eq!(
3578 inside, region,
3579 "{destination}: the region is the rendered block"
3580 );
3581 let (existing_head, existing_tail) = outside(&documents[destination], begin, end);
3582 assert_eq!(head, existing_head);
3583 assert_eq!(tail, existing_tail);
3584 }
3585
3586 let own_hooks =
3590 "repos:\n - repo: https://example.com/own\n rev: v1\n hooks:\n - id: own\n";
3591 let own_agents = "# Widget\n\nOwn rules.";
3592 let mut documents = std::collections::BTreeMap::new();
3593 documents.insert(HOOKS_DESTINATION.to_owned(), own_hooks.as_bytes().to_vec());
3594 documents.insert(
3595 AGENTS_DESTINATION.to_owned(),
3596 own_agents.as_bytes().to_vec(),
3597 );
3598 let projection = compute(TargetEvidence {
3599 documents,
3600 crate_shape: supported_shape(),
3601 ..TargetEvidence::default()
3602 });
3603 assert!(
3604 projection.collisions.is_empty(),
3605 "{:?}",
3606 projection.collisions
3607 );
3608 let hooks = candidate(&projection, HOOKS_DESTINATION);
3609 let hooks_text = String::from_utf8_lossy(&hooks.bytes);
3610 let region = String::from_utf8_lossy(hooks.region.as_deref().expect("a region"));
3611 assert!(
3612 hooks_text.starts_with(&format!(
3613 "repos:\n{region}\n - repo: https://example.com/own"
3614 )),
3615 "{hooks_text}"
3616 );
3617 assert!(!hooks_text.contains(HOOK_TYPES_LINE));
3618 let agents = candidate(&projection, AGENTS_DESTINATION);
3619 assert!(agents.bytes.starts_with(own_agents.as_bytes()));
3620 assert_eq!(
3621 extract_block(
3622 &String::from_utf8_lossy(&agents.bytes),
3623 BLOCK_BEGIN,
3624 BLOCK_END
3625 )
3626 .map(str::as_bytes),
3627 agents.region.as_deref()
3628 );
3629 let glossary = candidate(&projection, GLOSSARY_DESTINATION);
3630 let region = glossary.region.as_deref().expect("a region");
3631 assert_eq!(
3632 glossary.bytes,
3633 [region, b"\n"].concat(),
3634 "an absent file is fresh"
3635 );
3636 }
3637
3638 #[test]
3644 fn a_hook_document_that_is_not_utf8_collides_instead_of_being_rewritten() {
3645 let mut documents = std::collections::BTreeMap::new();
3646 let mut invalid = b"repos:\n# own \xff above\n".to_vec();
3647 invalid.extend_from_slice(format!("{HOOKS_BEGIN}\nstale\n{HOOKS_END}\n").as_bytes());
3648 invalid.extend_from_slice(b" - repo: local \xff below\n");
3649 documents.insert(HOOKS_DESTINATION.to_owned(), invalid);
3650 let mut agents = b"# Widget r\xe9sum\xe9\n\n".to_vec();
3651 agents.extend_from_slice(format!("{BLOCK_BEGIN}\nstale\n{BLOCK_END}\n\n").as_bytes());
3652 agents.extend_from_slice(b"r\xe9sum\xe9\n");
3653 documents.insert(AGENTS_DESTINATION.to_owned(), agents.clone());
3654 let projection = compute(TargetEvidence {
3655 documents,
3656 crate_shape: supported_shape(),
3657 ..TargetEvidence::default()
3658 });
3659 let collided: Vec<&str> = projection
3660 .collisions
3661 .iter()
3662 .map(|c| c.destination.as_str())
3663 .collect();
3664 assert_eq!(collided, [HOOKS_DESTINATION]);
3665 assert!(
3666 projection.collisions[0].reason.contains("not UTF-8"),
3667 "{}",
3668 projection.collisions[0].reason
3669 );
3670 assert!(
3671 !projection
3672 .candidates
3673 .iter()
3674 .any(|c| c.destination == HOOKS_DESTINATION),
3675 "a colliding destination projects no candidate"
3676 );
3677 let agents = candidate(&projection, AGENTS_DESTINATION);
3678 assert!(
3679 agents.bytes.starts_with(b"# Widget r\xe9sum\xe9\n\n"),
3680 "{:?}",
3681 agents.bytes
3682 );
3683 assert!(
3684 agents.bytes.ends_with(b"\n\nr\xe9sum\xe9\n"),
3685 "{:?}",
3686 agents.bytes
3687 );
3688 assert!(
3689 !agents.bytes.contains(&0xEF),
3690 "a replacement character landed"
3691 );
3692
3693 let mut documents = std::collections::BTreeMap::new();
3694 let valid =
3695 format!("repos:\n# own above\n{HOOKS_BEGIN}\nstale\n{HOOKS_END}\n - repo: local\n");
3696 documents.insert(HOOKS_DESTINATION.to_owned(), valid.into_bytes());
3697 let projection = compute(TargetEvidence {
3698 documents,
3699 crate_shape: supported_shape(),
3700 ..TargetEvidence::default()
3701 });
3702 assert!(
3703 projection.collisions.is_empty(),
3704 "{:?}",
3705 projection.collisions
3706 );
3707 let hooks = candidate(&projection, HOOKS_DESTINATION);
3708 let text = String::from_utf8(hooks.bytes.clone()).expect("a valid document stays text");
3709 assert!(text.starts_with("repos:\n# own above\n"), "{text}");
3710 assert!(text.ends_with("\n - repo: local\n"), "{text}");
3711 assert!(!text.contains("stale"), "the region is replaced: {text}");
3712 }
3713
3714 #[test]
3718 fn a_whole_file_colliding_with_a_marked_region_names_both_source_paths() {
3719 let files: Vec<(String, &[u8])> = vec![
3720 ("snippets/_shared/github/SECURITY.md".to_owned(), b"policy"),
3721 ("snippets/rust/github/AGENTS.md".to_owned(), b"whole"),
3722 ];
3723 let err = Projection::compute_over(&files, &input(TargetEvidence::default()))
3724 .expect_err("a whole file at a block destination refuses");
3725 let text = err.to_string();
3726 assert!(text.contains("snippets/rust/github/AGENTS.md"), "{text}");
3727 assert!(text.contains(super::AGENTS_BLOCK), "{text}");
3728 assert!(text.contains(super::AGENTS_LINE_AGENT_POLICY), "{text}");
3729 assert!(text.contains("embedded sources are defective"), "{text}");
3730 }
3731
3732 #[test]
3733 fn duplicate_whole_file_destinations_and_overlapping_marked_regions_refuse_with_the_conflicting_source_names()
3734 {
3735 let files: Vec<(String, &[u8])> = vec![
3738 ("snippets/_shared/github/SECURITY.md".to_owned(), b"shared"),
3739 ("snippets/rust/github/SECURITY.md".to_owned(), b"pair"),
3740 ("snippets/rust/github/release-plz.toml".to_owned(), b"seed"),
3741 ];
3742 let err = Projection::compute_over(&files, &input(TargetEvidence::default()))
3743 .expect_err("a doubled destination refuses");
3744 let text = err.to_string();
3745 assert!(
3746 text.contains("snippets/_shared/github/SECURITY.md"),
3747 "{text}"
3748 );
3749 assert!(text.contains("snippets/rust/github/SECURITY.md"), "{text}");
3750 assert!(text.contains("embedded sources are defective"), "{text}");
3751
3752 let clean: Vec<(String, &[u8])> = vec![
3753 ("snippets/_shared/github/SECURITY.md".to_owned(), b"shared"),
3754 ("snippets/rust/github/release-plz.toml".to_owned(), b"seed"),
3755 ];
3756 let projection = Projection::compute_over(&clean, &input(TargetEvidence::default()))
3757 .expect("a clean list projects");
3758 let whole: Vec<&str> = projection
3759 .candidates
3760 .iter()
3761 .filter(|c| c.placement == Placement::Whole)
3762 .map(|c| c.destination.as_str())
3763 .collect();
3764 assert_eq!(whole, ["SECURITY.md", "release-plz.toml"]);
3765 assert_eq!(
3766 projection
3767 .candidates
3768 .iter()
3769 .find(|c| c.destination == "SECURITY.md")
3770 .map(|c| c.sources.clone()),
3771 Some(vec!["snippets/_shared/github/SECURITY.md".to_owned()])
3772 );
3773
3774 let doubled = format!("{BLOCK_BEGIN}\na\n{BLOCK_END}\n{BLOCK_BEGIN}\nb\n{BLOCK_END}\n");
3775 let unmatched = format!("repos:\n{HOOKS_BEGIN}\n - repo: local\n");
3776 let misordered = format!("# G\n{BLOCK_END}\n{BLOCK_BEGIN}\n");
3777 let mut documents = std::collections::BTreeMap::new();
3778 documents.insert(AGENTS_DESTINATION.to_owned(), doubled.into_bytes());
3779 documents.insert(HOOKS_DESTINATION.to_owned(), unmatched.into_bytes());
3780 documents.insert(GLOSSARY_DESTINATION.to_owned(), misordered.into_bytes());
3781 let projection = compute(TargetEvidence {
3782 documents,
3783 crate_shape: supported_shape(),
3784 ..TargetEvidence::default()
3785 });
3786 let mut collided: Vec<&str> = projection
3787 .collisions
3788 .iter()
3789 .map(|Collision { destination, .. }| destination.as_str())
3790 .collect();
3791 collided.sort_unstable();
3792 let mut expected = BLOCK_DESTINATIONS.to_vec();
3793 expected.sort_unstable();
3794 assert_eq!(collided, expected);
3795 for collision in &projection.collisions {
3796 assert!(
3797 collision.reason.contains(&collision.destination),
3798 "{collision:?}"
3799 );
3800 assert!(
3801 !projection
3802 .candidates
3803 .iter()
3804 .any(|candidate| candidate.destination == collision.destination),
3805 "{} collided and still projects",
3806 collision.destination
3807 );
3808 }
3809 let agents = projection
3810 .collisions
3811 .iter()
3812 .find(|c| c.destination == AGENTS_DESTINATION)
3813 .expect("the doubled document collides");
3814 assert!(agents.reason.contains("more than one"), "{}", agents.reason);
3815 let hooks = projection
3816 .collisions
3817 .iter()
3818 .find(|c| c.destination == HOOKS_DESTINATION)
3819 .expect("the unmatched document collides");
3820 assert!(hooks.reason.contains("unmatched"), "{}", hooks.reason);
3821 }
3822}