1pub mod gpx;
18pub mod nmea;
19pub mod table;
20
21use std::fs::File;
22use std::io::{BufReader, Read};
23use std::path::{Path, PathBuf};
24use std::sync::atomic::{AtomicU64, Ordering};
25
26use color_eyre::Result;
27use color_eyre::eyre::eyre;
28use polars::prelude::*;
29
30use crate::download::TempDownload;
31use crate::notes::Note;
32use crate::numfmt::group_chrome;
33use crate::segments::{Converted, Segments};
34use crate::unfinished::Writer;
35use crate::{CompressionFormat, FileFormat, OpenOptions};
36
37pub(crate) const NMEA: crate::readers::Reader = crate::readers::Reader {
39 convert: Some(|input| {
41 let (converted, detail) = convert(
42 input.files,
43 input.display,
44 input.format,
45 input.options,
46 input.writer,
47 input.read,
48 )?;
49 Ok((converted, Some(std::sync::Arc::new(detail))))
50 }),
51 scan: crate::readers::read_into,
52 signatures: &[crate::readers::Signature {
53 says: |head, _| nmea::looks_like(head),
54 kind: crate::readers::Kind::Text,
55 trusted: crate::readers::Trusted {
56 listing: false,
57 tables: true,
58 ..crate::readers::EVERYWHERE
59 },
60 }],
61 tables: Some(|_| Ok(nmea_tables())),
64 ..crate::readers::BASE
65};
66
67fn nmea_tables() -> Vec<crate::sqlite::Table> {
69 nmea::Table::ALL
70 .into_iter()
71 .map(|t| crate::sqlite::Table {
72 name: t.name().to_string(),
73 kind: "table".to_string(),
74 internal: false,
75 columns: t
76 .columns()
77 .into_iter()
78 .map(|(name, _)| (name.to_string(), String::new()))
79 .collect(),
80 })
81 .collect()
82}
83
84pub(crate) const GPX: crate::readers::Reader = crate::readers::Reader {
86 convert: Some(|input| {
88 let (converted, detail) = convert(
89 input.files,
90 input.display,
91 input.format,
92 input.options,
93 input.writer,
94 input.read,
95 )?;
96 Ok((converted, Some(std::sync::Arc::new(detail))))
97 }),
98 scan: crate::readers::read_into,
99 signatures: &[crate::readers::Signature {
100 says: |head, _| gpx::looks_like(head),
101 kind: crate::readers::Kind::Text,
102 trusted: crate::readers::Trusted {
103 listing: false,
104 ..crate::readers::EVERYWHERE
105 },
106 }],
107 ..crate::readers::BASE
108};
109
110const CHUNK: usize = 1 << 16;
112
113struct Counted<'a, R> {
115 inner: R,
116 read: &'a AtomicU64,
117}
118
119impl<R: Read> Read for Counted<'_, R> {
120 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
121 let n = self.inner.read(buf)?;
122 self.read.fetch_add(n as u64, Ordering::Relaxed);
123 Ok(n)
124 }
125}
126
127pub(crate) fn open_reader<'a>(
130 file: &Path,
131 options: &OpenOptions,
132 read: &'a AtomicU64,
133) -> Result<Box<dyn Read + 'a>> {
134 let f = BufReader::new(Counted {
135 inner: File::open(file)?,
136 read,
137 });
138 Ok(
139 match options
140 .compression
141 .or_else(|| CompressionFormat::from_extension(file))
142 {
143 None => Box::new(f),
144 Some(CompressionFormat::Gzip) => Box::new(flate2::read::MultiGzDecoder::new(f)),
145 Some(CompressionFormat::Zstd) => Box::new(zstd::Decoder::new(f)?),
146 Some(CompressionFormat::Bzip2) => Box::new(bzip2::read::BzDecoder::new(f)),
147 Some(CompressionFormat::Xz) => Box::new(xz2::read::XzDecoder::new(f)),
148 },
149 )
150}
151
152pub(crate) fn convert(
158 files: &[PathBuf],
159 display: &Path,
160 format: FileFormat,
161 options: &OpenOptions,
162 writer: &Writer,
163 read: &AtomicU64,
164) -> Result<(Converted, crate::text_formats::Detail)> {
165 let [file] = files else {
166 return convert_many(files, format, options, writer, read);
167 };
168 let one = convert_one(file, display, format, options, writer, read)?;
169 let (notes, other_tables) = one.stats.notes(None, options)?;
170 let detail = one.stats.detail(None, &one.extent, options)?;
171 Ok((
172 Converted {
173 lf: one.lf,
174 files: one.files,
175 notes,
176 other_tables,
177 },
178 detail,
179 ))
180}
181
182fn convert_many(
186 files: &[PathBuf],
187 format: FileFormat,
188 options: &OpenOptions,
189 writer: &Writer,
190 read: &AtomicU64,
191) -> Result<(Converted, crate::text_formats::Detail)> {
192 let mut frames = Vec::with_capacity(files.len());
193 let mut written = Vec::new();
194 let mut stats: Option<Stats> = None;
195 let mut extent = Extent::default();
196 for file in files {
197 let one = convert_one(file, file, format, options, writer, read)
198 .map_err(|e| crate::error_display::in_file(file, e))?;
199 let name = file
200 .file_name()
201 .map(|n| n.to_string_lossy().into_owned())
202 .unwrap_or_else(|| file.display().to_string());
203 frames.push(one.lf.select([lit(name).alias("file"), all().as_expr()]));
204 written.extend(one.files);
205 extent.merge(&one.extent);
206 match &mut stats {
207 None => stats = Some(one.stats),
208 Some(stats) => stats.absorb(one.stats),
209 }
210 }
211 let stats = stats.ok_or_else(|| eyre!("No GPS logs to read."))?;
212 let lf = concat(
213 &frames,
214 UnionArgs {
215 diagonal: true,
216 to_supertypes: true,
217 ..Default::default()
218 },
219 )?;
220 let (notes, other_tables) = stats.notes(Some(files.len()), options)?;
221 let detail = stats.detail(Some(files.len()), &extent, options)?;
222 Ok((
223 Converted {
224 lf,
225 files: written,
226 notes,
227 other_tables,
228 },
229 detail,
230 ))
231}
232
233enum Stats {
235 Gpx {
236 stats: gpx::Stats,
237 truncated: usize,
239 },
240 Nmea {
241 stats: nmea::Stats,
242 undated: usize,
244 },
245}
246
247impl Stats {
248 fn absorb(&mut self, other: Stats) {
250 match (self, other) {
251 (
252 Stats::Gpx { stats, truncated },
253 Stats::Gpx {
254 stats: more,
255 truncated: also,
256 },
257 ) => {
258 stats.points += more.points;
259 stats.tracks += more.tracks;
260 stats.routes += more.routes;
261 stats.waypoints += more.waypoints;
262 stats.fields_dropped += more.fields_dropped;
263 stats.bad_times += more.bad_times;
264 stats.truncated |= more.truncated;
265 *truncated += also;
266 }
267 (
268 Stats::Nmea { stats, undated },
269 Stats::Nmea {
270 stats: more,
271 undated: also,
272 },
273 ) => {
274 stats.absorb(&more);
275 *undated += also;
276 }
277 _ => {}
279 }
280 }
281
282 fn notes(&self, of: Option<usize>, options: &OpenOptions) -> Result<(Vec<Note>, Vec<String>)> {
285 Ok(match self {
286 Stats::Gpx { stats, truncated } => (gpx_notes(stats, of, *truncated), Vec::new()),
287 Stats::Nmea { stats, undated } => {
288 let table = nmea_table(options)?;
289 (
290 nmea_notes(stats, of, *undated),
291 nmea_other_tables(stats, table),
292 )
293 }
294 })
295 }
296}
297
298impl Stats {
299 fn detail(
302 &self,
303 of: Option<usize>,
304 extent: &Extent,
305 options: &OpenOptions,
306 ) -> Result<crate::text_formats::Detail> {
307 use crate::model_files::MetaValue;
308 let middot = crate::glyphs::get().middot;
309 let mut lines = Vec::new();
310 let (list_title, list) = match self {
311 Stats::Gpx { stats, .. } => {
312 lines.push(
313 [
314 count(stats.points, "point", "points"),
315 count(stats.tracks, "track", "tracks"),
316 count(stats.routes, "route", "routes"),
317 count(stats.waypoints, "waypoint", "waypoints"),
318 ]
319 .join(&format!(" {middot} ")),
320 );
321 ("", Vec::new())
322 }
323 Stats::Nmea { stats, .. } => {
324 let table = nmea_table(options)?;
325 lines.push(format!(
326 "{} of {} {middot} {} of {}",
327 count(stats.rows, "row", "rows"),
328 table.name(),
329 count(stats.sentences, "sentence", "sentences"),
330 count(stats.lines, "line", "lines"),
331 ));
332 let mut list: Vec<(String, MetaValue)> = stats
333 .types
334 .iter()
335 .map(|(t, n)| (t.clone(), MetaValue::Text(group_chrome(*n as usize))))
336 .collect();
337 if stats.other_types > 0 {
338 list.push((
339 "other".to_string(),
340 MetaValue::Text(group_chrome(stats.other_types as usize)),
341 ));
342 }
343 ("Sentences", list)
344 }
345 };
346 if let Some(n) = of {
347 lines.push(count(n as u64, "file", "files"));
348 }
349 lines.extend(extent.lines());
350 Ok(crate::text_formats::Detail {
351 tab: crate::text_formats::tab(FileFormat::Nmea),
352 lines,
353 list_title,
354 list,
355 ..Default::default()
356 })
357 }
358}
359
360#[derive(Debug, Clone, Default, PartialEq)]
363struct Extent {
364 time: Option<(i64, i64)>,
366 lat: Option<(f64, f64)>,
367 lon: Option<(f64, f64)>,
368}
369
370impl Extent {
371 fn absorb(&mut self, df: &DataFrame) {
372 let time = df
373 .column("time")
374 .ok()
375 .and_then(|c| c.as_materialized_series().datetime().ok().cloned())
376 .and_then(|t| Some((t.physical().min()?, t.physical().max()?)));
377 let span = |name: &str| {
378 let c = df.column(name).ok()?;
379 let v = c.as_materialized_series().f64().ok()?;
380 Some((v.min()?, v.max()?))
381 };
382 self.merge(&Extent {
383 time,
384 lat: span("lat"),
385 lon: span("lon"),
386 });
387 }
388
389 fn merge(&mut self, other: &Extent) {
390 fn wider<T: PartialOrd + Copy>(a: Option<(T, T)>, b: Option<(T, T)>) -> Option<(T, T)> {
391 match (a, b) {
392 (Some((a0, a1)), Some((b0, b1))) => {
393 Some((if b0 < a0 { b0 } else { a0 }, if b1 > a1 { b1 } else { a1 }))
394 }
395 (a, b) => a.or(b),
396 }
397 }
398 self.time = wider(self.time, other.time);
399 self.lat = wider(self.lat, other.lat);
400 self.lon = wider(self.lon, other.lon);
401 }
402
403 fn lines(&self) -> Vec<String> {
406 let mut lines = Vec::new();
407 if let Some((first, last)) = self.time {
408 let at = |ms: i64| chrono::DateTime::from_timestamp_millis(ms);
409 if let (Some(a), Some(b)) = (at(first), at(last)) {
410 let end = if a.date_naive() == b.date_naive() {
411 b.format("%H:%M:%S").to_string()
412 } else {
413 b.format("%Y-%m-%d %H:%M:%S").to_string()
414 };
415 lines.push(format!(
416 "Time: {} to {end} UTC ({})",
417 a.format("%Y-%m-%d %H:%M:%S"),
418 crate::widgets::info::clock((last - first) as f64 / 1000.0)
419 ));
420 }
421 }
422 if let (Some((lat0, lat1)), Some((lon0, lon1))) = (self.lat, self.lon) {
423 lines.push(format!(
424 "Bounds: latitude {lat0:.5} to {lat1:.5}, longitude {lon0:.5} to {lon1:.5}"
425 ));
426 }
427 lines
428 }
429}
430
431fn nmea_table(options: &OpenOptions) -> Result<nmea::Table> {
433 match options.table.as_deref() {
434 None => Ok(nmea::Table::Fixes),
435 Some(name) => nmea::Table::from_name(name).ok_or_else(|| {
436 eyre!(
437 "no table \"{name}\". --table names one of an NMEA log's tables: {}.",
438 nmea::Table::ALL.map(nmea::Table::name).join(", ")
439 )
440 }),
441 }
442}
443
444struct One {
446 lf: LazyFrame,
447 files: Vec<TempDownload>,
448 stats: Stats,
449 extent: Extent,
450}
451
452fn convert_one(
454 file: &Path,
455 display: &Path,
456 format: FileFormat,
457 options: &OpenOptions,
458 writer: &Writer,
459 read: &AtomicU64,
460) -> Result<One> {
461 let mut reader = open_reader(file, options, read)?;
462 let mut segments = Segments::new(options, writer);
463 let mut extent = Extent::default();
464 let mut chunk = vec![0u8; CHUNK];
465 let mut next = |chunk: &mut [u8]| -> Result<usize> {
466 if writer.stopped() {
467 return Err(eyre!("Reading was stopped."));
468 }
469 loop {
470 match reader.read(chunk) {
471 Ok(n) => return Ok(n),
472 Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
473 Err(e) => return Err(e.into()),
474 }
475 }
476 };
477 match format {
478 FileFormat::Gpx => {
479 let mut gpx = gpx::GpxReader::new();
480 loop {
481 let n = next(&mut chunk)?;
482 if n == 0 {
483 break;
484 }
485 gpx.push(&chunk[..n]).map_err(|e| eyre!(e))?;
486 if let Some(df) = gpx.take_batch()? {
487 extent.absorb(&df);
488 segments.write(&df)?;
489 }
490 }
491 let last = gpx.finish().map_err(|e| eyre!(e))?;
492 extent.absorb(&last);
493 segments.write(&last)?;
494 let (lf, files) = segments.finish()?;
495 let stats = gpx.stats().clone();
496 Ok(One {
497 lf: type_gpx_fields(lf, gpx.fields()),
498 files,
499 extent,
500 stats: Stats::Gpx {
501 truncated: usize::from(stats.truncated),
502 stats,
503 },
504 })
505 }
506 FileFormat::Nmea => {
507 let table = nmea_table(options)?;
508 let mut log = nmea::NmeaReader::new(table);
509 loop {
510 let n = next(&mut chunk)?;
511 if n == 0 {
512 break;
513 }
514 log.push(&chunk[..n]);
515 if let Some(df) = log.take_batch()? {
516 extent.absorb(&df);
517 segments.write(&df)?;
518 }
519 }
520 let last = log.finish()?;
521 if log.stats().sentences == 0 {
522 return Err(crate::error_display::FileError::new(
523 display,
524 "no NMEA sentences: no line starts with $ and a sentence address.",
525 )
526 .into());
527 }
528 extent.absorb(&last);
529 segments.write(&last)?;
530 let (lf, files) = segments.finish()?;
531 let stats = log.stats().clone();
532 Ok(One {
533 lf,
534 files,
535 extent,
536 stats: Stats::Nmea {
537 undated: usize::from(!stats.dated && stats.rows > 0),
538 stats,
539 },
540 })
541 }
542 other => Err(eyre!("{} is not a GPS format.", other.name())),
543 }
544}
545
546fn type_gpx_fields(lf: LazyFrame, fields: &[gpx::FieldColumn]) -> LazyFrame {
548 let casts: Vec<Expr> = fields
549 .iter()
550 .filter_map(|f| {
551 let to = if f.integers {
552 DataType::Int64
553 } else if f.numbers {
554 DataType::Float64
555 } else {
556 return None;
557 };
558 Some(col(f.name.as_str()).cast(to))
559 })
560 .collect();
561 if casts.is_empty() {
562 lf
563 } else {
564 lf.with_columns(casts)
565 }
566}
567
568fn note(summary: String, scope: String) -> Note {
569 Note {
570 summary,
571 scope,
572 read_as_text: None,
573 passed_over: None,
574 }
575}
576
577fn count(n: u64, one: &str, many: &str) -> String {
578 let n = usize::try_from(n).unwrap_or(usize::MAX);
579 format!("{} {}", group_chrome(n), if n == 1 { one } else { many })
580}
581
582fn nmea_notes(stats: &nmea::Stats, of: Option<usize>, undated: usize) -> Vec<Note> {
584 let mut notes = Vec::new();
585 let of_lines = match of {
586 None => format!("of {}", count(stats.lines, "line", "lines")),
587 Some(n) => format!(
588 "of {} in {}",
589 count(stats.lines, "line", "lines"),
590 count(n as u64, "log", "logs")
591 ),
592 };
593 if stats.skipped > 0 {
594 notes.push(note(
595 format!(
596 "{} left out: not NMEA",
597 count(stats.skipped, "line", "lines")
598 ),
599 of_lines.clone(),
600 ));
601 }
602 if stats.bad_checksums > 0 {
603 notes.push(note(
604 format!(
605 "checksum failed: {} {} checksum_ok false",
606 count(stats.bad_checksums, "sentence", "sentences"),
607 crate::glyphs::get().middot
608 ),
609 format!("of {}", count(stats.sentences, "sentence", "sentences")),
610 ));
611 }
612 if undated > 0 {
613 let summary = match of {
614 None => format!(
615 "no RMC or ZDA date {} time empty",
616 crate::glyphs::get().middot
617 ),
618 Some(n) => format!(
619 "{undated} of {n} logs without an RMC or ZDA date {} time empty",
620 crate::glyphs::get().middot
621 ),
622 };
623 notes.push(note(summary, of_lines.clone()));
624 }
625 notes
626}
627
628fn nmea_other_tables(stats: &nmea::Stats, table: nmea::Table) -> Vec<String> {
633 use nmea::Table;
634 let typed = |t: Table| !matches!(t, Table::Fixes | Table::Sentences);
635 let merged = |t: Table| matches!(t, Table::Gga | Table::Rmc | Table::Vtg | Table::Gll);
636 let worth = Table::ALL.into_iter().any(|t| {
637 t != table && typed(t) && stats.of(t.name()) > 0 && !(table == Table::Fixes && merged(t))
638 });
639 if !worth {
640 return Vec::new();
641 }
642 Table::ALL
643 .into_iter()
644 .filter(|t| *t != table)
645 .filter_map(|t| match typed(t) {
646 false => Some(t.name().to_string()),
647 true => {
648 let n = stats.of(t.name());
649 (n > 0).then(|| format!("{} {}", t.name(), group_chrome(n as usize)))
650 }
651 })
652 .collect()
653}
654
655fn gpx_notes(stats: &gpx::Stats, of: Option<usize>, truncated: usize) -> Vec<Note> {
657 let mut notes = Vec::new();
658 let of_points = match of {
659 None => format!("of {}", count(stats.points, "point", "points")),
660 Some(n) => format!(
661 "of {} in {}",
662 count(stats.points, "point", "points"),
663 count(n as u64, "file", "files")
664 ),
665 };
666 if truncated > 0 {
667 let summary = match of {
668 None => format!(
669 "file cut short inside an element {} points before it shown",
670 crate::glyphs::get().middot
671 ),
672 Some(n) => format!(
673 "{truncated} of {n} files cut short inside an element {} points before it shown",
674 crate::glyphs::get().middot
675 ),
676 };
677 notes.push(note(summary, of_points.clone()));
678 }
679 if stats.bad_times > 0 {
680 notes.push(note(
681 format!(
682 "{} left empty: not ISO 8601",
683 count(stats.bad_times, "time", "times")
684 ),
685 of_points.clone(),
686 ));
687 }
688 if stats.fields_dropped > 0 {
689 notes.push(note(
690 format!(
691 "{} left out: past {} columns, or name too long",
692 count(stats.fields_dropped, "field value", "field values"),
693 gpx::MAX_FIELDS
694 ),
695 of_points,
696 ));
697 }
698 notes
699}
700
701#[cfg(test)]
702mod tests {
703 use super::*;
704
705 #[test]
707 fn errors_name_the_file() {
708 use crate::readers::bad_input::{assert_shape, each_names_its_file, opening};
709 each_names_its_file(
710 FileFormat::Gpx,
711 &[
712 (
713 "kml.gpx",
714 b"<?xml version=\"1.0\"?><kml></kml>",
715 "first element is <kml>",
716 ),
717 ("empty.gpx", b"<?xml version=\"1.0\"?>", "no <gpx> element"),
718 ],
719 );
720 each_names_its_file(
721 FileFormat::Nmea,
722 &[("words.nmea", b"hello\nthere\n", "No NMEA sentences")],
723 );
724 let dir = tempfile::tempdir().unwrap();
725 let options = OpenOptions {
726 table: Some("nope".into()),
727 ..Default::default()
728 };
729 let message = opening(
730 dir.path(),
731 "a.nmea",
732 b"$GPGGA,1,2\n",
733 FileFormat::Nmea,
734 &options,
735 )
736 .expect("no such table");
737 eprintln!("{message}");
738 assert_shape(&message, &dir.path().join("a.nmea"));
739 assert!(message.contains("--table names one of"), "{message}");
740 }
741
742 fn options(dir: &Path) -> OpenOptions {
743 OpenOptions {
744 temp_dir: Some(dir.to_path_buf()),
745 ..Default::default()
746 }
747 }
748
749 #[test]
751 fn other_tables_only_when_worth_opening() {
752 let stats = |types: &[(&str, u64)]| nmea::Stats {
753 types: types.iter().map(|(t, n)| (t.to_string(), *n)).collect(),
754 ..Default::default()
755 };
756 let fixes_only = stats(&[("GGA", 3), ("RMC", 3), ("VTG", 3)]);
757 assert!(nmea_other_tables(&fixes_only, nmea::Table::Fixes).is_empty());
758 assert_eq!(
759 nmea_other_tables(&fixes_only, nmea::Table::Gga),
760 ["fixes", "RMC 3", "VTG 3", "sentences"]
761 );
762 let gga_only = stats(&[("GGA", 3)]);
763 assert!(nmea_other_tables(&gga_only, nmea::Table::Gga).is_empty());
764 let with_gsv = stats(&[("GGA", 3), ("GSV", 9)]);
765 assert_eq!(
766 nmea_other_tables(&with_gsv, nmea::Table::Fixes),
767 ["GGA 3", "GSV 9", "sentences"]
768 );
769 }
770
771 #[test]
775 fn a_field_found_late_starts_a_segment() {
776 let dir = tempfile::tempdir().unwrap();
777 let path = dir.path().join("t.gpx");
778 let mut text = String::from("<gpx><trk><trkseg>");
780 for i in 0..gpx::BATCH_ROWS + 5000 {
781 text.push_str(&format!("<trkpt lat=\"1\" lon=\"{}\"/>", i % 100));
782 }
783 text.push_str("<trkpt lat=\"1\" lon=\"2\"><extensions><hr>99</hr></extensions></trkpt>");
784 text.push_str("</trkseg></trk></gpx>");
785 std::fs::write(&path, text).unwrap();
786 let out_dir = tempfile::tempdir().unwrap();
787 let writer = Writer::default();
788 let (converted, _) = convert(
789 std::slice::from_ref(&path),
790 &path,
791 FileFormat::Gpx,
792 &options(out_dir.path()),
793 &writer,
794 &AtomicU64::default(),
795 )
796 .unwrap();
797 assert_eq!(converted.files.len(), 2);
798 let df = converted.lf.clone().collect().unwrap();
799 assert_eq!(df.height(), gpx::BATCH_ROWS + 5001);
800 let hr = df.column("hr").unwrap();
801 assert_eq!(hr.dtype(), &DataType::Int64);
802 assert_eq!(hr.null_count(), gpx::BATCH_ROWS + 5000);
803 drop(converted);
804 assert_eq!(std::fs::read_dir(out_dir.path()).unwrap().count(), 0);
805 }
806
807 #[test]
810 fn segments_in_a_temp_directory_named_like_a_glob() {
811 let dir = tempfile::tempdir().unwrap();
812 let path = dir.path().join("t.nmea");
813 std::fs::write(
814 &path,
815 "$GPRMC,120000,A,4807.038,N,01131.000,E,1.0,0.0,010124,,,A\n",
816 )
817 .unwrap();
818 let temp = dir.path().join("t[1]");
819 std::fs::create_dir(&temp).unwrap();
820 std::fs::create_dir(dir.path().join("t1")).unwrap();
821 let (converted, _) = convert(
822 std::slice::from_ref(&path),
823 &path,
824 FileFormat::Nmea,
825 &options(&temp),
826 &Writer::default(),
827 &AtomicU64::default(),
828 )
829 .unwrap();
830 assert_eq!(converted.lf.clone().collect().unwrap().height(), 1);
831 }
832
833 #[test]
834 fn an_nmea_log_compressed_and_its_tables() {
835 let dir = tempfile::tempdir().unwrap();
836 let path = dir.path().join("t.nmea.gz");
837 let log = "$GPRMC,120000,A,4807.038,N,01131.000,E,1.0,0.0,010124,,,A\n\
838 junk\n$GPGSV,1,1,01,07,40,083,46\n";
839 let mut enc = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
840 std::io::Write::write_all(&mut enc, log.as_bytes()).unwrap();
841 std::fs::write(&path, enc.finish().unwrap()).unwrap();
842 let writer = Writer::default();
843 let read = AtomicU64::default();
844 let (converted, _) = convert(
845 std::slice::from_ref(&path),
846 &path,
847 FileFormat::Nmea,
848 &options(dir.path()),
849 &writer,
850 &read,
851 )
852 .unwrap();
853 assert_eq!(converted.lf.clone().collect().unwrap().height(), 1);
854 assert_eq!(
855 read.load(Ordering::Relaxed),
856 std::fs::metadata(&path).unwrap().len(),
857 "the bytes read are counted as stored, compressed"
858 );
859 let summaries: Vec<_> = converted.notes.iter().map(|n| n.summary.as_str()).collect();
860 assert!(
861 summaries[0].starts_with("1 line left out: not NMEA"),
862 "{summaries:?}"
863 );
864 assert_eq!(summaries.len(), 1, "{summaries:?}");
865 assert_eq!(
866 converted.other_tables,
867 ["RMC 1", "GSV 1", "sentences"],
868 "a GSV the fixes do not show"
869 );
870 let (gsv, _) = convert(
871 std::slice::from_ref(&path),
872 &path,
873 FileFormat::Nmea,
874 &OpenOptions {
875 table: Some("gsv".into()),
876 ..options(dir.path())
877 },
878 &writer,
879 &AtomicU64::default(),
880 )
881 .unwrap();
882 assert_eq!(gsv.lf.collect().unwrap().height(), 1);
883 assert_eq!(gsv.other_tables, ["fixes", "RMC 1", "sentences"]);
884 let none = convert(
885 std::slice::from_ref(&path),
886 &path,
887 FileFormat::Nmea,
888 &OpenOptions {
889 table: Some("nope".into()),
890 ..options(dir.path())
891 },
892 &writer,
893 &AtomicU64::default(),
894 );
895 assert!(none.err().unwrap().to_string().contains("GSV"));
896 let text = dir.path().join("t.txt");
897 std::fs::write(&text, "hello\nworld\n").unwrap();
898 assert!(
899 convert(
900 std::slice::from_ref(&text),
901 &text,
902 FileFormat::Nmea,
903 &options(dir.path()),
904 &writer,
905 &AtomicU64::default()
906 )
907 .is_err()
908 );
909 }
910
911 #[test]
916 fn several_logs_are_one_table_with_a_file_column() {
917 let dir = tempfile::tempdir().unwrap();
918 let gpx = |name: &str, points: &str| {
919 let path = dir.path().join(name);
920 std::fs::write(
921 &path,
922 format!("<gpx><trk><trkseg>{points}</trkseg></trk></gpx>"),
923 )
924 .unwrap();
925 path
926 };
927 let a = gpx(
928 "a.gpx",
929 "<trkpt lat=\"1\" lon=\"2\"><extensions><hr>90</hr><cad>7</cad></extensions></trkpt>\
930 <trkpt lat=\"1\" lon=\"3\"><time>yesterday</time></trkpt>",
931 );
932 let b = gpx(
933 "b.gpx",
934 "<trkpt lat=\"5\" lon=\"6\"><extensions><hr>91</hr><cad>fast</cad><pwr>200</pwr></extensions></trkpt>",
935 );
936 let out = tempfile::tempdir().unwrap();
937 let read = AtomicU64::default();
938 let (converted, _) = convert(
939 &[a.clone(), b.clone()],
940 dir.path(),
941 FileFormat::Gpx,
942 &options(out.path()),
943 &Writer::default(),
944 &read,
945 )
946 .unwrap();
947 let df = converted.lf.clone().collect().unwrap();
948 assert_eq!(df.get_column_names()[0].as_str(), "file");
949 let files: Vec<Option<&str>> = df.column("file").unwrap().str().unwrap().iter().collect();
950 assert_eq!(files, [Some("a.gpx"), Some("a.gpx"), Some("b.gpx")]);
951 let hr = df.column("hr").unwrap();
952 assert_eq!(hr.dtype(), &DataType::Int64, "a number in both files");
953 assert_eq!(hr.null_count(), 1);
954 assert_eq!(df.column("cad").unwrap().dtype(), &DataType::String);
955 assert_eq!(df.column("pwr").unwrap().null_count(), 2, "only b has it");
956 assert_eq!(
957 read.load(Ordering::Relaxed),
958 std::fs::metadata(&a).unwrap().len() + std::fs::metadata(&b).unwrap().len()
959 );
960 let notes: Vec<_> = converted
961 .notes
962 .iter()
963 .map(|n| (n.summary.as_str(), n.scope.as_str()))
964 .collect();
965 assert_eq!(
966 notes,
967 [("1 time left empty: not ISO 8601", "of 3 points in 2 files")]
968 );
969 assert!(converted.files.len() >= 2);
970 drop(converted);
971 assert_eq!(std::fs::read_dir(out.path()).unwrap().count(), 0);
972 }
973
974 #[test]
977 fn nmea_logs_stack_and_count_together() {
978 let dir = tempfile::tempdir().unwrap();
979 let dated = dir.path().join("a.nmea");
980 std::fs::write(
981 &dated,
982 "$GPRMC,120000,A,4807.038,N,01131.000,E,1.0,0.0,010124,,,A\n",
983 )
984 .unwrap();
985 let undated = dir.path().join("b.nmea");
986 std::fs::write(
987 &undated,
988 "$GPGGA,120001,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,\n$GPGSV,1,1,01,07,40,083,46\n",
989 )
990 .unwrap();
991 let (converted, _) = convert(
992 &[dated, undated],
993 dir.path(),
994 FileFormat::Nmea,
995 &options(dir.path()),
996 &Writer::default(),
997 &AtomicU64::default(),
998 )
999 .unwrap();
1000 let df = converted.lf.clone().collect().unwrap();
1001 assert_eq!(df.height(), 2);
1002 assert_eq!(df.get_column_names()[0].as_str(), "file");
1003 let summaries: Vec<_> = converted.notes.iter().map(|n| n.summary.as_str()).collect();
1004 assert_eq!(
1005 summaries,
1006 [format!(
1007 "1 of 2 logs without an RMC or ZDA date {} time empty",
1008 crate::glyphs::get().middot
1009 )]
1010 );
1011 assert_eq!(
1012 converted.other_tables,
1013 ["GGA 1", "RMC 1", "GSV 1", "sentences"]
1014 );
1015 }
1016
1017 #[test]
1020 fn the_tab_says_when_and_where() {
1021 let dir = tempfile::tempdir().unwrap();
1022 let path = dir.path().join("t.nmea");
1023 std::fs::write(
1024 &path,
1025 "$GPRMC,120000,A,4807.038,N,01131.000,E,1.0,0.0,010124,,,A\n\
1026 $GPRMC,121005,A,4808.000,N,01130.000,E,1.0,0.0,010124,,,A\n\
1027 $GPGSV,1,1,01,07,40,083,46\n",
1028 )
1029 .unwrap();
1030 let (_, detail) = convert(
1031 std::slice::from_ref(&path),
1032 &path,
1033 FileFormat::Nmea,
1034 &options(dir.path()),
1035 &Writer::default(),
1036 &AtomicU64::default(),
1037 )
1038 .unwrap();
1039 assert_eq!(detail.tab, "GPS");
1040 assert_eq!(
1041 detail.lines[0],
1042 format!(
1043 "2 rows of fixes {} 3 sentences of 3 lines",
1044 crate::glyphs::get().middot
1045 )
1046 );
1047 assert_eq!(
1048 detail.lines[1],
1049 "Time: 2024-01-01 12:00:00 to 12:10:05 UTC (10:05.000)"
1050 );
1051 assert!(
1052 detail.lines[2].starts_with(
1053 "Bounds: latitude 48.11730 to 48.13333, longitude 11.50000 to 11.51667"
1054 ),
1055 "{:?}",
1056 detail.lines
1057 );
1058 assert_eq!(detail.list_title, "Sentences");
1059 assert_eq!(detail.list.len(), 2);
1060
1061 let gpx = dir.path().join("t.gpx");
1062 std::fs::write(
1063 &gpx,
1064 "<gpx><wpt lat=\"1\" lon=\"2\"/><trk><trkseg><trkpt lat=\"-3\" lon=\"4\"><time>2024-01-01T00:00:00Z</time></trkpt></trkseg></trk></gpx>",
1065 )
1066 .unwrap();
1067 let (_, detail) = convert(
1068 &[gpx.clone(), gpx],
1069 dir.path(),
1070 FileFormat::Gpx,
1071 &options(dir.path()),
1072 &Writer::default(),
1073 &AtomicU64::default(),
1074 )
1075 .unwrap();
1076 let middot = crate::glyphs::get().middot;
1077 assert_eq!(
1078 detail.lines[0],
1079 format!("4 points {middot} 2 tracks {middot} 0 routes {middot} 2 waypoints")
1080 );
1081 assert_eq!(detail.lines[1], "2 files");
1082 assert!(
1083 detail
1084 .lines
1085 .iter()
1086 .any(|l| l == "Bounds: latitude -3.00000 to 1.00000, longitude 2.00000 to 4.00000"),
1087 "{:?}",
1088 detail.lines
1089 );
1090 }
1091}