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/// Whether `name` is a forge this binary has an adapter for.
327fn known_forge(name: &str) -> bool {
328    Forge::parse(name).is_some()
329}
330
331/// Select every capability for `params` against `availability`.
332///
333/// The order is [`ALL`]. The local guards are always selected; every
334/// other capability answers by its dimensions and its request.
335///
336/// SATISFIES project-profile:the-forge-gate-follows-the-merge-form
337#[must_use]
338#[allow(
339    clippy::too_many_lines,
340    reason = "one pass answers every capability, and splitting it would separate a selection from the dimensions that decided it"
341)]
342pub fn select(params: &Params, availability: &Availability) -> Vec<Selection> {
343    let forge = params.forge();
344    let driver = params.driver();
345    let known_drivers = availability.drivers();
346    let driver_known = driver.is_some_and(|d| known_drivers.iter().any(|k| k == d));
347    let forge_known = forge.is_some_and(known_forge);
348
349    let mut out = Vec::with_capacity(ALL.len());
350    out.push(Selection::selected(GUARDS, Vec::new(), None));
351    out.push(if params.workflow_guards() {
352        Selection::selected(WORKFLOW_GUARDS, Vec::new(), None)
353    } else {
354        Selection::omitted(
355            WORKFLOW_GUARDS,
356            Status::NotRequested,
357            "capabilities.workflow_guards is false",
358        )
359    });
360
361    // The release automation first, because the title gate's root
362    // pipeline depends on whether it lands.
363    let automation = match (params.release_mode(), driver, forge) {
364        (ReleaseMode::External, _, _) => Selection::omitted(
365            RELEASE_AUTOMATION,
366            Status::NotRequested,
367            "the release is external: the target releases through a process release-kit does not drive",
368        ),
369        (ReleaseMode::None, _, _) => Selection::omitted(
370            RELEASE_AUTOMATION,
371            Status::NotRequested,
372            "the release mode is none",
373        ),
374        (ReleaseMode::Automatic, None, _) => Selection::omitted(
375            RELEASE_AUTOMATION,
376            Status::NotApplicable,
377            "an automatic release names no driver",
378        ),
379        (ReleaseMode::Automatic, _, None) => Selection::omitted(
380            RELEASE_AUTOMATION,
381            Status::NotApplicable,
382            "the profile names no forge",
383        ),
384        (ReleaseMode::Automatic, Some(driver), Some(forge)) => {
385            if !driver_known {
386                Selection::omitted(
387                    RELEASE_AUTOMATION,
388                    Status::Unknown,
389                    format!(
390                        "the driver {driver} is not one this release knows; the bindings are: {}",
391                        known_drivers.join(", ")
392                    ),
393                )
394            } else if !forge_known {
395                Selection::omitted(
396                    RELEASE_AUTOMATION,
397                    Status::Unknown,
398                    format!(
399                        "the forge {forge} is not one this release drives; the forges are: github, gitlab"
400                    ),
401                )
402            } else {
403                let sources = availability.owned(driver, forge, RELEASE_AUTOMATION);
404                if sources.is_empty() {
405                    Selection::omitted(
406                        RELEASE_AUTOMATION,
407                        Status::Unavailable,
408                        format!(
409                            "the release automation at ({driver}, {forge}) has no landable files; the available tuples are: {}",
410                            availability.automation_tuples().join("; ")
411                        ),
412                    )
413                } else {
414                    Selection::selected(RELEASE_AUTOMATION, sources, Some(driver))
415                }
416            }
417        }
418    };
419    let automation_lands = automation.status == Status::Selected;
420
421    // The forge gate: complete where the forge is known, and the one the
422    // merge form names. Under `squash` the request's title becomes the
423    // trunk's commit, so the title gate judges it; under `rebase` every
424    // commit keeps its own message, so the commit gate judges each.
425    let (gate, zone, other, why_not) = match params.merge_form() {
426        MergeForm::Squash => (
427            TITLE_CHECK,
428            TITLE_GATE_ZONE,
429            COMMIT_CHECK,
430            "git.merge_form is squash, so the title gate judges the request",
431        ),
432        MergeForm::Rebase => (
433            COMMIT_CHECK,
434            COMMIT_GATE_ZONE,
435            TITLE_CHECK,
436            "git.merge_form is rebase, so the commit gate judges every commit of the request",
437        ),
438    };
439    // Where no request reaches the trunk, neither gate has anything to
440    // judge, and both say so rather than naming the merge form.
441    let no_gate = (!forge_gate_lands(params.integration(), params.release_mode())).then(|| {
442        format!(
443            "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",
444            params.release_mode().as_str()
445        )
446    });
447    let selected_gate = match forge {
448        None => Selection::omitted(gate, Status::NotApplicable, "the profile names no forge"),
449        Some(forge) if !forge_known => Selection::omitted(
450            gate,
451            Status::Unknown,
452            format!(
453                "the forge {forge} is not one this release drives; the forges are: github, gitlab"
454            ),
455        ),
456        Some(_) if no_gate.is_some() => Selection::omitted(
457            gate,
458            Status::NotApplicable,
459            no_gate.clone().unwrap_or_default(),
460        ),
461        Some(forge) => {
462            let mut sources = availability.owned("_shared", forge, gate);
463            if !automation_lands {
464                sources.extend(availability.owned(zone, forge, gate));
465            }
466            if sources.is_empty() {
467                Selection::omitted(
468                    gate,
469                    Status::Unavailable,
470                    format!(
471                        "this release ships no {} gate for {forge}",
472                        if gate == TITLE_CHECK {
473                            "title"
474                        } else {
475                            "commit"
476                        }
477                    ),
478                )
479            } else {
480                Selection::selected(gate, sources, None)
481            }
482        }
483    };
484    let other_gate = no_gate.map_or_else(
485        || Selection::omitted(other, Status::NotRequested, why_not),
486        |reason| Selection::omitted(other, Status::NotApplicable, reason),
487    );
488    if gate == TITLE_CHECK {
489        out.push(selected_gate);
490        out.push(other_gate);
491    } else {
492        out.push(other_gate);
493        out.push(selected_gate);
494    }
495
496    // The reporting policy.
497    out.push(match forge {
498        _ if !params.reporting_policy() => Selection::omitted(
499            REPORTING_POLICY,
500            Status::NotRequested,
501            "capabilities.reporting_policy is false",
502        ),
503        None => Selection::omitted(
504            REPORTING_POLICY,
505            Status::NotApplicable,
506            "the profile names no forge, and the policy names the forge's private channel",
507        ),
508        Some(forge) if !forge_known => Selection::omitted(
509            REPORTING_POLICY,
510            Status::Unknown,
511            format!(
512                "the forge {forge} is not one this release drives; the forges are: github, gitlab"
513            ),
514        ),
515        Some(forge) => {
516            let sources = availability.owned("_shared", forge, REPORTING_POLICY);
517            if sources.is_empty() {
518                Selection::omitted(
519                    REPORTING_POLICY,
520                    Status::Unavailable,
521                    format!("this release ships no reporting policy for {forge}"),
522                )
523            } else {
524                Selection::selected(REPORTING_POLICY, sources, None)
525            }
526        }
527    });
528
529    out.push(automation);
530
531    // The Nix packaging, keyed on the release driver and the forge.
532    out.push(match (params.nix_packaging(), driver, forge) {
533        (false, _, _) => Selection::omitted(
534            PACKAGING_NIX,
535            Status::NotRequested,
536            "capabilities.nix_packaging is false",
537        ),
538        (true, None, _) => Selection::omitted(
539            PACKAGING_NIX,
540            Status::NotApplicable,
541            "the seed is keyed on an automatic release driver, and the profile names none",
542        ),
543        (true, _, None) => Selection::omitted(
544            PACKAGING_NIX,
545            Status::NotApplicable,
546            "the profile names no forge",
547        ),
548        (true, Some(driver), Some(forge)) => {
549            if !driver_known || !forge_known {
550                Selection::omitted(
551                    PACKAGING_NIX,
552                    Status::Unknown,
553                    format!("({driver}, {forge}) names a category this release does not know"),
554                )
555            } else {
556                let sources = availability.owned(driver, forge, PACKAGING_NIX);
557                if sources.is_empty() {
558                    Selection::omitted(
559                        PACKAGING_NIX,
560                        Status::Unavailable,
561                        format!("the {driver} binding ships no Nix seed on {forge}"),
562                    )
563                } else {
564                    Selection::selected(PACKAGING_NIX, sources, Some(driver))
565                }
566            }
567        }
568    });
569
570    // The Scorecard workflow, GitHub's alone.
571    out.push(match forge {
572        _ if !params.scorecard() => Selection::omitted(
573            SCORECARD,
574            Status::NotRequested,
575            "capabilities.scorecard is false",
576        ),
577        None => Selection::omitted(
578            SCORECARD,
579            Status::NotApplicable,
580            "the profile names no forge",
581        ),
582        Some(forge) if !forge_known => Selection::omitted(
583            SCORECARD,
584            Status::Unknown,
585            format!(
586                "the forge {forge} is not one this release drives; the forges are: github, gitlab"
587            ),
588        ),
589        Some(forge) => {
590            let sources = availability.owned("_shared", forge, SCORECARD);
591            if sources.is_empty() {
592                Selection::omitted(
593                    SCORECARD,
594                    Status::Unavailable,
595                    format!(
596                        "the Scorecard workflow is GitHub's alone, and the {forge} zone ships none"
597                    ),
598                )
599            } else {
600                Selection::selected(SCORECARD, sources, None)
601            }
602        }
603    });
604
605    // The code scanning workflow, keyed on the driver, the forge, and the
606    // provider.
607    out.push(match (params.code_scanning(), driver, forge) {
608        (None, _, _) => Selection::omitted(
609            CODE_SCANNING,
610            Status::NotRequested,
611            "capabilities.code_scanning is off",
612        ),
613        (Some(_), None, _) => Selection::omitted(
614            CODE_SCANNING,
615            Status::NotApplicable,
616            "a scanner reads the release driver's language, and the profile names no driver",
617        ),
618        (Some(_), _, None) => Selection::omitted(
619            CODE_SCANNING,
620            Status::NotApplicable,
621            "the profile names no forge",
622        ),
623        (Some(provider), Some(driver), Some(forge)) => {
624            if !driver_known || !forge_known {
625                Selection::omitted(
626                    CODE_SCANNING,
627                    Status::Unknown,
628                    format!("({driver}, {forge}) names a category this release does not know"),
629                )
630            } else if let Some(reason) = crate::projection::code_scanning_incompatibility(
631                Some(provider),
632                Some(driver),
633                Some(forge),
634            ) {
635                Selection::omitted(CODE_SCANNING, Status::Unavailable, reason)
636            } else {
637                let sources: Vec<String> = availability
638                    .owned(driver, forge, CODE_SCANNING)
639                    .into_iter()
640                    .filter(|path| {
641                        CODE_SCANNING_DESTINATIONS
642                            .iter()
643                            .any(|(name, owner)| path.ends_with(name) && *owner == provider)
644                    })
645                    .collect();
646                if sources.is_empty() {
647                    Selection::omitted(
648                        CODE_SCANNING,
649                        Status::Unavailable,
650                        format!(
651                            "the {driver} binding ships no {} workflow on {forge}",
652                            provider.as_str()
653                        ),
654                    )
655                } else {
656                    Selection::selected(CODE_SCANNING, sources, Some(driver))
657                        .keyed_on(provider.as_str())
658                }
659            }
660        }
661    });
662    debug_assert_eq!(out.len(), ALL.len());
663    out
664}
665
666/// The pin keys one selection matches in the registry's `used_by`.
667///
668/// Three of them: the bare capability id, the id qualified by the driver
669/// where the capability has that dimension, and that qualified again by
670/// the provider where its parameter names one.
671///
672/// Three widths rather than one, so a pin declares the narrowest truth it
673/// can. A tool every provider runs takes the bare id; one a single
674/// provider runs takes the widest key, and a target that landed the other
675/// provider records neither the tool nor its staleness baseline.
676#[must_use]
677pub fn pin_keys(selection: &Selection) -> Vec<String> {
678    let mut keys = vec![selection.id.to_owned()];
679    if let Some(driver) = &selection.driver {
680        keys.push(format!("{}/{driver}", selection.id));
681        if let Some(provider) = &selection.provider {
682            keys.push(format!("{}/{driver}/{provider}", selection.id));
683        }
684    }
685    keys
686}
687
688#[cfg(test)]
689mod tests {
690    use super::{
691        ALL, Availability, CODE_SCANNING, COMMIT_CHECK, GUARDS, PACKAGING_NIX, RELEASE_AUTOMATION,
692        REPORTING_POLICY, SCORECARD, Status, TITLE_CHECK, WORKFLOW_GUARDS, owner_of, select,
693    };
694    use crate::landing::Params;
695    use crate::landing::manifest::{Provider, Style};
696    use crate::profile::ReleaseMode;
697
698    fn status_of(selections: &[super::Selection], id: &str) -> Status {
699        selections
700            .iter()
701            .find(|s| s.id == id)
702            .expect("every capability answers")
703            .status
704    }
705
706    /// Every embedded snippet has exactly one owner, and no shared path
707    /// falls to the release automation by default.
708    #[test]
709    fn every_embedded_snippet_has_one_owner() {
710        let availability = Availability::embedded();
711        assert!(!availability.files().is_empty());
712        for path in availability.files() {
713            let owner = owner_of(path);
714            assert!(owner.is_some(), "{path}: no capability owns it");
715            assert!(
716                ALL.contains(&owner.unwrap_or_default()),
717                "{path}: an unlisted owner"
718            );
719        }
720        assert_eq!(
721            owner_of("_shared/github/SECURITY.md"),
722            Some(REPORTING_POLICY)
723        );
724        assert_eq!(
725            owner_of("_shared/github/.github/workflows/scorecard.yml"),
726            Some(SCORECARD)
727        );
728        assert_eq!(owner_of("rust/github/flake.nix"), Some(PACKAGING_NIX));
729        assert_eq!(
730            owner_of("rust/github/.github/workflows/code-scanning-codeql.yml"),
731            Some(CODE_SCANNING)
732        );
733        assert_eq!(
734            owner_of("rust/github/release-plz.toml"),
735            Some(RELEASE_AUTOMATION)
736        );
737        assert_eq!(owner_of("_shared/github/unknown.txt"), None);
738    }
739
740    /// SATISFIES project-profile:availability-belongs-to-a-capability-at-its-dimensions
741    #[test]
742    fn the_catalog_answers_availability_per_tuple() {
743        let availability = Availability::embedded();
744        let mut github = Params::for_test("acme/widget", Some(Style::Trunk));
745        github.set_pair_for_test("python", "github");
746        let mut gitlab = github.clone();
747        gitlab.set_pair_for_test("python", "gitlab");
748        let on_github = select(&github, &availability);
749        let on_gitlab = select(&gitlab, &availability);
750        assert_eq!(status_of(&on_github, RELEASE_AUTOMATION), Status::Selected);
751        assert_eq!(
752            status_of(&on_gitlab, RELEASE_AUTOMATION),
753            Status::Unavailable
754        );
755        let reason = on_gitlab
756            .iter()
757            .find(|s| s.id == RELEASE_AUTOMATION)
758            .and_then(|s| s.reason.clone())
759            .expect("an unavailable capability states why");
760        assert!(reason.contains("rust, gitlab"), "{reason}");
761        assert!(reason.contains("python, github"), "{reason}");
762        // The title gate still lands on GitLab, with the release-less root
763        // pipeline beside the fragment.
764        let title = on_gitlab
765            .iter()
766            .find(|s| s.id == TITLE_CHECK)
767            .expect("the title gate answers");
768        assert_eq!(title.status, Status::Selected);
769        assert!(
770            title
771                .sources
772                .iter()
773                .any(|s| s == "_title-gate/gitlab/.gitlab-ci.yml"),
774            "{:?}",
775            title.sources
776        );
777    }
778
779    /// A no-forge, no-release input selects the local guards alone and
780    /// marks every forge capability not applicable.
781    #[test]
782    fn a_no_forge_input_selects_the_guards_alone() {
783        let params = Params::for_test_release_less(&[], None, ReleaseMode::None);
784        let selections = select(&params, &Availability::embedded());
785        assert_eq!(status_of(&selections, GUARDS), Status::Selected);
786        for id in [
787            TITLE_CHECK,
788            REPORTING_POLICY,
789            RELEASE_AUTOMATION,
790            PACKAGING_NIX,
791        ] {
792            assert_ne!(status_of(&selections, id), Status::Selected, "{id}");
793        }
794        assert_eq!(status_of(&selections, TITLE_CHECK), Status::NotApplicable);
795        assert_eq!(
796            status_of(&selections, RELEASE_AUTOMATION),
797            Status::NotRequested
798        );
799        assert_eq!(status_of(&selections, PACKAGING_NIX), Status::NotRequested);
800    }
801
802    /// The workflow guards answer their request alone, and the release
803    /// guards land whatever it says.
804    #[test]
805    fn the_workflow_guards_are_selected_only_when_requested() {
806        let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
807        let selections = select(&params, &Availability::embedded());
808        assert_eq!(status_of(&selections, WORKFLOW_GUARDS), Status::Selected);
809        params.set_workflow_guards_for_test(false);
810        let selections = select(&params, &Availability::embedded());
811        assert_eq!(
812            status_of(&selections, WORKFLOW_GUARDS),
813            Status::NotRequested
814        );
815        assert_eq!(status_of(&selections, GUARDS), Status::Selected);
816    }
817
818    /// A forge gate lands only where a request reaches the trunk: under
819    /// forge integration, or under an automatic release whose bot opens
820    /// one. A local, release-less landing selects neither gate on either
821    /// forge, and so lands no CI file; an external release integrated
822    /// locally takes none either, as the canon states it.
823    ///
824    /// SATISFIES project-profile:the-forge-gate-follows-the-merge-form
825    #[test]
826    fn workflow_only_local_landing_selects_no_forge_gate() {
827        use crate::landing::{Integration, MergeForm};
828        let availability = Availability::embedded();
829        for forge in ["github", "gitlab"] {
830            for form in [MergeForm::Squash, MergeForm::Rebase] {
831                for (mode, integration, lands) in [
832                    (ReleaseMode::None, Integration::Local, false),
833                    (ReleaseMode::External, Integration::Local, false),
834                    (ReleaseMode::None, Integration::Forge, true),
835                    (ReleaseMode::External, Integration::Forge, true),
836                ] {
837                    let mut params = Params::for_test_release_less(&[], Some(forge), mode);
838                    params.set_integration_for_test(integration);
839                    params.set_merge_form_for_test(form);
840                    let selections = select(&params, &availability);
841                    let (gate, other) = match form {
842                        MergeForm::Squash => (TITLE_CHECK, COMMIT_CHECK),
843                        MergeForm::Rebase => (COMMIT_CHECK, TITLE_CHECK),
844                    };
845                    let at = format!("{forge} {form:?} {mode:?} {integration:?}");
846                    let selection = selections
847                        .iter()
848                        .find(|s| s.id == gate)
849                        .expect("every capability answers");
850                    assert_eq!(selection.lands(), lands, "{at}");
851                    if !lands {
852                        assert_eq!(selection.status, Status::NotApplicable, "{at}");
853                        assert!(selection.sources.is_empty(), "{at}");
854                        let reason = selection.reason.as_deref().unwrap_or_default();
855                        assert!(reason.contains("no forge gate lands"), "{at}: {reason}");
856                        assert!(
857                            reason.contains("git.integration is local"),
858                            "{at}: {reason}"
859                        );
860                    }
861                    // The gate the form does not name gives the same real
862                    // reason where no gate lands, and the form's otherwise.
863                    let unnamed = selections
864                        .iter()
865                        .find(|s| s.id == other)
866                        .expect("every capability answers");
867                    let reason = unnamed.reason.as_deref().unwrap_or_default();
868                    if lands {
869                        assert_eq!(unnamed.status, Status::NotRequested, "{at}");
870                        assert!(reason.contains("git.merge_form is"), "{at}: {reason}");
871                    } else {
872                        assert_eq!(unnamed.status, Status::NotApplicable, "{at}");
873                        assert!(reason.contains("no forge gate lands"), "{at}: {reason}");
874                        assert!(!reason.contains("merge_form"), "{at}: {reason}");
875                    }
876                }
877            }
878            // An automatic release keeps its gate under local integration,
879            // because the bot's request still reaches the trunk.
880            let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
881            params.set_pair_for_test("rust", forge);
882            params.set_integration_for_test(Integration::Local);
883            let selections = select(&params, &availability);
884            assert_eq!(
885                status_of(&selections, TITLE_CHECK),
886                Status::Selected,
887                "{forge}"
888            );
889        }
890    }
891
892    /// An unknown forge keeps the guards and reports the rest unknown.
893    #[test]
894    fn an_unknown_forge_reads_as_unknown_and_lands_the_guards() {
895        let mut params = Params::for_test_release_less(&[], Some("codeberg"), ReleaseMode::None);
896        params.set_scorecard_for_test(true);
897        let selections = select(&params, &Availability::embedded());
898        assert_eq!(status_of(&selections, GUARDS), Status::Selected);
899        assert_eq!(status_of(&selections, TITLE_CHECK), Status::Unknown);
900        assert_eq!(status_of(&selections, SCORECARD), Status::Unknown);
901    }
902
903    /// The scanner follows the provider and the pair, and a request the
904    /// pair cannot run reports `Unavailable` with the reason the landing
905    /// then omits it by. Nothing refuses on it.
906    #[test]
907    fn code_scanning_selects_the_providers_own_file() {
908        let availability = Availability::embedded();
909        let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
910        params.set_code_scanning_for_test(Some(Provider::Semgrep));
911        let selections = select(&params, &availability);
912        let scanning = selections
913            .iter()
914            .find(|s| s.id == CODE_SCANNING)
915            .expect("answers");
916        assert_eq!(scanning.status, Status::Selected);
917        assert_eq!(
918            scanning.sources,
919            vec!["rust/github/.github/workflows/code-scanning-semgrep.yml".to_owned()]
920        );
921        params.set_pair_for_test("bash", "github");
922        let selections = select(&params, &availability);
923        assert_eq!(status_of(&selections, CODE_SCANNING), Status::Unavailable);
924    }
925
926    /// The merge form picks the forge gate: the title gate under squash,
927    /// the commit gate under rebase, never both, and on GitLab with no
928    /// release automation the root pipeline that activates the chosen
929    /// fragment.
930    ///
931    /// SATISFIES project-profile:the-forge-gate-follows-the-merge-form
932    #[test]
933    fn commit_gate_replaces_the_title_gate_under_rebase() {
934        use crate::landing::{Integration, MergeForm};
935        let availability = Availability::embedded();
936        for integration in [Integration::Forge, Integration::Local] {
937            for (driver, forge) in [("rust", "github"), ("rust", "gitlab"), ("python", "gitlab")] {
938                let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
939                params.set_pair_for_test(driver, forge);
940                params.set_integration_for_test(integration);
941                let squash = select(&params, &availability);
942                assert_eq!(status_of(&squash, TITLE_CHECK), Status::Selected);
943                assert_eq!(status_of(&squash, COMMIT_CHECK), Status::NotRequested);
944
945                params.set_merge_form_for_test(MergeForm::Rebase);
946                let rebase = select(&params, &availability);
947                assert_eq!(status_of(&rebase, TITLE_CHECK), Status::NotRequested);
948                let gate = rebase
949                    .iter()
950                    .find(|s| s.id == COMMIT_CHECK)
951                    .expect("the commit gate answers");
952                assert_eq!(gate.status, Status::Selected, "{driver} {forge}");
953                assert!(
954                    gate.sources.iter().all(|source| !source.contains("title")),
955                    "{:?}",
956                    gate.sources
957                );
958                let fragment = if forge == "github" {
959                    "_shared/github/.github/workflows/pr-commits.yml"
960                } else {
961                    "_shared/gitlab/.gitlab/ci/mr-commits.yml"
962                };
963                assert!(
964                    gate.sources.iter().any(|s| s == fragment),
965                    "{:?}",
966                    gate.sources
967                );
968                // Python automates no release on GitLab, so the commit
969                // gate's own root pipeline activates the fragment.
970                assert_eq!(
971                    gate.sources
972                        .iter()
973                        .any(|s| s == "_commit-gate/gitlab/.gitlab-ci.yml"),
974                    driver == "python",
975                    "{:?}",
976                    gate.sources
977                );
978            }
979        }
980        assert_eq!(
981            owner_of("_shared/github/.github/workflows/pr-commits.yml"),
982            Some(COMMIT_CHECK)
983        );
984        assert_eq!(
985            owner_of("_commit-gate/gitlab/.gitlab-ci.yml"),
986            Some(COMMIT_CHECK)
987        );
988        assert_eq!(ALL.len(), 9);
989    }
990}