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