Skip to main content

sidereon_core/ntrip/
sourcetable.rs

1use crate::{Error, Result};
2
3#[derive(Clone, Debug, PartialEq)]
4pub enum Field<T> {
5    Parsed(T),
6    Empty,
7    Raw(String),
8}
9
10impl<T> Field<T> {
11    pub fn value(&self) -> Option<&T> {
12        match self {
13            Field::Parsed(value) => Some(value),
14            Field::Empty | Field::Raw(_) => None,
15        }
16    }
17}
18
19#[derive(Clone, Debug, PartialEq, Eq)]
20pub enum StrAuth {
21    None,
22    Basic,
23    Digest,
24    Other(String),
25}
26
27#[derive(Clone, Debug, PartialEq)]
28pub struct Sourcetable {
29    pub records: Vec<SourcetableRecord>,
30}
31
32#[derive(Clone, Debug, PartialEq)]
33pub enum SourcetableRecord {
34    Str(StrRecord),
35    Cas(CasRecord),
36    Net(NetRecord),
37    Other(OtherRecord),
38}
39
40#[derive(Clone, Debug, PartialEq)]
41pub struct StrRecord {
42    pub mountpoint: String,
43    pub identifier: String,
44    pub format: String,
45    pub format_details: String,
46    pub carrier: Field<u8>,
47    pub nav_system: String,
48    pub network: String,
49    pub country: String,
50    pub lat_deg: Field<f64>,
51    pub lon_deg: Field<f64>,
52    pub nmea_required: Field<bool>,
53    pub network_solution: Field<bool>,
54    pub generator: String,
55    pub compression: String,
56    pub authentication: StrAuth,
57    pub fee: Field<bool>,
58    pub bitrate: Field<u32>,
59    pub misc: String,
60}
61
62#[derive(Clone, Debug, PartialEq)]
63pub struct CasRecord {
64    pub host: String,
65    pub port: Field<u16>,
66    pub identifier: String,
67    pub operator: String,
68    pub nmea_required: Field<bool>,
69    pub country: String,
70    pub lat_deg: Field<f64>,
71    pub lon_deg: Field<f64>,
72    pub fallback_host: String,
73    pub fallback_port: Field<u16>,
74    pub misc: String,
75}
76
77#[derive(Clone, Debug, PartialEq)]
78pub struct NetRecord {
79    pub identifier: String,
80    pub operator: String,
81    pub authentication: StrAuth,
82    pub fee: Field<bool>,
83    pub web_net: String,
84    pub web_str: String,
85    pub web_reg: String,
86    pub misc: String,
87}
88
89#[derive(Clone, Debug, PartialEq, Eq)]
90pub struct OtherRecord {
91    pub type_tag: String,
92    pub fields: Vec<String>,
93}
94
95pub fn parse_sourcetable(text: &str) -> Result<Sourcetable> {
96    let mut records = Vec::new();
97    for raw_line in text.lines() {
98        let line = raw_line.trim_end_matches('\r');
99        if line.is_empty() {
100            continue;
101        }
102        let fields: Vec<&str> = line.split(';').collect();
103        let tag = fields[0].trim();
104        if tag.eq_ignore_ascii_case("ENDSOURCETABLE") {
105            break;
106        }
107        let record = if tag.eq_ignore_ascii_case("STR") {
108            SourcetableRecord::Str(parse_str(&fields))
109        } else if tag.eq_ignore_ascii_case("CAS") {
110            SourcetableRecord::Cas(parse_cas(&fields))
111        } else if tag.eq_ignore_ascii_case("NET") {
112            SourcetableRecord::Net(parse_net(&fields))
113        } else {
114            SourcetableRecord::Other(OtherRecord {
115                type_tag: fields[0].to_string(),
116                fields: fields.iter().skip(1).map(|s| (*s).to_string()).collect(),
117            })
118        };
119        records.push(record);
120    }
121    Ok(Sourcetable { records })
122}
123
124impl Sourcetable {
125    pub fn to_text(&self) -> Result<String> {
126        let mut out = String::new();
127        for record in &self.records {
128            out.push_str(&record.to_line()?);
129            out.push_str("\r\n");
130        }
131        out.push_str("ENDSOURCETABLE\r\n");
132        Ok(out)
133    }
134
135    pub fn streams(&self) -> impl Iterator<Item = &StrRecord> {
136        self.records.iter().filter_map(|record| match record {
137            SourcetableRecord::Str(record) => Some(record),
138            _ => None,
139        })
140    }
141}
142
143impl SourcetableRecord {
144    fn to_line(&self) -> Result<String> {
145        match self {
146            SourcetableRecord::Str(record) => record.to_line(),
147            SourcetableRecord::Cas(record) => record.to_line(),
148            SourcetableRecord::Net(record) => record.to_line(),
149            SourcetableRecord::Other(record) => {
150                let mut fields = vec![ordinary_text(&record.type_tag, "other type tag")?];
151                for field in &record.fields {
152                    fields.push(ordinary_text(field, "other field")?);
153                }
154                Ok(fields.join(";"))
155            }
156        }
157    }
158}
159
160impl StrRecord {
161    fn to_line(&self) -> Result<String> {
162        Ok([
163            "STR".to_string(),
164            ordinary_text(&self.mountpoint, "STR mountpoint")?,
165            ordinary_text(&self.identifier, "STR identifier")?,
166            ordinary_text(&self.format, "STR format")?,
167            ordinary_text(&self.format_details, "STR format details")?,
168            field_to_string(&self.carrier, "STR carrier")?,
169            ordinary_text(&self.nav_system, "STR nav system")?,
170            ordinary_text(&self.network, "STR network")?,
171            ordinary_text(&self.country, "STR country")?,
172            field_to_string(&self.lat_deg, "STR latitude")?,
173            field_to_string(&self.lon_deg, "STR longitude")?,
174            bool01_to_string(&self.nmea_required, "STR NMEA required")?,
175            bool01_to_string(&self.network_solution, "STR network solution")?,
176            ordinary_text(&self.generator, "STR generator")?,
177            ordinary_text(&self.compression, "STR compression")?,
178            auth_to_string(&self.authentication, "STR authentication")?,
179            boolyn_to_string(&self.fee, "STR fee")?,
180            field_to_string(&self.bitrate, "STR bitrate")?,
181            tail_text(&self.misc, "STR misc")?,
182        ]
183        .join(";"))
184    }
185}
186
187impl CasRecord {
188    fn to_line(&self) -> Result<String> {
189        Ok([
190            "CAS".to_string(),
191            ordinary_text(&self.host, "CAS host")?,
192            field_to_string(&self.port, "CAS port")?,
193            ordinary_text(&self.identifier, "CAS identifier")?,
194            ordinary_text(&self.operator, "CAS operator")?,
195            bool01_to_string(&self.nmea_required, "CAS NMEA required")?,
196            ordinary_text(&self.country, "CAS country")?,
197            field_to_string(&self.lat_deg, "CAS latitude")?,
198            field_to_string(&self.lon_deg, "CAS longitude")?,
199            ordinary_text(&self.fallback_host, "CAS fallback host")?,
200            field_to_string(&self.fallback_port, "CAS fallback port")?,
201            tail_text(&self.misc, "CAS misc")?,
202        ]
203        .join(";"))
204    }
205}
206
207impl NetRecord {
208    fn to_line(&self) -> Result<String> {
209        Ok([
210            "NET".to_string(),
211            ordinary_text(&self.identifier, "NET identifier")?,
212            ordinary_text(&self.operator, "NET operator")?,
213            auth_to_string(&self.authentication, "NET authentication")?,
214            boolyn_to_string(&self.fee, "NET fee")?,
215            ordinary_text(&self.web_net, "NET web net")?,
216            ordinary_text(&self.web_str, "NET web str")?,
217            ordinary_text(&self.web_reg, "NET web reg")?,
218            tail_text(&self.misc, "NET misc")?,
219        ]
220        .join(";"))
221    }
222}
223
224fn parse_str(fields: &[&str]) -> StrRecord {
225    StrRecord {
226        mountpoint: get(fields, 1).to_string(),
227        identifier: get(fields, 2).to_string(),
228        format: get(fields, 3).to_string(),
229        format_details: get(fields, 4).to_string(),
230        carrier: parse_field(get(fields, 5)),
231        nav_system: get(fields, 6).to_string(),
232        network: get(fields, 7).to_string(),
233        country: get(fields, 8).to_string(),
234        lat_deg: parse_finite_f64_field(get(fields, 9)),
235        lon_deg: parse_finite_f64_field(get(fields, 10)),
236        nmea_required: parse_bool01(get(fields, 11)),
237        network_solution: parse_bool01(get(fields, 12)),
238        generator: get(fields, 13).to_string(),
239        compression: get(fields, 14).to_string(),
240        authentication: parse_auth(get(fields, 15)),
241        fee: parse_boolyn(get(fields, 16)),
242        bitrate: parse_field(get(fields, 17)),
243        misc: join_tail(fields, 18),
244    }
245}
246
247fn parse_cas(fields: &[&str]) -> CasRecord {
248    CasRecord {
249        host: get(fields, 1).to_string(),
250        port: parse_field(get(fields, 2)),
251        identifier: get(fields, 3).to_string(),
252        operator: get(fields, 4).to_string(),
253        nmea_required: parse_bool01(get(fields, 5)),
254        country: get(fields, 6).to_string(),
255        lat_deg: parse_finite_f64_field(get(fields, 7)),
256        lon_deg: parse_finite_f64_field(get(fields, 8)),
257        fallback_host: get(fields, 9).to_string(),
258        fallback_port: parse_field(get(fields, 10)),
259        misc: join_tail(fields, 11),
260    }
261}
262
263fn parse_net(fields: &[&str]) -> NetRecord {
264    NetRecord {
265        identifier: get(fields, 1).to_string(),
266        operator: get(fields, 2).to_string(),
267        authentication: parse_auth(get(fields, 3)),
268        fee: parse_boolyn(get(fields, 4)),
269        web_net: get(fields, 5).to_string(),
270        web_str: get(fields, 6).to_string(),
271        web_reg: get(fields, 7).to_string(),
272        misc: join_tail(fields, 8),
273    }
274}
275
276fn get<'a>(fields: &'a [&str], index: usize) -> &'a str {
277    fields.get(index).copied().unwrap_or("")
278}
279
280fn join_tail(fields: &[&str], index: usize) -> String {
281    if index >= fields.len() {
282        String::new()
283    } else {
284        fields[index..].join(";")
285    }
286}
287
288fn parse_field<T>(value: &str) -> Field<T>
289where
290    T: core::str::FromStr,
291{
292    if value.is_empty() {
293        Field::Empty
294    } else {
295        value
296            .parse()
297            .map(Field::Parsed)
298            .unwrap_or_else(|_| Field::Raw(value.to_string()))
299    }
300}
301
302fn parse_finite_f64_field(value: &str) -> Field<f64> {
303    if value.is_empty() {
304        Field::Empty
305    } else {
306        match value.parse::<f64>() {
307            Ok(parsed) if parsed.is_finite() => Field::Parsed(parsed),
308            _ => Field::Raw(value.to_string()),
309        }
310    }
311}
312
313fn parse_bool01(value: &str) -> Field<bool> {
314    match value {
315        "" => Field::Empty,
316        "0" => Field::Parsed(false),
317        "1" => Field::Parsed(true),
318        _ => Field::Raw(value.to_string()),
319    }
320}
321
322fn parse_boolyn(value: &str) -> Field<bool> {
323    match value {
324        "" => Field::Empty,
325        "N" => Field::Parsed(false),
326        "Y" => Field::Parsed(true),
327        _ => Field::Raw(value.to_string()),
328    }
329}
330
331fn parse_auth(value: &str) -> StrAuth {
332    match value {
333        "N" => StrAuth::None,
334        "B" => StrAuth::Basic,
335        "D" => StrAuth::Digest,
336        other => StrAuth::Other(other.to_string()),
337    }
338}
339
340fn field_to_string<T: ToString>(field: &Field<T>, name: &str) -> Result<String> {
341    match field {
342        Field::Parsed(value) => ordinary_text(&value.to_string(), name),
343        Field::Empty => Ok(String::new()),
344        Field::Raw(value) => ordinary_text(value, name),
345    }
346}
347
348fn bool01_to_string(field: &Field<bool>, name: &str) -> Result<String> {
349    match field {
350        Field::Parsed(false) => Ok("0".into()),
351        Field::Parsed(true) => Ok("1".into()),
352        Field::Empty => Ok(String::new()),
353        Field::Raw(value) => ordinary_text(value, name),
354    }
355}
356
357fn boolyn_to_string(field: &Field<bool>, name: &str) -> Result<String> {
358    match field {
359        Field::Parsed(false) => Ok("N".into()),
360        Field::Parsed(true) => Ok("Y".into()),
361        Field::Empty => Ok(String::new()),
362        Field::Raw(value) => ordinary_text(value, name),
363    }
364}
365
366fn auth_to_string(auth: &StrAuth, name: &str) -> Result<String> {
367    match auth {
368        StrAuth::None => Ok("N".into()),
369        StrAuth::Basic => Ok("B".into()),
370        StrAuth::Digest => Ok("D".into()),
371        StrAuth::Other(value) => ordinary_text(value, name),
372    }
373}
374
375fn ordinary_text(value: &str, name: &str) -> Result<String> {
376    checked_text(value, name, false)
377}
378
379fn tail_text(value: &str, name: &str) -> Result<String> {
380    checked_text(value, name, true)
381}
382
383fn checked_text(value: &str, name: &str, allow_semicolon: bool) -> Result<String> {
384    if value.contains('\r') || value.contains('\n') {
385        return Err(Error::InvalidInput(format!(
386            "sourcetable {name} contains a line break"
387        )));
388    }
389    if !allow_semicolon && value.contains(';') {
390        return Err(Error::InvalidInput(format!(
391            "sourcetable {name} contains a semicolon"
392        )));
393    }
394    Ok(value.to_string())
395}