1use serde::Serialize;
19
20use super::{Params, ReleaseMode};
21use crate::detect::Forge;
22use crate::landing::{Integration, MergeForm};
23use crate::projection::{CODE_SCANNING_DESTINATIONS, NIX_DESTINATIONS};
24
25pub const GUARDS: &str = "git.guards";
31pub const WORKFLOW_GUARDS: &str = "git.workflow-guards";
35pub const TITLE_CHECK: &str = "git.title-check";
39pub const COMMIT_CHECK: &str = "git.commit-check";
43pub const REPORTING_POLICY: &str = "security.reporting-policy";
45pub const RELEASE_AUTOMATION: &str = "release.automation";
48pub const PACKAGING_NIX: &str = "packaging.nix";
50pub const SCORECARD: &str = "supply-chain.scorecard";
52pub const CODE_SCANNING: &str = "supply-chain.code-scanning";
54
55pub const ALL: [&str; 9] = [
57 GUARDS,
58 WORKFLOW_GUARDS,
59 TITLE_CHECK,
60 COMMIT_CHECK,
61 REPORTING_POLICY,
62 RELEASE_AUTOMATION,
63 PACKAGING_NIX,
64 SCORECARD,
65 CODE_SCANNING,
66];
67
68pub const TITLE_GATE_ZONE: &str = "_title-gate";
72
73pub const COMMIT_GATE_ZONE: &str = "_commit-gate";
77
78#[must_use]
85pub fn owner_of(path: &str) -> Option<&'static str> {
86 let mut segments = path.splitn(3, '/');
87 let (zone, _forge, destination) = (segments.next()?, segments.next()?, segments.next()?);
88 if zone == "_shared" {
89 return match destination {
90 "SECURITY.md" => Some(REPORTING_POLICY),
91 ".github/workflows/pr-title.yml" | ".gitlab/ci/mr-title.yml" => Some(TITLE_CHECK),
92 ".github/workflows/pr-commits.yml" | ".gitlab/ci/mr-commits.yml" => Some(COMMIT_CHECK),
93 ".github/workflows/scorecard.yml" => Some(SCORECARD),
94 _ => None,
95 };
96 }
97 if zone == TITLE_GATE_ZONE {
98 return (destination == ".gitlab-ci.yml").then_some(TITLE_CHECK);
99 }
100 if zone == COMMIT_GATE_ZONE {
101 return (destination == ".gitlab-ci.yml").then_some(COMMIT_CHECK);
102 }
103 if zone.starts_with('_') {
104 return None;
105 }
106 if NIX_DESTINATIONS.contains(&destination) {
107 return Some(PACKAGING_NIX);
108 }
109 if CODE_SCANNING_DESTINATIONS
110 .iter()
111 .any(|(name, _)| *name == destination)
112 {
113 return Some(CODE_SCANNING);
114 }
115 Some(RELEASE_AUTOMATION)
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
120#[serde(rename_all = "kebab-case")]
121pub enum Status {
122 Selected,
125 NotRequested,
127 NotApplicable,
130 Unavailable,
132 Unknown,
134 Withheld,
138}
139
140impl Status {
141 #[must_use]
143 pub const fn as_str(self) -> &'static str {
144 match self {
145 Self::Selected => "selected",
146 Self::NotRequested => "not-requested",
147 Self::NotApplicable => "not-applicable",
148 Self::Unavailable => "unavailable",
149 Self::Unknown => "unknown",
150 Self::Withheld => "withheld",
151 }
152 }
153}
154
155#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
157pub struct Selection {
158 pub id: &'static str,
160 pub status: Status,
162 #[serde(skip_serializing_if = "Option::is_none")]
164 pub reason: Option<String>,
165 #[serde(skip_serializing_if = "Option::is_none")]
167 pub action: Option<String>,
168 #[serde(skip)]
171 pub sources: Vec<String>,
172 #[serde(skip_serializing_if = "Option::is_none")]
175 pub driver: Option<String>,
176 #[serde(skip_serializing_if = "Option::is_none")]
181 pub provider: Option<String>,
182}
183
184impl Selection {
185 #[must_use]
187 pub const fn lands(&self) -> bool {
188 matches!(self.status, Status::Selected | Status::Withheld)
189 }
190
191 fn selected(id: &'static str, sources: Vec<String>, driver: Option<&str>) -> Self {
192 Self {
193 id,
194 status: Status::Selected,
195 reason: None,
196 action: None,
197 sources,
198 driver: driver.map(str::to_owned),
199 provider: None,
200 }
201 }
202
203 fn keyed_on(mut self, provider: &str) -> Self {
205 self.provider = Some(provider.to_owned());
206 self
207 }
208
209 fn omitted(id: &'static str, status: Status, reason: impl Into<String>) -> Self {
210 Self {
211 id,
212 status,
213 reason: Some(reason.into()),
214 action: None,
215 sources: Vec::new(),
216 driver: None,
217 provider: None,
218 }
219 }
220}
221
222#[derive(Debug, Clone, PartialEq, Eq)]
225pub struct Availability {
226 files: Vec<String>,
227}
228
229impl Availability {
230 #[must_use]
233 pub fn over(files: Vec<String>) -> Self {
234 let mut files = files;
235 files.sort();
236 Self { files }
237 }
238
239 #[must_use]
241 pub fn embedded() -> Self {
242 Self::over(
243 crate::embedded::walk(&crate::embedded::SNIPPETS)
244 .into_iter()
245 .map(|(path, _)| path)
246 .collect(),
247 )
248 }
249
250 #[must_use]
252 pub fn files(&self) -> &[String] {
253 &self.files
254 }
255
256 fn owned(&self, zone: &str, forge: &str, capability: &str) -> Vec<String> {
258 let prefix = format!("{zone}/{forge}/");
259 self.files
260 .iter()
261 .filter(|path| path.starts_with(&prefix) && owner_of(path) == Some(capability))
262 .cloned()
263 .collect()
264 }
265
266 #[must_use]
268 pub fn drivers(&self) -> Vec<String> {
269 let mut out: Vec<String> = Vec::new();
270 for path in &self.files {
271 if let Some((zone, _)) = path.split_once('/')
272 && !zone.starts_with('_')
273 && !out.iter().any(|known| known == zone)
274 {
275 out.push(zone.to_owned());
276 }
277 }
278 out
279 }
280
281 #[must_use]
284 pub fn automation_tuples(&self) -> Vec<String> {
285 let mut out: Vec<String> = Vec::new();
286 for path in &self.files {
287 let mut segments = path.splitn(3, '/');
288 if let (Some(driver), Some(forge), Some(_)) =
289 (segments.next(), segments.next(), segments.next())
290 && !driver.starts_with('_')
291 && owner_of(path) == Some(RELEASE_AUTOMATION)
292 {
293 let entry = format!("{driver}, {forge}");
294 if !out.contains(&entry) {
295 out.push(entry);
296 }
297 }
298 }
299 out
300 }
301}
302
303#[must_use]
317pub const fn forge_gate_lands(integration: Integration, mode: ReleaseMode) -> bool {
318 matches!(integration, Integration::Forge) || matches!(mode, ReleaseMode::Automatic)
319}
320
321#[must_use]
323pub fn known_drivers() -> Vec<String> {
324 Availability::embedded().drivers()
325}
326
327pub fn registry_answer_fits(
340 mode: ReleaseMode,
341 driver: Option<&str>,
342 answer: crate::profile::RegistryAnswer,
343) -> Result<(), String> {
344 let shape = format!(
345 "profile.release.registry takes {}, under profile.release.mode = \"automatic\" alone",
346 crate::profile::RegistryAnswer::SHAPE
347 );
348 match (mode, driver) {
349 (ReleaseMode::Automatic, Some(driver)) if answer.fits(driver) => Ok(()),
350 (ReleaseMode::Automatic, Some(other)) => Err(format!(
351 "profile.release.registry is {} while profile.release.driver is {other}; {shape}",
352 answer.as_str()
353 )),
354 (ReleaseMode::Automatic, None) => Err(format!(
355 "profile.release.registry is set while no profile.release.driver is named; {shape}"
356 )),
357 (mode, _) => Err(format!(
358 "profile.release.registry is set while profile.release.mode is {}; {shape}",
359 mode.as_str()
360 )),
361 }
362}
363
364pub fn build_answer_fits(mode: ReleaseMode, driver: Option<&str>) -> Result<(), String> {
370 let shape = format!(
371 "profile.release.build names the command and the packages of a release whose pipeline builds the distribution, under profile.release.mode = \"automatic\" with profile.release.driver = \"{}\" alone",
372 crate::profile::BuildAnswer::DRIVER
373 );
374 match (mode, driver) {
375 (ReleaseMode::Automatic, Some(crate::profile::BuildAnswer::DRIVER)) => Ok(()),
376 (ReleaseMode::Automatic, Some(other)) => Err(format!(
377 "profile.release.build is set while profile.release.driver is {other}; {shape}"
378 )),
379 (ReleaseMode::Automatic, None) => Err(format!(
380 "profile.release.build is set while no profile.release.driver is named; {shape}"
381 )),
382 (mode, _) => Err(format!(
383 "profile.release.build is set while profile.release.mode is {}; {shape}",
384 mode.as_str()
385 )),
386 }
387}
388
389fn known_forge(name: &str) -> bool {
391 Forge::parse(name).is_some()
392}
393
394#[must_use]
401#[allow(
402 clippy::too_many_lines,
403 reason = "one pass answers every capability, and splitting it would separate a selection from the dimensions that decided it"
404)]
405pub fn select(params: &Params, availability: &Availability) -> Vec<Selection> {
406 let forge = params.forge();
407 let driver = params.driver();
408 let known_drivers = availability.drivers();
409 let driver_known = driver.is_some_and(|d| known_drivers.iter().any(|k| k == d));
410 let forge_known = forge.is_some_and(known_forge);
411
412 let mut out = Vec::with_capacity(ALL.len());
413 out.push(Selection::selected(GUARDS, Vec::new(), None));
414 out.push(if params.workflow_guards() {
415 Selection::selected(WORKFLOW_GUARDS, Vec::new(), None)
416 } else {
417 Selection::omitted(
418 WORKFLOW_GUARDS,
419 Status::NotRequested,
420 "capabilities.workflow_guards is false",
421 )
422 });
423
424 let automation = match (params.release_mode(), driver, forge) {
427 (ReleaseMode::External, _, _) => Selection::omitted(
428 RELEASE_AUTOMATION,
429 Status::NotRequested,
430 "the release is external: the target releases through a process release-kit does not drive",
431 ),
432 (ReleaseMode::None, _, _) => Selection::omitted(
433 RELEASE_AUTOMATION,
434 Status::NotRequested,
435 "the release mode is none",
436 ),
437 (ReleaseMode::Automatic, None, _) => Selection::omitted(
438 RELEASE_AUTOMATION,
439 Status::NotApplicable,
440 "an automatic release names no driver",
441 ),
442 (ReleaseMode::Automatic, _, None) => Selection::omitted(
443 RELEASE_AUTOMATION,
444 Status::NotApplicable,
445 "the profile names no forge",
446 ),
447 (ReleaseMode::Automatic, Some(driver), Some(forge)) => {
448 if !driver_known {
449 Selection::omitted(
450 RELEASE_AUTOMATION,
451 Status::Unknown,
452 format!(
453 "the driver {driver} is not one this release knows; the bindings are: {}",
454 known_drivers.join(", ")
455 ),
456 )
457 } else if !forge_known {
458 Selection::omitted(
459 RELEASE_AUTOMATION,
460 Status::Unknown,
461 format!(
462 "the forge {forge} is not one this release drives; the forges are: github, gitlab"
463 ),
464 )
465 } else {
466 let sources = availability.owned(driver, forge, RELEASE_AUTOMATION);
467 if sources.is_empty() {
468 Selection::omitted(
469 RELEASE_AUTOMATION,
470 Status::Unavailable,
471 format!(
472 "the release automation at ({driver}, {forge}) has no landable files; the available tuples are: {}",
473 availability.automation_tuples().join("; ")
474 ),
475 )
476 } else {
477 Selection::selected(RELEASE_AUTOMATION, sources, Some(driver))
478 }
479 }
480 }
481 };
482 let automation_lands = automation.status == Status::Selected;
483
484 let (gate, zone, other, why_not) = match params.merge_form() {
489 MergeForm::Squash => (
490 TITLE_CHECK,
491 TITLE_GATE_ZONE,
492 COMMIT_CHECK,
493 "git.merge_form is squash, so the title gate judges the request",
494 ),
495 MergeForm::Rebase => (
496 COMMIT_CHECK,
497 COMMIT_GATE_ZONE,
498 TITLE_CHECK,
499 "git.merge_form is rebase, so the commit gate judges every commit of the request",
500 ),
501 };
502 let no_gate = (!forge_gate_lands(params.integration(), params.release_mode())).then(|| {
505 format!(
506 "no forge gate lands: git.integration is local and the release is not automatic (profile.release.mode is {}), so no request reaches the trunk for a gate to judge",
507 params.release_mode().as_str()
508 )
509 });
510 let selected_gate = match forge {
511 None => Selection::omitted(gate, Status::NotApplicable, "the profile names no forge"),
512 Some(forge) if !forge_known => Selection::omitted(
513 gate,
514 Status::Unknown,
515 format!(
516 "the forge {forge} is not one this release drives; the forges are: github, gitlab"
517 ),
518 ),
519 Some(_) if no_gate.is_some() => Selection::omitted(
520 gate,
521 Status::NotApplicable,
522 no_gate.clone().unwrap_or_default(),
523 ),
524 Some(forge) => {
525 let mut sources = availability.owned("_shared", forge, gate);
526 if !automation_lands {
527 sources.extend(availability.owned(zone, forge, gate));
528 }
529 if sources.is_empty() {
530 Selection::omitted(
531 gate,
532 Status::Unavailable,
533 format!(
534 "this release ships no {} gate for {forge}",
535 if gate == TITLE_CHECK {
536 "title"
537 } else {
538 "commit"
539 }
540 ),
541 )
542 } else {
543 Selection::selected(gate, sources, None)
544 }
545 }
546 };
547 let other_gate = no_gate.map_or_else(
548 || Selection::omitted(other, Status::NotRequested, why_not),
549 |reason| Selection::omitted(other, Status::NotApplicable, reason),
550 );
551 if gate == TITLE_CHECK {
552 out.push(selected_gate);
553 out.push(other_gate);
554 } else {
555 out.push(other_gate);
556 out.push(selected_gate);
557 }
558
559 out.push(match forge {
561 _ if !params.reporting_policy() => Selection::omitted(
562 REPORTING_POLICY,
563 Status::NotRequested,
564 "capabilities.reporting_policy is false",
565 ),
566 None => Selection::omitted(
567 REPORTING_POLICY,
568 Status::NotApplicable,
569 "the profile names no forge, and the policy names the forge's private channel",
570 ),
571 Some(forge) if !forge_known => Selection::omitted(
572 REPORTING_POLICY,
573 Status::Unknown,
574 format!(
575 "the forge {forge} is not one this release drives; the forges are: github, gitlab"
576 ),
577 ),
578 Some(forge) => {
579 let sources = availability.owned("_shared", forge, REPORTING_POLICY);
580 if sources.is_empty() {
581 Selection::omitted(
582 REPORTING_POLICY,
583 Status::Unavailable,
584 format!("this release ships no reporting policy for {forge}"),
585 )
586 } else {
587 Selection::selected(REPORTING_POLICY, sources, None)
588 }
589 }
590 });
591
592 out.push(automation);
593
594 out.push(match (params.nix_packaging(), driver, forge) {
596 (false, _, _) => Selection::omitted(
597 PACKAGING_NIX,
598 Status::NotRequested,
599 "capabilities.nix_packaging is false",
600 ),
601 (true, None, _) => Selection::omitted(
602 PACKAGING_NIX,
603 Status::NotApplicable,
604 "the seed is keyed on an automatic release driver, and the profile names none",
605 ),
606 (true, _, None) => Selection::omitted(
607 PACKAGING_NIX,
608 Status::NotApplicable,
609 "the profile names no forge",
610 ),
611 (true, Some(driver), Some(forge)) => {
612 if !driver_known || !forge_known {
613 Selection::omitted(
614 PACKAGING_NIX,
615 Status::Unknown,
616 format!("({driver}, {forge}) names a category this release does not know"),
617 )
618 } else {
619 let sources = availability.owned(driver, forge, PACKAGING_NIX);
620 if sources.is_empty() {
621 Selection::omitted(
622 PACKAGING_NIX,
623 Status::Unavailable,
624 format!("the {driver} binding ships no Nix seed on {forge}"),
625 )
626 } else {
627 Selection::selected(PACKAGING_NIX, sources, Some(driver))
628 }
629 }
630 }
631 });
632
633 out.push(match forge {
635 _ if !params.scorecard() => Selection::omitted(
636 SCORECARD,
637 Status::NotRequested,
638 "capabilities.scorecard is false",
639 ),
640 None => Selection::omitted(
641 SCORECARD,
642 Status::NotApplicable,
643 "the profile names no forge",
644 ),
645 Some(forge) if !forge_known => Selection::omitted(
646 SCORECARD,
647 Status::Unknown,
648 format!(
649 "the forge {forge} is not one this release drives; the forges are: github, gitlab"
650 ),
651 ),
652 Some(forge) => {
653 let sources = availability.owned("_shared", forge, SCORECARD);
654 if sources.is_empty() {
655 Selection::omitted(
656 SCORECARD,
657 Status::Unavailable,
658 format!(
659 "the Scorecard workflow is GitHub's alone, and the {forge} zone ships none"
660 ),
661 )
662 } else {
663 Selection::selected(SCORECARD, sources, None)
664 }
665 }
666 });
667
668 out.push(match (params.code_scanning(), driver, forge) {
671 (None, _, _) => Selection::omitted(
672 CODE_SCANNING,
673 Status::NotRequested,
674 "capabilities.code_scanning is off",
675 ),
676 (Some(_), None, _) => Selection::omitted(
677 CODE_SCANNING,
678 Status::NotApplicable,
679 "a scanner reads the release driver's language, and the profile names no driver",
680 ),
681 (Some(_), _, None) => Selection::omitted(
682 CODE_SCANNING,
683 Status::NotApplicable,
684 "the profile names no forge",
685 ),
686 (Some(provider), Some(driver), Some(forge)) => {
687 if !driver_known || !forge_known {
688 Selection::omitted(
689 CODE_SCANNING,
690 Status::Unknown,
691 format!("({driver}, {forge}) names a category this release does not know"),
692 )
693 } else if let Some(reason) = crate::projection::code_scanning_incompatibility(
694 Some(provider),
695 Some(driver),
696 Some(forge),
697 ) {
698 Selection::omitted(CODE_SCANNING, Status::Unavailable, reason)
699 } else {
700 let sources: Vec<String> = availability
701 .owned(driver, forge, CODE_SCANNING)
702 .into_iter()
703 .filter(|path| {
704 CODE_SCANNING_DESTINATIONS
705 .iter()
706 .any(|(name, owner)| path.ends_with(name) && *owner == provider)
707 })
708 .collect();
709 if sources.is_empty() {
710 Selection::omitted(
711 CODE_SCANNING,
712 Status::Unavailable,
713 format!(
714 "the {driver} binding ships no {} workflow on {forge}",
715 provider.as_str()
716 ),
717 )
718 } else {
719 Selection::selected(CODE_SCANNING, sources, Some(driver))
720 .keyed_on(provider.as_str())
721 }
722 }
723 }
724 });
725 debug_assert_eq!(out.len(), ALL.len());
726 out
727}
728
729#[must_use]
740pub fn pin_keys(selection: &Selection) -> Vec<String> {
741 let mut keys = vec![selection.id.to_owned()];
742 if let Some(driver) = &selection.driver {
743 keys.push(format!("{}/{driver}", selection.id));
744 if let Some(provider) = &selection.provider {
745 keys.push(format!("{}/{driver}/{provider}", selection.id));
746 }
747 }
748 keys
749}
750
751#[cfg(test)]
752mod tests {
753 use super::{
754 ALL, Availability, CODE_SCANNING, COMMIT_CHECK, GUARDS, PACKAGING_NIX, RELEASE_AUTOMATION,
755 REPORTING_POLICY, SCORECARD, Status, TITLE_CHECK, WORKFLOW_GUARDS, owner_of, select,
756 };
757 use crate::landing::Params;
758 use crate::landing::manifest::{Provider, Style};
759 use crate::profile::ReleaseMode;
760
761 fn status_of(selections: &[super::Selection], id: &str) -> Status {
762 selections
763 .iter()
764 .find(|s| s.id == id)
765 .expect("every capability answers")
766 .status
767 }
768
769 #[test]
772 fn every_embedded_snippet_has_one_owner() {
773 let availability = Availability::embedded();
774 assert!(!availability.files().is_empty());
775 for path in availability.files() {
776 let owner = owner_of(path);
777 assert!(owner.is_some(), "{path}: no capability owns it");
778 assert!(
779 ALL.contains(&owner.unwrap_or_default()),
780 "{path}: an unlisted owner"
781 );
782 }
783 assert_eq!(
784 owner_of("_shared/github/SECURITY.md"),
785 Some(REPORTING_POLICY)
786 );
787 assert_eq!(
788 owner_of("_shared/github/.github/workflows/scorecard.yml"),
789 Some(SCORECARD)
790 );
791 assert_eq!(owner_of("rust/github/flake.nix"), Some(PACKAGING_NIX));
792 assert_eq!(
793 owner_of("rust/github/.github/workflows/code-scanning-codeql.yml"),
794 Some(CODE_SCANNING)
795 );
796 assert_eq!(
797 owner_of("rust/github/release-plz.toml"),
798 Some(RELEASE_AUTOMATION)
799 );
800 assert_eq!(owner_of("_shared/github/unknown.txt"), None);
801 }
802
803 #[test]
805 fn the_catalog_answers_availability_per_tuple() {
806 let availability = Availability::embedded();
807 let mut rust = Params::for_test("acme/widget", Some(Style::Trunk));
810 rust.set_pair_for_test("rust", "gitlab");
811 rust.set_code_scanning_for_test(Some(crate::projection::Provider::Semgrep));
812 let mut python = rust.clone();
813 python.set_pair_for_test("python", "gitlab");
814 assert_eq!(
815 status_of(&select(&rust, &availability), CODE_SCANNING),
816 Status::Selected
817 );
818 let on_python = select(&python, &availability);
819 assert_eq!(status_of(&on_python, CODE_SCANNING), Status::Unavailable);
820 let mut github = python.clone();
822 github.set_pair_for_test("python", "github");
823 for params in [&python, &github] {
824 assert_eq!(
825 status_of(&select(params, &availability), RELEASE_AUTOMATION),
826 Status::Selected
827 );
828 }
829 let narrowed = Availability::over(
832 availability
833 .files()
834 .iter()
835 .filter(|path| !path.starts_with("python/gitlab/"))
836 .cloned()
837 .collect(),
838 );
839 let on_gitlab = select(&python, &narrowed);
840 assert_eq!(
841 status_of(&on_gitlab, RELEASE_AUTOMATION),
842 Status::Unavailable
843 );
844 let reason = on_gitlab
845 .iter()
846 .find(|s| s.id == RELEASE_AUTOMATION)
847 .and_then(|s| s.reason.clone())
848 .expect("an unavailable capability states why");
849 assert!(reason.contains("rust, gitlab"), "{reason}");
850 assert!(reason.contains("python, github"), "{reason}");
851 let tuples = reason
852 .split_once("the available tuples are:")
853 .map(|(_, tuples)| tuples)
854 .expect("the reason names the tuples");
855 assert!(!tuples.contains("python, gitlab"), "{reason}");
856 let title = on_gitlab
859 .iter()
860 .find(|s| s.id == TITLE_CHECK)
861 .expect("the title gate answers");
862 assert_eq!(title.status, Status::Selected);
863 assert!(
864 title
865 .sources
866 .iter()
867 .any(|s| s == "_title-gate/gitlab/.gitlab-ci.yml"),
868 "{:?}",
869 title.sources
870 );
871 }
872
873 #[test]
876 fn a_no_forge_input_selects_the_guards_alone() {
877 let params = Params::for_test_release_less(&[], None, ReleaseMode::None);
878 let selections = select(¶ms, &Availability::embedded());
879 assert_eq!(status_of(&selections, GUARDS), Status::Selected);
880 for id in [
881 TITLE_CHECK,
882 REPORTING_POLICY,
883 RELEASE_AUTOMATION,
884 PACKAGING_NIX,
885 ] {
886 assert_ne!(status_of(&selections, id), Status::Selected, "{id}");
887 }
888 assert_eq!(status_of(&selections, TITLE_CHECK), Status::NotApplicable);
889 assert_eq!(
890 status_of(&selections, RELEASE_AUTOMATION),
891 Status::NotRequested
892 );
893 assert_eq!(status_of(&selections, PACKAGING_NIX), Status::NotRequested);
894 }
895
896 #[test]
899 fn the_workflow_guards_are_selected_only_when_requested() {
900 let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
901 let selections = select(¶ms, &Availability::embedded());
902 assert_eq!(status_of(&selections, WORKFLOW_GUARDS), Status::Selected);
903 params.set_workflow_guards_for_test(false);
904 let selections = select(¶ms, &Availability::embedded());
905 assert_eq!(
906 status_of(&selections, WORKFLOW_GUARDS),
907 Status::NotRequested
908 );
909 assert_eq!(status_of(&selections, GUARDS), Status::Selected);
910 }
911
912 #[test]
920 fn workflow_only_local_landing_selects_no_forge_gate() {
921 use crate::landing::{Integration, MergeForm};
922 let availability = Availability::embedded();
923 for forge in ["github", "gitlab"] {
924 for form in [MergeForm::Squash, MergeForm::Rebase] {
925 for (mode, integration, lands) in [
926 (ReleaseMode::None, Integration::Local, false),
927 (ReleaseMode::External, Integration::Local, false),
928 (ReleaseMode::None, Integration::Forge, true),
929 (ReleaseMode::External, Integration::Forge, true),
930 ] {
931 let mut params = Params::for_test_release_less(&[], Some(forge), mode);
932 params.set_integration_for_test(integration);
933 params.set_merge_form_for_test(form);
934 let selections = select(¶ms, &availability);
935 let (gate, other) = match form {
936 MergeForm::Squash => (TITLE_CHECK, COMMIT_CHECK),
937 MergeForm::Rebase => (COMMIT_CHECK, TITLE_CHECK),
938 };
939 let at = format!("{forge} {form:?} {mode:?} {integration:?}");
940 let selection = selections
941 .iter()
942 .find(|s| s.id == gate)
943 .expect("every capability answers");
944 assert_eq!(selection.lands(), lands, "{at}");
945 if !lands {
946 assert_eq!(selection.status, Status::NotApplicable, "{at}");
947 assert!(selection.sources.is_empty(), "{at}");
948 let reason = selection.reason.as_deref().unwrap_or_default();
949 assert!(reason.contains("no forge gate lands"), "{at}: {reason}");
950 assert!(
951 reason.contains("git.integration is local"),
952 "{at}: {reason}"
953 );
954 }
955 let unnamed = selections
958 .iter()
959 .find(|s| s.id == other)
960 .expect("every capability answers");
961 let reason = unnamed.reason.as_deref().unwrap_or_default();
962 if lands {
963 assert_eq!(unnamed.status, Status::NotRequested, "{at}");
964 assert!(reason.contains("git.merge_form is"), "{at}: {reason}");
965 } else {
966 assert_eq!(unnamed.status, Status::NotApplicable, "{at}");
967 assert!(reason.contains("no forge gate lands"), "{at}: {reason}");
968 assert!(!reason.contains("merge_form"), "{at}: {reason}");
969 }
970 }
971 }
972 let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
975 params.set_pair_for_test("rust", forge);
976 params.set_integration_for_test(Integration::Local);
977 let selections = select(¶ms, &availability);
978 assert_eq!(
979 status_of(&selections, TITLE_CHECK),
980 Status::Selected,
981 "{forge}"
982 );
983 }
984 }
985
986 #[test]
988 fn an_unknown_forge_reads_as_unknown_and_lands_the_guards() {
989 let mut params = Params::for_test_release_less(&[], Some("codeberg"), ReleaseMode::None);
990 params.set_scorecard_for_test(true);
991 let selections = select(¶ms, &Availability::embedded());
992 assert_eq!(status_of(&selections, GUARDS), Status::Selected);
993 assert_eq!(status_of(&selections, TITLE_CHECK), Status::Unknown);
994 assert_eq!(status_of(&selections, SCORECARD), Status::Unknown);
995 }
996
997 #[test]
1001 fn code_scanning_selects_the_providers_own_file() {
1002 let availability = Availability::embedded();
1003 let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
1004 params.set_code_scanning_for_test(Some(Provider::Semgrep));
1005 let selections = select(¶ms, &availability);
1006 let scanning = selections
1007 .iter()
1008 .find(|s| s.id == CODE_SCANNING)
1009 .expect("answers");
1010 assert_eq!(scanning.status, Status::Selected);
1011 assert_eq!(
1012 scanning.sources,
1013 vec!["rust/github/.github/workflows/code-scanning-semgrep.yml".to_owned()]
1014 );
1015 params.set_pair_for_test("bash", "github");
1016 let selections = select(¶ms, &availability);
1017 assert_eq!(status_of(&selections, CODE_SCANNING), Status::Unavailable);
1018 }
1019
1020 #[test]
1027 fn commit_gate_replaces_the_title_gate_under_rebase() {
1028 use crate::landing::{Integration, MergeForm};
1029 let availability = Availability::embedded();
1030 for integration in [Integration::Forge, Integration::Local] {
1031 for (driver, forge) in [("rust", "github"), ("rust", "gitlab"), ("python", "gitlab")] {
1032 let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
1033 params.set_pair_for_test(driver, forge);
1034 params.set_integration_for_test(integration);
1035 let squash = select(¶ms, &availability);
1036 assert_eq!(status_of(&squash, TITLE_CHECK), Status::Selected);
1037 assert_eq!(status_of(&squash, COMMIT_CHECK), Status::NotRequested);
1038
1039 params.set_merge_form_for_test(MergeForm::Rebase);
1040 let rebase = select(¶ms, &availability);
1041 assert_eq!(status_of(&rebase, TITLE_CHECK), Status::NotRequested);
1042 let gate = rebase
1043 .iter()
1044 .find(|s| s.id == COMMIT_CHECK)
1045 .expect("the commit gate answers");
1046 assert_eq!(gate.status, Status::Selected, "{driver} {forge}");
1047 assert!(
1048 gate.sources.iter().all(|source| !source.contains("title")),
1049 "{:?}",
1050 gate.sources
1051 );
1052 let fragment = if forge == "github" {
1053 "_shared/github/.github/workflows/pr-commits.yml"
1054 } else {
1055 "_shared/gitlab/.gitlab/ci/mr-commits.yml"
1056 };
1057 assert!(
1058 gate.sources.iter().any(|s| s == fragment),
1059 "{:?}",
1060 gate.sources
1061 );
1062 assert!(
1066 !gate
1067 .sources
1068 .iter()
1069 .any(|s| s == "_commit-gate/gitlab/.gitlab-ci.yml"),
1070 "{driver} {forge}: {:?}",
1071 gate.sources
1072 );
1073 }
1074 }
1075 assert_eq!(
1076 owner_of("_shared/github/.github/workflows/pr-commits.yml"),
1077 Some(COMMIT_CHECK)
1078 );
1079 assert_eq!(
1080 owner_of("_commit-gate/gitlab/.gitlab-ci.yml"),
1081 Some(COMMIT_CHECK)
1082 );
1083 assert_eq!(ALL.len(), 9);
1084 }
1085}