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