Skip to main content

release_kit/profile/
catalog.rs

1//! The capability catalog: which complete release-kit product each
2//! embedded source belongs to, which dimensions select it, and whether it
3//! is available at the dimensions a target resolved to.
4//!
5//! Availability belongs to a capability at its dimensions and never to a
6//! category alone: `release.automation` at `(python, github)` is available
7//! while the same capability at `(python, gitlab)` is unavailable. Every
8//! omission is recorded with one of the five reasons the vocabulary
9//! names, so a reader can tell a product nobody asked for from one this
10//! release cannot land.
11//!
12//! The catalog is pure: it reads the snippet paths it is given and the
13//! resolved parameters, and nothing else.
14//!
15//! SATISFIES project-profile:availability-belongs-to-a-capability-at-its-dimensions
16
17use 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
24/// The release guards every valid target selects.
25///
26/// What the release process needs in the target's own files: the routing
27/// block and the hook block, with the release lines and the hooks that
28/// guard the tags and the files release-kit owns.
29pub const GUARDS: &str = "git.guards";
30/// The opt-in workflow guards: the agent and commit policy lines of the
31/// routing block, the glossary, the workflow hooks, and the two workflow
32/// setup steps. Off for a fresh landing.
33pub const WORKFLOW_GUARDS: &str = "git.workflow-guards";
34/// The complete, active title gate at the forge: the forge gate of the
35/// `squash` merge form, which judges the request's title because it
36/// becomes the trunk's commit message.
37pub const TITLE_CHECK: &str = "git.title-check";
38/// The complete, active commit gate at the forge: the forge gate of the
39/// `rebase` merge form, which judges every commit of the request because
40/// each one reaches the trunk with its own message.
41pub const COMMIT_CHECK: &str = "git.commit-check";
42/// The landed vulnerability reporting policy.
43pub const REPORTING_POLICY: &str = "security.reporting-policy";
44/// The release automation at one driver and one forge: the version
45/// source, the bot configuration, and the release workflow.
46pub const RELEASE_AUTOMATION: &str = "release.automation";
47/// The seeded package expression and the seed flake pair.
48pub const PACKAGING_NIX: &str = "packaging.nix";
49/// The `OpenSSF` Scorecard workflow.
50pub const SCORECARD: &str = "supply-chain.scorecard";
51/// The code scanning workflow, by provider.
52pub const CODE_SCANNING: &str = "supply-chain.code-scanning";
53
54/// Every capability, in the order a report lists them.
55pub 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
67/// The zone below `snippets/` that carries the release-less GitLab root
68/// pipeline: the minimal `.gitlab-ci.yml` that activates the title
69/// fragment where no release automation ships a root pipeline.
70pub const TITLE_GATE_ZONE: &str = "_title-gate";
71
72/// The zone below `snippets/` that carries the release-less GitLab root
73/// pipeline of the `rebase` merge form, which activates the commit
74/// fragment instead.
75pub const COMMIT_GATE_ZONE: &str = "_commit-gate";
76
77/// The one owner of every embedded snippet: which capability lands the
78/// file at `path`, the path relative to `snippets/`.
79///
80/// `None` is a source defect: a snippet no capability claims would land
81/// under no selection, and a test holds every embedded file to one
82/// owner.
83#[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/// The status of one capability for one target.
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
119#[serde(rename_all = "kebab-case")]
120pub enum Status {
121    /// The catalog has the complete contribution, and the target selects
122    /// it.
123    Selected,
124    /// The target did not request the optional capability.
125    NotRequested,
126    /// The capability lacks a dimension the target does not have, a forge
127    /// or a release driver.
128    NotApplicable,
129    /// The catalog knows the capability but not at these dimensions.
130    Unavailable,
131    /// A category name the catalog does not know.
132    Unknown,
133    /// Selected, but a target-owned destination blocks its activation:
134    /// the safe prerequisite files still land, and the omission names
135    /// the operator's one remaining edit.
136    Withheld,
137}
138
139impl Status {
140    /// The report form.
141    #[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/// One capability's answer for one target.
155#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
156pub struct Selection {
157    /// The capability id.
158    pub id: &'static str,
159    /// Its status.
160    pub status: Status,
161    /// Why, for every status but selected.
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub reason: Option<String>,
164    /// The one edit the operator makes, for a withheld capability.
165    #[serde(skip_serializing_if = "Option::is_none")]
166    pub action: Option<String>,
167    /// The embedded sources it lands, relative to `snippets/`, empty for
168    /// a capability the blocks land or one that lands nothing.
169    #[serde(skip)]
170    pub sources: Vec<String>,
171    /// The release driver the selection is keyed on, where the capability
172    /// has that dimension; the registry pins are keyed on it too.
173    #[serde(skip_serializing_if = "Option::is_none")]
174    pub driver: Option<String>,
175    /// The provider the selection is keyed on, where the capability's
176    /// parameter names one. Two providers of one capability land different
177    /// files and run different tools, so the registry pins are keyed on it
178    /// too and a target records the pins of the provider it landed.
179    #[serde(skip_serializing_if = "Option::is_none")]
180    pub provider: Option<String>,
181}
182
183impl Selection {
184    /// Whether the capability contributes destinations.
185    #[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    /// The same selection, keyed on the provider its parameter named.
203    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/// What the embedded sources can land: every snippet path, relative to
222/// `snippets/`, sorted.
223#[derive(Debug, Clone, PartialEq, Eq)]
224pub struct Availability {
225    files: Vec<String>,
226}
227
228impl Availability {
229    /// The availability over an explicit snippet list, each path relative
230    /// to `snippets/`.
231    #[must_use]
232    pub fn over(files: Vec<String>) -> Self {
233        let mut files = files;
234        files.sort();
235        Self { files }
236    }
237
238    /// The availability the installed binary embeds.
239    #[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    /// Every embedded path, relative to `snippets/`.
250    #[must_use]
251    pub fn files(&self) -> &[String] {
252        &self.files
253    }
254
255    /// The files under one zone and forge that `capability` owns.
256    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    /// The drivers the sources know: every non-underscore zone.
266    #[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    /// The `(driver, forge)` tuples at which the release automation is
281    /// available, in path order, as `driver, forge` strings.
282    #[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/// Whether a target lands a forge gate at all.
303///
304/// It does under forge integration, where a request is the way onto the
305/// trunk, and under an automatic release, whose bot opens a request
306/// whatever the integration mode. A local integration with no automatic
307/// release opens no request, so no gate has anything to judge and no CI
308/// file lands for it.
309///
310/// The one owner of the answer: the catalog selects by it, and the setup's
311/// effective protection requires a check and a pipeline by it, so a forge
312/// never requires a check no landed workflow reports.
313///
314/// SATISFIES project-profile:the-forge-gate-follows-the-merge-form
315#[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/// The release drivers this binary's sources know.
321#[must_use]
322pub fn known_drivers() -> Vec<String> {
323    Availability::embedded().drivers()
324}
325
326/// Judge a stated `profile.release.registry` answer against its release.
327///
328/// The answer is valid for an automatic release driven by `rust` alone,
329/// because only that driver's automation reads where the crate publishes.
330///
331/// # Errors
332///
333/// Returns the reason, naming the key and the shape it needs.
334///
335/// SATISFIES project-profile:release-intent-has-three-modes
336pub 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
356/// Whether a build answer fits the release intent: an automatic bash
357/// release alone calls a project-supplied build command.
358///
359/// # Errors
360/// The refusal text, naming the key, what it found, and the shape it needs.
361pub 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
381/// Whether `name` is a forge this binary has an adapter for.
382fn known_forge(name: &str) -> bool {
383    Forge::parse(name).is_some()
384}
385
386/// Select every capability for `params` against `availability`.
387///
388/// The order is [`ALL`]. The local guards are always selected; every
389/// other capability answers by its dimensions and its request.
390///
391/// SATISFIES project-profile:the-forge-gate-follows-the-merge-form
392#[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    // The release automation first, because the title gate's root
417    // pipeline depends on whether it lands.
418    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    // The forge gate: complete where the forge is known, and the one the
477    // merge form names. Under `squash` the request's title becomes the
478    // trunk's commit, so the title gate judges it; under `rebase` every
479    // commit keeps its own message, so the commit gate judges each.
480    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    // Where no request reaches the trunk, neither gate has anything to
495    // judge, and both say so rather than naming the merge form.
496    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    // The reporting policy.
552    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    // The Nix packaging, keyed on the release driver and the forge.
587    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    // The Scorecard workflow, GitHub's alone.
626    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    // The code scanning workflow, keyed on the driver, the forge, and the
661    // provider.
662    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/// The pin keys one selection matches in the registry's `used_by`.
722///
723/// Three of them: the bare capability id, the id qualified by the driver
724/// where the capability has that dimension, and that qualified again by
725/// the provider where its parameter names one.
726///
727/// Three widths rather than one, so a pin declares the narrowest truth it
728/// can. A tool every provider runs takes the bare id; one a single
729/// provider runs takes the widest key, and a target that landed the other
730/// provider records neither the tool nor its staleness baseline.
731#[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    /// Every embedded snippet has exactly one owner, and no shared path
762    /// falls to the release automation by default.
763    #[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    /// SATISFIES project-profile:availability-belongs-to-a-capability-at-its-dimensions
796    #[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        // The title gate still lands on GitLab, with the release-less root
818        // pipeline beside the fragment.
819        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    /// A no-forge, no-release input selects the local guards alone and
835    /// marks every forge capability not applicable.
836    #[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(&params, &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    /// The workflow guards answer their request alone, and the release
858    /// guards land whatever it says.
859    #[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(&params, &Availability::embedded());
863        assert_eq!(status_of(&selections, WORKFLOW_GUARDS), Status::Selected);
864        params.set_workflow_guards_for_test(false);
865        let selections = select(&params, &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    /// A forge gate lands only where a request reaches the trunk: under
874    /// forge integration, or under an automatic release whose bot opens
875    /// one. A local, release-less landing selects neither gate on either
876    /// forge, and so lands no CI file; an external release integrated
877    /// locally takes none either, as the canon states it.
878    ///
879    /// SATISFIES project-profile:the-forge-gate-follows-the-merge-form
880    #[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(&params, &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                    // The gate the form does not name gives the same real
917                    // reason where no gate lands, and the form's otherwise.
918                    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            // An automatic release keeps its gate under local integration,
934            // because the bot's request still reaches the trunk.
935            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(&params, &availability);
939            assert_eq!(
940                status_of(&selections, TITLE_CHECK),
941                Status::Selected,
942                "{forge}"
943            );
944        }
945    }
946
947    /// An unknown forge keeps the guards and reports the rest unknown.
948    #[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(&params, &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    /// The scanner follows the provider and the pair, and a request the
959    /// pair cannot run reports `Unavailable` with the reason the landing
960    /// then omits it by. Nothing refuses on it.
961    #[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(&params, &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(&params, &availability);
978        assert_eq!(status_of(&selections, CODE_SCANNING), Status::Unavailable);
979    }
980
981    /// The merge form picks the forge gate: the title gate under squash,
982    /// the commit gate under rebase, never both, and on GitLab with no
983    /// release automation the root pipeline that activates the chosen
984    /// fragment.
985    ///
986    /// SATISFIES project-profile:the-forge-gate-follows-the-merge-form
987    #[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(&params, &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(&params, &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                // Python automates no release on GitLab, so the commit
1024                // gate's own root pipeline activates the fragment.
1025                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}