Skip to main content

backhopper_core/compat/
cuttlefish_features.rs

1// Copyright (C) 2026 Michael S. Klishin and Contributors
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3// See LICENSE-APACHE and LICENSE-MIT for details.
4
5//! C6: the first verdict-pipeline integration of `backhopper-cuttlefish`.
6//! `backhopper-core` stays free of the parser crate: the CLI extracts
7//! `IntroducedMapping` values from `CuttlefishFragment`s whose span
8//! intersects an added region, and this module compares those values
9//! against the pinned cuttlefish version and the target tree.
10
11use std::cmp::Ordering;
12use std::collections::BTreeMap;
13use std::path::Path;
14
15use crate::compat::suite_registration::word_boundary_contains;
16use crate::compat::target_tree_index::TargetTreeIndex;
17use crate::model::names::{ApplicationName, RelativePath, SchemaFeature};
18use crate::model::verdict::Reason;
19use crate::versions::version_cmp;
20
21/// A cuttlefish `mapping` fragment introduced by the patch (its span
22/// intersects an added region), reduced to the plain values the two
23/// C6 checks need: no `backhopper-cuttlefish` type crosses into core.
24#[derive(Debug, Clone)]
25pub struct IntroducedMapping {
26    pub schema_path: RelativePath,
27    /// The mapping's conf key (second tuple element).
28    pub conf_key: Option<String>,
29    /// The mapping's target Erlang key (third tuple element), e.g.
30    /// `"rabbit.message_interceptors"`.
31    pub mapping_target: Option<String>,
32    /// Attribute names from the mapping's fourth tuple element.
33    pub attr_names: Vec<String>,
34}
35
36/// Mapping attributes gated on a minimum cuttlefish version.
37pub const SCHEMA_FEATURE_FLOORS: &[(&str, &str)] = &[("alias", "3.7.0")];
38
39/// C6.2: fire when an introduced mapping uses a floor-gated attribute
40/// the pinned cuttlefish version predates. Silent with no cuttlefish
41/// pin (`pinned_version: None`).
42pub fn check_schema_feature_unsupported(
43    mappings: &[IntroducedMapping],
44    pinned_version: Option<&str>,
45) -> Vec<Reason> {
46    let Some(pinned) = pinned_version else {
47        return Vec::new();
48    };
49    let mut reasons = Vec::new();
50    for mapping in mappings {
51        for attr in &mapping.attr_names {
52            let Some((_, required)) = SCHEMA_FEATURE_FLOORS.iter().find(|(name, _)| name == attr)
53            else {
54                continue;
55            };
56            // `version_cmp` orders descending (newer first): `Greater`
57            // here means `pinned` is older than `required`. Equal to
58            // the floor is supported.
59            if version_cmp(pinned, required) != Ordering::Greater {
60                continue;
61            }
62            let Ok(feature) = SchemaFeature::new((*attr).clone()) else {
63                continue;
64            };
65            reasons.push(Reason::SchemaFeatureUnsupportedOnPin {
66                schema_path: mapping.schema_path.clone(),
67                conf_key: mapping.conf_key.clone(),
68                feature,
69                required_version: (*required).to_owned(),
70                pinned_version: pinned.to_owned(),
71            });
72        }
73    }
74    reasons
75}
76
77/// C6.3: fire when an introduced mapping's target env key is not read
78/// as a word-bounded token anywhere in the target tree's `.erl`
79/// sources. All introduced target keys are checked against one cache
80/// of blob reads, so a pick introducing eight mappings costs at most
81/// one scan per distinct owning app, not eight.
82pub fn check_schema_key_reader_missing(
83    mappings: &[IntroducedMapping],
84    target: &TargetTreeIndex,
85    read_target: &dyn Fn(&RelativePath) -> Option<String>,
86) -> Vec<Reason> {
87    let mut reasons = Vec::new();
88    let mut app_source_cache: BTreeMap<String, Vec<String>> = BTreeMap::new();
89    for mapping in mappings {
90        let Some(target_key) = &mapping.mapping_target else {
91            continue;
92        };
93        let mut segments = target_key.split('.');
94        let (Some(app), Some(env_key)) = (segments.next(), segments.next()) else {
95            continue;
96        };
97        let app_name = ApplicationName::new(app).ok();
98        let exists = app_has_any_path(target, app);
99        let searched_apps: Vec<ApplicationName> = app_name.clone().into_iter().collect();
100        let absent_apps: Vec<ApplicationName> = if exists {
101            Vec::new()
102        } else {
103            app_name.into_iter().collect()
104        };
105        let found = exists && {
106            let sources = app_source_cache
107                .entry(app.to_owned())
108                .or_insert_with(|| read_app_sources(target, app, read_target));
109            sources
110                .iter()
111                .any(|src| word_boundary_contains(src, env_key))
112        };
113        if found {
114            continue;
115        }
116        reasons.push(Reason::SchemaKeyReaderMissing {
117            schema_path: mapping.schema_path.clone(),
118            target_key: target_key.clone(),
119            searched_apps,
120            absent_apps,
121        });
122    }
123    reasons
124}
125
126/// True when some present target-tree path lives under `deps/<app>/`
127/// or a top-level `<app>/`: the app is real evidence of the app's
128/// presence, cheap because `present_paths` is already in memory.
129fn app_has_any_path(target: &TargetTreeIndex, app: &str) -> bool {
130    let under_deps = format!("deps/{app}/");
131    let top_level = format!("{app}/");
132    target.present_paths().iter().any(|p| {
133        let s = p.to_string_lossy();
134        s.starts_with(under_deps.as_str()) || s.starts_with(top_level.as_str())
135    })
136}
137
138/// Every `.erl` blob under the app's `src/` directory, read once.
139fn read_app_sources(
140    target: &TargetTreeIndex,
141    app: &str,
142    read_target: &dyn Fn(&RelativePath) -> Option<String>,
143) -> Vec<String> {
144    let under_deps = format!("deps/{app}/src/");
145    let top_level = format!("{app}/src/");
146    target
147        .present_paths()
148        .iter()
149        .filter(|p| {
150            let s = p.to_string_lossy();
151            (s.starts_with(under_deps.as_str()) || s.starts_with(top_level.as_str()))
152                && s.ends_with(".erl")
153        })
154        .filter_map(|p: &std::path::PathBuf| relative_path_of(p))
155        .filter_map(|p| read_target(&p))
156        .collect()
157}
158
159fn relative_path_of(p: &Path) -> Option<RelativePath> {
160    RelativePath::new(p.to_str()?.to_owned()).ok()
161}