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
356fn known_forge(name: &str) -> bool {
358 Forge::parse(name).is_some()
359}
360
361#[must_use]
368#[allow(
369 clippy::too_many_lines,
370 reason = "one pass answers every capability, and splitting it would separate a selection from the dimensions that decided it"
371)]
372pub fn select(params: &Params, availability: &Availability) -> Vec<Selection> {
373 let forge = params.forge();
374 let driver = params.driver();
375 let known_drivers = availability.drivers();
376 let driver_known = driver.is_some_and(|d| known_drivers.iter().any(|k| k == d));
377 let forge_known = forge.is_some_and(known_forge);
378
379 let mut out = Vec::with_capacity(ALL.len());
380 out.push(Selection::selected(GUARDS, Vec::new(), None));
381 out.push(if params.workflow_guards() {
382 Selection::selected(WORKFLOW_GUARDS, Vec::new(), None)
383 } else {
384 Selection::omitted(
385 WORKFLOW_GUARDS,
386 Status::NotRequested,
387 "capabilities.workflow_guards is false",
388 )
389 });
390
391 let automation = match (params.release_mode(), driver, forge) {
394 (ReleaseMode::External, _, _) => Selection::omitted(
395 RELEASE_AUTOMATION,
396 Status::NotRequested,
397 "the release is external: the target releases through a process release-kit does not drive",
398 ),
399 (ReleaseMode::None, _, _) => Selection::omitted(
400 RELEASE_AUTOMATION,
401 Status::NotRequested,
402 "the release mode is none",
403 ),
404 (ReleaseMode::Automatic, None, _) => Selection::omitted(
405 RELEASE_AUTOMATION,
406 Status::NotApplicable,
407 "an automatic release names no driver",
408 ),
409 (ReleaseMode::Automatic, _, None) => Selection::omitted(
410 RELEASE_AUTOMATION,
411 Status::NotApplicable,
412 "the profile names no forge",
413 ),
414 (ReleaseMode::Automatic, Some(driver), Some(forge)) => {
415 if !driver_known {
416 Selection::omitted(
417 RELEASE_AUTOMATION,
418 Status::Unknown,
419 format!(
420 "the driver {driver} is not one this release knows; the bindings are: {}",
421 known_drivers.join(", ")
422 ),
423 )
424 } else if !forge_known {
425 Selection::omitted(
426 RELEASE_AUTOMATION,
427 Status::Unknown,
428 format!(
429 "the forge {forge} is not one this release drives; the forges are: github, gitlab"
430 ),
431 )
432 } else {
433 let sources = availability.owned(driver, forge, RELEASE_AUTOMATION);
434 if sources.is_empty() {
435 Selection::omitted(
436 RELEASE_AUTOMATION,
437 Status::Unavailable,
438 format!(
439 "the release automation at ({driver}, {forge}) has no landable files; the available tuples are: {}",
440 availability.automation_tuples().join("; ")
441 ),
442 )
443 } else {
444 Selection::selected(RELEASE_AUTOMATION, sources, Some(driver))
445 }
446 }
447 }
448 };
449 let automation_lands = automation.status == Status::Selected;
450
451 let (gate, zone, other, why_not) = match params.merge_form() {
456 MergeForm::Squash => (
457 TITLE_CHECK,
458 TITLE_GATE_ZONE,
459 COMMIT_CHECK,
460 "git.merge_form is squash, so the title gate judges the request",
461 ),
462 MergeForm::Rebase => (
463 COMMIT_CHECK,
464 COMMIT_GATE_ZONE,
465 TITLE_CHECK,
466 "git.merge_form is rebase, so the commit gate judges every commit of the request",
467 ),
468 };
469 let no_gate = (!forge_gate_lands(params.integration(), params.release_mode())).then(|| {
472 format!(
473 "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",
474 params.release_mode().as_str()
475 )
476 });
477 let selected_gate = match forge {
478 None => Selection::omitted(gate, Status::NotApplicable, "the profile names no forge"),
479 Some(forge) if !forge_known => Selection::omitted(
480 gate,
481 Status::Unknown,
482 format!(
483 "the forge {forge} is not one this release drives; the forges are: github, gitlab"
484 ),
485 ),
486 Some(_) if no_gate.is_some() => Selection::omitted(
487 gate,
488 Status::NotApplicable,
489 no_gate.clone().unwrap_or_default(),
490 ),
491 Some(forge) => {
492 let mut sources = availability.owned("_shared", forge, gate);
493 if !automation_lands {
494 sources.extend(availability.owned(zone, forge, gate));
495 }
496 if sources.is_empty() {
497 Selection::omitted(
498 gate,
499 Status::Unavailable,
500 format!(
501 "this release ships no {} gate for {forge}",
502 if gate == TITLE_CHECK {
503 "title"
504 } else {
505 "commit"
506 }
507 ),
508 )
509 } else {
510 Selection::selected(gate, sources, None)
511 }
512 }
513 };
514 let other_gate = no_gate.map_or_else(
515 || Selection::omitted(other, Status::NotRequested, why_not),
516 |reason| Selection::omitted(other, Status::NotApplicable, reason),
517 );
518 if gate == TITLE_CHECK {
519 out.push(selected_gate);
520 out.push(other_gate);
521 } else {
522 out.push(other_gate);
523 out.push(selected_gate);
524 }
525
526 out.push(match forge {
528 _ if !params.reporting_policy() => Selection::omitted(
529 REPORTING_POLICY,
530 Status::NotRequested,
531 "capabilities.reporting_policy is false",
532 ),
533 None => Selection::omitted(
534 REPORTING_POLICY,
535 Status::NotApplicable,
536 "the profile names no forge, and the policy names the forge's private channel",
537 ),
538 Some(forge) if !forge_known => Selection::omitted(
539 REPORTING_POLICY,
540 Status::Unknown,
541 format!(
542 "the forge {forge} is not one this release drives; the forges are: github, gitlab"
543 ),
544 ),
545 Some(forge) => {
546 let sources = availability.owned("_shared", forge, REPORTING_POLICY);
547 if sources.is_empty() {
548 Selection::omitted(
549 REPORTING_POLICY,
550 Status::Unavailable,
551 format!("this release ships no reporting policy for {forge}"),
552 )
553 } else {
554 Selection::selected(REPORTING_POLICY, sources, None)
555 }
556 }
557 });
558
559 out.push(automation);
560
561 out.push(match (params.nix_packaging(), driver, forge) {
563 (false, _, _) => Selection::omitted(
564 PACKAGING_NIX,
565 Status::NotRequested,
566 "capabilities.nix_packaging is false",
567 ),
568 (true, None, _) => Selection::omitted(
569 PACKAGING_NIX,
570 Status::NotApplicable,
571 "the seed is keyed on an automatic release driver, and the profile names none",
572 ),
573 (true, _, None) => Selection::omitted(
574 PACKAGING_NIX,
575 Status::NotApplicable,
576 "the profile names no forge",
577 ),
578 (true, Some(driver), Some(forge)) => {
579 if !driver_known || !forge_known {
580 Selection::omitted(
581 PACKAGING_NIX,
582 Status::Unknown,
583 format!("({driver}, {forge}) names a category this release does not know"),
584 )
585 } else {
586 let sources = availability.owned(driver, forge, PACKAGING_NIX);
587 if sources.is_empty() {
588 Selection::omitted(
589 PACKAGING_NIX,
590 Status::Unavailable,
591 format!("the {driver} binding ships no Nix seed on {forge}"),
592 )
593 } else {
594 Selection::selected(PACKAGING_NIX, sources, Some(driver))
595 }
596 }
597 }
598 });
599
600 out.push(match forge {
602 _ if !params.scorecard() => Selection::omitted(
603 SCORECARD,
604 Status::NotRequested,
605 "capabilities.scorecard is false",
606 ),
607 None => Selection::omitted(
608 SCORECARD,
609 Status::NotApplicable,
610 "the profile names no forge",
611 ),
612 Some(forge) if !forge_known => Selection::omitted(
613 SCORECARD,
614 Status::Unknown,
615 format!(
616 "the forge {forge} is not one this release drives; the forges are: github, gitlab"
617 ),
618 ),
619 Some(forge) => {
620 let sources = availability.owned("_shared", forge, SCORECARD);
621 if sources.is_empty() {
622 Selection::omitted(
623 SCORECARD,
624 Status::Unavailable,
625 format!(
626 "the Scorecard workflow is GitHub's alone, and the {forge} zone ships none"
627 ),
628 )
629 } else {
630 Selection::selected(SCORECARD, sources, None)
631 }
632 }
633 });
634
635 out.push(match (params.code_scanning(), driver, forge) {
638 (None, _, _) => Selection::omitted(
639 CODE_SCANNING,
640 Status::NotRequested,
641 "capabilities.code_scanning is off",
642 ),
643 (Some(_), None, _) => Selection::omitted(
644 CODE_SCANNING,
645 Status::NotApplicable,
646 "a scanner reads the release driver's language, and the profile names no driver",
647 ),
648 (Some(_), _, None) => Selection::omitted(
649 CODE_SCANNING,
650 Status::NotApplicable,
651 "the profile names no forge",
652 ),
653 (Some(provider), Some(driver), Some(forge)) => {
654 if !driver_known || !forge_known {
655 Selection::omitted(
656 CODE_SCANNING,
657 Status::Unknown,
658 format!("({driver}, {forge}) names a category this release does not know"),
659 )
660 } else if let Some(reason) = crate::projection::code_scanning_incompatibility(
661 Some(provider),
662 Some(driver),
663 Some(forge),
664 ) {
665 Selection::omitted(CODE_SCANNING, Status::Unavailable, reason)
666 } else {
667 let sources: Vec<String> = availability
668 .owned(driver, forge, CODE_SCANNING)
669 .into_iter()
670 .filter(|path| {
671 CODE_SCANNING_DESTINATIONS
672 .iter()
673 .any(|(name, owner)| path.ends_with(name) && *owner == provider)
674 })
675 .collect();
676 if sources.is_empty() {
677 Selection::omitted(
678 CODE_SCANNING,
679 Status::Unavailable,
680 format!(
681 "the {driver} binding ships no {} workflow on {forge}",
682 provider.as_str()
683 ),
684 )
685 } else {
686 Selection::selected(CODE_SCANNING, sources, Some(driver))
687 .keyed_on(provider.as_str())
688 }
689 }
690 }
691 });
692 debug_assert_eq!(out.len(), ALL.len());
693 out
694}
695
696#[must_use]
707pub fn pin_keys(selection: &Selection) -> Vec<String> {
708 let mut keys = vec![selection.id.to_owned()];
709 if let Some(driver) = &selection.driver {
710 keys.push(format!("{}/{driver}", selection.id));
711 if let Some(provider) = &selection.provider {
712 keys.push(format!("{}/{driver}/{provider}", selection.id));
713 }
714 }
715 keys
716}
717
718#[cfg(test)]
719mod tests {
720 use super::{
721 ALL, Availability, CODE_SCANNING, COMMIT_CHECK, GUARDS, PACKAGING_NIX, RELEASE_AUTOMATION,
722 REPORTING_POLICY, SCORECARD, Status, TITLE_CHECK, WORKFLOW_GUARDS, owner_of, select,
723 };
724 use crate::landing::Params;
725 use crate::landing::manifest::{Provider, Style};
726 use crate::profile::ReleaseMode;
727
728 fn status_of(selections: &[super::Selection], id: &str) -> Status {
729 selections
730 .iter()
731 .find(|s| s.id == id)
732 .expect("every capability answers")
733 .status
734 }
735
736 #[test]
739 fn every_embedded_snippet_has_one_owner() {
740 let availability = Availability::embedded();
741 assert!(!availability.files().is_empty());
742 for path in availability.files() {
743 let owner = owner_of(path);
744 assert!(owner.is_some(), "{path}: no capability owns it");
745 assert!(
746 ALL.contains(&owner.unwrap_or_default()),
747 "{path}: an unlisted owner"
748 );
749 }
750 assert_eq!(
751 owner_of("_shared/github/SECURITY.md"),
752 Some(REPORTING_POLICY)
753 );
754 assert_eq!(
755 owner_of("_shared/github/.github/workflows/scorecard.yml"),
756 Some(SCORECARD)
757 );
758 assert_eq!(owner_of("rust/github/flake.nix"), Some(PACKAGING_NIX));
759 assert_eq!(
760 owner_of("rust/github/.github/workflows/code-scanning-codeql.yml"),
761 Some(CODE_SCANNING)
762 );
763 assert_eq!(
764 owner_of("rust/github/release-plz.toml"),
765 Some(RELEASE_AUTOMATION)
766 );
767 assert_eq!(owner_of("_shared/github/unknown.txt"), None);
768 }
769
770 #[test]
772 fn the_catalog_answers_availability_per_tuple() {
773 let availability = Availability::embedded();
774 let mut github = Params::for_test("acme/widget", Some(Style::Trunk));
775 github.set_pair_for_test("python", "github");
776 let mut gitlab = github.clone();
777 gitlab.set_pair_for_test("python", "gitlab");
778 let on_github = select(&github, &availability);
779 let on_gitlab = select(&gitlab, &availability);
780 assert_eq!(status_of(&on_github, RELEASE_AUTOMATION), Status::Selected);
781 assert_eq!(
782 status_of(&on_gitlab, RELEASE_AUTOMATION),
783 Status::Unavailable
784 );
785 let reason = on_gitlab
786 .iter()
787 .find(|s| s.id == RELEASE_AUTOMATION)
788 .and_then(|s| s.reason.clone())
789 .expect("an unavailable capability states why");
790 assert!(reason.contains("rust, gitlab"), "{reason}");
791 assert!(reason.contains("python, github"), "{reason}");
792 let title = on_gitlab
795 .iter()
796 .find(|s| s.id == TITLE_CHECK)
797 .expect("the title gate answers");
798 assert_eq!(title.status, Status::Selected);
799 assert!(
800 title
801 .sources
802 .iter()
803 .any(|s| s == "_title-gate/gitlab/.gitlab-ci.yml"),
804 "{:?}",
805 title.sources
806 );
807 }
808
809 #[test]
812 fn a_no_forge_input_selects_the_guards_alone() {
813 let params = Params::for_test_release_less(&[], None, ReleaseMode::None);
814 let selections = select(¶ms, &Availability::embedded());
815 assert_eq!(status_of(&selections, GUARDS), Status::Selected);
816 for id in [
817 TITLE_CHECK,
818 REPORTING_POLICY,
819 RELEASE_AUTOMATION,
820 PACKAGING_NIX,
821 ] {
822 assert_ne!(status_of(&selections, id), Status::Selected, "{id}");
823 }
824 assert_eq!(status_of(&selections, TITLE_CHECK), Status::NotApplicable);
825 assert_eq!(
826 status_of(&selections, RELEASE_AUTOMATION),
827 Status::NotRequested
828 );
829 assert_eq!(status_of(&selections, PACKAGING_NIX), Status::NotRequested);
830 }
831
832 #[test]
835 fn the_workflow_guards_are_selected_only_when_requested() {
836 let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
837 let selections = select(¶ms, &Availability::embedded());
838 assert_eq!(status_of(&selections, WORKFLOW_GUARDS), Status::Selected);
839 params.set_workflow_guards_for_test(false);
840 let selections = select(¶ms, &Availability::embedded());
841 assert_eq!(
842 status_of(&selections, WORKFLOW_GUARDS),
843 Status::NotRequested
844 );
845 assert_eq!(status_of(&selections, GUARDS), Status::Selected);
846 }
847
848 #[test]
856 fn workflow_only_local_landing_selects_no_forge_gate() {
857 use crate::landing::{Integration, MergeForm};
858 let availability = Availability::embedded();
859 for forge in ["github", "gitlab"] {
860 for form in [MergeForm::Squash, MergeForm::Rebase] {
861 for (mode, integration, lands) in [
862 (ReleaseMode::None, Integration::Local, false),
863 (ReleaseMode::External, Integration::Local, false),
864 (ReleaseMode::None, Integration::Forge, true),
865 (ReleaseMode::External, Integration::Forge, true),
866 ] {
867 let mut params = Params::for_test_release_less(&[], Some(forge), mode);
868 params.set_integration_for_test(integration);
869 params.set_merge_form_for_test(form);
870 let selections = select(¶ms, &availability);
871 let (gate, other) = match form {
872 MergeForm::Squash => (TITLE_CHECK, COMMIT_CHECK),
873 MergeForm::Rebase => (COMMIT_CHECK, TITLE_CHECK),
874 };
875 let at = format!("{forge} {form:?} {mode:?} {integration:?}");
876 let selection = selections
877 .iter()
878 .find(|s| s.id == gate)
879 .expect("every capability answers");
880 assert_eq!(selection.lands(), lands, "{at}");
881 if !lands {
882 assert_eq!(selection.status, Status::NotApplicable, "{at}");
883 assert!(selection.sources.is_empty(), "{at}");
884 let reason = selection.reason.as_deref().unwrap_or_default();
885 assert!(reason.contains("no forge gate lands"), "{at}: {reason}");
886 assert!(
887 reason.contains("git.integration is local"),
888 "{at}: {reason}"
889 );
890 }
891 let unnamed = selections
894 .iter()
895 .find(|s| s.id == other)
896 .expect("every capability answers");
897 let reason = unnamed.reason.as_deref().unwrap_or_default();
898 if lands {
899 assert_eq!(unnamed.status, Status::NotRequested, "{at}");
900 assert!(reason.contains("git.merge_form is"), "{at}: {reason}");
901 } else {
902 assert_eq!(unnamed.status, Status::NotApplicable, "{at}");
903 assert!(reason.contains("no forge gate lands"), "{at}: {reason}");
904 assert!(!reason.contains("merge_form"), "{at}: {reason}");
905 }
906 }
907 }
908 let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
911 params.set_pair_for_test("rust", forge);
912 params.set_integration_for_test(Integration::Local);
913 let selections = select(¶ms, &availability);
914 assert_eq!(
915 status_of(&selections, TITLE_CHECK),
916 Status::Selected,
917 "{forge}"
918 );
919 }
920 }
921
922 #[test]
924 fn an_unknown_forge_reads_as_unknown_and_lands_the_guards() {
925 let mut params = Params::for_test_release_less(&[], Some("codeberg"), ReleaseMode::None);
926 params.set_scorecard_for_test(true);
927 let selections = select(¶ms, &Availability::embedded());
928 assert_eq!(status_of(&selections, GUARDS), Status::Selected);
929 assert_eq!(status_of(&selections, TITLE_CHECK), Status::Unknown);
930 assert_eq!(status_of(&selections, SCORECARD), Status::Unknown);
931 }
932
933 #[test]
937 fn code_scanning_selects_the_providers_own_file() {
938 let availability = Availability::embedded();
939 let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
940 params.set_code_scanning_for_test(Some(Provider::Semgrep));
941 let selections = select(¶ms, &availability);
942 let scanning = selections
943 .iter()
944 .find(|s| s.id == CODE_SCANNING)
945 .expect("answers");
946 assert_eq!(scanning.status, Status::Selected);
947 assert_eq!(
948 scanning.sources,
949 vec!["rust/github/.github/workflows/code-scanning-semgrep.yml".to_owned()]
950 );
951 params.set_pair_for_test("bash", "github");
952 let selections = select(¶ms, &availability);
953 assert_eq!(status_of(&selections, CODE_SCANNING), Status::Unavailable);
954 }
955
956 #[test]
963 fn commit_gate_replaces_the_title_gate_under_rebase() {
964 use crate::landing::{Integration, MergeForm};
965 let availability = Availability::embedded();
966 for integration in [Integration::Forge, Integration::Local] {
967 for (driver, forge) in [("rust", "github"), ("rust", "gitlab"), ("python", "gitlab")] {
968 let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
969 params.set_pair_for_test(driver, forge);
970 params.set_integration_for_test(integration);
971 let squash = select(¶ms, &availability);
972 assert_eq!(status_of(&squash, TITLE_CHECK), Status::Selected);
973 assert_eq!(status_of(&squash, COMMIT_CHECK), Status::NotRequested);
974
975 params.set_merge_form_for_test(MergeForm::Rebase);
976 let rebase = select(¶ms, &availability);
977 assert_eq!(status_of(&rebase, TITLE_CHECK), Status::NotRequested);
978 let gate = rebase
979 .iter()
980 .find(|s| s.id == COMMIT_CHECK)
981 .expect("the commit gate answers");
982 assert_eq!(gate.status, Status::Selected, "{driver} {forge}");
983 assert!(
984 gate.sources.iter().all(|source| !source.contains("title")),
985 "{:?}",
986 gate.sources
987 );
988 let fragment = if forge == "github" {
989 "_shared/github/.github/workflows/pr-commits.yml"
990 } else {
991 "_shared/gitlab/.gitlab/ci/mr-commits.yml"
992 };
993 assert!(
994 gate.sources.iter().any(|s| s == fragment),
995 "{:?}",
996 gate.sources
997 );
998 assert_eq!(
1001 gate.sources
1002 .iter()
1003 .any(|s| s == "_commit-gate/gitlab/.gitlab-ci.yml"),
1004 driver == "python",
1005 "{:?}",
1006 gate.sources
1007 );
1008 }
1009 }
1010 assert_eq!(
1011 owner_of("_shared/github/.github/workflows/pr-commits.yml"),
1012 Some(COMMIT_CHECK)
1013 );
1014 assert_eq!(
1015 owner_of("_commit-gate/gitlab/.gitlab-ci.yml"),
1016 Some(COMMIT_CHECK)
1017 );
1018 assert_eq!(ALL.len(), 9);
1019 }
1020}