ngdp_bpsv/
schema.rs

1//! BPSV schema definitions for field structure
2
3use crate::error::{Error, Result};
4use crate::field_type::BpsvFieldType;
5use std::collections::HashMap;
6
7/// Represents a single field in a BPSV schema
8#[derive(Debug, Clone, PartialEq)]
9#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
10pub struct BpsvField {
11    /// Field name (case-sensitive as specified in header)
12    pub name: String,
13    /// Field type and length specification
14    pub field_type: BpsvFieldType,
15    /// Zero-based index in the schema
16    pub index: usize,
17}
18
19impl BpsvField {
20    /// Create a new field
21    pub fn new(name: String, field_type: BpsvFieldType, index: usize) -> Self {
22        Self {
23            name,
24            field_type,
25            index,
26        }
27    }
28
29    /// Validate a value for this field
30    pub fn validate_value(&self, value: &str) -> Result<String> {
31        self.field_type.validate_value(value).map_err(|mut err| {
32            if let Error::InvalidValue { field, .. } = &mut err {
33                *field = self.name.clone();
34            }
35            err
36        })
37    }
38}
39
40/// Represents the complete schema of a BPSV document
41#[derive(Debug, Clone, PartialEq)]
42#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
43pub struct BpsvSchema {
44    /// All fields in order
45    fields: Vec<BpsvField>,
46    /// Map from field name to field index for fast lookup
47    field_map: HashMap<String, usize>,
48}
49
50impl BpsvSchema {
51    /// Create a new empty schema
52    pub fn new() -> Self {
53        Self {
54            fields: Vec::new(),
55            field_map: HashMap::new(),
56        }
57    }
58
59    /// Parse schema from a header line
60    ///
61    /// # Examples
62    ///
63    /// ```
64    /// use ngdp_bpsv::BpsvSchema;
65    ///
66    /// let header = "Region!STRING:0|BuildConfig!HEX:16|BuildId!DEC:4";
67    /// let schema = BpsvSchema::parse_header(header)?;
68    ///
69    /// assert_eq!(schema.field_count(), 3);
70    /// assert!(schema.has_field("Region"));
71    /// assert!(schema.has_field("BuildConfig"));
72    /// assert!(schema.has_field("BuildId"));
73    /// # Ok::<(), ngdp_bpsv::Error>(())
74    /// ```
75    pub fn parse_header(header_line: &str) -> Result<Self> {
76        let mut schema = Self::new();
77
78        for field_spec in header_line.split('|') {
79            let parts: Vec<&str> = field_spec.split('!').collect();
80            if parts.len() != 2 {
81                return Err(Error::InvalidHeader {
82                    reason: format!("Invalid field specification: {field_spec}"),
83                });
84            }
85
86            let field_name = parts[0].to_string();
87            let type_spec = parts[1];
88
89            // Check for duplicate field names
90            if schema.field_map.contains_key(&field_name) {
91                return Err(Error::DuplicateField { field: field_name });
92            }
93
94            let field_type = BpsvFieldType::parse(type_spec)?;
95            schema.add_field(field_name, field_type)?;
96        }
97
98        if schema.fields.is_empty() {
99            return Err(Error::InvalidHeader {
100                reason: "No fields found in header".to_string(),
101            });
102        }
103
104        Ok(schema)
105    }
106
107    /// Add a field to the schema
108    pub fn add_field(&mut self, name: String, field_type: BpsvFieldType) -> Result<()> {
109        if self.field_map.contains_key(&name) {
110            return Err(Error::DuplicateField { field: name });
111        }
112
113        let index = self.fields.len();
114        let field = BpsvField::new(name.clone(), field_type, index);
115
116        self.fields.push(field);
117        self.field_map.insert(name, index);
118
119        Ok(())
120    }
121
122    /// Get the number of fields
123    pub fn field_count(&self) -> usize {
124        self.fields.len()
125    }
126
127    /// Check if a field exists
128    pub fn has_field(&self, name: &str) -> bool {
129        self.field_map.contains_key(name)
130    }
131
132    /// Get a field by name
133    pub fn get_field(&self, name: &str) -> Option<&BpsvField> {
134        self.field_map.get(name).map(|&index| &self.fields[index])
135    }
136
137    /// Get a field by index
138    pub fn get_field_by_index(&self, index: usize) -> Option<&BpsvField> {
139        self.fields.get(index)
140    }
141
142    /// Get all fields
143    pub fn fields(&self) -> &[BpsvField] {
144        &self.fields
145    }
146
147    /// Get field names in order
148    pub fn field_names(&self) -> Vec<&str> {
149        self.fields.iter().map(|f| f.name.as_str()).collect()
150    }
151
152    /// Validate a row of values against this schema
153    pub fn validate_row(&self, values: &[String]) -> Result<Vec<String>> {
154        if values.len() != self.fields.len() {
155            return Err(Error::SchemaMismatch {
156                expected: self.fields.len(),
157                actual: values.len(),
158            });
159        }
160
161        let mut validated = Vec::new();
162        for (field, value) in self.fields.iter().zip(values.iter()) {
163            let normalized = field.validate_value(value)?;
164            validated.push(normalized);
165        }
166
167        Ok(validated)
168    }
169
170    /// Validate a row of string references against this schema (zero-copy)
171    pub fn validate_row_refs<'a>(&self, values: &[&'a str]) -> Result<Vec<&'a str>> {
172        if values.len() != self.fields.len() {
173            return Err(Error::SchemaMismatch {
174                expected: self.fields.len(),
175                actual: values.len(),
176            });
177        }
178
179        // For zero-copy validation, we just verify the format is correct
180        // without allocating new strings
181        for (field, value) in self.fields.iter().zip(values.iter()) {
182            // Validate without normalization to avoid allocation
183            field.field_type.validate_value(value).map_err(|mut err| {
184                if let Error::InvalidValue {
185                    field: field_name, ..
186                } = &mut err
187                {
188                    *field_name = field.name.clone();
189                }
190                err
191            })?;
192        }
193
194        Ok(values.to_vec())
195    }
196
197    /// Generate the header line for this schema
198    pub fn to_header_line(&self) -> String {
199        self.fields
200            .iter()
201            .map(|field| format!("{}!{}", field.name, field.field_type))
202            .collect::<Vec<_>>()
203            .join("|")
204    }
205}
206
207impl Default for BpsvSchema {
208    fn default() -> Self {
209        Self::new()
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216    use crate::BpsvFieldType;
217
218    #[test]
219    fn test_parse_header() {
220        let header = "Region!STRING:0|BuildConfig!HEX:16|BuildId!DEC:4";
221        let schema = BpsvSchema::parse_header(header).unwrap();
222
223        assert_eq!(schema.field_count(), 3);
224        assert!(schema.has_field("Region"));
225        assert!(schema.has_field("BuildConfig"));
226        assert!(schema.has_field("BuildId"));
227
228        let region_field = schema.get_field("Region").unwrap();
229        assert_eq!(region_field.field_type, BpsvFieldType::String(0));
230        assert_eq!(region_field.index, 0);
231
232        let build_field = schema.get_field("BuildConfig").unwrap();
233        assert_eq!(build_field.field_type, BpsvFieldType::Hex(16));
234        assert_eq!(build_field.index, 1);
235    }
236
237    #[test]
238    fn test_parse_header_case_insensitive() {
239        let header = "Region!string:0|BuildConfig!hex:16|BuildId!dec:4";
240        let schema = BpsvSchema::parse_header(header).unwrap();
241
242        assert_eq!(schema.field_count(), 3);
243
244        let region_field = schema.get_field("Region").unwrap();
245        assert_eq!(region_field.field_type, BpsvFieldType::String(0));
246    }
247
248    #[test]
249    fn test_duplicate_field_error() {
250        let header = "Region!STRING:0|Region!HEX:16";
251        let result = BpsvSchema::parse_header(header);
252        assert!(matches!(result, Err(Error::DuplicateField { .. })));
253    }
254
255    #[test]
256    fn test_invalid_header_format() {
257        let header = "Region|BuildConfig!HEX:16"; // Missing type for Region
258        let result = BpsvSchema::parse_header(header);
259        assert!(matches!(result, Err(Error::InvalidHeader { .. })));
260    }
261
262    #[test]
263    fn test_validate_row() {
264        let header = "Region!STRING:0|BuildConfig!HEX:16|BuildId!DEC:4";
265        let schema = BpsvSchema::parse_header(header).unwrap();
266
267        let valid_row = vec![
268            "us".to_string(),
269            "abcd1234abcd1234abcd1234abcd1234".to_string(),
270            "1234".to_string(),
271        ];
272        let result = schema.validate_row(&valid_row);
273        assert!(result.is_ok());
274
275        let invalid_row = vec![
276            "us".to_string(),
277            "invalid_hex".to_string(),
278            "1234".to_string(),
279        ];
280        let result = schema.validate_row(&invalid_row);
281        assert!(result.is_err());
282
283        let wrong_length = vec!["us".to_string()]; // Too few fields
284        let result = schema.validate_row(&wrong_length);
285        assert!(matches!(result, Err(Error::SchemaMismatch { .. })));
286    }
287
288    #[test]
289    fn test_to_header_line() {
290        let mut schema = BpsvSchema::new();
291        schema
292            .add_field("Region".to_string(), BpsvFieldType::String(0))
293            .unwrap();
294        schema
295            .add_field("BuildConfig".to_string(), BpsvFieldType::Hex(16))
296            .unwrap();
297        schema
298            .add_field("BuildId".to_string(), BpsvFieldType::Decimal(4))
299            .unwrap();
300
301        let header_line = schema.to_header_line();
302        assert_eq!(
303            header_line,
304            "Region!STRING:0|BuildConfig!HEX:16|BuildId!DEC:4"
305        );
306    }
307
308    #[test]
309    fn test_field_access() {
310        let header = "Region!STRING:0|BuildConfig!HEX:16|BuildId!DEC:4";
311        let schema = BpsvSchema::parse_header(header).unwrap();
312
313        assert_eq!(
314            schema.field_names(),
315            vec!["Region", "BuildConfig", "BuildId"]
316        );
317
318        assert_eq!(schema.get_field_by_index(0).unwrap().name, "Region");
319        assert_eq!(schema.get_field_by_index(1).unwrap().name, "BuildConfig");
320        assert_eq!(schema.get_field_by_index(2).unwrap().name, "BuildId");
321        assert!(schema.get_field_by_index(3).is_none());
322    }
323}