Skip to main content

copybook_support_matrix/
lib.rs

1#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used))]
2// SPDX-License-Identifier: AGPL-3.0-or-later
3//! COBOL feature support matrix registry.
4
5use serde::{Deserialize, Serialize};
6
7/// Identifier for a COBOL feature tracked in the support matrix.
8///
9/// # Examples
10///
11/// ```
12/// use copybook_support_matrix::{FeatureId, find_feature_by_id};
13///
14/// let id = FeatureId::Level88Conditions;
15/// if let Some(feature) = find_feature_by_id(id) {
16///     assert_eq!(feature.name, "LEVEL 88 condition names");
17/// }
18/// ```
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
20#[serde(rename_all = "kebab-case")]
21#[non_exhaustive]
22pub enum FeatureId {
23    /// Level-88 condition name VALUE clauses.
24    #[serde(rename = "level-88")]
25    Level88Conditions,
26
27    /// Level-66 RENAMES non-storage renaming.
28    #[serde(rename = "level-66-renames")]
29    Level66Renames,
30
31    /// Variable-length OCCURS DEPENDING ON arrays.
32    #[serde(rename = "occurs-depending")]
33    OccursDepending,
34
35    /// Edited numeric PICTURE clauses (e.g. `PIC Z,ZZZ.99`).
36    #[serde(rename = "edited-pic")]
37    EditedPic,
38
39    /// COMP-1 / COMP-2 IEEE 754 floating-point types.
40    #[serde(rename = "comp-1-comp-2")]
41    Comp1Comp2,
42
43    /// SIGN LEADING / TRAILING SEPARATE directives.
44    #[serde(rename = "sign-separate")]
45    SignSeparate,
46
47    /// Nested OCCURS DEPENDING ON (ODO inside ODO).
48    #[serde(rename = "nested-odo")]
49    NestedOdo,
50}
51
52/// Current implementation status of a COBOL feature.
53///
54/// # Examples
55///
56/// ```
57/// use copybook_support_matrix::{SupportStatus, FeatureId, find_feature_by_id};
58///
59/// if let Some(feature) = find_feature_by_id(FeatureId::EditedPic) {
60///     if let SupportStatus::Supported = feature.status {
61///         // Feature is fully supported
62///     }
63/// }
64/// ```
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
66#[serde(rename_all = "kebab-case")]
67#[non_exhaustive]
68pub enum SupportStatus {
69    /// Fully implemented and tested.
70    Supported,
71    /// Partially implemented with known limitations.
72    Partial,
73    /// Planned for a future release.
74    Planned,
75    /// Not planned for implementation.
76    NotPlanned,
77}
78
79/// Metadata entry for a single COBOL feature in the support matrix.
80#[derive(Debug, Clone, Serialize)]
81pub struct FeatureSupport {
82    /// Unique identifier for this feature.
83    pub id: FeatureId,
84    /// Human-readable feature name.
85    pub name: &'static str,
86    /// Brief description of the feature.
87    pub description: &'static str,
88    /// Current implementation status.
89    pub status: SupportStatus,
90    /// Path to the relevant documentation section, if any.
91    pub doc_ref: Option<&'static str>,
92}
93
94/// Returns the complete list of tracked COBOL features and their support status.
95#[inline]
96#[must_use]
97pub fn all_features() -> &'static [FeatureSupport] {
98    use FeatureId::{
99        Comp1Comp2, EditedPic, Level66Renames, Level88Conditions, NestedOdo, OccursDepending,
100        SignSeparate,
101    };
102    use SupportStatus::{Partial, Supported};
103
104    &[
105        FeatureSupport {
106            id: Level88Conditions,
107            name: "LEVEL 88 condition names",
108            description: "Condition-name VALUE clauses (space- and comma-separated).",
109            status: Supported,
110            doc_ref: Some("docs/reference/COBOL_SUPPORT_MATRIX.md#level-88-condition-names"),
111        },
112        FeatureSupport {
113            id: Level66Renames,
114            name: "LEVEL 66 RENAMES",
115            description: "Non-storage renaming with same-scope and THRU support.",
116            status: Partial,
117            doc_ref: Some("docs/reference/COBOL_SUPPORT_MATRIX.md#level-66-renames"),
118        },
119        FeatureSupport {
120            id: OccursDepending,
121            name: "OCCURS DEPENDING ON",
122            description: "Variable-length OCCURS; tail-only, no nesting.",
123            status: Partial,
124            doc_ref: Some("docs/reference/COBOL_SUPPORT_MATRIX.md#occurs-depending-on"),
125        },
126        FeatureSupport {
127            id: EditedPic,
128            name: "Edited PIC clauses",
129            description: "Masks like PIC Z,ZZZ.99; full parse/decode/encode support.",
130            status: Supported,
131            doc_ref: Some("docs/reference/COBOL_SUPPORT_MATRIX.md#edited-pic"),
132        },
133        FeatureSupport {
134            id: Comp1Comp2,
135            name: "COMP-1/COMP-2 floating-point",
136            description: "Single/double precision IEEE 754 floating-point types; enabled by default.",
137            status: Supported,
138            doc_ref: Some("docs/reference/COBOL_SUPPORT_MATRIX.md#data-types"),
139        },
140        FeatureSupport {
141            id: SignSeparate,
142            name: "SIGN LEADING/TRAILING SEPARATE",
143            description: "Separate sign byte directives; enabled by default.",
144            status: Supported,
145            doc_ref: Some("docs/reference/COBOL_SUPPORT_MATRIX.md#sign-handling"),
146        },
147        FeatureSupport {
148            id: NestedOdo,
149            name: "Nested OCCURS DEPENDING ON",
150            description: "ODO arrays inside ODO arrays (O1-O4 supported, O5/O6 rejected).",
151            status: Partial,
152            doc_ref: Some(
153                "docs/reference/COBOL_SUPPORT_MATRIX.md#nested-odo--occurs-behavior---support-status",
154            ),
155        },
156    ]
157}
158
159/// Looks up a feature by its [`FeatureId`] enum variant.
160#[inline]
161#[must_use]
162pub fn find_feature_by_id(id: FeatureId) -> Option<&'static FeatureSupport> {
163    all_features().iter().find(|f| f.id == id)
164}
165
166/// Looks up a feature by its kebab-case string identifier (e.g. `"level-88"`).
167#[inline]
168#[must_use]
169pub fn find_feature(id: &str) -> Option<&'static FeatureSupport> {
170    all_features()
171        .iter()
172        .find(|f| serde_plain::to_string(&f.id).ok().as_deref() == Some(id))
173}
174
175#[cfg(test)]
176#[allow(clippy::expect_used)]
177#[allow(clippy::unwrap_used)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn test_all_features_nonempty() {
183        assert!(!all_features().is_empty());
184    }
185
186    #[test]
187    fn test_find_feature_level88() {
188        let feature = find_feature("level-88");
189        assert!(feature.is_some());
190        let f = feature.expect("feature should exist");
191        assert_eq!(f.id, FeatureId::Level88Conditions);
192        assert_eq!(f.status, SupportStatus::Supported);
193    }
194
195    #[test]
196    fn test_find_feature_unknown() {
197        let feature = find_feature("no-such-feature");
198        assert!(feature.is_none());
199    }
200
201    #[test]
202    fn test_find_feature_by_id() {
203        let feature = find_feature_by_id(FeatureId::SignSeparate);
204        assert!(feature.is_some());
205    }
206
207    #[test]
208    fn test_feature_id_serde_roundtrip() {
209        let id = FeatureId::Level88Conditions;
210        let serialized = serde_plain::to_string(&id).expect("serialization should succeed");
211        assert_eq!(serialized, "level-88");
212    }
213
214    #[test]
215    fn test_all_features_returns_seven_entries() {
216        assert_eq!(all_features().len(), 7);
217    }
218
219    #[test]
220    fn test_find_feature_by_id_all_variants() {
221        let ids = [
222            FeatureId::Level88Conditions,
223            FeatureId::Level66Renames,
224            FeatureId::OccursDepending,
225            FeatureId::EditedPic,
226            FeatureId::Comp1Comp2,
227            FeatureId::SignSeparate,
228            FeatureId::NestedOdo,
229        ];
230        for id in ids {
231            assert!(
232                find_feature_by_id(id).is_some(),
233                "missing feature for {id:?}"
234            );
235        }
236    }
237
238    #[test]
239    fn test_find_feature_all_kebab_strings() {
240        let names = [
241            "level-88",
242            "level-66-renames",
243            "occurs-depending",
244            "edited-pic",
245            "comp-1-comp-2",
246            "sign-separate",
247            "nested-odo",
248        ];
249        for name in names {
250            assert!(
251                find_feature(name).is_some(),
252                "missing feature for string '{name}'"
253            );
254        }
255    }
256
257    #[test]
258    fn test_all_features_have_nonempty_name_and_description() {
259        for f in all_features() {
260            assert!(!f.name.is_empty(), "feature {:?} has empty name", f.id);
261            assert!(
262                !f.description.is_empty(),
263                "feature {:?} has empty description",
264                f.id
265            );
266        }
267    }
268
269    #[test]
270    fn test_all_features_have_doc_ref() {
271        for f in all_features() {
272            assert!(
273                f.doc_ref.is_some(),
274                "feature {:?} should have a doc_ref",
275                f.id
276            );
277        }
278    }
279
280    #[test]
281    fn test_supported_status_for_known_features() {
282        let f = find_feature_by_id(FeatureId::Level88Conditions).unwrap();
283        assert_eq!(f.status, SupportStatus::Supported);
284
285        let f = find_feature_by_id(FeatureId::EditedPic).unwrap();
286        assert_eq!(f.status, SupportStatus::Supported);
287
288        let f = find_feature_by_id(FeatureId::Comp1Comp2).unwrap();
289        assert_eq!(f.status, SupportStatus::Supported);
290
291        let f = find_feature_by_id(FeatureId::SignSeparate).unwrap();
292        assert_eq!(f.status, SupportStatus::Supported);
293    }
294
295    #[test]
296    fn test_partial_status_for_known_features() {
297        let f = find_feature_by_id(FeatureId::Level66Renames).unwrap();
298        assert_eq!(f.status, SupportStatus::Partial);
299
300        let f = find_feature_by_id(FeatureId::OccursDepending).unwrap();
301        assert_eq!(f.status, SupportStatus::Partial);
302
303        let f = find_feature_by_id(FeatureId::NestedOdo).unwrap();
304        assert_eq!(f.status, SupportStatus::Partial);
305    }
306
307    #[test]
308    fn test_feature_id_serde_all_variants() {
309        let expected = [
310            (FeatureId::Level88Conditions, "level-88"),
311            (FeatureId::Level66Renames, "level-66-renames"),
312            (FeatureId::OccursDepending, "occurs-depending"),
313            (FeatureId::EditedPic, "edited-pic"),
314            (FeatureId::Comp1Comp2, "comp-1-comp-2"),
315            (FeatureId::SignSeparate, "sign-separate"),
316            (FeatureId::NestedOdo, "nested-odo"),
317        ];
318        for (id, expected_str) in expected {
319            let s = serde_plain::to_string(&id).unwrap();
320            assert_eq!(s, expected_str, "serde mismatch for {id:?}");
321        }
322    }
323
324    #[test]
325    fn test_feature_id_deserialize_roundtrip() {
326        let id = FeatureId::EditedPic;
327        let s = serde_plain::to_string(&id).unwrap();
328        let back: FeatureId = serde_plain::from_str(&s).unwrap();
329        assert_eq!(back, id);
330    }
331
332    #[test]
333    fn test_support_status_serialization() {
334        let json = serde_json::to_string(&SupportStatus::Supported).unwrap();
335        assert_eq!(json, "\"supported\"");
336        let json = serde_json::to_string(&SupportStatus::Partial).unwrap();
337        assert_eq!(json, "\"partial\"");
338        let json = serde_json::to_string(&SupportStatus::Planned).unwrap();
339        assert_eq!(json, "\"planned\"");
340        let json = serde_json::to_string(&SupportStatus::NotPlanned).unwrap();
341        assert_eq!(json, "\"not-planned\"");
342    }
343
344    #[test]
345    fn test_feature_support_json_serialization() {
346        let f = find_feature_by_id(FeatureId::Level88Conditions).unwrap();
347        let json = serde_json::to_value(f).unwrap();
348        assert_eq!(json["id"], "level-88");
349        assert_eq!(json["status"], "supported");
350        assert!(json["name"].is_string());
351        assert!(json["description"].is_string());
352    }
353
354    #[test]
355    fn test_no_duplicate_feature_ids() {
356        let ids: Vec<FeatureId> = all_features().iter().map(|f| f.id).collect();
357        for (i, id) in ids.iter().enumerate() {
358            assert!(!ids[i + 1..].contains(id), "duplicate feature id: {id:?}");
359        }
360    }
361
362    #[test]
363    fn test_find_feature_empty_string_returns_none() {
364        assert!(find_feature("").is_none());
365    }
366}