1use serde::Serialize;
18
19use super::{Params, ReleaseMode};
20use crate::detect::Forge;
21use crate::projection::{CODE_SCANNING_DESTINATIONS, NIX_DESTINATIONS};
22
23pub const GUARDS: &str = "git.guards";
29pub const WORKFLOW_GUARDS: &str = "git.workflow-guards";
33pub const TITLE_CHECK: &str = "git.title-check";
35pub const REPORTING_POLICY: &str = "security.reporting-policy";
37pub const RELEASE_AUTOMATION: &str = "release.automation";
40pub const PACKAGING_NIX: &str = "packaging.nix";
42pub const SCORECARD: &str = "supply-chain.scorecard";
44pub const CODE_SCANNING: &str = "supply-chain.code-scanning";
46
47pub const ALL: [&str; 8] = [
49 GUARDS,
50 WORKFLOW_GUARDS,
51 TITLE_CHECK,
52 REPORTING_POLICY,
53 RELEASE_AUTOMATION,
54 PACKAGING_NIX,
55 SCORECARD,
56 CODE_SCANNING,
57];
58
59pub const TITLE_GATE_ZONE: &str = "_title-gate";
63
64#[must_use]
71pub fn owner_of(path: &str) -> Option<&'static str> {
72 let mut segments = path.splitn(3, '/');
73 let (zone, _forge, destination) = (segments.next()?, segments.next()?, segments.next()?);
74 if zone == "_shared" {
75 return match destination {
76 "SECURITY.md" => Some(REPORTING_POLICY),
77 ".github/workflows/pr-title.yml" | ".gitlab/ci/mr-title.yml" => Some(TITLE_CHECK),
78 ".github/workflows/scorecard.yml" => Some(SCORECARD),
79 _ => None,
80 };
81 }
82 if zone == TITLE_GATE_ZONE {
83 return (destination == ".gitlab-ci.yml").then_some(TITLE_CHECK);
84 }
85 if zone.starts_with('_') {
86 return None;
87 }
88 if NIX_DESTINATIONS.contains(&destination) {
89 return Some(PACKAGING_NIX);
90 }
91 if CODE_SCANNING_DESTINATIONS
92 .iter()
93 .any(|(name, _)| *name == destination)
94 {
95 return Some(CODE_SCANNING);
96 }
97 Some(RELEASE_AUTOMATION)
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
102#[serde(rename_all = "kebab-case")]
103pub enum Status {
104 Selected,
107 NotRequested,
109 NotApplicable,
112 Unavailable,
114 Unknown,
116 Withheld,
120}
121
122impl Status {
123 #[must_use]
125 pub const fn as_str(self) -> &'static str {
126 match self {
127 Self::Selected => "selected",
128 Self::NotRequested => "not-requested",
129 Self::NotApplicable => "not-applicable",
130 Self::Unavailable => "unavailable",
131 Self::Unknown => "unknown",
132 Self::Withheld => "withheld",
133 }
134 }
135}
136
137#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
139pub struct Selection {
140 pub id: &'static str,
142 pub status: Status,
144 #[serde(skip_serializing_if = "Option::is_none")]
146 pub reason: Option<String>,
147 #[serde(skip_serializing_if = "Option::is_none")]
149 pub action: Option<String>,
150 #[serde(skip)]
153 pub sources: Vec<String>,
154 #[serde(skip_serializing_if = "Option::is_none")]
157 pub driver: Option<String>,
158 #[serde(skip_serializing_if = "Option::is_none")]
163 pub provider: Option<String>,
164}
165
166impl Selection {
167 #[must_use]
169 pub const fn lands(&self) -> bool {
170 matches!(self.status, Status::Selected | Status::Withheld)
171 }
172
173 fn selected(id: &'static str, sources: Vec<String>, driver: Option<&str>) -> Self {
174 Self {
175 id,
176 status: Status::Selected,
177 reason: None,
178 action: None,
179 sources,
180 driver: driver.map(str::to_owned),
181 provider: None,
182 }
183 }
184
185 fn keyed_on(mut self, provider: &str) -> Self {
187 self.provider = Some(provider.to_owned());
188 self
189 }
190
191 fn omitted(id: &'static str, status: Status, reason: impl Into<String>) -> Self {
192 Self {
193 id,
194 status,
195 reason: Some(reason.into()),
196 action: None,
197 sources: Vec::new(),
198 driver: None,
199 provider: None,
200 }
201 }
202}
203
204#[derive(Debug, Clone, PartialEq, Eq)]
207pub struct Availability {
208 files: Vec<String>,
209}
210
211impl Availability {
212 #[must_use]
215 pub fn over(files: Vec<String>) -> Self {
216 let mut files = files;
217 files.sort();
218 Self { files }
219 }
220
221 #[must_use]
223 pub fn embedded() -> Self {
224 Self::over(
225 crate::embedded::walk(&crate::embedded::SNIPPETS)
226 .into_iter()
227 .map(|(path, _)| path)
228 .collect(),
229 )
230 }
231
232 #[must_use]
234 pub fn files(&self) -> &[String] {
235 &self.files
236 }
237
238 fn owned(&self, zone: &str, forge: &str, capability: &str) -> Vec<String> {
240 let prefix = format!("{zone}/{forge}/");
241 self.files
242 .iter()
243 .filter(|path| path.starts_with(&prefix) && owner_of(path) == Some(capability))
244 .cloned()
245 .collect()
246 }
247
248 #[must_use]
250 pub fn drivers(&self) -> Vec<String> {
251 let mut out: Vec<String> = Vec::new();
252 for path in &self.files {
253 if let Some((zone, _)) = path.split_once('/')
254 && !zone.starts_with('_')
255 && !out.iter().any(|known| known == zone)
256 {
257 out.push(zone.to_owned());
258 }
259 }
260 out
261 }
262
263 #[must_use]
266 pub fn automation_tuples(&self) -> Vec<String> {
267 let mut out: Vec<String> = Vec::new();
268 for path in &self.files {
269 let mut segments = path.splitn(3, '/');
270 if let (Some(driver), Some(forge), Some(_)) =
271 (segments.next(), segments.next(), segments.next())
272 && !driver.starts_with('_')
273 && owner_of(path) == Some(RELEASE_AUTOMATION)
274 {
275 let entry = format!("{driver}, {forge}");
276 if !out.contains(&entry) {
277 out.push(entry);
278 }
279 }
280 }
281 out
282 }
283}
284
285#[must_use]
287pub fn known_drivers() -> Vec<String> {
288 Availability::embedded().drivers()
289}
290
291fn known_forge(name: &str) -> bool {
293 Forge::parse(name).is_some()
294}
295
296#[must_use]
301#[allow(
302 clippy::too_many_lines,
303 reason = "one pass answers every capability, and splitting it would separate a selection from the dimensions that decided it"
304)]
305pub fn select(params: &Params, availability: &Availability) -> Vec<Selection> {
306 let forge = params.forge();
307 let driver = params.driver();
308 let known_drivers = availability.drivers();
309 let driver_known = driver.is_some_and(|d| known_drivers.iter().any(|k| k == d));
310 let forge_known = forge.is_some_and(known_forge);
311
312 let mut out = Vec::with_capacity(ALL.len());
313 out.push(Selection::selected(GUARDS, Vec::new(), None));
314 out.push(if params.workflow_guards() {
315 Selection::selected(WORKFLOW_GUARDS, Vec::new(), None)
316 } else {
317 Selection::omitted(
318 WORKFLOW_GUARDS,
319 Status::NotRequested,
320 "capabilities.workflow_guards is false",
321 )
322 });
323
324 let automation = match (params.release_mode(), driver, forge) {
327 (ReleaseMode::External, _, _) => Selection::omitted(
328 RELEASE_AUTOMATION,
329 Status::NotRequested,
330 "the release is external: the target releases through a process release-kit does not drive",
331 ),
332 (ReleaseMode::None, _, _) => Selection::omitted(
333 RELEASE_AUTOMATION,
334 Status::NotRequested,
335 "the release mode is none",
336 ),
337 (ReleaseMode::Automatic, None, _) => Selection::omitted(
338 RELEASE_AUTOMATION,
339 Status::NotApplicable,
340 "an automatic release names no driver",
341 ),
342 (ReleaseMode::Automatic, _, None) => Selection::omitted(
343 RELEASE_AUTOMATION,
344 Status::NotApplicable,
345 "the profile names no forge",
346 ),
347 (ReleaseMode::Automatic, Some(driver), Some(forge)) => {
348 if !driver_known {
349 Selection::omitted(
350 RELEASE_AUTOMATION,
351 Status::Unknown,
352 format!(
353 "the driver {driver} is not one this release knows; the bindings are: {}",
354 known_drivers.join(", ")
355 ),
356 )
357 } else if !forge_known {
358 Selection::omitted(
359 RELEASE_AUTOMATION,
360 Status::Unknown,
361 format!(
362 "the forge {forge} is not one this release drives; the forges are: github, gitlab"
363 ),
364 )
365 } else {
366 let sources = availability.owned(driver, forge, RELEASE_AUTOMATION);
367 if sources.is_empty() {
368 Selection::omitted(
369 RELEASE_AUTOMATION,
370 Status::Unavailable,
371 format!(
372 "the release automation at ({driver}, {forge}) has no landable files; the available tuples are: {}",
373 availability.automation_tuples().join("; ")
374 ),
375 )
376 } else {
377 Selection::selected(RELEASE_AUTOMATION, sources, Some(driver))
378 }
379 }
380 }
381 };
382 let automation_lands = automation.status == Status::Selected;
383
384 out.push(match forge {
386 None => Selection::omitted(
387 TITLE_CHECK,
388 Status::NotApplicable,
389 "the profile names no forge",
390 ),
391 Some(forge) if !forge_known => Selection::omitted(
392 TITLE_CHECK,
393 Status::Unknown,
394 format!(
395 "the forge {forge} is not one this release drives; the forges are: github, gitlab"
396 ),
397 ),
398 Some(forge) => {
399 let mut sources = availability.owned("_shared", forge, TITLE_CHECK);
400 if !automation_lands {
401 sources.extend(availability.owned(TITLE_GATE_ZONE, forge, TITLE_CHECK));
402 }
403 if sources.is_empty() {
404 Selection::omitted(
405 TITLE_CHECK,
406 Status::Unavailable,
407 format!("this release ships no title gate for {forge}"),
408 )
409 } else {
410 Selection::selected(TITLE_CHECK, sources, None)
411 }
412 }
413 });
414
415 out.push(match forge {
417 _ if !params.reporting_policy() => Selection::omitted(
418 REPORTING_POLICY,
419 Status::NotRequested,
420 "capabilities.reporting_policy is false",
421 ),
422 None => Selection::omitted(
423 REPORTING_POLICY,
424 Status::NotApplicable,
425 "the profile names no forge, and the policy names the forge's private channel",
426 ),
427 Some(forge) if !forge_known => Selection::omitted(
428 REPORTING_POLICY,
429 Status::Unknown,
430 format!(
431 "the forge {forge} is not one this release drives; the forges are: github, gitlab"
432 ),
433 ),
434 Some(forge) => {
435 let sources = availability.owned("_shared", forge, REPORTING_POLICY);
436 if sources.is_empty() {
437 Selection::omitted(
438 REPORTING_POLICY,
439 Status::Unavailable,
440 format!("this release ships no reporting policy for {forge}"),
441 )
442 } else {
443 Selection::selected(REPORTING_POLICY, sources, None)
444 }
445 }
446 });
447
448 out.push(automation);
449
450 out.push(match (params.nix_packaging(), driver, forge) {
452 (false, _, _) => Selection::omitted(
453 PACKAGING_NIX,
454 Status::NotRequested,
455 "capabilities.nix_packaging is false",
456 ),
457 (true, None, _) => Selection::omitted(
458 PACKAGING_NIX,
459 Status::NotApplicable,
460 "the seed is keyed on an automatic release driver, and the profile names none",
461 ),
462 (true, _, None) => Selection::omitted(
463 PACKAGING_NIX,
464 Status::NotApplicable,
465 "the profile names no forge",
466 ),
467 (true, Some(driver), Some(forge)) => {
468 if !driver_known || !forge_known {
469 Selection::omitted(
470 PACKAGING_NIX,
471 Status::Unknown,
472 format!("({driver}, {forge}) names a category this release does not know"),
473 )
474 } else {
475 let sources = availability.owned(driver, forge, PACKAGING_NIX);
476 if sources.is_empty() {
477 Selection::omitted(
478 PACKAGING_NIX,
479 Status::Unavailable,
480 format!("the {driver} binding ships no Nix seed on {forge}"),
481 )
482 } else {
483 Selection::selected(PACKAGING_NIX, sources, Some(driver))
484 }
485 }
486 }
487 });
488
489 out.push(match forge {
491 _ if !params.scorecard() => Selection::omitted(
492 SCORECARD,
493 Status::NotRequested,
494 "capabilities.scorecard is false",
495 ),
496 None => Selection::omitted(
497 SCORECARD,
498 Status::NotApplicable,
499 "the profile names no forge",
500 ),
501 Some(forge) if !forge_known => Selection::omitted(
502 SCORECARD,
503 Status::Unknown,
504 format!(
505 "the forge {forge} is not one this release drives; the forges are: github, gitlab"
506 ),
507 ),
508 Some(forge) => {
509 let sources = availability.owned("_shared", forge, SCORECARD);
510 if sources.is_empty() {
511 Selection::omitted(
512 SCORECARD,
513 Status::Unavailable,
514 format!(
515 "the Scorecard workflow is GitHub's alone, and the {forge} zone ships none"
516 ),
517 )
518 } else {
519 Selection::selected(SCORECARD, sources, None)
520 }
521 }
522 });
523
524 out.push(match (params.code_scanning(), driver, forge) {
527 (None, _, _) => Selection::omitted(
528 CODE_SCANNING,
529 Status::NotRequested,
530 "capabilities.code_scanning is off",
531 ),
532 (Some(_), None, _) => Selection::omitted(
533 CODE_SCANNING,
534 Status::NotApplicable,
535 "a scanner reads the release driver's language, and the profile names no driver",
536 ),
537 (Some(_), _, None) => Selection::omitted(
538 CODE_SCANNING,
539 Status::NotApplicable,
540 "the profile names no forge",
541 ),
542 (Some(provider), Some(driver), Some(forge)) => {
543 if !driver_known || !forge_known {
544 Selection::omitted(
545 CODE_SCANNING,
546 Status::Unknown,
547 format!("({driver}, {forge}) names a category this release does not know"),
548 )
549 } else if let Some(reason) = crate::projection::code_scanning_incompatibility(
550 Some(provider),
551 Some(driver),
552 Some(forge),
553 ) {
554 Selection::omitted(CODE_SCANNING, Status::Unavailable, reason)
555 } else {
556 let sources: Vec<String> = availability
557 .owned(driver, forge, CODE_SCANNING)
558 .into_iter()
559 .filter(|path| {
560 CODE_SCANNING_DESTINATIONS
561 .iter()
562 .any(|(name, owner)| path.ends_with(name) && *owner == provider)
563 })
564 .collect();
565 if sources.is_empty() {
566 Selection::omitted(
567 CODE_SCANNING,
568 Status::Unavailable,
569 format!(
570 "the {driver} binding ships no {} workflow on {forge}",
571 provider.as_str()
572 ),
573 )
574 } else {
575 Selection::selected(CODE_SCANNING, sources, Some(driver))
576 .keyed_on(provider.as_str())
577 }
578 }
579 }
580 });
581 debug_assert_eq!(out.len(), ALL.len());
582 out
583}
584
585#[must_use]
596pub fn pin_keys(selection: &Selection) -> Vec<String> {
597 let mut keys = vec![selection.id.to_owned()];
598 if let Some(driver) = &selection.driver {
599 keys.push(format!("{}/{driver}", selection.id));
600 if let Some(provider) = &selection.provider {
601 keys.push(format!("{}/{driver}/{provider}", selection.id));
602 }
603 }
604 keys
605}
606
607#[cfg(test)]
608mod tests {
609 use super::{
610 ALL, Availability, CODE_SCANNING, GUARDS, PACKAGING_NIX, RELEASE_AUTOMATION,
611 REPORTING_POLICY, SCORECARD, Status, TITLE_CHECK, WORKFLOW_GUARDS, owner_of, select,
612 };
613 use crate::landing::Params;
614 use crate::landing::manifest::{Provider, Style};
615 use crate::profile::ReleaseMode;
616
617 fn status_of(selections: &[super::Selection], id: &str) -> Status {
618 selections
619 .iter()
620 .find(|s| s.id == id)
621 .expect("every capability answers")
622 .status
623 }
624
625 #[test]
628 fn every_embedded_snippet_has_one_owner() {
629 let availability = Availability::embedded();
630 assert!(!availability.files().is_empty());
631 for path in availability.files() {
632 let owner = owner_of(path);
633 assert!(owner.is_some(), "{path}: no capability owns it");
634 assert!(
635 ALL.contains(&owner.unwrap_or_default()),
636 "{path}: an unlisted owner"
637 );
638 }
639 assert_eq!(
640 owner_of("_shared/github/SECURITY.md"),
641 Some(REPORTING_POLICY)
642 );
643 assert_eq!(
644 owner_of("_shared/github/.github/workflows/scorecard.yml"),
645 Some(SCORECARD)
646 );
647 assert_eq!(owner_of("rust/github/flake.nix"), Some(PACKAGING_NIX));
648 assert_eq!(
649 owner_of("rust/github/.github/workflows/code-scanning-codeql.yml"),
650 Some(CODE_SCANNING)
651 );
652 assert_eq!(
653 owner_of("rust/github/release-plz.toml"),
654 Some(RELEASE_AUTOMATION)
655 );
656 assert_eq!(owner_of("_shared/github/unknown.txt"), None);
657 }
658
659 #[test]
661 fn the_catalog_answers_availability_per_tuple() {
662 let availability = Availability::embedded();
663 let mut github = Params::for_test("acme/widget", Some(Style::Trunk));
664 github.set_pair_for_test("python", "github");
665 let mut gitlab = github.clone();
666 gitlab.set_pair_for_test("python", "gitlab");
667 let on_github = select(&github, &availability);
668 let on_gitlab = select(&gitlab, &availability);
669 assert_eq!(status_of(&on_github, RELEASE_AUTOMATION), Status::Selected);
670 assert_eq!(
671 status_of(&on_gitlab, RELEASE_AUTOMATION),
672 Status::Unavailable
673 );
674 let reason = on_gitlab
675 .iter()
676 .find(|s| s.id == RELEASE_AUTOMATION)
677 .and_then(|s| s.reason.clone())
678 .expect("an unavailable capability states why");
679 assert!(reason.contains("rust, gitlab"), "{reason}");
680 assert!(reason.contains("python, github"), "{reason}");
681 let title = on_gitlab
684 .iter()
685 .find(|s| s.id == TITLE_CHECK)
686 .expect("the title gate answers");
687 assert_eq!(title.status, Status::Selected);
688 assert!(
689 title
690 .sources
691 .iter()
692 .any(|s| s == "_title-gate/gitlab/.gitlab-ci.yml"),
693 "{:?}",
694 title.sources
695 );
696 }
697
698 #[test]
701 fn a_no_forge_input_selects_the_guards_alone() {
702 let params = Params::for_test_release_less(&[], None, ReleaseMode::None);
703 let selections = select(¶ms, &Availability::embedded());
704 assert_eq!(status_of(&selections, GUARDS), Status::Selected);
705 for id in [
706 TITLE_CHECK,
707 REPORTING_POLICY,
708 RELEASE_AUTOMATION,
709 PACKAGING_NIX,
710 ] {
711 assert_ne!(status_of(&selections, id), Status::Selected, "{id}");
712 }
713 assert_eq!(status_of(&selections, TITLE_CHECK), Status::NotApplicable);
714 assert_eq!(
715 status_of(&selections, RELEASE_AUTOMATION),
716 Status::NotRequested
717 );
718 assert_eq!(status_of(&selections, PACKAGING_NIX), Status::NotRequested);
719 }
720
721 #[test]
724 fn the_workflow_guards_are_selected_only_when_requested() {
725 let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
726 let selections = select(¶ms, &Availability::embedded());
727 assert_eq!(status_of(&selections, WORKFLOW_GUARDS), Status::Selected);
728 params.set_workflow_guards_for_test(false);
729 let selections = select(¶ms, &Availability::embedded());
730 assert_eq!(
731 status_of(&selections, WORKFLOW_GUARDS),
732 Status::NotRequested
733 );
734 assert_eq!(status_of(&selections, GUARDS), Status::Selected);
735 }
736
737 #[test]
739 fn an_unknown_forge_reads_as_unknown_and_lands_the_guards() {
740 let mut params = Params::for_test_release_less(&[], Some("codeberg"), ReleaseMode::None);
741 params.set_scorecard_for_test(true);
742 let selections = select(¶ms, &Availability::embedded());
743 assert_eq!(status_of(&selections, GUARDS), Status::Selected);
744 assert_eq!(status_of(&selections, TITLE_CHECK), Status::Unknown);
745 assert_eq!(status_of(&selections, SCORECARD), Status::Unknown);
746 }
747
748 #[test]
752 fn code_scanning_selects_the_providers_own_file() {
753 let availability = Availability::embedded();
754 let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
755 params.set_code_scanning_for_test(Some(Provider::Semgrep));
756 let selections = select(¶ms, &availability);
757 let scanning = selections
758 .iter()
759 .find(|s| s.id == CODE_SCANNING)
760 .expect("answers");
761 assert_eq!(scanning.status, Status::Selected);
762 assert_eq!(
763 scanning.sources,
764 vec!["rust/github/.github/workflows/code-scanning-semgrep.yml".to_owned()]
765 );
766 params.set_pair_for_test("bash", "github");
767 let selections = select(¶ms, &availability);
768 assert_eq!(status_of(&selections, CODE_SCANNING), Status::Unavailable);
769 }
770}