Skip to main content

datui_lib/gps/
mod.rs

1//! GPS logs: NMEA 0183 and GPX, read into a table.
2//!
3//! Neither format can be scanned in place, so an open reads the file once, start to
4//! end, a piece at a time, and writes the rows to a temporary Arrow IPC file a batch
5//! at a time. The dataset scans that file lazily like any other, and memory stays at
6//! one batch however long the log. The file is claimed through the open's
7//! [`Writer`], so quitting or replacing the open removes it.
8//!
9//! A GPX file can find a new extension field a million points in: the rows after it
10//! go to a new segment, a second IPC file with the wider schema, and the frame is the
11//! segments joined with the missing columns null. An NMEA table's columns are fixed,
12//! so it is always one segment.
13//!
14//! The readers take bytes as they come and hand back rows as they are ready, so a log
15//! still being written can later be followed by reading on from where they stopped.
16
17pub 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
37/// What datui does with an NMEA log: see [`crate::readers`].
38pub(crate) const NMEA: crate::readers::Reader = crate::readers::Reader {
39    // Several logs are one table.
40    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    // The tables are the sentence types read, whichever the log holds: listing them
62    // reads nothing.
63    tables: Some(|_| Ok(nmea_tables())),
64    ..crate::readers::BASE
65};
66
67/// The tables of an NMEA log, as the home screen lists them and `--table` names them.
68fn 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
84/// What datui does with a GPX file: see [`crate::readers`].
85pub(crate) const GPX: crate::readers::Reader = crate::readers::Reader {
86    // Several logs are one table.
87    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
110/// How much of the file is read at a time.
111const CHUNK: usize = 1 << 16;
112
113/// A reader that counts the bytes read through it, for the loading screen.
114struct 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
127/// The file, decompressed as it is read when its name or `--compression` says so;
128/// `read` counts the bytes of the file as stored.
129pub(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
152/// Read `files` (named `display` to the user when there is one) as `format` into
153/// temporary IPC files, written through `writer`, counting the bytes of the files read
154/// in `read`. Several files are one table with a `file` column first, each file's
155/// columns its own and the others null; what the reads noticed is counted across them,
156/// and is the GPS tab of the Info panel.
157pub(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
182/// Each of `files` read as [`convert_one`] reads it, then stacked under a `file`
183/// column. A GPX file's fields are typed in that file, and stacked as one type where
184/// two files disagree.
185fn 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
233/// What one log's read noticed, of either format.
234enum Stats {
235    Gpx {
236        stats: gpx::Stats,
237        /// Files that end inside an element.
238        truncated: usize,
239    },
240    Nmea {
241        stats: nmea::Stats,
242        /// Files with rows and no sentence that gives the date.
243        undated: usize,
244    },
245}
246
247impl Stats {
248    /// Count `other`, another file's, in with these.
249    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            // One format per open.
278            _ => {}
279        }
280    }
281
282    /// The notes for the Notes tab, and the other tables for the Schema tab, of `of`
283    /// files (`None` for one).
284    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    /// The GPS tab of the Info panel, for `of` files (`None` for one) whose rows
300    /// covered `extent`.
301    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/// When and where a log's rows are: the least and most of their `time`, `lat` and
361/// `lon`, taken from each batch as it is written, so nothing is read twice.
362#[derive(Debug, Clone, Default, PartialEq)]
363struct Extent {
364    /// Milliseconds since the epoch, UTC.
365    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    /// `Time: 2024-05-01 12:00:00 to 13:05:12 UTC (1:05:12.000)` and
404    /// `Bounds: 51.40000 to 51.60000 N, -0.20000 to 0.10000 E`.
405    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
431/// The NMEA table `--table` names, the fixes when it names none.
432fn 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
444/// One log read: the frame over its segments, the files they are, and its counts.
445struct One {
446    lf: LazyFrame,
447    files: Vec<TempDownload>,
448    stats: Stats,
449    extent: Extent,
450}
451
452/// Read `file` (named `display` to the user) as `format` into temporary IPC files.
453fn 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
546/// A GPX field column as numbers when every value in it was one.
547fn 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
582/// What reading an NMEA log noticed; of `of` logs, `undated` of them without a date.
583fn 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
628/// The tables of an NMEA log besides `table`, each named as `--table` takes it with the
629/// sentences it holds, when one of them holds rows `table` does not show: a sentence
630/// type the fixes do not merge (GSA, GSV, ZDA), or any other type beside one type's
631/// table. Empty otherwise, so a log of fixes only says nothing.
632fn 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
655/// What reading a GPX file noticed; of `of` files, `truncated` of them cut short.
656fn 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    /// Every way a GPS log is refused names the file, in the one shape.
706    #[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    /// Other tables are offered only when one holds rows this one does not show.
750    #[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    /// A GPX file whose second batch brings a new field is two segments, joined with
772    /// the field null where the first lacks it; the files go when the frame's holder
773    /// lets them go.
774    #[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        // The new field comes a few chunks after the first batch is full.
779        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    /// A temp directory named like a glob holds the segments, and they are read
808    /// from there, not from what its name would match (#625).
809    #[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    /// Several logs are one table with a `file` column first; a field one GPX file has
912    /// and another lacks is null in the other, typed where the files agree and stacked
913    /// as text where they do not; notes count across the files; the files go with the
914    /// frame.
915    #[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    /// NMEA logs stack with their counts added: one without a date says so of itself
975    /// alone, and a table the fixes do not show is offered from either.
976    #[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    /// The GPS tab: what was read, when and where, from the rows as they were written;
1018    /// several files' extents are one.
1019    #[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}