Skip to main content

perl_feature_catalog/
lib.rs

1//! Shared feature catalog parsing and code-generation helpers.
2//!
3//! This crate centralizes `features.toml` parsing so LSP, DAP, and xtask all
4//! consume the same metadata, validation, and rendering behavior.
5
6use std::collections::{BTreeMap, BTreeSet};
7use std::env;
8use std::fs;
9use std::path::{Path, PathBuf};
10
11/// Default DAP feature identifiers emitted when catalog processing fails.
12pub const DEFAULT_DAP_FEATURES: &[&str] =
13    &["dap.breakpoints.basic", "dap.core", "dap.inline_values"];
14
15/// Source metadata for the catalog file.
16#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
17pub struct Meta {
18    /// Canonical release or feature-set version.
19    pub version: String,
20    /// LSP version this catalog was built against.
21    pub lsp_version: String,
22    /// Optional tracked compliance percentage from the catalog source.
23    #[serde(default)]
24    pub compliance_percent: Option<u32>,
25}
26
27/// Feature maturity state used to drive advertising and tracking behavior.
28#[derive(
29    Debug, Clone, Copy, serde::Deserialize, serde::Serialize, PartialEq, Eq, PartialOrd, Ord, Hash,
30)]
31#[serde(rename_all = "lowercase")]
32pub enum Maturity {
33    /// Early-stage feature.
34    Experimental,
35    /// Feature can be tested but not yet stable.
36    Preview,
37    /// Fully released and advertizable feature.
38    Ga,
39    /// Planned work item, not yet implemented or measured.
40    Planned,
41    /// Fully production-ready and typically exposed like GA.
42    Production,
43}
44
45impl Maturity {
46    /// Returns `true` when the feature contributes to advertised API coverage.
47    pub const fn is_advertised(self) -> bool {
48        matches!(self, Self::Ga | Self::Production)
49    }
50
51    /// Returns `true` when the feature should be considered in compliance math.
52    pub const fn is_trackable(self) -> bool {
53        !matches!(self, Self::Planned)
54    }
55
56    /// Human-readable lowercase label.
57    pub const fn label(self) -> &'static str {
58        match self {
59            Self::Experimental => "experimental",
60            Self::Preview => "preview",
61            Self::Ga => "ga",
62            Self::Planned => "planned",
63            Self::Production => "production",
64        }
65    }
66}
67
68/// Per-feature catalog entry.
69#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
70pub struct Feature {
71    /// Canonical feature identifier (for example: `lsp.completion`).
72    pub id: String,
73    /// Spec reference string (for example: `LSP 3.18`).
74    #[serde(default)]
75    pub spec: String,
76    /// Area bucket (`text_document`, `workspace`, `debug`, etc.).
77    #[serde(default)]
78    pub area: String,
79    /// Maturity state.
80    pub maturity: Maturity,
81    /// Whether this feature is advertised/visible to clients.
82    #[serde(default)]
83    pub advertised: bool,
84    /// Test cases validating the feature.
85    #[serde(default)]
86    pub tests: Vec<String>,
87    /// Include this feature in coverage/compliance accounting.
88    #[serde(default = "default_counts_in_coverage")]
89    pub counts_in_coverage: bool,
90    /// Human-readable description.
91    #[serde(default)]
92    pub description: String,
93}
94
95const fn default_counts_in_coverage() -> bool {
96    true
97}
98
99/// Full catalog loaded from `features.toml`.
100#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
101pub struct Catalog {
102    /// Shared metadata section.
103    pub meta: Meta,
104    /// Ordered feature rows.
105    pub feature: Vec<Feature>,
106}
107
108impl Catalog {
109    /// All features in declaration order.
110    pub fn features(&self) -> &[Feature] {
111        &self.feature
112    }
113
114    /// IDs for advertised trackable features (GA/production + `advertised = true`).
115    pub fn advertised_feature_ids(&self) -> Vec<&str> {
116        let mut ids = self
117            .feature
118            .iter()
119            .filter(|feature| feature.advertised && feature.maturity.is_advertised())
120            .map(|feature| feature.id.as_str())
121            .collect::<Vec<_>>();
122        ids.sort_unstable();
123        ids
124    }
125
126    /// IDs for all features in a specific area.
127    pub fn area_feature_ids(&self, area: &str) -> Vec<&str> {
128        let mut ids: Vec<&str> = self
129            .feature
130            .iter()
131            .filter(|feature| feature.area == area)
132            .map(|feature| feature.id.as_str())
133            .collect();
134        ids.sort_unstable();
135        ids
136    }
137
138    /// Trackable feature count (`maturity != planned`).
139    pub fn trackable_feature_count(&self) -> usize {
140        self.feature.iter().filter(|feature| feature.maturity.is_trackable()).count()
141    }
142
143    /// Advertised trackable count.
144    pub fn advertised_trackable_count(&self) -> usize {
145        self.feature
146            .iter()
147            .filter(|feature| feature.advertised && feature.maturity.is_advertised())
148            .count()
149    }
150
151    /// Trackable feature count for BDD/compliance grids.
152    /// Excludes entries explicitly marked `counts_in_coverage = false`.
153    pub fn trackable_feature_count_for_grid(&self) -> usize {
154        self.feature
155            .iter()
156            .filter(|feature| feature.maturity.is_trackable() && feature.counts_in_coverage)
157            .count()
158    }
159
160    /// Advertised trackable count for BDD/compliance grids.
161    /// Excludes entries explicitly marked `counts_in_coverage = false`.
162    pub fn advertised_trackable_count_for_grid(&self) -> usize {
163        self.feature
164            .iter()
165            .filter(|feature| {
166                feature.advertised && feature.maturity.is_advertised() && feature.counts_in_coverage
167            })
168            .count()
169    }
170
171    /// Compliance percentage for BDD/compliance grids.
172    pub fn compliance_percent_for_grid(&self) -> f32 {
173        let trackable = self.trackable_feature_count_for_grid();
174        if trackable == 0 {
175            return 0.0;
176        }
177        let advertised = self.advertised_trackable_count_for_grid();
178        (advertised as f64 / trackable as f64 * 100.0).round() as f32
179    }
180
181    /// Compliance percentage calculated as advertised(trackable) / trackable.
182    pub fn compliance_percent(&self) -> f32 {
183        let trackable = self.trackable_feature_count();
184        if trackable == 0 {
185            return 0.0;
186        }
187        let advertised = self.advertised_trackable_count();
188        (advertised as f64 / trackable as f64 * 100.0).round() as f32
189    }
190
191    /// Per-area statistics useful for documentation and reporting.
192    pub fn area_statistics(&self) -> BTreeMap<String, AreaStats> {
193        let mut stats: BTreeMap<String, AreaStats> = BTreeMap::new();
194
195        for feature in &self.feature {
196            let entry = stats.entry(feature.area.clone()).or_default();
197            entry.total += 1;
198            if feature.advertised {
199                entry.advertised += 1;
200            }
201
202            match feature.maturity {
203                Maturity::Ga => entry.ga += 1,
204                Maturity::Production => entry.production += 1,
205                Maturity::Preview => entry.preview += 1,
206                Maturity::Experimental => entry.experimental += 1,
207                Maturity::Planned => entry.planned += 1,
208            }
209        }
210
211        stats
212    }
213
214    /// Validate constraints not captured by serde parsing alone.
215    pub fn validate(&self) -> Result<(), CatalogError> {
216        let mut seen = BTreeSet::new();
217        let mut issues = Vec::new();
218
219        for feature in &self.feature {
220            if feature.id.trim().is_empty() {
221                issues.push("feature id must not be empty".to_string());
222                continue;
223            }
224            if !seen.insert(&feature.id) {
225                issues.push(format!("duplicate feature id: {}", feature.id));
226            }
227        }
228
229        if issues.is_empty() { Ok(()) } else { Err(CatalogError::Validation(issues.join(", "))) }
230    }
231}
232
233/// Aggregate area-level information.
234#[derive(Debug, Default, Clone, Copy)]
235pub struct AreaStats {
236    /// Total number of rows in the area.
237    pub total: usize,
238    /// Advertised row count in the area.
239    pub advertised: usize,
240    /// Experimental count.
241    pub experimental: usize,
242    /// Preview count.
243    pub preview: usize,
244    /// GA count.
245    pub ga: usize,
246    /// Production count.
247    pub production: usize,
248    /// Planned count.
249    pub planned: usize,
250}
251
252impl AreaStats {
253    /// Number of rows eligible for trackability.
254    pub const fn trackable(&self) -> usize {
255        self.total - self.planned
256    }
257
258    /// Advertised ratio in percent for this area.
259    pub fn coverage_percent(&self) -> u32 {
260        if self.total == 0 {
261            return 0;
262        }
263        ((self.advertised as f64 / self.total as f64) * 100.0).round() as u32
264    }
265
266    /// Advertised ratio for trackable features.
267    pub fn trackable_coverage_percent(&self) -> u32 {
268        let trackable = self.trackable();
269        if trackable == 0 {
270            return 0;
271        }
272        ((self.advertised as f64 / trackable as f64) * 100.0).round() as u32
273    }
274}
275
276/// Error type used by catalog operations.
277#[derive(Debug, thiserror::Error)]
278pub enum CatalogError {
279    /// Missing catalog source file on the expected paths.
280    #[error("features catalog not found for manifest dir: {0}")]
281    MissingSource(PathBuf),
282
283    /// I/O failure while reading the catalog source.
284    #[error("failed to read features catalog: {0}")]
285    Io(#[from] std::io::Error),
286
287    /// TOML parser error.
288    #[error("failed to parse features catalog: {0}")]
289    Parse(#[from] toml::de::Error),
290
291    /// Validation failure after deserialization.
292    #[error("invalid features catalog: {0}")]
293    Validation(String),
294}
295
296/// Source selection detail for generated outputs and traceability.
297#[derive(Debug, Clone)]
298pub struct CatalogSource {
299    /// Resolved source path.
300    pub path: PathBuf,
301    /// Selected source type.
302    pub kind: CatalogSourceKind,
303}
304
305impl CatalogSource {
306    /// Source tag emitted into generated modules.
307    pub const fn comment(&self) -> &'static str {
308        match self.kind {
309            CatalogSourceKind::Override => "// source: FEATURES_TOML_OVERRIDE\n",
310            CatalogSourceKind::Workspace => "// source: features.toml\n",
311            CatalogSourceKind::Vendored => "// source: features_sot.toml\n",
312        }
313    }
314}
315
316/// Which catalog source path was selected.
317#[derive(Debug, Clone, Copy)]
318pub enum CatalogSourceKind {
319    /// Path came from `FEATURES_TOML_OVERRIDE`.
320    Override,
321    /// Path came from workspace `features.toml`.
322    Workspace,
323    /// Path came from crate-local `features_sot.toml`.
324    Vendored,
325}
326
327/// Resolve catalog path using workspace-first lookup and override support.
328pub fn resolve_catalog_source(manifest_dir: &Path) -> Result<CatalogSource, CatalogError> {
329    if let Ok(override_path) = env::var("FEATURES_TOML_OVERRIDE") {
330        let override_path = PathBuf::from(override_path);
331        if override_path.exists() {
332            return Ok(CatalogSource { path: override_path, kind: CatalogSourceKind::Override });
333        }
334    }
335
336    let local_workspace_candidate = manifest_dir.join("features.toml");
337    if local_workspace_candidate.exists() {
338        return Ok(CatalogSource {
339            path: local_workspace_candidate,
340            kind: CatalogSourceKind::Workspace,
341        });
342    }
343
344    let parent_workspace = manifest_dir.parent().and_then(Path::parent).and_then(|p| {
345        let path = p.join("features.toml");
346        path.exists().then_some(path)
347    });
348    if let Some(path) = parent_workspace {
349        return Ok(CatalogSource { path, kind: CatalogSourceKind::Workspace });
350    }
351
352    let vendored = manifest_dir.join("features_sot.toml");
353    if vendored.exists() {
354        return Ok(CatalogSource { path: vendored, kind: CatalogSourceKind::Vendored });
355    }
356
357    Err(CatalogError::MissingSource(manifest_dir.to_path_buf()))
358}
359
360/// Load and validate catalog from an explicit path.
361pub fn read_catalog(path: &Path) -> Result<Catalog, CatalogError> {
362    let content = fs::read_to_string(path)?;
363    let catalog: Catalog = toml::from_str(&content)?;
364    catalog.validate()?;
365    Ok(catalog)
366}
367
368/// Load and validate catalog using workspace-style resolution.
369pub fn load_catalog_for_build(
370    manifest_dir: &Path,
371) -> Result<(Catalog, CatalogSource), CatalogError> {
372    let source = resolve_catalog_source(manifest_dir)?;
373    let catalog = read_catalog(&source.path)?;
374    Ok((catalog, source))
375}
376
377/// Render `features.rs`-compatible LSP runtime module source.
378pub fn render_lsp_feature_catalog_module(catalog: &Catalog, source_comment: &str) -> String {
379    let mut sorted = catalog.feature.clone();
380    sorted.sort_by(|a, b| a.area.cmp(&b.area).then_with(|| a.id.cmp(&b.id)));
381
382    let advertised = catalog.advertised_feature_ids();
383
384    let mut code = String::new();
385    code.push_str("// @generated by build.rs; DO NOT EDIT.\n");
386    code.push_str(source_comment);
387    code.push('\n');
388
389    code.push_str("/// Current parser version extracted from features.toml metadata\n");
390    code.push_str(&format!("pub const VERSION: &str = {:?};\n", catalog.meta.version));
391    code.push_str("/// LSP protocol version supported by this parser implementation\n");
392    code.push_str(&format!("pub const LSP_VERSION: &str = {:?};\n", catalog.meta.lsp_version));
393    code.push_str("/// Compliance percentage of advertised GA features vs trackable features\n");
394    code.push_str(&format!(
395        "pub const COMPLIANCE_PERCENT: f32 = {:.2};\n\n",
396        catalog.compliance_percent()
397    ));
398
399    code.push_str(
400        "/// Represents a single LSP feature with its metadata and implementation status\n",
401    );
402    code.push_str("#[derive(Debug, Clone)]\n");
403    code.push_str("pub struct Feature {\n");
404    code.push_str("    /// Unique identifier for this feature\n");
405    code.push_str("    pub id: &'static str,\n");
406    code.push_str("    /// LSP specification reference\n");
407    code.push_str("    pub spec: &'static str,\n");
408    code.push_str("    /// Functional area for this feature\n");
409    code.push_str("    pub area: &'static str,\n");
410    code.push_str(
411        "    /// Maturity level (`experimental`, `preview`, `ga`, `planned`, `production`)\n",
412    );
413    code.push_str("    pub maturity: &'static str,\n");
414    code.push_str("    /// Advertised feature flag\n");
415    code.push_str("    pub advertised: bool,\n");
416    code.push_str("    /// Human-readable description\n");
417    code.push_str("    pub description: &'static str,\n");
418    code.push_str("    /// Include this feature in coverage / compliance accounting\n");
419    code.push_str("    pub counts_in_coverage: bool,\n");
420    code.push_str("    /// Test cases validating the feature\n");
421    code.push_str("    pub tests: &'static [&'static str],\n");
422    code.push_str("}\n\n");
423
424    code.push_str(
425        "/// Comprehensive catalog of all LSP features with their implementation status\n",
426    );
427    code.push_str("pub const ALL_FEATURES: &[Feature] = &[\n");
428    for feature in sorted {
429        code.push_str("    Feature {\n");
430        code.push_str(&format!("        id: {:?},\n", feature.id));
431        code.push_str(&format!("        spec: {:?},\n", feature.spec));
432        code.push_str(&format!("        area: {:?},\n", feature.area));
433        code.push_str(&format!("        maturity: {:?},\n", feature.maturity.label()));
434        code.push_str(&format!("        advertised: {},\n", feature.advertised));
435        code.push_str(&format!("        description: {:?},\n", feature.description));
436        code.push_str(&format!("        counts_in_coverage: {},\n", feature.counts_in_coverage));
437        code.push_str(&format!("        tests: &{:?},\n", feature.tests));
438        code.push_str("    },\n");
439    }
440    code.push_str("];\n\n");
441
442    code.push_str("/// Advertised feature IDs (GA/production and `advertised = true`).\n");
443    code.push_str("pub const ADVERTISED_LSP_FEATURES: &[&str] = &[\n");
444    for id in &advertised {
445        code.push_str(&format!("    {:?},\n", id));
446    }
447    code.push_str("];\n\n");
448
449    code.push_str("/// Returns advertised feature IDs (GA/production and `advertised = true`).\n");
450    code.push_str("pub fn advertised_features() -> &'static [&'static str] {\n");
451    code.push_str("    ADVERTISED_LSP_FEATURES\n");
452    code.push_str("}\n\n");
453
454    code.push_str("/// Checks whether a feature is currently advertised.\n");
455    code.push_str("pub fn has_feature(id: &str) -> bool {\n");
456    code.push_str("    ADVERTISED_LSP_FEATURES.contains(&id)\n");
457    code.push_str("}\n\n");
458
459    code.push_str("/// Returns the current LSP compliance percentage as a float.\n");
460    code.push_str("pub fn compliance_percent() -> f32 {\n");
461    code.push_str("    COMPLIANCE_PERCENT\n");
462    code.push_str("}\n");
463
464    code
465}
466
467/// Render DAP runtime module source.
468pub fn render_dap_feature_catalog_module(ids: &[&str]) -> String {
469    let mut sorted = ids.to_vec();
470    sorted.sort_unstable();
471    sorted.dedup();
472
473    let mut code = String::new();
474    code.push_str("// @generated by build.rs; DO NOT EDIT.\n\n");
475    code.push_str("pub const ADVERTISED_DAP_FEATURES: &[&str] = &[\n");
476    for id in &sorted {
477        code.push_str(&format!("    {:?},\n", id));
478    }
479    code.push_str("];\n\n");
480    code.push_str("pub fn advertised_features() -> &'static [&'static str] {\n");
481    code.push_str("    ADVERTISED_DAP_FEATURES\n");
482    code.push_str("}\n\n");
483    code.push_str("pub fn has_feature(id: &str) -> bool {\n");
484    code.push_str("    ADVERTISED_DAP_FEATURES.contains(&id)\n");
485    code.push_str("}\n");
486    code
487}
488
489/// Render fallback DAP catalog for offline or error cases.
490pub fn render_dap_fallback_module(default_features: &[&str]) -> String {
491    render_dap_feature_catalog_module(default_features)
492}
493
494/// Minimal fallback module for build failures in `perl-lsp`.
495pub fn render_lsp_fallback_module() -> String {
496    let mut code = String::new();
497    code.push_str("// Auto-generated minimal catalog - features.toml not found\n\n");
498    code.push_str("pub struct Feature {\n");
499    code.push_str("    pub id: &'static str,\n");
500    code.push_str("    pub spec: &'static str,\n");
501    code.push_str("    pub area: &'static str,\n");
502    code.push_str("    pub maturity: &'static str,\n");
503    code.push_str("    pub advertised: bool,\n");
504    code.push_str("    pub description: &'static str,\n");
505    code.push_str("    pub counts_in_coverage: bool,\n");
506    code.push_str("    pub tests: &'static [&'static str],\n");
507    code.push_str("}\n");
508    code.push_str("pub const VERSION: &str = \"0.10.0\";\n");
509    code.push_str("pub const LSP_VERSION: &str = \"3.18\";\n");
510    code.push_str("pub const COMPLIANCE_PERCENT: f32 = 0.0;\n");
511    code.push_str("pub const ALL_FEATURES: &[Feature] = &[];\n");
512    code.push_str("pub const ADVERTISED_LSP_FEATURES: &[&str] = &[];\n");
513    code.push_str(
514        "pub fn advertised_features() -> &'static [&'static str] { ADVERTISED_LSP_FEATURES }\n",
515    );
516    code.push_str("pub fn has_feature(_id: &str) -> bool { false }\n");
517    code.push_str("pub fn compliance_percent() -> f32 { 0.0 }\n");
518    code
519}