perl_lsp_feature_grid/
lib.rs1#![warn(missing_docs)]
2pub 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
21pub const fn feature_profile_contracts() -> &'static [FeatureProfileSpec] {
23 feature_profile_specs()
24}
25
26pub const FEATURE_GRID_COLUMNS: &[&str] =
28 &["area", "id", "spec", "maturity", "advertised", "counts_in_coverage", "description", "tests"];
29
30pub fn to_json() -> String {
36 to_json_for_profiles(FeatureProfile::all())
37}
38
39pub fn to_json_for_profile(profile: FeatureProfile) -> String {
44 feature_grid_payload(&[profile], Some(profile)).to_string()
45}
46
47pub fn to_json_for_profiles(profiles: &[FeatureProfile]) -> String {
49 feature_grid_payload(profiles, None).to_string()
50}
51
52pub fn to_json_for_all_profiles() -> String {
54 to_json_for_profiles(FeatureProfile::all())
55}
56
57pub 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}