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