Skip to main content

perl_lsp_feature_policy/
lib.rs

1#![warn(missing_docs)]
2//! LSP feature policy and capability profile helpers.
3//!
4//! This microcrate centralizes capability set selection, turning high-level profile
5//! decisions (e.g. `ga-lock`, `production`, `all`) into runtime [`BuildFlags`] and
6//! catalog-oriented feature IDs. It bridges [`FeatureProfileKind`] to the
7//! [`AdvertisedFeatures`] projection consumed by server startup and the
8//! `initialize` response.
9
10use perl_lsp_feature_contracts::advertised_features;
11use perl_lsp_feature_flags::{AdvertisedFeatures, BuildFlags};
12use perl_lsp_feature_profile::{FeatureProfileKind, parse_profile_token};
13
14/// Parse a user-facing feature profile name into a `FeatureProfile`.
15///
16/// Supported values:
17/// - `ga-lock` or `ga`
18/// - `production` or `prod`
19/// - `all`
20/// - `auto` (falls back to `cfg`-gated default)
21///
22/// Unknown values return `None`.
23pub fn from_str_name(s: &str) -> Option<FeatureProfile> {
24    FeatureProfileKind::from_str_name(s).map(FeatureProfile::from_kind)
25}
26
27/// Known feature profiles for runtime capability selection.
28#[derive(Debug, Clone, Copy, Eq, PartialEq)]
29pub enum FeatureProfile {
30    /// Conservative GA-lock set (legacy compatibility mode).
31    GaLock,
32    /// Standard production profile used for normal runtime operation.
33    Production,
34    /// All in-tree capabilities, useful for test matrices and snapshots.
35    All,
36}
37
38impl FeatureProfile {
39    /// Convert canonical profile IDs to `FeatureProfile` values.
40    pub const fn from_kind(profile: FeatureProfileKind) -> Self {
41        match profile {
42            FeatureProfileKind::GaLock => Self::GaLock,
43            FeatureProfileKind::Production => Self::Production,
44            FeatureProfileKind::All => Self::All,
45        }
46    }
47
48    /// Build the profile from an explicit GA-lock toggle.
49    pub const fn from_ga_lock_enabled(ga_lock_enabled: bool) -> Self {
50        Self::from_kind(FeatureProfileKind::from_ga_lock_enabled(ga_lock_enabled))
51    }
52
53    /// Resolve the active policy from compiled crate features.
54    ///
55    /// This keeps all consumers using a single profile source and reduces
56    /// duplication where capability selection previously hardcoded
57    /// `cfg!(feature = "lsp-ga-lock")` at each call-site.
58    pub const fn current() -> Self {
59        Self::from_kind(FeatureProfileKind::current())
60    }
61
62    /// Resolve a user-provided profile, falling back to `current()` on invalid input.
63    ///
64    /// This API is intended for CLI and editor integration where users may provide
65    /// explicit profile controls at runtime.
66    pub fn from_cli_argument(raw_profile: &str) -> Self {
67        parse_profile_token(raw_profile).map(Self::from_kind).unwrap_or_else(Self::current)
68    }
69
70    /// Parse a CLI argument using the same normalization rules as editor and CLI inputs.
71    pub fn parse_profile(raw_profile: &str) -> Option<Self> {
72        parse_profile_token(raw_profile).map(Self::from_kind)
73    }
74
75    /// Convert this policy into base `BuildFlags`.
76    pub fn build_flags(self) -> BuildFlags {
77        match self {
78            Self::GaLock => BuildFlags::ga_lock(),
79            Self::Production => BuildFlags::production(),
80            Self::All => BuildFlags::all(),
81        }
82    }
83
84    /// Convert this policy into runtime `BuildFlags` that include
85    /// per-tool availability effects.
86    pub fn runtime_flags(self, has_perltidy: bool) -> BuildFlags {
87        let mut flags = self.build_flags();
88
89        if has_perltidy {
90            flags.formatting = true;
91            flags.range_formatting = true;
92        }
93
94        flags
95    }
96
97    /// Convert this policy into server advertised features.
98    pub fn advertised_features(self) -> AdvertisedFeatures {
99        self.build_flags().to_advertised_features()
100    }
101
102    /// Convert this policy into advertised features with runtime tooling checks.
103    pub fn runtime_advertised_features(self, has_perltidy: bool) -> AdvertisedFeatures {
104        self.runtime_flags(has_perltidy).to_advertised_features()
105    }
106
107    /// Return the user-facing CLI/profile display label for this profile.
108    pub const fn as_str(self) -> &'static str {
109        match self {
110            Self::GaLock => FeatureProfileKind::GaLock.as_str(),
111            Self::Production => FeatureProfileKind::Production.as_str(),
112            Self::All => FeatureProfileKind::All.as_str(),
113        }
114    }
115
116    /// Return every supported CLI token accepted by `FeatureProfile::parse_profile`.
117    pub const fn supported_cli_profiles() -> &'static [&'static str] {
118        perl_lsp_feature_profile::supported_cli_profiles()
119    }
120
121    /// Return all canonical profiles in declaration order.
122    pub const fn all() -> &'static [Self] {
123        &[Self::GaLock, Self::Production, Self::All]
124    }
125}
126
127/// Resolve `BuildFlags` for the profile.
128pub fn flags_for_profile(profile: FeatureProfile) -> BuildFlags {
129    profile.build_flags()
130}
131
132/// Resolve `BuildFlags` for runtime startup where formatting is conditional
133/// on external tooling availability.
134pub fn flags_for_runtime(profile: FeatureProfile, has_perltidy: bool) -> BuildFlags {
135    profile.runtime_flags(has_perltidy)
136}
137
138/// Convert `BuildFlags` into canonical LSP feature identifiers.
139pub fn feature_ids_from_flags(flags: &BuildFlags) -> Vec<&'static str> {
140    flags.to_feature_ids()
141}
142
143/// Return advertised feature IDs from the current profile, intersecting with
144/// the catalog so this API remains aligned to the BDD grid.
145pub fn catalog_advertised_feature_ids(profile: FeatureProfile) -> Vec<&'static str> {
146    let catalog_ids = advertised_features();
147    let mut ids = feature_ids_from_flags(&flags_for_profile(profile));
148
149    ids.retain(|id| catalog_ids.contains(id));
150    ids
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    #[test]
158    fn profile_labels_are_stable() {
159        assert_eq!(FeatureProfile::GaLock.as_str(), "ga-lock");
160        assert_eq!(FeatureProfile::Production.as_str(), "production");
161        assert_eq!(FeatureProfile::All.as_str(), "all");
162    }
163
164    #[test]
165    fn supported_cli_profiles_contains_expected_values() {
166        let supported = FeatureProfile::supported_cli_profiles();
167        assert!(supported.contains(&"auto"));
168        assert!(supported.contains(&"ga"));
169        assert!(supported.contains(&"ga_lock"));
170        assert!(supported.contains(&"ga-lock"));
171        assert!(supported.contains(&"prod"));
172        assert!(supported.contains(&"production"));
173        assert!(supported.contains(&"all"));
174    }
175
176    // ── from_kind round-trip ────────────────────────────────────────
177
178    #[test]
179    fn from_kind_preserves_all_variants() {
180        assert_eq!(FeatureProfile::from_kind(FeatureProfileKind::GaLock), FeatureProfile::GaLock,);
181        assert_eq!(
182            FeatureProfile::from_kind(FeatureProfileKind::Production),
183            FeatureProfile::Production,
184        );
185        assert_eq!(FeatureProfile::from_kind(FeatureProfileKind::All), FeatureProfile::All,);
186    }
187
188    // ── from_ga_lock_enabled ────────────────────────────────────────
189
190    #[test]
191    fn from_ga_lock_enabled_true_is_ga_lock() {
192        assert_eq!(FeatureProfile::from_ga_lock_enabled(true), FeatureProfile::GaLock);
193    }
194
195    #[test]
196    fn from_ga_lock_enabled_false_is_production() {
197        assert_eq!(FeatureProfile::from_ga_lock_enabled(false), FeatureProfile::Production);
198    }
199
200    // ── from_cli_argument ───────────────────────────────────────────
201
202    #[test]
203    fn from_cli_argument_resolves_known_tokens() {
204        assert_eq!(FeatureProfile::from_cli_argument("ga-lock"), FeatureProfile::GaLock);
205        assert_eq!(FeatureProfile::from_cli_argument(" Prod "), FeatureProfile::Production);
206        assert_eq!(FeatureProfile::from_cli_argument("all"), FeatureProfile::All);
207    }
208
209    #[test]
210    fn from_cli_argument_falls_back_for_unknown() {
211        let result = FeatureProfile::from_cli_argument("bogus");
212        assert_eq!(result, FeatureProfile::current());
213    }
214
215    // ── parse_profile ───────────────────────────────────────────────
216
217    #[test]
218    fn parse_profile_returns_none_for_unknown() {
219        assert!(FeatureProfile::parse_profile("nope").is_none());
220    }
221
222    #[test]
223    fn parse_profile_returns_some_for_valid() {
224        assert_eq!(FeatureProfile::parse_profile("all"), Some(FeatureProfile::All));
225        assert_eq!(FeatureProfile::parse_profile("  GA_LOCK  "), Some(FeatureProfile::GaLock));
226    }
227
228    // ── build_flags and profile shapes ──────────────────────────────
229
230    #[test]
231    fn build_flags_returns_ga_lock_for_ga_lock_profile() {
232        let flags = FeatureProfile::GaLock.build_flags();
233        let expected = BuildFlags::ga_lock();
234        assert_eq!(flags, expected);
235    }
236
237    #[test]
238    fn build_flags_returns_production_for_production_profile() {
239        let flags = FeatureProfile::Production.build_flags();
240        let expected = BuildFlags::production();
241        assert_eq!(flags, expected);
242    }
243
244    #[test]
245    fn build_flags_returns_all_for_all_profile() {
246        let flags = FeatureProfile::All.build_flags();
247        let expected = BuildFlags::all();
248        assert_eq!(flags, expected);
249    }
250
251    // ── runtime_flags with perltidy ─────────────────────────────────
252
253    #[test]
254    fn runtime_flags_enables_formatting_when_perltidy_available() {
255        let flags = FeatureProfile::Production.runtime_flags(true);
256        assert!(flags.formatting, "formatting should be enabled with perltidy");
257        assert!(flags.range_formatting, "range_formatting should be enabled with perltidy");
258    }
259
260    #[test]
261    fn runtime_flags_preserves_disabled_formatting_without_perltidy() {
262        let flags = FeatureProfile::Production.runtime_flags(false);
263        assert!(!flags.formatting, "formatting should remain off without perltidy");
264        assert!(!flags.range_formatting, "range_formatting should remain off without perltidy");
265    }
266
267    // ── flags_for_profile / flags_for_runtime ───────────────────────
268
269    #[test]
270    fn flags_for_profile_matches_build_flags() {
271        for profile in FeatureProfile::all() {
272            assert_eq!(
273                flags_for_profile(*profile),
274                profile.build_flags(),
275                "flags_for_profile({}) should match build_flags()",
276                profile.as_str(),
277            );
278        }
279    }
280
281    #[test]
282    fn flags_for_runtime_matches_runtime_flags() {
283        for &has_perltidy in &[true, false] {
284            for profile in FeatureProfile::all() {
285                assert_eq!(
286                    flags_for_runtime(*profile, has_perltidy),
287                    profile.runtime_flags(has_perltidy),
288                );
289            }
290        }
291    }
292
293    // ── advertised_features ─────────────────────────────────────────
294
295    #[test]
296    fn advertised_features_reflects_build_flags() {
297        let adv = FeatureProfile::Production.advertised_features();
298        assert!(adv.completion);
299        assert!(adv.hover);
300        assert!(!adv.formatting, "production does not advertise formatting without perltidy");
301    }
302
303    #[test]
304    fn runtime_advertised_features_with_perltidy() {
305        let adv = FeatureProfile::Production.runtime_advertised_features(true);
306        assert!(adv.formatting, "production should advertise formatting with perltidy");
307    }
308
309    // ── catalog_advertised_feature_ids ──────────────────────────────
310
311    #[test]
312    fn catalog_advertised_ids_are_non_empty_for_all_profiles() {
313        for profile in FeatureProfile::all() {
314            let ids = catalog_advertised_feature_ids(*profile);
315            assert!(
316                !ids.is_empty(),
317                "catalog_advertised_feature_ids({}) should not be empty",
318                profile.as_str(),
319            );
320        }
321    }
322
323    #[test]
324    fn catalog_advertised_ids_all_superset_of_ga_lock() {
325        let all_ids = catalog_advertised_feature_ids(FeatureProfile::All);
326        let ga_ids = catalog_advertised_feature_ids(FeatureProfile::GaLock);
327        for id in &ga_ids {
328            assert!(all_ids.contains(id), "'all' advertised IDs should contain ga-lock ID '{id}'");
329        }
330    }
331
332    #[test]
333    fn catalog_advertised_ids_only_contain_catalog_known_ids() {
334        let catalog_ids = advertised_features();
335        for profile in FeatureProfile::all() {
336            let ids = catalog_advertised_feature_ids(*profile);
337            for id in &ids {
338                assert!(
339                    catalog_ids.contains(id),
340                    "profile '{}' emitted non-catalog ID '{id}'",
341                    profile.as_str(),
342                );
343            }
344        }
345    }
346
347    // ── all() profiles ──────────────────────────────────────────────
348
349    #[test]
350    fn all_profiles_returns_three() {
351        assert_eq!(FeatureProfile::all().len(), 3);
352    }
353
354    // ── feature_ids_from_flags ──────────────────────────────────────
355
356    #[test]
357    fn feature_ids_from_flags_for_default_is_empty() {
358        let ids = feature_ids_from_flags(&BuildFlags::default());
359        assert!(ids.is_empty());
360    }
361
362    // ── Feature flag evaluation (per-flag granularity) ──────────────
363
364    #[test]
365    fn feature_ids_from_flags_partial_enables_only_selected() {
366        let flags = BuildFlags { completion: true, hover: true, ..Default::default() };
367        let ids = feature_ids_from_flags(&flags);
368        assert!(ids.contains(&"lsp.completion"));
369        assert!(ids.contains(&"lsp.hover"));
370        assert!(!ids.contains(&"lsp.definition"));
371        assert!(!ids.contains(&"lsp.references"));
372        assert_eq!(ids.len(), 2, "should contain exactly 2 feature IDs");
373    }
374
375    #[test]
376    fn feature_ids_from_flags_single_flag_produces_one_id() {
377        let flags = BuildFlags { rename: true, ..Default::default() };
378        let ids = feature_ids_from_flags(&flags);
379        assert_eq!(ids.len(), 1);
380        assert_eq!(ids[0], "lsp.rename");
381    }
382
383    // ── Build profile feature gating ────────────────────────────────
384
385    #[test]
386    fn ga_lock_profile_gates_inline_values_out() {
387        let flags = FeatureProfile::GaLock.build_flags();
388        assert!(!flags.inline_values, "ga-lock must gate out inline_values");
389    }
390
391    #[test]
392    fn production_profile_gates_formatting_out() {
393        let flags = FeatureProfile::Production.build_flags();
394        assert!(!flags.formatting, "production must gate out formatting");
395        assert!(!flags.range_formatting, "production must gate out range_formatting");
396    }
397
398    #[test]
399    fn all_profile_gates_nothing_out() {
400        let flags = FeatureProfile::All.build_flags();
401        assert!(flags.formatting, "all must include formatting");
402        assert!(flags.range_formatting, "all must include range_formatting");
403        assert!(flags.inline_values, "all must include inline_values");
404    }
405
406    #[test]
407    fn all_profile_is_strict_superset_of_ga_lock() {
408        let all_ids = feature_ids_from_flags(&FeatureProfile::All.build_flags());
409        let ga_ids = feature_ids_from_flags(&FeatureProfile::GaLock.build_flags());
410        for id in &ga_ids {
411            assert!(all_ids.contains(id), "'all' must contain ga-lock feature '{id}'");
412        }
413        assert!(
414            all_ids.len() > ga_ids.len(),
415            "'all' should have strictly more features than ga-lock"
416        );
417    }
418
419    #[test]
420    fn all_profile_is_superset_of_production() {
421        let all_ids = feature_ids_from_flags(&FeatureProfile::All.build_flags());
422        let prod_ids = feature_ids_from_flags(&FeatureProfile::Production.build_flags());
423        for id in &prod_ids {
424            assert!(all_ids.contains(id), "'all' must contain production feature '{id}'");
425        }
426    }
427
428    // ── Feature ID lookup and validation ────────────────────────────
429
430    #[test]
431    fn catalog_advertised_ids_are_sorted_for_all_profiles() {
432        for profile in FeatureProfile::all() {
433            let ids = catalog_advertised_feature_ids(*profile);
434            let mut sorted = ids.clone();
435            sorted.sort_unstable();
436            assert_eq!(
437                ids,
438                sorted,
439                "catalog_advertised_feature_ids for {} should be sorted",
440                profile.as_str()
441            );
442        }
443    }
444
445    #[test]
446    fn catalog_advertised_ids_ga_lock_and_production_overlap_on_core_features() {
447        let prod_ids = catalog_advertised_feature_ids(FeatureProfile::Production);
448        let ga_ids = catalog_advertised_feature_ids(FeatureProfile::GaLock);
449        // Both profiles should share core features
450        let core_features = ["lsp.completion", "lsp.hover", "lsp.definition", "lsp.references"];
451        for id in &core_features {
452            assert!(prod_ids.contains(id), "production should contain core feature '{id}'");
453            assert!(ga_ids.contains(id), "ga-lock should contain core feature '{id}'");
454        }
455    }
456
457    #[test]
458    fn catalog_advertised_ids_ga_lock_includes_formatting_production_does_not() {
459        let prod_ids = catalog_advertised_feature_ids(FeatureProfile::Production);
460        let ga_ids = catalog_advertised_feature_ids(FeatureProfile::GaLock);
461        // GA-lock includes formatting by default, production does not
462        assert!(ga_ids.contains(&"lsp.formatting"), "ga-lock should include formatting");
463        assert!(
464            !prod_ids.contains(&"lsp.formatting"),
465            "production should not include formatting (requires perltidy)"
466        );
467    }
468
469    // ── Feature enablement/disablement ──────────────────────────────
470
471    #[test]
472    fn runtime_flags_perltidy_enables_formatting_for_all_profiles() {
473        for profile in FeatureProfile::all() {
474            let flags = profile.runtime_flags(true);
475            assert!(
476                flags.formatting,
477                "runtime with perltidy should enable formatting for {}",
478                profile.as_str()
479            );
480            assert!(
481                flags.range_formatting,
482                "runtime with perltidy should enable range_formatting for {}",
483                profile.as_str()
484            );
485        }
486    }
487
488    #[test]
489    fn runtime_flags_no_perltidy_matches_base_for_production() {
490        let base = FeatureProfile::Production.build_flags();
491        let runtime = FeatureProfile::Production.runtime_flags(false);
492        assert_eq!(base, runtime, "runtime(false) should match build_flags for production");
493    }
494
495    #[test]
496    fn runtime_advertised_features_without_perltidy_disables_formatting() {
497        let adv = FeatureProfile::Production.runtime_advertised_features(false);
498        assert!(!adv.formatting, "production without perltidy should not advertise formatting");
499        assert!(
500            !adv.range_formatting,
501            "production without perltidy should not advertise range_formatting"
502        );
503    }
504
505    #[test]
506    fn runtime_advertised_features_with_perltidy_enables_formatting() {
507        let adv = FeatureProfile::Production.runtime_advertised_features(true);
508        assert!(adv.formatting, "production with perltidy should advertise formatting");
509        assert!(adv.range_formatting, "production with perltidy should advertise range_formatting");
510    }
511
512    #[test]
513    fn advertised_features_all_profile_enables_everything_without_perltidy() {
514        let adv = FeatureProfile::All.advertised_features();
515        assert!(adv.completion);
516        assert!(adv.hover);
517        assert!(adv.definition);
518        assert!(adv.formatting, "all profile should advertise formatting");
519        assert!(adv.semantic_tokens);
520    }
521
522    // ── Default feature profile ─────────────────────────────────────
523
524    #[test]
525    fn current_profile_is_deterministic() {
526        let a = FeatureProfile::current();
527        let b = FeatureProfile::current();
528        assert_eq!(a, b, "current() must be deterministic across calls");
529    }
530
531    #[test]
532    fn current_profile_is_production_or_ga_lock() {
533        let current = FeatureProfile::current();
534        let valid = current == FeatureProfile::Production || current == FeatureProfile::GaLock;
535        assert!(valid, "current() must be Production or GaLock, got {:?}", current);
536    }
537
538    #[test]
539    fn current_profile_enables_core_capabilities() {
540        let flags = FeatureProfile::current().build_flags();
541        assert!(flags.completion);
542        assert!(flags.hover);
543        assert!(flags.definition);
544        assert!(flags.references);
545        assert!(flags.document_symbol);
546    }
547
548    // ── from_str_name module function ───────────────────────────────
549
550    #[test]
551    fn from_str_name_resolves_canonical_names() {
552        assert_eq!(from_str_name("ga-lock"), Some(FeatureProfile::GaLock));
553        assert_eq!(from_str_name("production"), Some(FeatureProfile::Production));
554        assert_eq!(from_str_name("all"), Some(FeatureProfile::All));
555    }
556
557    #[test]
558    fn from_str_name_resolves_aliases() {
559        assert_eq!(from_str_name("ga"), Some(FeatureProfile::GaLock));
560        assert_eq!(from_str_name("ga_lock"), Some(FeatureProfile::GaLock));
561        assert_eq!(from_str_name("prod"), Some(FeatureProfile::Production));
562    }
563
564    #[test]
565    fn from_str_name_resolves_auto_to_current() {
566        assert_eq!(from_str_name("auto"), Some(FeatureProfile::current()));
567    }
568
569    #[test]
570    fn from_str_name_returns_none_for_unknown() {
571        assert!(from_str_name("debug").is_none());
572        assert!(from_str_name("").is_none());
573        assert!(from_str_name("GA-LOCK").is_none());
574    }
575
576    // ── Trait derivations ───────────────────────────────────────────
577
578    #[test]
579    fn feature_profile_debug_is_human_readable() {
580        let debug_str = format!("{:?}", FeatureProfile::Production);
581        assert!(debug_str.contains("Production"), "Debug output should contain variant name");
582    }
583
584    #[test]
585    fn feature_profile_copy_preserves_equality() {
586        let original = FeatureProfile::All;
587        let copied: FeatureProfile = original;
588        let also_copied: FeatureProfile = original;
589        assert_eq!(original, copied);
590        assert_eq!(copied, also_copied);
591    }
592
593    // ── Profile ordering invariants ─────────────────────────────────
594
595    #[test]
596    fn all_profile_has_most_feature_ids() {
597        let ga_count = feature_ids_from_flags(&FeatureProfile::GaLock.build_flags()).len();
598        let prod_count = feature_ids_from_flags(&FeatureProfile::Production.build_flags()).len();
599        let all_count = feature_ids_from_flags(&FeatureProfile::All.build_flags()).len();
600        assert!(
601            all_count >= prod_count,
602            "all ({all_count}) should have >= features than production ({prod_count})"
603        );
604        assert!(
605            all_count >= ga_count,
606            "all ({all_count}) should have >= features than ga-lock ({ga_count})"
607        );
608    }
609}