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: `supply-chain.code-scanning` at `(rust, gitlab)` is
7//! available while the same capability at `(python, gitlab)` is
8//! unavailable. Every
9//! omission is recorded with one of the five reasons the vocabulary
10//! names, so a reader can tell a product nobody asked for from one this
11//! release cannot land.
12//!
13//! The catalog is pure: it reads the snippet paths it is given and the
14//! resolved parameters, and nothing else.
15//!
16//! SATISFIES project-profile:availability-belongs-to-a-capability-at-its-dimensions
17
18use serde::Serialize;
19
20use super::{Params, ReleaseMode};
21use crate::detect::Forge;
22use crate::landing::{Integration, MergeForm};
23use crate::projection::{CODE_SCANNING_DESTINATIONS, NIX_DESTINATIONS};
24
25/// The release guards every valid target selects.
26///
27/// What the release process needs in the target's own files: the routing
28/// block and the hook block, with the release lines and the hooks that
29/// guard the tags and the files release-kit owns.
30pub const GUARDS: &str = "git.guards";
31/// The opt-in workflow guards: the agent and commit policy lines of the
32/// routing block, the glossary, the workflow hooks, and the two workflow
33/// setup steps. Off for a fresh landing.
34pub const WORKFLOW_GUARDS: &str = "git.workflow-guards";
35/// The complete, active title gate at the forge: the forge gate of the
36/// `squash` merge form, which judges the request's title because it
37/// becomes the trunk's commit message.
38pub const TITLE_CHECK: &str = "git.title-check";
39/// The complete, active commit gate at the forge: the forge gate of the
40/// `rebase` merge form, which judges every commit of the request because
41/// each one reaches the trunk with its own message.
42pub const COMMIT_CHECK: &str = "git.commit-check";
43/// The landed vulnerability reporting policy.
44pub const REPORTING_POLICY: &str = "security.reporting-policy";
45/// The release automation at one driver and one forge: the version
46/// source, the bot configuration, and the release workflow.
47pub const RELEASE_AUTOMATION: &str = "release.automation";
48/// The seeded package expression and the seed flake pair.
49pub const PACKAGING_NIX: &str = "packaging.nix";
50/// The `OpenSSF` Scorecard workflow.
51pub const SCORECARD: &str = "supply-chain.scorecard";
52/// The code scanning workflow, by provider.
53pub const CODE_SCANNING: &str = "supply-chain.code-scanning";
54
55/// Every capability, in the order a report lists them.
56pub const ALL: [&str; 9] = [
57    GUARDS,
58    WORKFLOW_GUARDS,
59    TITLE_CHECK,
60    COMMIT_CHECK,
61    REPORTING_POLICY,
62    RELEASE_AUTOMATION,
63    PACKAGING_NIX,
64    SCORECARD,
65    CODE_SCANNING,
66];
67
68/// The zone below `snippets/` that carries the release-less GitLab root
69/// pipeline: the minimal `.gitlab-ci.yml` that activates the title
70/// fragment where no release automation ships a root pipeline.
71pub const TITLE_GATE_ZONE: &str = "_title-gate";
72
73/// The zone below `snippets/` that carries the release-less GitLab root
74/// pipeline of the `rebase` merge form, which activates the commit
75/// fragment instead.
76pub const COMMIT_GATE_ZONE: &str = "_commit-gate";
77
78/// The one owner of every embedded snippet: which capability lands the
79/// file at `path`, the path relative to `snippets/`.
80///
81/// `None` is a source defect: a snippet no capability claims would land
82/// under no selection, and a test holds every embedded file to one
83/// owner.
84#[must_use]
85pub fn owner_of(path: &str) -> Option<&'static str> {
86    let mut segments = path.splitn(3, '/');
87    let (zone, _forge, destination) = (segments.next()?, segments.next()?, segments.next()?);
88    if zone == "_shared" {
89        return match destination {
90            "SECURITY.md" => Some(REPORTING_POLICY),
91            ".github/workflows/pr-title.yml" | ".gitlab/ci/mr-title.yml" => Some(TITLE_CHECK),
92            ".github/workflows/pr-commits.yml" | ".gitlab/ci/mr-commits.yml" => Some(COMMIT_CHECK),
93            ".github/workflows/scorecard.yml" => Some(SCORECARD),
94            _ => None,
95        };
96    }
97    if zone == TITLE_GATE_ZONE {
98        return (destination == ".gitlab-ci.yml").then_some(TITLE_CHECK);
99    }
100    if zone == COMMIT_GATE_ZONE {
101        return (destination == ".gitlab-ci.yml").then_some(COMMIT_CHECK);
102    }
103    if zone.starts_with('_') {
104        return None;
105    }
106    if NIX_DESTINATIONS.contains(&destination) {
107        return Some(PACKAGING_NIX);
108    }
109    if CODE_SCANNING_DESTINATIONS
110        .iter()
111        .any(|(name, _)| *name == destination)
112    {
113        return Some(CODE_SCANNING);
114    }
115    Some(RELEASE_AUTOMATION)
116}
117
118/// The status of one capability for one target.
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
120#[serde(rename_all = "kebab-case")]
121pub enum Status {
122    /// The catalog has the complete contribution, and the target selects
123    /// it.
124    Selected,
125    /// The target did not request the optional capability.
126    NotRequested,
127    /// The capability lacks a dimension the target does not have, a forge
128    /// or a release driver.
129    NotApplicable,
130    /// The catalog knows the capability but not at these dimensions.
131    Unavailable,
132    /// A category name the catalog does not know.
133    Unknown,
134    /// Selected, but a target-owned destination blocks its activation:
135    /// the safe prerequisite files still land, and the omission names
136    /// the operator's one remaining edit.
137    Withheld,
138}
139
140impl Status {
141    /// The report form.
142    #[must_use]
143    pub const fn as_str(self) -> &'static str {
144        match self {
145            Self::Selected => "selected",
146            Self::NotRequested => "not-requested",
147            Self::NotApplicable => "not-applicable",
148            Self::Unavailable => "unavailable",
149            Self::Unknown => "unknown",
150            Self::Withheld => "withheld",
151        }
152    }
153}
154
155/// One capability's answer for one target.
156#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
157pub struct Selection {
158    /// The capability id.
159    pub id: &'static str,
160    /// Its status.
161    pub status: Status,
162    /// Why, for every status but selected.
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub reason: Option<String>,
165    /// The one edit the operator makes, for a withheld capability.
166    #[serde(skip_serializing_if = "Option::is_none")]
167    pub action: Option<String>,
168    /// The embedded sources it lands, relative to `snippets/`, empty for
169    /// a capability the blocks land or one that lands nothing.
170    #[serde(skip)]
171    pub sources: Vec<String>,
172    /// The release driver the selection is keyed on, where the capability
173    /// has that dimension; the registry pins are keyed on it too.
174    #[serde(skip_serializing_if = "Option::is_none")]
175    pub driver: Option<String>,
176    /// The provider the selection is keyed on, where the capability's
177    /// parameter names one. Two providers of one capability land different
178    /// files and run different tools, so the registry pins are keyed on it
179    /// too and a target records the pins of the provider it landed.
180    #[serde(skip_serializing_if = "Option::is_none")]
181    pub provider: Option<String>,
182}
183
184impl Selection {
185    /// Whether the capability contributes destinations.
186    #[must_use]
187    pub const fn lands(&self) -> bool {
188        matches!(self.status, Status::Selected | Status::Withheld)
189    }
190
191    fn selected(id: &'static str, sources: Vec<String>, driver: Option<&str>) -> Self {
192        Self {
193            id,
194            status: Status::Selected,
195            reason: None,
196            action: None,
197            sources,
198            driver: driver.map(str::to_owned),
199            provider: None,
200        }
201    }
202
203    /// The same selection, keyed on the provider its parameter named.
204    fn keyed_on(mut self, provider: &str) -> Self {
205        self.provider = Some(provider.to_owned());
206        self
207    }
208
209    fn omitted(id: &'static str, status: Status, reason: impl Into<String>) -> Self {
210        Self {
211            id,
212            status,
213            reason: Some(reason.into()),
214            action: None,
215            sources: Vec::new(),
216            driver: None,
217            provider: None,
218        }
219    }
220}
221
222/// What the embedded sources can land: every snippet path, relative to
223/// `snippets/`, sorted.
224#[derive(Debug, Clone, PartialEq, Eq)]
225pub struct Availability {
226    files: Vec<String>,
227}
228
229impl Availability {
230    /// The availability over an explicit snippet list, each path relative
231    /// to `snippets/`.
232    #[must_use]
233    pub fn over(files: Vec<String>) -> Self {
234        let mut files = files;
235        files.sort();
236        Self { files }
237    }
238
239    /// The availability the installed binary embeds.
240    #[must_use]
241    pub fn embedded() -> Self {
242        Self::over(
243            crate::embedded::walk(&crate::embedded::SNIPPETS)
244                .into_iter()
245                .map(|(path, _)| path)
246                .collect(),
247        )
248    }
249
250    /// Every embedded path, relative to `snippets/`.
251    #[must_use]
252    pub fn files(&self) -> &[String] {
253        &self.files
254    }
255
256    /// The files under one zone and forge that `capability` owns.
257    fn owned(&self, zone: &str, forge: &str, capability: &str) -> Vec<String> {
258        let prefix = format!("{zone}/{forge}/");
259        self.files
260            .iter()
261            .filter(|path| path.starts_with(&prefix) && owner_of(path) == Some(capability))
262            .cloned()
263            .collect()
264    }
265
266    /// The drivers the sources know: every non-underscore zone.
267    #[must_use]
268    pub fn drivers(&self) -> Vec<String> {
269        let mut out: Vec<String> = Vec::new();
270        for path in &self.files {
271            if let Some((zone, _)) = path.split_once('/')
272                && !zone.starts_with('_')
273                && !out.iter().any(|known| known == zone)
274            {
275                out.push(zone.to_owned());
276            }
277        }
278        out
279    }
280
281    /// The `(driver, forge)` tuples at which the release automation is
282    /// available, in path order, as `driver, forge` strings.
283    #[must_use]
284    pub fn automation_tuples(&self) -> Vec<String> {
285        let mut out: Vec<String> = Vec::new();
286        for path in &self.files {
287            let mut segments = path.splitn(3, '/');
288            if let (Some(driver), Some(forge), Some(_)) =
289                (segments.next(), segments.next(), segments.next())
290                && !driver.starts_with('_')
291                && owner_of(path) == Some(RELEASE_AUTOMATION)
292            {
293                let entry = format!("{driver}, {forge}");
294                if !out.contains(&entry) {
295                    out.push(entry);
296                }
297            }
298        }
299        out
300    }
301}
302
303/// Whether a target lands a forge gate at all.
304///
305/// It does under forge integration, where a request is the way onto the
306/// trunk, and under an automatic release, whose bot opens a request
307/// whatever the integration mode. A local integration with no automatic
308/// release opens no request, so no gate has anything to judge and no CI
309/// file lands for it.
310///
311/// The one owner of the answer: the catalog selects by it, and the setup's
312/// effective protection requires a check and a pipeline by it, so a forge
313/// never requires a check no landed workflow reports.
314///
315/// SATISFIES project-profile:the-forge-gate-follows-the-merge-form
316#[must_use]
317pub const fn forge_gate_lands(integration: Integration, mode: ReleaseMode) -> bool {
318    matches!(integration, Integration::Forge) || matches!(mode, ReleaseMode::Automatic)
319}
320
321/// The release drivers this binary's sources know.
322#[must_use]
323pub fn known_drivers() -> Vec<String> {
324    Availability::embedded().drivers()
325}
326
327/// Judge a stated `profile.release.registry` answer against its release.
328///
329/// The answer is valid for an automatic release driven by `rust`, which
330/// takes `crates-io` or `none`, or by `python`, which takes `pypi` or
331/// `none`, because only those drivers' automation reads where the release
332/// publishes.
333///
334/// # Errors
335///
336/// Returns the reason, naming the key and the shape it needs.
337///
338/// SATISFIES project-profile:release-intent-has-three-modes
339pub fn registry_answer_fits(
340    mode: ReleaseMode,
341    driver: Option<&str>,
342    answer: crate::profile::RegistryAnswer,
343) -> Result<(), String> {
344    let shape = format!(
345        "profile.release.registry takes {}, under profile.release.mode = \"automatic\" alone",
346        crate::profile::RegistryAnswer::SHAPE
347    );
348    match (mode, driver) {
349        (ReleaseMode::Automatic, Some(driver)) if answer.fits(driver) => Ok(()),
350        (ReleaseMode::Automatic, Some(other)) => Err(format!(
351            "profile.release.registry is {} while profile.release.driver is {other}; {shape}",
352            answer.as_str()
353        )),
354        (ReleaseMode::Automatic, None) => Err(format!(
355            "profile.release.registry is set while no profile.release.driver is named; {shape}"
356        )),
357        (mode, _) => Err(format!(
358            "profile.release.registry is set while profile.release.mode is {}; {shape}",
359            mode.as_str()
360        )),
361    }
362}
363
364/// Whether a build answer fits the release intent: an automatic bash
365/// release alone calls a project-supplied build command.
366///
367/// # Errors
368/// The refusal text, naming the key, what it found, and the shape it needs.
369pub fn build_answer_fits(mode: ReleaseMode, driver: Option<&str>) -> Result<(), String> {
370    let shape = format!(
371        "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",
372        crate::profile::BuildAnswer::DRIVER
373    );
374    match (mode, driver) {
375        (ReleaseMode::Automatic, Some(crate::profile::BuildAnswer::DRIVER)) => Ok(()),
376        (ReleaseMode::Automatic, Some(other)) => Err(format!(
377            "profile.release.build is set while profile.release.driver is {other}; {shape}"
378        )),
379        (ReleaseMode::Automatic, None) => Err(format!(
380            "profile.release.build is set while no profile.release.driver is named; {shape}"
381        )),
382        (mode, _) => Err(format!(
383            "profile.release.build is set while profile.release.mode is {}; {shape}",
384            mode.as_str()
385        )),
386    }
387}
388
389/// Whether `name` is a forge this binary has an adapter for.
390fn known_forge(name: &str) -> bool {
391    Forge::parse(name).is_some()
392}
393
394/// Select every capability for `params` against `availability`.
395///
396/// The order is [`ALL`]. The local guards are always selected; every
397/// other capability answers by its dimensions and its request.
398///
399/// SATISFIES project-profile:the-forge-gate-follows-the-merge-form
400#[must_use]
401#[allow(
402    clippy::too_many_lines,
403    reason = "one pass answers every capability, and splitting it would separate a selection from the dimensions that decided it"
404)]
405pub fn select(params: &Params, availability: &Availability) -> Vec<Selection> {
406    let forge = params.forge();
407    let driver = params.driver();
408    let known_drivers = availability.drivers();
409    let driver_known = driver.is_some_and(|d| known_drivers.iter().any(|k| k == d));
410    let forge_known = forge.is_some_and(known_forge);
411
412    let mut out = Vec::with_capacity(ALL.len());
413    out.push(Selection::selected(GUARDS, Vec::new(), None));
414    out.push(if params.workflow_guards() {
415        Selection::selected(WORKFLOW_GUARDS, Vec::new(), None)
416    } else {
417        Selection::omitted(
418            WORKFLOW_GUARDS,
419            Status::NotRequested,
420            "capabilities.workflow_guards is false",
421        )
422    });
423
424    // The release automation first, because the title gate's root
425    // pipeline depends on whether it lands.
426    let automation = match (params.release_mode(), driver, forge) {
427        (ReleaseMode::External, _, _) => Selection::omitted(
428            RELEASE_AUTOMATION,
429            Status::NotRequested,
430            "the release is external: the target releases through a process release-kit does not drive",
431        ),
432        (ReleaseMode::None, _, _) => Selection::omitted(
433            RELEASE_AUTOMATION,
434            Status::NotRequested,
435            "the release mode is none",
436        ),
437        (ReleaseMode::Automatic, None, _) => Selection::omitted(
438            RELEASE_AUTOMATION,
439            Status::NotApplicable,
440            "an automatic release names no driver",
441        ),
442        (ReleaseMode::Automatic, _, None) => Selection::omitted(
443            RELEASE_AUTOMATION,
444            Status::NotApplicable,
445            "the profile names no forge",
446        ),
447        (ReleaseMode::Automatic, Some(driver), Some(forge)) => {
448            if !driver_known {
449                Selection::omitted(
450                    RELEASE_AUTOMATION,
451                    Status::Unknown,
452                    format!(
453                        "the driver {driver} is not one this release knows; the bindings are: {}",
454                        known_drivers.join(", ")
455                    ),
456                )
457            } else if !forge_known {
458                Selection::omitted(
459                    RELEASE_AUTOMATION,
460                    Status::Unknown,
461                    format!(
462                        "the forge {forge} is not one this release drives; the forges are: github, gitlab"
463                    ),
464                )
465            } else {
466                let sources = availability.owned(driver, forge, RELEASE_AUTOMATION);
467                if sources.is_empty() {
468                    Selection::omitted(
469                        RELEASE_AUTOMATION,
470                        Status::Unavailable,
471                        format!(
472                            "the release automation at ({driver}, {forge}) has no landable files; the available tuples are: {}",
473                            availability.automation_tuples().join("; ")
474                        ),
475                    )
476                } else {
477                    Selection::selected(RELEASE_AUTOMATION, sources, Some(driver))
478                }
479            }
480        }
481    };
482    let automation_lands = automation.status == Status::Selected;
483
484    // The forge gate: complete where the forge is known, and the one the
485    // merge form names. Under `squash` the request's title becomes the
486    // trunk's commit, so the title gate judges it; under `rebase` every
487    // commit keeps its own message, so the commit gate judges each.
488    let (gate, zone, other, why_not) = match params.merge_form() {
489        MergeForm::Squash => (
490            TITLE_CHECK,
491            TITLE_GATE_ZONE,
492            COMMIT_CHECK,
493            "git.merge_form is squash, so the title gate judges the request",
494        ),
495        MergeForm::Rebase => (
496            COMMIT_CHECK,
497            COMMIT_GATE_ZONE,
498            TITLE_CHECK,
499            "git.merge_form is rebase, so the commit gate judges every commit of the request",
500        ),
501    };
502    // Where no request reaches the trunk, neither gate has anything to
503    // judge, and both say so rather than naming the merge form.
504    let no_gate = (!forge_gate_lands(params.integration(), params.release_mode())).then(|| {
505        format!(
506            "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",
507            params.release_mode().as_str()
508        )
509    });
510    let selected_gate = match forge {
511        None => Selection::omitted(gate, Status::NotApplicable, "the profile names no forge"),
512        Some(forge) if !forge_known => Selection::omitted(
513            gate,
514            Status::Unknown,
515            format!(
516                "the forge {forge} is not one this release drives; the forges are: github, gitlab"
517            ),
518        ),
519        Some(_) if no_gate.is_some() => Selection::omitted(
520            gate,
521            Status::NotApplicable,
522            no_gate.clone().unwrap_or_default(),
523        ),
524        Some(forge) => {
525            let mut sources = availability.owned("_shared", forge, gate);
526            if !automation_lands {
527                sources.extend(availability.owned(zone, forge, gate));
528            }
529            if sources.is_empty() {
530                Selection::omitted(
531                    gate,
532                    Status::Unavailable,
533                    format!(
534                        "this release ships no {} gate for {forge}",
535                        if gate == TITLE_CHECK {
536                            "title"
537                        } else {
538                            "commit"
539                        }
540                    ),
541                )
542            } else {
543                Selection::selected(gate, sources, None)
544            }
545        }
546    };
547    let other_gate = no_gate.map_or_else(
548        || Selection::omitted(other, Status::NotRequested, why_not),
549        |reason| Selection::omitted(other, Status::NotApplicable, reason),
550    );
551    if gate == TITLE_CHECK {
552        out.push(selected_gate);
553        out.push(other_gate);
554    } else {
555        out.push(other_gate);
556        out.push(selected_gate);
557    }
558
559    // The reporting policy.
560    out.push(match forge {
561        _ if !params.reporting_policy() => Selection::omitted(
562            REPORTING_POLICY,
563            Status::NotRequested,
564            "capabilities.reporting_policy is false",
565        ),
566        None => Selection::omitted(
567            REPORTING_POLICY,
568            Status::NotApplicable,
569            "the profile names no forge, and the policy names the forge's private channel",
570        ),
571        Some(forge) if !forge_known => Selection::omitted(
572            REPORTING_POLICY,
573            Status::Unknown,
574            format!(
575                "the forge {forge} is not one this release drives; the forges are: github, gitlab"
576            ),
577        ),
578        Some(forge) => {
579            let sources = availability.owned("_shared", forge, REPORTING_POLICY);
580            if sources.is_empty() {
581                Selection::omitted(
582                    REPORTING_POLICY,
583                    Status::Unavailable,
584                    format!("this release ships no reporting policy for {forge}"),
585                )
586            } else {
587                Selection::selected(REPORTING_POLICY, sources, None)
588            }
589        }
590    });
591
592    out.push(automation);
593
594    // The Nix packaging, keyed on the release driver and the forge.
595    out.push(match (params.nix_packaging(), driver, forge) {
596        (false, _, _) => Selection::omitted(
597            PACKAGING_NIX,
598            Status::NotRequested,
599            "capabilities.nix_packaging is false",
600        ),
601        (true, None, _) => Selection::omitted(
602            PACKAGING_NIX,
603            Status::NotApplicable,
604            "the seed is keyed on an automatic release driver, and the profile names none",
605        ),
606        (true, _, None) => Selection::omitted(
607            PACKAGING_NIX,
608            Status::NotApplicable,
609            "the profile names no forge",
610        ),
611        (true, Some(driver), Some(forge)) => {
612            if !driver_known || !forge_known {
613                Selection::omitted(
614                    PACKAGING_NIX,
615                    Status::Unknown,
616                    format!("({driver}, {forge}) names a category this release does not know"),
617                )
618            } else {
619                let sources = availability.owned(driver, forge, PACKAGING_NIX);
620                if sources.is_empty() {
621                    Selection::omitted(
622                        PACKAGING_NIX,
623                        Status::Unavailable,
624                        format!("the {driver} binding ships no Nix seed on {forge}"),
625                    )
626                } else {
627                    Selection::selected(PACKAGING_NIX, sources, Some(driver))
628                }
629            }
630        }
631    });
632
633    // The Scorecard workflow, GitHub's alone.
634    out.push(match forge {
635        _ if !params.scorecard() => Selection::omitted(
636            SCORECARD,
637            Status::NotRequested,
638            "capabilities.scorecard is false",
639        ),
640        None => Selection::omitted(
641            SCORECARD,
642            Status::NotApplicable,
643            "the profile names no forge",
644        ),
645        Some(forge) if !forge_known => Selection::omitted(
646            SCORECARD,
647            Status::Unknown,
648            format!(
649                "the forge {forge} is not one this release drives; the forges are: github, gitlab"
650            ),
651        ),
652        Some(forge) => {
653            let sources = availability.owned("_shared", forge, SCORECARD);
654            if sources.is_empty() {
655                Selection::omitted(
656                    SCORECARD,
657                    Status::Unavailable,
658                    format!(
659                        "the Scorecard workflow is GitHub's alone, and the {forge} zone ships none"
660                    ),
661                )
662            } else {
663                Selection::selected(SCORECARD, sources, None)
664            }
665        }
666    });
667
668    // The code scanning workflow, keyed on the driver, the forge, and the
669    // provider.
670    out.push(match (params.code_scanning(), driver, forge) {
671        (None, _, _) => Selection::omitted(
672            CODE_SCANNING,
673            Status::NotRequested,
674            "capabilities.code_scanning is off",
675        ),
676        (Some(_), None, _) => Selection::omitted(
677            CODE_SCANNING,
678            Status::NotApplicable,
679            "a scanner reads the release driver's language, and the profile names no driver",
680        ),
681        (Some(_), _, None) => Selection::omitted(
682            CODE_SCANNING,
683            Status::NotApplicable,
684            "the profile names no forge",
685        ),
686        (Some(provider), Some(driver), Some(forge)) => {
687            if !driver_known || !forge_known {
688                Selection::omitted(
689                    CODE_SCANNING,
690                    Status::Unknown,
691                    format!("({driver}, {forge}) names a category this release does not know"),
692                )
693            } else if let Some(reason) = crate::projection::code_scanning_incompatibility(
694                Some(provider),
695                Some(driver),
696                Some(forge),
697            ) {
698                Selection::omitted(CODE_SCANNING, Status::Unavailable, reason)
699            } else {
700                let sources: Vec<String> = availability
701                    .owned(driver, forge, CODE_SCANNING)
702                    .into_iter()
703                    .filter(|path| {
704                        CODE_SCANNING_DESTINATIONS
705                            .iter()
706                            .any(|(name, owner)| path.ends_with(name) && *owner == provider)
707                    })
708                    .collect();
709                if sources.is_empty() {
710                    Selection::omitted(
711                        CODE_SCANNING,
712                        Status::Unavailable,
713                        format!(
714                            "the {driver} binding ships no {} workflow on {forge}",
715                            provider.as_str()
716                        ),
717                    )
718                } else {
719                    Selection::selected(CODE_SCANNING, sources, Some(driver))
720                        .keyed_on(provider.as_str())
721                }
722            }
723        }
724    });
725    debug_assert_eq!(out.len(), ALL.len());
726    out
727}
728
729/// The pin keys one selection matches in the registry's `used_by`.
730///
731/// Three of them: the bare capability id, the id qualified by the driver
732/// where the capability has that dimension, and that qualified again by
733/// the provider where its parameter names one.
734///
735/// Three widths rather than one, so a pin declares the narrowest truth it
736/// can. A tool every provider runs takes the bare id; one a single
737/// provider runs takes the widest key, and a target that landed the other
738/// provider records neither the tool nor its staleness baseline.
739#[must_use]
740pub fn pin_keys(selection: &Selection) -> Vec<String> {
741    let mut keys = vec![selection.id.to_owned()];
742    if let Some(driver) = &selection.driver {
743        keys.push(format!("{}/{driver}", selection.id));
744        if let Some(provider) = &selection.provider {
745            keys.push(format!("{}/{driver}/{provider}", selection.id));
746        }
747    }
748    keys
749}
750
751#[cfg(test)]
752mod tests {
753    use super::{
754        ALL, Availability, CODE_SCANNING, COMMIT_CHECK, GUARDS, PACKAGING_NIX, RELEASE_AUTOMATION,
755        REPORTING_POLICY, SCORECARD, Status, TITLE_CHECK, WORKFLOW_GUARDS, owner_of, select,
756    };
757    use crate::landing::Params;
758    use crate::landing::manifest::{Provider, Style};
759    use crate::profile::ReleaseMode;
760
761    fn status_of(selections: &[super::Selection], id: &str) -> Status {
762        selections
763            .iter()
764            .find(|s| s.id == id)
765            .expect("every capability answers")
766            .status
767    }
768
769    /// Every embedded snippet has exactly one owner, and no shared path
770    /// falls to the release automation by default.
771    #[test]
772    fn every_embedded_snippet_has_one_owner() {
773        let availability = Availability::embedded();
774        assert!(!availability.files().is_empty());
775        for path in availability.files() {
776            let owner = owner_of(path);
777            assert!(owner.is_some(), "{path}: no capability owns it");
778            assert!(
779                ALL.contains(&owner.unwrap_or_default()),
780                "{path}: an unlisted owner"
781            );
782        }
783        assert_eq!(
784            owner_of("_shared/github/SECURITY.md"),
785            Some(REPORTING_POLICY)
786        );
787        assert_eq!(
788            owner_of("_shared/github/.github/workflows/scorecard.yml"),
789            Some(SCORECARD)
790        );
791        assert_eq!(owner_of("rust/github/flake.nix"), Some(PACKAGING_NIX));
792        assert_eq!(
793            owner_of("rust/github/.github/workflows/code-scanning-codeql.yml"),
794            Some(CODE_SCANNING)
795        );
796        assert_eq!(
797            owner_of("rust/github/release-plz.toml"),
798            Some(RELEASE_AUTOMATION)
799        );
800        assert_eq!(owner_of("_shared/github/unknown.txt"), None);
801    }
802
803    /// SATISFIES project-profile:availability-belongs-to-a-capability-at-its-dimensions
804    #[test]
805    fn the_catalog_answers_availability_per_tuple() {
806        let availability = Availability::embedded();
807        // One capability, two answers at two dimensions: code scanning lands
808        // for the rust binding on GitLab and not for the python one.
809        let mut rust = Params::for_test("acme/widget", Some(Style::Trunk));
810        rust.set_pair_for_test("rust", "gitlab");
811        rust.set_code_scanning_for_test(Some(crate::projection::Provider::Semgrep));
812        let mut python = rust.clone();
813        python.set_pair_for_test("python", "gitlab");
814        assert_eq!(
815            status_of(&select(&rust, &availability), CODE_SCANNING),
816            Status::Selected
817        );
818        let on_python = select(&python, &availability);
819        assert_eq!(status_of(&on_python, CODE_SCANNING), Status::Unavailable);
820        // The release automation lands for python on both forges.
821        let mut github = python.clone();
822        github.set_pair_for_test("python", "github");
823        for params in [&python, &github] {
824            assert_eq!(
825                status_of(&select(params, &availability), RELEASE_AUTOMATION),
826                Status::Selected
827            );
828        }
829        // A tree that carries no python GitLab files answers the same tuple
830        // unavailable, naming the tuples it does carry.
831        let narrowed = Availability::over(
832            availability
833                .files()
834                .iter()
835                .filter(|path| !path.starts_with("python/gitlab/"))
836                .cloned()
837                .collect(),
838        );
839        let on_gitlab = select(&python, &narrowed);
840        assert_eq!(
841            status_of(&on_gitlab, RELEASE_AUTOMATION),
842            Status::Unavailable
843        );
844        let reason = on_gitlab
845            .iter()
846            .find(|s| s.id == RELEASE_AUTOMATION)
847            .and_then(|s| s.reason.clone())
848            .expect("an unavailable capability states why");
849        assert!(reason.contains("rust, gitlab"), "{reason}");
850        assert!(reason.contains("python, github"), "{reason}");
851        let tuples = reason
852            .split_once("the available tuples are:")
853            .map(|(_, tuples)| tuples)
854            .expect("the reason names the tuples");
855        assert!(!tuples.contains("python, gitlab"), "{reason}");
856        // The title gate still lands on GitLab, with the release-less root
857        // pipeline beside the fragment.
858        let title = on_gitlab
859            .iter()
860            .find(|s| s.id == TITLE_CHECK)
861            .expect("the title gate answers");
862        assert_eq!(title.status, Status::Selected);
863        assert!(
864            title
865                .sources
866                .iter()
867                .any(|s| s == "_title-gate/gitlab/.gitlab-ci.yml"),
868            "{:?}",
869            title.sources
870        );
871    }
872
873    /// A no-forge, no-release input selects the local guards alone and
874    /// marks every forge capability not applicable.
875    #[test]
876    fn a_no_forge_input_selects_the_guards_alone() {
877        let params = Params::for_test_release_less(&[], None, ReleaseMode::None);
878        let selections = select(&params, &Availability::embedded());
879        assert_eq!(status_of(&selections, GUARDS), Status::Selected);
880        for id in [
881            TITLE_CHECK,
882            REPORTING_POLICY,
883            RELEASE_AUTOMATION,
884            PACKAGING_NIX,
885        ] {
886            assert_ne!(status_of(&selections, id), Status::Selected, "{id}");
887        }
888        assert_eq!(status_of(&selections, TITLE_CHECK), Status::NotApplicable);
889        assert_eq!(
890            status_of(&selections, RELEASE_AUTOMATION),
891            Status::NotRequested
892        );
893        assert_eq!(status_of(&selections, PACKAGING_NIX), Status::NotRequested);
894    }
895
896    /// The workflow guards answer their request alone, and the release
897    /// guards land whatever it says.
898    #[test]
899    fn the_workflow_guards_are_selected_only_when_requested() {
900        let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
901        let selections = select(&params, &Availability::embedded());
902        assert_eq!(status_of(&selections, WORKFLOW_GUARDS), Status::Selected);
903        params.set_workflow_guards_for_test(false);
904        let selections = select(&params, &Availability::embedded());
905        assert_eq!(
906            status_of(&selections, WORKFLOW_GUARDS),
907            Status::NotRequested
908        );
909        assert_eq!(status_of(&selections, GUARDS), Status::Selected);
910    }
911
912    /// A forge gate lands only where a request reaches the trunk: under
913    /// forge integration, or under an automatic release whose bot opens
914    /// one. A local, release-less landing selects neither gate on either
915    /// forge, and so lands no CI file; an external release integrated
916    /// locally takes none either, as the canon states it.
917    ///
918    /// SATISFIES project-profile:the-forge-gate-follows-the-merge-form
919    #[test]
920    fn workflow_only_local_landing_selects_no_forge_gate() {
921        use crate::landing::{Integration, MergeForm};
922        let availability = Availability::embedded();
923        for forge in ["github", "gitlab"] {
924            for form in [MergeForm::Squash, MergeForm::Rebase] {
925                for (mode, integration, lands) in [
926                    (ReleaseMode::None, Integration::Local, false),
927                    (ReleaseMode::External, Integration::Local, false),
928                    (ReleaseMode::None, Integration::Forge, true),
929                    (ReleaseMode::External, Integration::Forge, true),
930                ] {
931                    let mut params = Params::for_test_release_less(&[], Some(forge), mode);
932                    params.set_integration_for_test(integration);
933                    params.set_merge_form_for_test(form);
934                    let selections = select(&params, &availability);
935                    let (gate, other) = match form {
936                        MergeForm::Squash => (TITLE_CHECK, COMMIT_CHECK),
937                        MergeForm::Rebase => (COMMIT_CHECK, TITLE_CHECK),
938                    };
939                    let at = format!("{forge} {form:?} {mode:?} {integration:?}");
940                    let selection = selections
941                        .iter()
942                        .find(|s| s.id == gate)
943                        .expect("every capability answers");
944                    assert_eq!(selection.lands(), lands, "{at}");
945                    if !lands {
946                        assert_eq!(selection.status, Status::NotApplicable, "{at}");
947                        assert!(selection.sources.is_empty(), "{at}");
948                        let reason = selection.reason.as_deref().unwrap_or_default();
949                        assert!(reason.contains("no forge gate lands"), "{at}: {reason}");
950                        assert!(
951                            reason.contains("git.integration is local"),
952                            "{at}: {reason}"
953                        );
954                    }
955                    // The gate the form does not name gives the same real
956                    // reason where no gate lands, and the form's otherwise.
957                    let unnamed = selections
958                        .iter()
959                        .find(|s| s.id == other)
960                        .expect("every capability answers");
961                    let reason = unnamed.reason.as_deref().unwrap_or_default();
962                    if lands {
963                        assert_eq!(unnamed.status, Status::NotRequested, "{at}");
964                        assert!(reason.contains("git.merge_form is"), "{at}: {reason}");
965                    } else {
966                        assert_eq!(unnamed.status, Status::NotApplicable, "{at}");
967                        assert!(reason.contains("no forge gate lands"), "{at}: {reason}");
968                        assert!(!reason.contains("merge_form"), "{at}: {reason}");
969                    }
970                }
971            }
972            // An automatic release keeps its gate under local integration,
973            // because the bot's request still reaches the trunk.
974            let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
975            params.set_pair_for_test("rust", forge);
976            params.set_integration_for_test(Integration::Local);
977            let selections = select(&params, &availability);
978            assert_eq!(
979                status_of(&selections, TITLE_CHECK),
980                Status::Selected,
981                "{forge}"
982            );
983        }
984    }
985
986    /// An unknown forge keeps the guards and reports the rest unknown.
987    #[test]
988    fn an_unknown_forge_reads_as_unknown_and_lands_the_guards() {
989        let mut params = Params::for_test_release_less(&[], Some("codeberg"), ReleaseMode::None);
990        params.set_scorecard_for_test(true);
991        let selections = select(&params, &Availability::embedded());
992        assert_eq!(status_of(&selections, GUARDS), Status::Selected);
993        assert_eq!(status_of(&selections, TITLE_CHECK), Status::Unknown);
994        assert_eq!(status_of(&selections, SCORECARD), Status::Unknown);
995    }
996
997    /// The scanner follows the provider and the pair, and a request the
998    /// pair cannot run reports `Unavailable` with the reason the landing
999    /// then omits it by. Nothing refuses on it.
1000    #[test]
1001    fn code_scanning_selects_the_providers_own_file() {
1002        let availability = Availability::embedded();
1003        let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
1004        params.set_code_scanning_for_test(Some(Provider::Semgrep));
1005        let selections = select(&params, &availability);
1006        let scanning = selections
1007            .iter()
1008            .find(|s| s.id == CODE_SCANNING)
1009            .expect("answers");
1010        assert_eq!(scanning.status, Status::Selected);
1011        assert_eq!(
1012            scanning.sources,
1013            vec!["rust/github/.github/workflows/code-scanning-semgrep.yml".to_owned()]
1014        );
1015        params.set_pair_for_test("bash", "github");
1016        let selections = select(&params, &availability);
1017        assert_eq!(status_of(&selections, CODE_SCANNING), Status::Unavailable);
1018    }
1019
1020    /// The merge form picks the forge gate: the title gate under squash,
1021    /// the commit gate under rebase, never both, and on GitLab with no
1022    /// release automation the root pipeline that activates the chosen
1023    /// fragment.
1024    ///
1025    /// SATISFIES project-profile:the-forge-gate-follows-the-merge-form
1026    #[test]
1027    fn commit_gate_replaces_the_title_gate_under_rebase() {
1028        use crate::landing::{Integration, MergeForm};
1029        let availability = Availability::embedded();
1030        for integration in [Integration::Forge, Integration::Local] {
1031            for (driver, forge) in [("rust", "github"), ("rust", "gitlab"), ("python", "gitlab")] {
1032                let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
1033                params.set_pair_for_test(driver, forge);
1034                params.set_integration_for_test(integration);
1035                let squash = select(&params, &availability);
1036                assert_eq!(status_of(&squash, TITLE_CHECK), Status::Selected);
1037                assert_eq!(status_of(&squash, COMMIT_CHECK), Status::NotRequested);
1038
1039                params.set_merge_form_for_test(MergeForm::Rebase);
1040                let rebase = select(&params, &availability);
1041                assert_eq!(status_of(&rebase, TITLE_CHECK), Status::NotRequested);
1042                let gate = rebase
1043                    .iter()
1044                    .find(|s| s.id == COMMIT_CHECK)
1045                    .expect("the commit gate answers");
1046                assert_eq!(gate.status, Status::Selected, "{driver} {forge}");
1047                assert!(
1048                    gate.sources.iter().all(|source| !source.contains("title")),
1049                    "{:?}",
1050                    gate.sources
1051                );
1052                let fragment = if forge == "github" {
1053                    "_shared/github/.github/workflows/pr-commits.yml"
1054                } else {
1055                    "_shared/gitlab/.gitlab/ci/mr-commits.yml"
1056                };
1057                assert!(
1058                    gate.sources.iter().any(|s| s == fragment),
1059                    "{:?}",
1060                    gate.sources
1061                );
1062                // Every pair here automates its release, so the binding's
1063                // own root pipeline activates the fragment, and the commit
1064                // gate's release-less root pipeline stays out.
1065                assert!(
1066                    !gate
1067                        .sources
1068                        .iter()
1069                        .any(|s| s == "_commit-gate/gitlab/.gitlab-ci.yml"),
1070                    "{driver} {forge}: {:?}",
1071                    gate.sources
1072                );
1073            }
1074        }
1075        assert_eq!(
1076            owner_of("_shared/github/.github/workflows/pr-commits.yml"),
1077            Some(COMMIT_CHECK)
1078        );
1079        assert_eq!(
1080            owner_of("_commit-gate/gitlab/.gitlab-ci.yml"),
1081            Some(COMMIT_CHECK)
1082        );
1083        assert_eq!(ALL.len(), 9);
1084    }
1085}