Skip to main content

perl_lsp_feature_contracts/
lib.rs

1#![warn(missing_docs)]
2//! Shared feature contracts for profile parsing, BDD-grid rows, and capability mapping.
3//!
4//! This crate defines the canonical [`FeatureProfileKind`] enum and associated
5//! [`FeatureProfileSpec`] metadata used for feature-coverage reporting. It sits
6//! between `perl-lsp-feature-ids` (raw identifiers) and
7//! `perl-lsp-feature-policy` (runtime capability selection).
8
9pub use perl_lsp_capability_map::{caps_from_feature_ids, feature_ids_from_caps};
10use serde::Serialize;
11
12/// Canonical metadata for profile aliases and normalization behavior.
13#[derive(Debug, Clone, Copy, Serialize)]
14pub struct FeatureProfileSpec {
15    /// Canonical profile label used by CLI and runtime APIs.
16    pub canonical: &'static str,
17    /// Additional accepted CLI aliases for this profile.
18    pub aliases: &'static [&'static str],
19    /// Short human-friendly description for settings/docs tooling.
20    pub description: &'static str,
21}
22
23const GA_LOCK_ALIASES: &[&str] = &["ga-lock", "ga", "ga_lock"];
24const PRODUCTION_ALIASES: &[&str] = &["production", "prod"];
25const ALL_ALIASES: &[&str] = &["all"];
26
27/// Canonical profile definitions and alias map.
28#[derive(Debug, Clone, Copy, Eq, PartialEq)]
29pub enum FeatureProfileKind {
30    /// Conservative GA-lock feature profile.
31    GaLock,
32    /// Default production profile.
33    Production,
34    /// All features enabled.
35    All,
36}
37
38impl FeatureProfileKind {
39    /// Parse a raw profile token into canonical form.
40    pub fn from_str_name(s: &str) -> Option<Self> {
41        match s {
42            "auto" => Some(Self::current()),
43            "ga-lock" | "ga" | "ga_lock" => Some(Self::GaLock),
44            "production" | "prod" => Some(Self::Production),
45            "all" => Some(Self::All),
46            _ => None,
47        }
48    }
49
50    /// Resolve whether the compiled binary default enables GA-lock mode.
51    pub const fn current() -> Self {
52        Self::from_ga_lock_enabled(cfg!(feature = "lsp-ga-lock"))
53    }
54
55    /// Resolve explicit GA-lock toggle into canonical profile.
56    pub const fn from_ga_lock_enabled(ga_lock_enabled: bool) -> Self {
57        if ga_lock_enabled { Self::GaLock } else { Self::Production }
58    }
59
60    /// Canonical runtime label for diagnostics and APIs.
61    pub const fn as_str(self) -> &'static str {
62        match self {
63            Self::GaLock => "ga-lock",
64            Self::Production => "production",
65            Self::All => "all",
66        }
67    }
68
69    /// All canonical profiles.
70    pub const fn all() -> &'static [Self] {
71        &[Self::GaLock, Self::Production, Self::All]
72    }
73
74    /// Supported CLI tokens, including aliases and backward compatible forms.
75    pub const fn supported_cli_profiles() -> &'static [&'static str] {
76        const PROFILE_CLI_NAMES: &[&str] =
77            &["auto", "ga-lock", "ga", "ga_lock", "prod", "production", "all"];
78
79        PROFILE_CLI_NAMES
80    }
81
82    /// Static alias metadata for this profile.
83    pub const fn aliases(self) -> &'static [&'static str] {
84        match self {
85            Self::GaLock => GA_LOCK_ALIASES,
86            Self::Production => PRODUCTION_ALIASES,
87            Self::All => ALL_ALIASES,
88        }
89    }
90}
91
92/// A serializable profile metadata row for tooling and interoperability.
93pub const FEATURE_PROFILE_SPECS: &[FeatureProfileSpec] = &[
94    FeatureProfileSpec {
95        canonical: "ga-lock",
96        aliases: GA_LOCK_ALIASES,
97        description: "Conservative GA-lock profile for minimal runtime surface.",
98    },
99    FeatureProfileSpec {
100        canonical: "production",
101        aliases: PRODUCTION_ALIASES,
102        description: "Production profile for normal runtime feature set.",
103    },
104    FeatureProfileSpec {
105        canonical: "all",
106        aliases: ALL_ALIASES,
107        description: "All in-tree features enabled for snapshot and testing.",
108    },
109];
110
111/// Return canonical feature profile descriptors for tooling.
112pub const fn feature_profile_specs() -> &'static [FeatureProfileSpec] {
113    FEATURE_PROFILE_SPECS
114}
115
116/// Auto-generated feature catalog from `features.toml`.
117#[allow(dead_code, clippy::all, missing_docs)]
118pub mod catalog {
119    include!(concat!(env!("OUT_DIR"), "/feature_contracts.rs"));
120}
121
122/// Human-readable BDD-oriented feature row for automation and reporting.
123#[derive(Debug, Clone, Serialize)]
124pub struct BddFeatureRow {
125    /// Canonical feature identifier (e.g., `lsp.completion`).
126    pub id: &'static str,
127    /// LSP specification section this feature implements.
128    pub spec: &'static str,
129    /// Feature area grouping (e.g., `text_document`, `workspace`).
130    pub area: &'static str,
131    /// Maturity level: `ga`, `beta`, `alpha`, or `planned`.
132    pub maturity: &'static str,
133    /// Whether this feature is advertised to clients.
134    pub advertised: bool,
135    /// Whether this feature counts toward compliance percentage.
136    pub counts_in_coverage: bool,
137    /// Short human-readable description of the feature.
138    pub description: &'static str,
139    /// Test identifiers that verify this feature.
140    pub tests: &'static [&'static str],
141}
142
143pub use catalog::{
144    Feature, LSP_VERSION, VERSION, advertised_features, compliance_percent, has_feature,
145};
146
147/// All discovered LSP features in canonical declaration order.
148pub fn all_features() -> &'static [Feature] {
149    catalog::ALL_FEATURES
150}
151
152/// Export feature rows suitable for BDD matrices and acceptance criteria tooling.
153pub fn bdd_feature_rows() -> Vec<BddFeatureRow> {
154    let mut rows = all_features()
155        .iter()
156        .map(|feature| BddFeatureRow {
157            id: feature.id,
158            spec: feature.spec,
159            area: feature.area,
160            maturity: feature.maturity,
161            advertised: feature.advertised,
162            counts_in_coverage: feature.counts_in_coverage,
163            description: feature.description,
164            tests: feature.tests,
165        })
166        .collect::<Vec<_>>();
167
168    rows.sort_by(|a, b| a.area.cmp(b.area).then(a.id.cmp(b.id)));
169    rows
170}
171
172/// Number of BDD rows that participate in coverage accounting.
173pub fn trackable_feature_count_for_grid() -> usize {
174    all_features()
175        .iter()
176        .filter(|feature| feature.maturity != "planned" && feature.counts_in_coverage)
177        .count()
178}
179
180/// Number of advertised BDD rows that participate in coverage accounting.
181pub fn advertised_trackable_feature_count_for_grid() -> usize {
182    all_features()
183        .iter()
184        .filter(|feature| {
185            feature.maturity != "planned" && feature.counts_in_coverage && feature.advertised
186        })
187        .count()
188}
189
190/// Compliance percentage for the BDD grid (`advertised / trackable`, rounded).
191pub fn compliance_percent_for_grid() -> f32 {
192    let trackable = trackable_feature_count_for_grid();
193    if trackable == 0 {
194        return 0.0;
195    }
196    let advertised = advertised_trackable_feature_count_for_grid();
197    (advertised as f64 / trackable as f64 * 100.0).round() as f32
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    // ── FeatureProfileKind ──────────────────────────────────────────
205
206    #[test]
207    fn from_ga_lock_enabled_true_yields_ga_lock() {
208        assert_eq!(FeatureProfileKind::from_ga_lock_enabled(true), FeatureProfileKind::GaLock);
209    }
210
211    #[test]
212    fn from_ga_lock_enabled_false_yields_production() {
213        assert_eq!(FeatureProfileKind::from_ga_lock_enabled(false), FeatureProfileKind::Production,);
214    }
215
216    #[test]
217    fn all_profiles_returns_three_variants() {
218        let all = FeatureProfileKind::all();
219        assert_eq!(all.len(), 3);
220        assert_eq!(all[0], FeatureProfileKind::GaLock);
221        assert_eq!(all[1], FeatureProfileKind::Production);
222        assert_eq!(all[2], FeatureProfileKind::All);
223    }
224
225    #[test]
226    fn from_str_name_rejects_unknown_token() {
227        assert!(FeatureProfileKind::from_str_name("bogus").is_none());
228        assert!(FeatureProfileKind::from_str_name("").is_none());
229        assert!(FeatureProfileKind::from_str_name("GA-LOCK").is_none());
230    }
231
232    #[test]
233    fn aliases_are_non_empty_for_every_profile() {
234        for profile in FeatureProfileKind::all() {
235            assert!(
236                !profile.aliases().is_empty(),
237                "aliases for {} should not be empty",
238                profile.as_str()
239            );
240        }
241    }
242
243    #[test]
244    fn aliases_contain_canonical_name() {
245        for profile in FeatureProfileKind::all() {
246            let aliases = profile.aliases();
247            assert!(
248                aliases.contains(&profile.as_str()),
249                "aliases for {} should contain canonical name",
250                profile.as_str()
251            );
252        }
253    }
254
255    #[test]
256    fn supported_cli_profiles_covers_all_aliases() {
257        let cli_tokens = FeatureProfileKind::supported_cli_profiles();
258        for profile in FeatureProfileKind::all() {
259            for alias in profile.aliases() {
260                assert!(
261                    cli_tokens.contains(alias),
262                    "CLI tokens should include alias '{}' for profile '{}'",
263                    alias,
264                    profile.as_str()
265                );
266            }
267        }
268    }
269
270    #[test]
271    fn auto_token_resolves_to_current() {
272        let resolved = FeatureProfileKind::from_str_name("auto");
273        assert_eq!(resolved, Some(FeatureProfileKind::current()));
274    }
275
276    // ── FeatureProfileSpec ──────────────────────────────────────────
277
278    #[test]
279    fn feature_profile_specs_has_three_entries() {
280        let specs = feature_profile_specs();
281        assert_eq!(specs.len(), 3);
282    }
283
284    #[test]
285    fn feature_profile_specs_canonical_names_match_enum() {
286        let specs = feature_profile_specs();
287        let expected_names: Vec<&str> =
288            FeatureProfileKind::all().iter().map(|p| p.as_str()).collect();
289        let spec_names: Vec<&str> = specs.iter().map(|s| s.canonical).collect();
290        assert_eq!(spec_names, expected_names);
291    }
292
293    #[test]
294    fn feature_profile_specs_descriptions_are_non_empty() {
295        for spec in feature_profile_specs() {
296            assert!(
297                !spec.description.is_empty(),
298                "description for '{}' should not be empty",
299                spec.canonical
300            );
301        }
302    }
303
304    // ── Catalog / BDD grid ──────────────────────────────────────────
305
306    #[test]
307    fn all_features_is_non_empty() {
308        assert!(!all_features().is_empty());
309    }
310
311    #[test]
312    fn all_features_have_non_empty_ids() {
313        for feature in all_features() {
314            assert!(!feature.id.is_empty(), "feature id should not be empty");
315        }
316    }
317
318    #[test]
319    fn all_features_have_valid_areas() {
320        let valid_areas = ["text_document", "workspace", "window", "notebook", "debug", "protocol"];
321        for feature in all_features() {
322            assert!(
323                valid_areas.contains(&feature.area),
324                "feature '{}' has unexpected area '{}'",
325                feature.id,
326                feature.area
327            );
328        }
329    }
330
331    #[test]
332    fn all_features_have_valid_maturity() {
333        let valid_maturities = ["ga", "beta", "alpha", "planned"];
334        for feature in all_features() {
335            assert!(
336                valid_maturities.contains(&feature.maturity),
337                "feature '{}' has unexpected maturity '{}'",
338                feature.id,
339                feature.maturity
340            );
341        }
342    }
343
344    #[test]
345    fn feature_ids_are_unique() {
346        let ids: Vec<&str> = all_features().iter().map(|f| f.id).collect();
347        let mut deduped = ids.clone();
348        deduped.sort_unstable();
349        deduped.dedup();
350        assert_eq!(ids.len(), deduped.len(), "feature IDs must be unique");
351    }
352
353    #[test]
354    fn bdd_feature_rows_sorted_by_area_then_id() {
355        let rows = bdd_feature_rows();
356        for window in rows.windows(2) {
357            let ordering = window[0].area.cmp(window[1].area).then(window[0].id.cmp(window[1].id));
358            assert!(
359                ordering.is_le(),
360                "BDD rows not sorted: '{}' in '{}' should come before '{}' in '{}'",
361                window[0].id,
362                window[0].area,
363                window[1].id,
364                window[1].area,
365            );
366        }
367    }
368
369    #[test]
370    fn bdd_feature_rows_count_matches_all_features() {
371        assert_eq!(bdd_feature_rows().len(), all_features().len());
372    }
373
374    #[test]
375    fn trackable_features_are_subset_of_all() {
376        let all_count = all_features().len();
377        let trackable = trackable_feature_count_for_grid();
378        assert!(trackable <= all_count);
379    }
380
381    #[test]
382    fn advertised_trackable_is_subset_of_trackable() {
383        let trackable = trackable_feature_count_for_grid();
384        let advertised = advertised_trackable_feature_count_for_grid();
385        assert!(advertised <= trackable);
386    }
387
388    #[test]
389    fn compliance_percent_is_in_valid_range() {
390        let pct = compliance_percent_for_grid();
391        assert!((0.0..=100.0).contains(&pct), "compliance must be 0-100, got {pct}");
392    }
393
394    #[test]
395    fn has_feature_returns_true_for_known_ids() {
396        assert!(has_feature("lsp.completion"));
397        assert!(has_feature("lsp.hover"));
398        assert!(has_feature("lsp.definition"));
399    }
400
401    #[test]
402    fn has_feature_returns_false_for_unknown_ids() {
403        assert!(!has_feature("lsp.nonexistent"));
404        assert!(!has_feature(""));
405    }
406
407    #[test]
408    fn advertised_features_is_non_empty() {
409        assert!(!advertised_features().is_empty());
410    }
411
412    #[test]
413    fn version_strings_are_non_empty() {
414        assert!(!VERSION.is_empty());
415        assert!(!LSP_VERSION.is_empty());
416    }
417}