Skip to main content

copybook_dialect/
lib.rs

1#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used))]
2// SPDX-License-Identifier: AGPL-3.0-or-later
3//! Dialect types for ODO (OCCURS DEPENDING ON) `min_count` behavior.
4//!
5//! This crate defines the dialect lever that controls how `min_count` is interpreted
6//! for ODO arrays. Different dialects provide different levels of strictness in
7//! enforcing the minimum count constraint.
8//!
9//! Use [`Dialect`] to select the interpretation mode and [`effective_min_count`]
10//! to apply it.
11
12use serde::{Deserialize, Serialize};
13use std::fmt;
14use std::str::FromStr;
15
16/// Dialect for ODO `min_count` interpretation
17///
18/// The dialect lever controls how `min_count` is interpreted for ODO arrays:
19///
20/// | Dialect | `min_count` Interpretation | Description |
21/// |---------|------------------------|-------------|
22/// | `Normative` | `min_count` is enforced | Counter must be ≥ `min_count` (strict) |
23/// | `ZeroTolerant` | `min_count` is ignored | Counter can be `0..max_count` (relaxed) |
24/// | `OneTolerant` | `min_count` clamped to 1 | Counter must be ≥ max(1, `min_count`) |
25///
26/// # Examples
27///
28/// ```rust
29/// use copybook_dialect::Dialect;
30/// use std::str::FromStr;
31///
32/// // Default dialect is Normative
33/// let default = Dialect::default();
34/// assert_eq!(default, Dialect::Normative);
35///
36/// // Parse from string
37/// let normative = Dialect::from_str("n").unwrap();
38/// assert_eq!(normative, Dialect::Normative);
39///
40/// let zero_tolerant = Dialect::from_str("0").unwrap();
41/// assert_eq!(zero_tolerant, Dialect::ZeroTolerant);
42///
43/// let one_tolerant = Dialect::from_str("1").unwrap();
44/// assert_eq!(one_tolerant, Dialect::OneTolerant);
45/// ```
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
47pub enum Dialect {
48    /// Normative dialect - `min_count` is strictly enforced
49    ///
50    /// Counter must be ≥ `min_count`. This is the default behavior.
51    #[default]
52    Normative,
53
54    /// Zero-tolerant dialect - `min_count` is ignored
55    ///
56    /// Counter can be `0..max_count`, regardless of declared `min_count`.
57    ZeroTolerant,
58
59    /// One-tolerant dialect - `min_count` is clamped to 1
60    ///
61    /// Counter must be ≥ max(1, `min_count`). This allows zero-length arrays
62    /// when `min_count` is 0, but enforces at least one element otherwise.
63    OneTolerant,
64}
65
66impl FromStr for Dialect {
67    type Err = String;
68
69    #[inline]
70    fn from_str(s: &str) -> Result<Self, Self::Err> {
71        match s.trim() {
72            "n" | "N" => Ok(Self::Normative),
73            "0" => Ok(Self::ZeroTolerant),
74            "1" => Ok(Self::OneTolerant),
75            _ => Err(format!(
76                "Invalid dialect '{s}'. Valid values are: 'n' (normative), '0' (zero-tolerant), '1' (one-tolerant)"
77            )),
78        }
79    }
80}
81
82impl fmt::Display for Dialect {
83    #[inline]
84    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85        match self {
86            Self::Normative => write!(f, "n"),
87            Self::ZeroTolerant => write!(f, "0"),
88            Self::OneTolerant => write!(f, "1"),
89        }
90    }
91}
92
93/// Compute the effective `min_count` based on dialect
94///
95/// This function applies the dialect-specific transformation to the declared
96/// `min_count` to produce the effective minimum count used for validation.
97///
98/// # Arguments
99///
100/// * `dialect` - The dialect to apply
101/// * `declared_min_count` - The `min_count` value declared in the copybook
102///
103/// # Returns
104///
105/// The effective `min_count` based on dialect:
106///
107/// | Dialect | Result |
108/// |---------|--------|
109/// | `Normative` | Returns `declared_min_count` unchanged |
110/// | `ZeroTolerant` | Returns `0` (ignores declared `min_count`) |
111/// | `OneTolerant` | Returns `max(1, declared_min_count)` |
112///
113/// # Examples
114///
115/// ```rust
116/// use copybook_dialect::{Dialect, effective_min_count};
117///
118/// // Normative: min_count is enforced as-is
119/// assert_eq!(effective_min_count(Dialect::Normative, 0), 0);
120/// assert_eq!(effective_min_count(Dialect::Normative, 1), 1);
121/// assert_eq!(effective_min_count(Dialect::Normative, 5), 5);
122///
123/// // ZeroTolerant: min_count is always 0
124/// assert_eq!(effective_min_count(Dialect::ZeroTolerant, 0), 0);
125/// assert_eq!(effective_min_count(Dialect::ZeroTolerant, 1), 0);
126/// assert_eq!(effective_min_count(Dialect::ZeroTolerant, 5), 0);
127///
128/// // OneTolerant: min_count is clamped to 1
129/// assert_eq!(effective_min_count(Dialect::OneTolerant, 0), 1);
130/// assert_eq!(effective_min_count(Dialect::OneTolerant, 1), 1);
131/// assert_eq!(effective_min_count(Dialect::OneTolerant, 5), 5);
132/// ```
133#[inline]
134#[must_use]
135pub fn effective_min_count(dialect: Dialect, declared_min_count: u32) -> u32 {
136    match dialect {
137        Dialect::Normative => declared_min_count,
138        Dialect::ZeroTolerant => 0,
139        Dialect::OneTolerant => declared_min_count.max(1),
140    }
141}
142
143#[cfg(test)]
144#[allow(clippy::unwrap_used)]
145mod tests {
146    use super::*;
147
148    #[test]
149    fn test_dialect_default() {
150        assert_eq!(Dialect::default(), Dialect::Normative);
151    }
152
153    #[test]
154    fn test_dialect_from_str() {
155        // Normative variants
156        assert_eq!(Dialect::from_str("n").unwrap(), Dialect::Normative);
157        assert_eq!(Dialect::from_str("N").unwrap(), Dialect::Normative);
158
159        // ZeroTolerant
160        assert_eq!(Dialect::from_str("0").unwrap(), Dialect::ZeroTolerant);
161
162        // OneTolerant
163        assert_eq!(Dialect::from_str("1").unwrap(), Dialect::OneTolerant);
164
165        // Invalid values
166        assert!(Dialect::from_str("x").is_err());
167        assert!(Dialect::from_str("normative").is_err());
168        assert!(Dialect::from_str("").is_err());
169    }
170
171    #[test]
172    fn test_dialect_display() {
173        assert_eq!(Dialect::Normative.to_string(), "n");
174        assert_eq!(Dialect::ZeroTolerant.to_string(), "0");
175        assert_eq!(Dialect::OneTolerant.to_string(), "1");
176    }
177
178    #[test]
179    fn test_effective_min_count_normative() {
180        // Normative: min_count is enforced as-is
181        assert_eq!(effective_min_count(Dialect::Normative, 0), 0);
182        assert_eq!(effective_min_count(Dialect::Normative, 1), 1);
183        assert_eq!(effective_min_count(Dialect::Normative, 5), 5);
184        assert_eq!(effective_min_count(Dialect::Normative, 100), 100);
185    }
186
187    #[test]
188    fn test_effective_min_count_zero_tolerant() {
189        // ZeroTolerant: min_count is always 0
190        assert_eq!(effective_min_count(Dialect::ZeroTolerant, 0), 0);
191        assert_eq!(effective_min_count(Dialect::ZeroTolerant, 1), 0);
192        assert_eq!(effective_min_count(Dialect::ZeroTolerant, 5), 0);
193        assert_eq!(effective_min_count(Dialect::ZeroTolerant, 100), 0);
194    }
195
196    #[test]
197    fn test_effective_min_count_one_tolerant() {
198        // OneTolerant: min_count is clamped to 1
199        assert_eq!(effective_min_count(Dialect::OneTolerant, 0), 1);
200        assert_eq!(effective_min_count(Dialect::OneTolerant, 1), 1);
201        assert_eq!(effective_min_count(Dialect::OneTolerant, 5), 5);
202        assert_eq!(effective_min_count(Dialect::OneTolerant, 100), 100);
203    }
204
205    #[test]
206    fn test_dialect_roundtrip() {
207        // Test that parsing and displaying are consistent
208        let dialects = [
209            Dialect::Normative,
210            Dialect::ZeroTolerant,
211            Dialect::OneTolerant,
212        ];
213        for dialect in dialects {
214            let s = dialect.to_string();
215            let parsed = Dialect::from_str(&s).unwrap();
216            assert_eq!(parsed, dialect);
217        }
218    }
219
220    // --- Additional coverage ---
221
222    #[test]
223    fn test_dialect_debug_format() {
224        assert!(format!("{:?}", Dialect::Normative).contains("Normative"));
225        assert!(format!("{:?}", Dialect::ZeroTolerant).contains("ZeroTolerant"));
226        assert!(format!("{:?}", Dialect::OneTolerant).contains("OneTolerant"));
227    }
228
229    #[test]
230    fn test_dialect_clone_preserves_value() {
231        let d = Dialect::ZeroTolerant;
232        let cloned = d;
233        assert_eq!(d, cloned);
234    }
235
236    #[test]
237    fn test_dialect_eq_different_variants() {
238        assert_ne!(Dialect::Normative, Dialect::ZeroTolerant);
239        assert_ne!(Dialect::ZeroTolerant, Dialect::OneTolerant);
240        assert_ne!(Dialect::OneTolerant, Dialect::Normative);
241    }
242
243    #[test]
244    fn test_dialect_hash_consistency() {
245        use std::collections::HashSet;
246        let mut set = HashSet::new();
247        set.insert(Dialect::Normative);
248        set.insert(Dialect::ZeroTolerant);
249        set.insert(Dialect::OneTolerant);
250        assert_eq!(set.len(), 3);
251        // Inserting duplicate should not increase size
252        set.insert(Dialect::Normative);
253        assert_eq!(set.len(), 3);
254    }
255
256    #[test]
257    fn test_dialect_serde_roundtrip_all_variants() {
258        let variants = [
259            Dialect::Normative,
260            Dialect::ZeroTolerant,
261            Dialect::OneTolerant,
262        ];
263        for dialect in variants {
264            let json = serde_json::to_string(&dialect).unwrap();
265            let deserialized: Dialect = serde_json::from_str(&json).unwrap();
266            assert_eq!(
267                dialect, deserialized,
268                "Serde roundtrip failed for {dialect}"
269            );
270        }
271    }
272
273    #[test]
274    fn test_dialect_from_str_with_whitespace() {
275        // FromStr trims input
276        assert_eq!(Dialect::from_str(" n ").unwrap(), Dialect::Normative);
277        assert_eq!(Dialect::from_str(" 0 ").unwrap(), Dialect::ZeroTolerant);
278        assert_eq!(Dialect::from_str(" 1 ").unwrap(), Dialect::OneTolerant);
279    }
280
281    #[test]
282    fn test_dialect_from_str_error_message_content() {
283        let err = Dialect::from_str("invalid").unwrap_err();
284        assert!(err.contains("Invalid dialect"));
285        assert!(err.contains("'invalid'"));
286        assert!(err.contains("normative"));
287        assert!(err.contains("zero-tolerant"));
288        assert!(err.contains("one-tolerant"));
289    }
290
291    #[test]
292    fn test_dialect_from_str_numeric_invalid() {
293        assert!(Dialect::from_str("2").is_err());
294        assert!(Dialect::from_str("3").is_err());
295        assert!(Dialect::from_str("-1").is_err());
296    }
297
298    #[test]
299    fn test_effective_min_count_normative_u32_max() {
300        assert_eq!(effective_min_count(Dialect::Normative, u32::MAX), u32::MAX);
301    }
302
303    #[test]
304    fn test_effective_min_count_zero_tolerant_u32_max() {
305        assert_eq!(effective_min_count(Dialect::ZeroTolerant, u32::MAX), 0);
306    }
307
308    #[test]
309    fn test_effective_min_count_one_tolerant_u32_max() {
310        assert_eq!(
311            effective_min_count(Dialect::OneTolerant, u32::MAX),
312            u32::MAX
313        );
314    }
315
316    #[test]
317    fn test_effective_min_count_one_tolerant_zero_clamps_to_one() {
318        // Key behavior: OneTolerant forces 0 → 1
319        assert_eq!(effective_min_count(Dialect::OneTolerant, 0), 1);
320    }
321
322    #[test]
323    fn test_effective_min_count_normative_zero_stays_zero() {
324        // Key behavior: Normative preserves 0 as-is
325        assert_eq!(effective_min_count(Dialect::Normative, 0), 0);
326    }
327
328    #[test]
329    fn test_effective_min_count_zero_tolerant_one_becomes_zero() {
330        // Key behavior: ZeroTolerant always returns 0
331        assert_eq!(effective_min_count(Dialect::ZeroTolerant, 1), 0);
332    }
333}