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}
151
152impl Selection {
153 #[must_use]
155 pub const fn lands(&self) -> bool {
156 matches!(self.status, Status::Selected | Status::Withheld)
157 }
158
159 fn selected(id: &'static str, sources: Vec<String>, driver: Option<&str>) -> Self {
160 Self {
161 id,
162 status: Status::Selected,
163 reason: None,
164 action: None,
165 sources,
166 driver: driver.map(str::to_owned),
167 }
168 }
169
170 fn omitted(id: &'static str, status: Status, reason: impl Into<String>) -> Self {
171 Self {
172 id,
173 status,
174 reason: Some(reason.into()),
175 action: None,
176 sources: Vec::new(),
177 driver: None,
178 }
179 }
180}
181
182#[derive(Debug, Clone, PartialEq, Eq)]
185pub struct Availability {
186 files: Vec<String>,
187}
188
189impl Availability {
190 #[must_use]
193 pub fn over(files: Vec<String>) -> Self {
194 let mut files = files;
195 files.sort();
196 Self { files }
197 }
198
199 #[must_use]
201 pub fn embedded() -> Self {
202 Self::over(
203 crate::embedded::walk(&crate::embedded::SNIPPETS)
204 .into_iter()
205 .map(|(path, _)| path)
206 .collect(),
207 )
208 }
209
210 #[must_use]
212 pub fn files(&self) -> &[String] {
213 &self.files
214 }
215
216 fn owned(&self, zone: &str, forge: &str, capability: &str) -> Vec<String> {
218 let prefix = format!("{zone}/{forge}/");
219 self.files
220 .iter()
221 .filter(|path| path.starts_with(&prefix) && owner_of(path) == Some(capability))
222 .cloned()
223 .collect()
224 }
225
226 #[must_use]
228 pub fn drivers(&self) -> Vec<String> {
229 let mut out: Vec<String> = Vec::new();
230 for path in &self.files {
231 if let Some((zone, _)) = path.split_once('/')
232 && !zone.starts_with('_')
233 && !out.iter().any(|known| known == zone)
234 {
235 out.push(zone.to_owned());
236 }
237 }
238 out
239 }
240
241 #[must_use]
244 pub fn automation_tuples(&self) -> Vec<String> {
245 let mut out: Vec<String> = Vec::new();
246 for path in &self.files {
247 let mut segments = path.splitn(3, '/');
248 if let (Some(driver), Some(forge), Some(_)) =
249 (segments.next(), segments.next(), segments.next())
250 && !driver.starts_with('_')
251 && owner_of(path) == Some(RELEASE_AUTOMATION)
252 {
253 let entry = format!("{driver}, {forge}");
254 if !out.contains(&entry) {
255 out.push(entry);
256 }
257 }
258 }
259 out
260 }
261}
262
263#[must_use]
265pub fn known_drivers() -> Vec<String> {
266 Availability::embedded().drivers()
267}
268
269fn known_forge(name: &str) -> bool {
271 Forge::parse(name).is_some()
272}
273
274#[must_use]
279#[allow(
280 clippy::too_many_lines,
281 reason = "one pass answers every capability, and splitting it would separate a selection from the dimensions that decided it"
282)]
283pub fn select(params: &Params, availability: &Availability) -> Vec<Selection> {
284 let forge = params.forge();
285 let driver = params.driver();
286 let known_drivers = availability.drivers();
287 let driver_known = driver.is_some_and(|d| known_drivers.iter().any(|k| k == d));
288 let forge_known = forge.is_some_and(known_forge);
289
290 let mut out = Vec::with_capacity(ALL.len());
291 out.push(Selection::selected(GUARDS, Vec::new(), None));
292
293 let automation = match (params.release_mode(), driver, forge) {
296 (ReleaseMode::External, _, _) => Selection::omitted(
297 RELEASE_AUTOMATION,
298 Status::NotRequested,
299 "the release is external: the target releases through a process release-kit does not drive",
300 ),
301 (ReleaseMode::None, _, _) => Selection::omitted(
302 RELEASE_AUTOMATION,
303 Status::NotRequested,
304 "the release mode is none",
305 ),
306 (ReleaseMode::Automatic, None, _) => Selection::omitted(
307 RELEASE_AUTOMATION,
308 Status::NotApplicable,
309 "an automatic release names no driver",
310 ),
311 (ReleaseMode::Automatic, _, None) => Selection::omitted(
312 RELEASE_AUTOMATION,
313 Status::NotApplicable,
314 "the profile names no forge",
315 ),
316 (ReleaseMode::Automatic, Some(driver), Some(forge)) => {
317 if !driver_known {
318 Selection::omitted(
319 RELEASE_AUTOMATION,
320 Status::Unknown,
321 format!(
322 "the driver {driver} is not one this release knows; the bindings are: {}",
323 known_drivers.join(", ")
324 ),
325 )
326 } else if !forge_known {
327 Selection::omitted(
328 RELEASE_AUTOMATION,
329 Status::Unknown,
330 format!(
331 "the forge {forge} is not one this release drives; the forges are: github, gitlab"
332 ),
333 )
334 } else {
335 let sources = availability.owned(driver, forge, RELEASE_AUTOMATION);
336 if sources.is_empty() {
337 Selection::omitted(
338 RELEASE_AUTOMATION,
339 Status::Unavailable,
340 format!(
341 "the release automation at ({driver}, {forge}) has no landable files; the available tuples are: {}",
342 availability.automation_tuples().join("; ")
343 ),
344 )
345 } else {
346 Selection::selected(RELEASE_AUTOMATION, sources, Some(driver))
347 }
348 }
349 }
350 };
351 let automation_lands = automation.status == Status::Selected;
352
353 out.push(match forge {
355 None => Selection::omitted(
356 TITLE_CHECK,
357 Status::NotApplicable,
358 "the profile names no forge",
359 ),
360 Some(forge) if !forge_known => Selection::omitted(
361 TITLE_CHECK,
362 Status::Unknown,
363 format!(
364 "the forge {forge} is not one this release drives; the forges are: github, gitlab"
365 ),
366 ),
367 Some(forge) => {
368 let mut sources = availability.owned("_shared", forge, TITLE_CHECK);
369 if !automation_lands {
370 sources.extend(availability.owned(TITLE_GATE_ZONE, forge, TITLE_CHECK));
371 }
372 if sources.is_empty() {
373 Selection::omitted(
374 TITLE_CHECK,
375 Status::Unavailable,
376 format!("this release ships no title gate for {forge}"),
377 )
378 } else {
379 Selection::selected(TITLE_CHECK, sources, None)
380 }
381 }
382 });
383
384 out.push(match forge {
386 _ if !params.reporting_policy() => Selection::omitted(
387 REPORTING_POLICY,
388 Status::NotRequested,
389 "capabilities.reporting_policy is false",
390 ),
391 None => Selection::omitted(
392 REPORTING_POLICY,
393 Status::NotApplicable,
394 "the profile names no forge, and the policy names the forge's private channel",
395 ),
396 Some(forge) if !forge_known => Selection::omitted(
397 REPORTING_POLICY,
398 Status::Unknown,
399 format!(
400 "the forge {forge} is not one this release drives; the forges are: github, gitlab"
401 ),
402 ),
403 Some(forge) => {
404 let sources = availability.owned("_shared", forge, REPORTING_POLICY);
405 if sources.is_empty() {
406 Selection::omitted(
407 REPORTING_POLICY,
408 Status::Unavailable,
409 format!("this release ships no reporting policy for {forge}"),
410 )
411 } else {
412 Selection::selected(REPORTING_POLICY, sources, None)
413 }
414 }
415 });
416
417 out.push(automation);
418
419 out.push(match (params.nix_packaging(), driver, forge) {
421 (false, _, _) => Selection::omitted(
422 PACKAGING_NIX,
423 Status::NotRequested,
424 "capabilities.nix_packaging is false",
425 ),
426 (true, None, _) => Selection::omitted(
427 PACKAGING_NIX,
428 Status::NotApplicable,
429 "the seed is keyed on an automatic release driver, and the profile names none",
430 ),
431 (true, _, None) => Selection::omitted(
432 PACKAGING_NIX,
433 Status::NotApplicable,
434 "the profile names no forge",
435 ),
436 (true, Some(driver), Some(forge)) => {
437 if !driver_known || !forge_known {
438 Selection::omitted(
439 PACKAGING_NIX,
440 Status::Unknown,
441 format!("({driver}, {forge}) names a category this release does not know"),
442 )
443 } else {
444 let sources = availability.owned(driver, forge, PACKAGING_NIX);
445 if sources.is_empty() {
446 Selection::omitted(
447 PACKAGING_NIX,
448 Status::Unavailable,
449 format!("the {driver} binding ships no Nix seed on {forge}"),
450 )
451 } else {
452 Selection::selected(PACKAGING_NIX, sources, Some(driver))
453 }
454 }
455 }
456 });
457
458 out.push(match forge {
460 _ if !params.scorecard() => Selection::omitted(
461 SCORECARD,
462 Status::NotRequested,
463 "capabilities.scorecard is false",
464 ),
465 None => Selection::omitted(
466 SCORECARD,
467 Status::NotApplicable,
468 "the profile names no forge",
469 ),
470 Some(forge) if !forge_known => Selection::omitted(
471 SCORECARD,
472 Status::Unknown,
473 format!(
474 "the forge {forge} is not one this release drives; the forges are: github, gitlab"
475 ),
476 ),
477 Some(forge) => {
478 let sources = availability.owned("_shared", forge, SCORECARD);
479 if sources.is_empty() {
480 Selection::omitted(
481 SCORECARD,
482 Status::Unavailable,
483 format!(
484 "the Scorecard workflow is GitHub's alone, and the {forge} zone ships none"
485 ),
486 )
487 } else {
488 Selection::selected(SCORECARD, sources, None)
489 }
490 }
491 });
492
493 out.push(match (params.code_scanning(), driver, forge) {
496 (None, _, _) => Selection::omitted(
497 CODE_SCANNING,
498 Status::NotRequested,
499 "capabilities.code_scanning is off",
500 ),
501 (Some(_), None, _) => Selection::omitted(
502 CODE_SCANNING,
503 Status::NotApplicable,
504 "a scanner reads the release driver's language, and the profile names no driver",
505 ),
506 (Some(_), _, None) => Selection::omitted(
507 CODE_SCANNING,
508 Status::NotApplicable,
509 "the profile names no forge",
510 ),
511 (Some(provider), Some(driver), Some(forge)) => {
512 if !driver_known || !forge_known {
513 Selection::omitted(
514 CODE_SCANNING,
515 Status::Unknown,
516 format!("({driver}, {forge}) names a category this release does not know"),
517 )
518 } else if let Some(reason) = crate::projection::code_scanning_incompatibility(
519 Some(provider),
520 Some(driver),
521 Some(forge),
522 ) {
523 Selection::omitted(CODE_SCANNING, Status::Unavailable, reason)
524 } else {
525 let sources: Vec<String> = availability
526 .owned(driver, forge, CODE_SCANNING)
527 .into_iter()
528 .filter(|path| {
529 CODE_SCANNING_DESTINATIONS
530 .iter()
531 .any(|(name, owner)| path.ends_with(name) && *owner == provider)
532 })
533 .collect();
534 if sources.is_empty() {
535 Selection::omitted(
536 CODE_SCANNING,
537 Status::Unavailable,
538 format!(
539 "the {driver} binding ships no {} workflow on {forge}",
540 provider.as_str()
541 ),
542 )
543 } else {
544 Selection::selected(CODE_SCANNING, sources, Some(driver))
545 }
546 }
547 }
548 });
549 debug_assert_eq!(out.len(), ALL.len());
550 out
551}
552
553#[must_use]
557pub fn pin_keys(selection: &Selection) -> Vec<String> {
558 let mut keys = vec![selection.id.to_owned()];
559 if let Some(driver) = &selection.driver {
560 keys.push(format!("{}/{driver}", selection.id));
561 }
562 keys
563}
564
565#[cfg(test)]
566mod tests {
567 use super::{
568 ALL, Availability, CODE_SCANNING, GUARDS, PACKAGING_NIX, RELEASE_AUTOMATION,
569 REPORTING_POLICY, SCORECARD, Status, TITLE_CHECK, owner_of, select,
570 };
571 use crate::landing::Params;
572 use crate::landing::manifest::{Provider, Style};
573 use crate::profile::ReleaseMode;
574
575 fn status_of(selections: &[super::Selection], id: &str) -> Status {
576 selections
577 .iter()
578 .find(|s| s.id == id)
579 .expect("every capability answers")
580 .status
581 }
582
583 #[test]
586 fn every_embedded_snippet_has_one_owner() {
587 let availability = Availability::embedded();
588 assert!(!availability.files().is_empty());
589 for path in availability.files() {
590 let owner = owner_of(path);
591 assert!(owner.is_some(), "{path}: no capability owns it");
592 assert!(
593 ALL.contains(&owner.unwrap_or_default()),
594 "{path}: an unlisted owner"
595 );
596 }
597 assert_eq!(
598 owner_of("_shared/github/SECURITY.md"),
599 Some(REPORTING_POLICY)
600 );
601 assert_eq!(
602 owner_of("_shared/github/.github/workflows/scorecard.yml"),
603 Some(SCORECARD)
604 );
605 assert_eq!(owner_of("rust/github/flake.nix"), Some(PACKAGING_NIX));
606 assert_eq!(
607 owner_of("rust/github/.github/workflows/code-scanning-codeql.yml"),
608 Some(CODE_SCANNING)
609 );
610 assert_eq!(
611 owner_of("rust/github/release-plz.toml"),
612 Some(RELEASE_AUTOMATION)
613 );
614 assert_eq!(owner_of("_shared/github/unknown.txt"), None);
615 }
616
617 #[test]
619 fn the_catalog_answers_availability_per_tuple() {
620 let availability = Availability::embedded();
621 let mut github = Params::for_test("acme/widget", Some(Style::Trunk));
622 github.set_pair_for_test("python", "github");
623 let mut gitlab = github.clone();
624 gitlab.set_pair_for_test("python", "gitlab");
625 let on_github = select(&github, &availability);
626 let on_gitlab = select(&gitlab, &availability);
627 assert_eq!(status_of(&on_github, RELEASE_AUTOMATION), Status::Selected);
628 assert_eq!(
629 status_of(&on_gitlab, RELEASE_AUTOMATION),
630 Status::Unavailable
631 );
632 let reason = on_gitlab
633 .iter()
634 .find(|s| s.id == RELEASE_AUTOMATION)
635 .and_then(|s| s.reason.clone())
636 .expect("an unavailable capability states why");
637 assert!(reason.contains("rust, gitlab"), "{reason}");
638 assert!(reason.contains("python, github"), "{reason}");
639 let title = on_gitlab
642 .iter()
643 .find(|s| s.id == TITLE_CHECK)
644 .expect("the title gate answers");
645 assert_eq!(title.status, Status::Selected);
646 assert!(
647 title
648 .sources
649 .iter()
650 .any(|s| s == "_title-gate/gitlab/.gitlab-ci.yml"),
651 "{:?}",
652 title.sources
653 );
654 }
655
656 #[test]
659 fn a_no_forge_input_selects_the_guards_alone() {
660 let params = Params::for_test_release_less(&[], None, ReleaseMode::None);
661 let selections = select(¶ms, &Availability::embedded());
662 assert_eq!(status_of(&selections, GUARDS), Status::Selected);
663 for id in [
664 TITLE_CHECK,
665 REPORTING_POLICY,
666 RELEASE_AUTOMATION,
667 PACKAGING_NIX,
668 ] {
669 assert_ne!(status_of(&selections, id), Status::Selected, "{id}");
670 }
671 assert_eq!(status_of(&selections, TITLE_CHECK), Status::NotApplicable);
672 assert_eq!(
673 status_of(&selections, RELEASE_AUTOMATION),
674 Status::NotRequested
675 );
676 assert_eq!(status_of(&selections, PACKAGING_NIX), Status::NotRequested);
677 }
678
679 #[test]
681 fn an_unknown_forge_reads_as_unknown_and_lands_the_guards() {
682 let mut params = Params::for_test_release_less(&[], Some("codeberg"), ReleaseMode::None);
683 params.set_scorecard_for_test(true);
684 let selections = select(¶ms, &Availability::embedded());
685 assert_eq!(status_of(&selections, GUARDS), Status::Selected);
686 assert_eq!(status_of(&selections, TITLE_CHECK), Status::Unknown);
687 assert_eq!(status_of(&selections, SCORECARD), Status::Unknown);
688 }
689
690 #[test]
694 fn code_scanning_selects_the_providers_own_file() {
695 let availability = Availability::embedded();
696 let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
697 params.set_code_scanning_for_test(Some(Provider::Semgrep));
698 let selections = select(¶ms, &availability);
699 let scanning = selections
700 .iter()
701 .find(|s| s.id == CODE_SCANNING)
702 .expect("answers");
703 assert_eq!(scanning.status, Status::Selected);
704 assert_eq!(
705 scanning.sources,
706 vec!["rust/github/.github/workflows/code-scanning-semgrep.yml".to_owned()]
707 );
708 params.set_pair_for_test("bash", "github");
709 let selections = select(¶ms, &availability);
710 assert_eq!(status_of(&selections, CODE_SCANNING), Status::Unavailable);
711 }
712}