Skip to main content

perl_lsp_feature_grid/
lib.rs

1#![warn(missing_docs)]
2//! BDD grid and feature-profile interoperability primitives.
3//!
4//! This crate intentionally contains only compatibility and reporting logic used by
5//! both the LSP binary and external tooling. It sits above the contract and
6//! policy microcrates to avoid feature-flag logic leaking back into the server
7//! module tree.
8
9pub use perl_lsp_feature_contracts::FeatureProfileSpec;
10pub use perl_lsp_feature_contracts::catalog;
11pub use perl_lsp_feature_contracts::feature_profile_specs;
12pub use perl_lsp_feature_contracts::{
13    BddFeatureRow, Feature, LSP_VERSION, VERSION, advertised_features,
14    advertised_trackable_feature_count_for_grid, all_features, bdd_feature_rows,
15    compliance_percent, compliance_percent_for_grid, has_feature, trackable_feature_count_for_grid,
16};
17pub use perl_lsp_feature_policy::{FeatureProfile, catalog_advertised_feature_ids};
18
19use serde_json::{Value, json};
20
21/// Return profile metadata for interoperability with CLI and editor tooling.
22pub const fn feature_profile_contracts() -> &'static [FeatureProfileSpec] {
23    feature_profile_specs()
24}
25
26/// Stable BDD grid column order used by reporting tools.
27pub const FEATURE_GRID_COLUMNS: &[&str] =
28    &["area", "id", "spec", "maturity", "advertised", "counts_in_coverage", "description", "tests"];
29
30/// Get the global feature catalog as JSON.
31///
32/// This mirrors the historical server output and includes catalog-wide
33/// advertised features (not profile-filtered), plus all profile summaries for
34/// visibility and interoperability.
35pub fn to_json() -> String {
36    to_json_for_profiles(FeatureProfile::all())
37}
38
39/// Profile-aware feature catalog JSON.
40///
41/// The advertised feature list and compliance math are derived from the provided
42/// runtime profile. This is useful for feature flag snapshots in CI and tooling.
43pub fn to_json_for_profile(profile: FeatureProfile) -> String {
44    feature_grid_payload(&[profile], Some(profile)).to_string()
45}
46
47/// BDD-compatible feature catalog JSON for an explicit profile set.
48pub fn to_json_for_profiles(profiles: &[FeatureProfile]) -> String {
49    feature_grid_payload(profiles, None).to_string()
50}
51
52/// BDD-compatible feature catalog JSON with all canonical profiles.
53pub fn to_json_for_all_profiles() -> String {
54    to_json_for_profiles(FeatureProfile::all())
55}
56
57/// Compliance percent for a specific runtime profile, using the same grid semantics.
58pub fn compliance_percent_for_profile(profile: FeatureProfile) -> f32 {
59    let trackable_feature_count = trackable_feature_count_for_grid();
60    if trackable_feature_count == 0 {
61        return 0.0;
62    }
63
64    let advertised = catalog_advertised_feature_ids(profile);
65    let advertised_trackable_feature_count = advertised_trackable_feature_count(&advertised);
66    (advertised_trackable_feature_count as f64 / trackable_feature_count as f64 * 100.0).round()
67        as f32
68}
69
70fn advertised_trackable_feature_count(advertised: &[&'static str]) -> usize {
71    advertised
72        .iter()
73        .filter(|&&id| {
74            has_feature(id)
75                && all_features()
76                    .iter()
77                    .find(|feature| feature.id == id)
78                    .is_some_and(|feature| feature.counts_in_coverage)
79        })
80        .count()
81}
82
83fn feature_grid_payload(
84    profiles: &[FeatureProfile],
85    selected_profile: Option<FeatureProfile>,
86) -> Value {
87    let profile_summaries: Vec<Value> = profiles.iter().copied().map(profile_summary).collect();
88
89    let (advertised, advertised_trackable_feature_count) = match selected_profile {
90        Some(profile) => {
91            let advertised = catalog_advertised_feature_ids(profile);
92            let advertised_trackable_feature_count =
93                advertised_trackable_feature_count(&advertised);
94            (advertised, advertised_trackable_feature_count)
95        }
96        None => (advertised_features().to_vec(), advertised_trackable_feature_count_for_grid()),
97    };
98    let trackable_feature_count = trackable_feature_count_for_grid();
99    let compliance_percent = if trackable_feature_count == 0 {
100        0.0
101    } else {
102        (advertised_trackable_feature_count as f64 / trackable_feature_count as f64 * 100.0).round()
103            as f32
104    };
105    let mut payload = json!({
106        "version": VERSION,
107        "lsp_version": LSP_VERSION,
108        "compliance_percent": compliance_percent,
109        "trackable_feature_count": trackable_feature_count,
110        "advertised_trackable_feature_count": advertised_trackable_feature_count,
111        "advertised": advertised,
112        "feature_profiles": feature_profile_contracts(),
113        "feature_grid": {
114            "columns": FEATURE_GRID_COLUMNS,
115            "rows": bdd_feature_rows(),
116        },
117        "profiles": profile_summaries,
118        "feature_count": all_features().len(),
119    });
120
121    if let Some(profile) = selected_profile {
122        payload["profile"] = json!(profile.as_str());
123    }
124
125    payload
126}
127
128fn profile_summary(profile: FeatureProfile) -> Value {
129    let advertised = catalog_advertised_feature_ids(profile);
130    let advertised_trackable_feature_count = advertised_trackable_feature_count(&advertised);
131    let trackable_feature_count = trackable_feature_count_for_grid();
132    let compliance_percent = if trackable_feature_count == 0 {
133        0.0
134    } else {
135        (advertised_trackable_feature_count as f64 / trackable_feature_count as f64 * 100.0).round()
136            as f32
137    };
138
139    json!({
140        "profile": profile.as_str(),
141        "advertised": advertised,
142        "compliance_percent": compliance_percent,
143        "trackable_feature_count": trackable_feature_count,
144        "advertised_trackable_feature_count": advertised_trackable_feature_count,
145        "advertised_feature_count": advertised.len(),
146    })
147}
148
149#[cfg(test)]
150mod tests {
151    use super::{
152        FeatureProfile, compliance_percent_for_profile, to_json, to_json_for_all_profiles,
153        to_json_for_profile,
154    };
155    use perl_tdd_support::{must, must_some};
156
157    #[test]
158    fn payload_is_stable_for_default_catalog_json() {
159        let payload = to_json();
160        let value: serde_json::Value = must(serde_json::from_str(&payload));
161
162        assert!(value.get("version").is_some());
163        assert!(value.get("lsp_version").is_some());
164        assert!(value.get("compliance_percent").is_some());
165        assert!(value.get("feature_grid").is_some());
166        assert!(value.get("feature_profiles").is_some());
167        assert!(value.get("profiles").is_some());
168        assert!(value["feature_grid"].get("columns").is_some());
169        assert!(value["feature_grid"].get("rows").is_some());
170        let profiles = must_some(value.get("profiles").and_then(|profiles| profiles.as_array()));
171        assert!(!profiles.is_empty());
172        let rows = must_some(
173            value
174                .get("feature_grid")
175                .and_then(|grid| grid.get("rows"))
176                .and_then(|rows| rows.as_array()),
177        );
178        assert!(!rows.is_empty());
179    }
180
181    #[test]
182    fn payload_is_profile_scoped() {
183        let all = to_json_for_profile(FeatureProfile::All);
184        let ga_lock = to_json_for_profile(FeatureProfile::GaLock);
185        let all_compliance = compliance_percent_for_profile(FeatureProfile::All);
186        let ga_compliance = compliance_percent_for_profile(FeatureProfile::GaLock);
187
188        let all_value: serde_json::Value = must(serde_json::from_str(&all));
189        let ga_lock_value: serde_json::Value = must(serde_json::from_str(&ga_lock));
190
191        assert_eq!(all_value["profile"].as_str(), Some("all"));
192        assert_eq!(ga_lock_value["profile"].as_str(), Some("ga-lock"));
193
194        let json_all_compliance = must_some(all_value["compliance_percent"].as_f64());
195        let json_ga_compliance = must_some(ga_lock_value["compliance_percent"].as_f64());
196        assert!((json_all_compliance - all_compliance as f64).abs() < f32::EPSILON as f64);
197        assert!((json_ga_compliance - ga_compliance as f64).abs() < f32::EPSILON as f64);
198
199        let all_count = all_value["advertised_trackable_feature_count"].as_u64().unwrap_or(0);
200        let ga_count = ga_lock_value["advertised_trackable_feature_count"].as_u64().unwrap_or(0);
201        assert!(all_count >= ga_count);
202    }
203
204    #[test]
205    fn payload_includes_multi_profile_projection() {
206        let payload = to_json_for_all_profiles();
207        let value: serde_json::Value = must(serde_json::from_str(&payload));
208        let profiles = must_some(value.get("profiles").and_then(|value| value.as_array()));
209        assert!(profiles.len() >= 3);
210
211        let keys: Vec<_> = profiles
212            .iter()
213            .filter_map(|profile| profile.get("profile").and_then(|p| p.as_str()))
214            .collect();
215        assert!(keys.contains(&"ga-lock"));
216        assert!(keys.contains(&"production"));
217        assert!(keys.contains(&"all"));
218    }
219
220    // ── compliance_percent_for_profile ───────────────────────────────
221
222    #[test]
223    fn compliance_percent_is_in_valid_range_for_all_profiles() {
224        for profile in FeatureProfile::all() {
225            let pct = compliance_percent_for_profile(*profile);
226            assert!(
227                (0.0..=100.0).contains(&pct),
228                "compliance for {} should be in [0, 100], got {}",
229                profile.as_str(),
230                pct
231            );
232        }
233    }
234
235    #[test]
236    fn all_profile_compliance_gte_ga_lock_compliance() {
237        let all_pct = compliance_percent_for_profile(FeatureProfile::All);
238        let ga_pct = compliance_percent_for_profile(FeatureProfile::GaLock);
239        assert!(all_pct >= ga_pct, "'all' compliance ({all_pct}) should be >= ga-lock ({ga_pct})");
240    }
241
242    // ── feature_profile_contracts ───────────────────────────────────
243
244    #[test]
245    fn feature_profile_contracts_returns_specs() {
246        let contracts = super::feature_profile_contracts();
247        assert_eq!(contracts.len(), 3);
248        assert_eq!(contracts[0].canonical, "ga-lock");
249        assert_eq!(contracts[1].canonical, "production");
250        assert_eq!(contracts[2].canonical, "all");
251    }
252
253    // ── FEATURE_GRID_COLUMNS ────────────────────────────────────────
254
255    #[test]
256    fn feature_grid_columns_has_expected_entries() {
257        assert!(super::FEATURE_GRID_COLUMNS.contains(&"id"));
258        assert!(super::FEATURE_GRID_COLUMNS.contains(&"area"));
259        assert!(super::FEATURE_GRID_COLUMNS.contains(&"spec"));
260        assert!(super::FEATURE_GRID_COLUMNS.contains(&"maturity"));
261        assert!(super::FEATURE_GRID_COLUMNS.contains(&"advertised"));
262        assert!(super::FEATURE_GRID_COLUMNS.contains(&"counts_in_coverage"));
263        assert!(super::FEATURE_GRID_COLUMNS.contains(&"description"));
264        assert!(super::FEATURE_GRID_COLUMNS.contains(&"tests"));
265    }
266
267    // ── to_json_for_profiles ────────────────────────────────────────
268
269    #[test]
270    fn to_json_for_profiles_subset() {
271        let payload = super::to_json_for_profiles(&[FeatureProfile::GaLock]);
272        let value: serde_json::Value = must(serde_json::from_str(&payload));
273        let profiles = must_some(value.get("profiles").and_then(|v| v.as_array()));
274        assert_eq!(profiles.len(), 1);
275        assert_eq!(profiles[0]["profile"].as_str(), Some("ga-lock"));
276    }
277
278    // ── Profile summary fields ──────────────────────────────────────
279
280    #[test]
281    fn profile_summary_contains_required_keys() {
282        let payload = to_json_for_all_profiles();
283        let value: serde_json::Value = must(serde_json::from_str(&payload));
284        let profiles = must_some(value.get("profiles").and_then(|v| v.as_array()));
285        for profile_value in profiles {
286            assert!(profile_value.get("profile").is_some(), "missing 'profile' key");
287            assert!(profile_value.get("advertised").is_some(), "missing 'advertised' key");
288            assert!(
289                profile_value.get("compliance_percent").is_some(),
290                "missing 'compliance_percent'"
291            );
292            assert!(
293                profile_value.get("trackable_feature_count").is_some(),
294                "missing 'trackable_feature_count'"
295            );
296            assert!(
297                profile_value.get("advertised_trackable_feature_count").is_some(),
298                "missing 'advertised_trackable_feature_count'"
299            );
300            assert!(
301                profile_value.get("advertised_feature_count").is_some(),
302                "missing 'advertised_feature_count'"
303            );
304        }
305    }
306
307    // ── Production profile JSON ─────────────────────────────────────
308
309    #[test]
310    fn to_json_for_production_profile() {
311        let payload = to_json_for_profile(FeatureProfile::Production);
312        let value: serde_json::Value = must(serde_json::from_str(&payload));
313        assert_eq!(value["profile"].as_str(), Some("production"));
314        assert!(value.get("feature_count").is_some());
315    }
316
317    // ── Default to_json has no profile key ───────────────────────────
318
319    #[test]
320    fn default_to_json_omits_profile_key() {
321        let payload = to_json();
322        let value: serde_json::Value = must(serde_json::from_str(&payload));
323        assert!(
324            value.get("profile").is_none(),
325            "default to_json() should not have a 'profile' key"
326        );
327    }
328}