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}