Skip to main content

sidereon_core/format/
mod.rs

1//! Sans-I/O parsing and formatting primitives for format readers.
2//!
3//! This module is always present and crate-internal. It carries shared helpers
4//! for parsers without depending on any GNSS-gated item.
5//!
6//! Invariant: a forgiving parse must push a typed [`Skip`] with a concrete
7//! reason. It must never silently `continue`, and it must never fabricate a
8//! default value for a malformed record. The API makes "skip with a reason" the
9//! ordinary path.
10
11#![allow(dead_code)]
12
13/// Character-boundary-safe fixed-width column helpers.
14pub(crate) mod columns;
15/// Format-faithful numeric formatting helpers.
16pub(crate) mod fmtnum;
17/// KVN tokenizer and key/value field map.
18pub(crate) mod kvn;
19/// Logical-record grouping helpers.
20pub(crate) mod records;
21/// Whitespace-token scanner helpers.
22pub(crate) mod tokens;
23
24use crate::validate::FieldError;
25
26/// Sans-I/O reader entry points for an in-memory format.
27pub(crate) trait FormatReader {
28    /// The value produced by this reader.
29    type Output;
30
31    /// Read a value from an in-memory UTF-8 string.
32    fn read_str(&self, input: &str) -> Parsed<Self::Output>;
33
34    /// Read a value from an in-memory byte slice.
35    fn read_bytes(&self, input: &[u8]) -> Parsed<Self::Output>;
36}
37
38/// Sans-I/O writer entry point for an in-memory format.
39pub(crate) trait FormatWriter {
40    /// The value consumed by this writer.
41    type Input;
42
43    /// Write a value into a newly allocated string.
44    fn write_string(&self, value: &Self::Input) -> String;
45}
46
47/// A parsed value plus diagnostics collected while reading it.
48#[derive(Debug, Clone)]
49pub struct Parsed<T> {
50    /// The successfully parsed value.
51    pub value: T,
52    /// Non-fatal diagnostics collected while producing the value.
53    pub diagnostics: Diagnostics,
54}
55
56impl<T> Parsed<T> {
57    /// Build a parsed value with caller-supplied diagnostics.
58    pub fn new(value: T, diagnostics: Diagnostics) -> Self {
59        Self { value, diagnostics }
60    }
61
62    /// Build a parsed value with no diagnostics.
63    pub fn clean(value: T) -> Self {
64        Self::new(value, Diagnostics::new())
65    }
66
67    /// Borrow the parsed value.
68    pub fn value(&self) -> &T {
69        &self.value
70    }
71
72    /// Borrow the diagnostics.
73    pub fn diagnostics(&self) -> &Diagnostics {
74        &self.diagnostics
75    }
76
77    /// Split this parsed result into its value and diagnostics.
78    pub fn into_parts(self) -> (T, Diagnostics) {
79        (self.value, self.diagnostics)
80    }
81}
82
83/// Non-fatal parser diagnostics.
84#[derive(Debug, Clone, Default, PartialEq)]
85pub struct Diagnostics {
86    /// Records skipped during a forgiving parse.
87    pub skips: Vec<Skip>,
88    /// Advisory warnings that did not prevent decoding.
89    pub warnings: Vec<Warning>,
90}
91
92impl Diagnostics {
93    /// Build an empty diagnostics set.
94    pub fn new() -> Self {
95        Self::default()
96    }
97
98    /// Return `true` when no skips or warnings are present.
99    pub fn is_empty(&self) -> bool {
100        self.skips.is_empty() && self.warnings.is_empty()
101    }
102
103    /// Add a skipped-record diagnostic.
104    pub fn push_skip(&mut self, skip: Skip) {
105        self.skips.push(skip);
106    }
107
108    /// Add an advisory warning.
109    pub fn push_warning(&mut self, warning: Warning) {
110        self.warnings.push(warning);
111    }
112}
113
114/// A skipped record and the reason it was skipped.
115#[derive(Debug, Clone, PartialEq)]
116pub struct Skip {
117    /// Where the skipped record came from.
118    pub at: RecordRef,
119    /// Why the record was skipped.
120    pub reason: SkipReason,
121}
122
123/// Typed reasons a forgiving parser may skip a record.
124#[derive(Debug, Clone, PartialEq)]
125pub enum SkipReason {
126    /// The record names a satellite that cannot be represented downstream.
127    UnrepresentableSatellite,
128    /// The record type is outside the reader's supported subset.
129    UnsupportedRecordType(&'static str),
130    /// A field failed typed validation.
131    MalformedField(FieldError),
132    /// The epoch lies outside the representable range for the target format.
133    OutOfRangeEpoch,
134    /// The record ended before all required fields were available.
135    Truncated,
136    /// The record names a unit outside the reader's supported set.
137    UnsupportedUnit(String),
138    /// A logical block is not modeled by this reader.
139    UnknownBlock(String),
140    /// The record is internally inconsistent.
141    InconsistentRecord(&'static str),
142}
143
144/// An advisory warning attached to a record.
145#[derive(Debug, Clone, PartialEq)]
146pub struct Warning {
147    /// Where the warning came from.
148    pub at: RecordRef,
149    /// The warning category.
150    pub kind: WarningKind,
151}
152
153/// Advisory warning categories.
154#[derive(Debug, Clone, Copy, PartialEq, Eq)]
155pub enum WarningKind {
156    /// A checksum did not match the record body.
157    Checksum,
158    /// A value was clamped to fit the target range.
159    Clamped,
160    /// A value lost precision or fidelity during conversion.
161    Degraded,
162    /// A declared count or mode did not match decoded records.
163    Mismatch,
164    /// Published validity intervals overlap.
165    Overlap,
166    /// A required-by-format metadata block was absent but recoverable.
167    MissingMetadata,
168}
169
170/// A reference to a record in an input stream.
171#[derive(Debug, Clone, PartialEq, Default)]
172pub struct RecordRef {
173    /// The one-based input line number, when known.
174    pub line: Option<usize>,
175    /// The zero- or one-based logical record index chosen by the caller.
176    pub record_index: Option<usize>,
177    /// A raw satellite token, when known.
178    pub satellite: Option<String>,
179}
180
181impl RecordRef {
182    /// Build a record reference at a one-based line number.
183    pub fn at_line(line: usize) -> Self {
184        Self {
185            line: Some(line),
186            ..Self::default()
187        }
188    }
189
190    /// Build a record reference at a logical record index.
191    pub fn at_record(record_index: usize) -> Self {
192        Self {
193            record_index: Some(record_index),
194            ..Self::default()
195        }
196    }
197
198    /// Attach a raw satellite token to this reference.
199    pub fn with_satellite(mut self, satellite: impl Into<String>) -> Self {
200        self.satellite = Some(satellite.into());
201        self
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    #[test]
210    fn parsed_round_trips_diagnostics() {
211        let mut diagnostics = Diagnostics::new();
212        assert!(diagnostics.is_empty());
213
214        let skip = Skip {
215            at: RecordRef::at_line(3).with_satellite("G05"),
216            reason: SkipReason::UnsupportedRecordType("DATA"),
217        };
218        diagnostics.push_skip(skip.clone());
219        assert!(!diagnostics.is_empty());
220
221        let warning = Warning {
222            at: RecordRef::at_record(0),
223            kind: WarningKind::Checksum,
224        };
225        diagnostics.push_warning(warning.clone());
226
227        let parsed = Parsed::new(42, diagnostics.clone());
228        assert_eq!(*parsed.value(), 42);
229        assert_eq!(parsed.diagnostics(), &diagnostics);
230
231        let (value, round_trip) = parsed.into_parts();
232        assert_eq!(value, 42);
233        assert_eq!(round_trip.skips, vec![skip]);
234        assert_eq!(round_trip.warnings, vec![warning]);
235    }
236
237    #[test]
238    fn clean_parsed_has_empty_diagnostics() {
239        let parsed = Parsed::clean("ok");
240        assert_eq!(parsed.value(), &"ok");
241        assert!(parsed.diagnostics().is_empty());
242    }
243
244    #[test]
245    fn malformed_field_wraps_field_error() {
246        let field_error = FieldError::FloatParse {
247            field: "epoch",
248            value: "bad".to_string(),
249        };
250        let reason = SkipReason::MalformedField(field_error.clone());
251        assert_eq!(reason, SkipReason::MalformedField(field_error));
252    }
253}