Skip to main content

datui_lib/formats/
members.rs

1//! Files that hold several tables: a SQLite database's tables and views, a NumPy
2//! archive's arrays.
3//!
4//! Each table has a path inside its file (`shop.db/orders`, `run.npz/weights`) that
5//! nothing on disk has. The home screen lists a file of several as a place whose rows
6//! are its tables, recents record a table by that path, and `--table` names one. A file
7//! of one table opens it.
8
9use std::path::{Path, PathBuf};
10
11use crate::FileFormat;
12pub use crate::formats::sqlite::{Pick, Table};
13
14/// The format of a file that can hold several tables, by its first bytes (and, for a
15/// NumPy archive, its name: a zip file is many things).
16pub fn holder(path: &Path) -> Option<FileFormat> {
17    crate::formats::readers::sniff_file(path, crate::formats::readers::Asked::Tables)
18}
19
20/// The path of the table `name` inside `file`, as the home screen lists it and recents
21/// record it: `shop.db/orders`. The name is appended as it is, so a name that looks like
22/// a path (`/etc`, `a/../b`) stays inside the file and reads back whole.
23pub fn place(file: &Path, name: &str) -> PathBuf {
24    let mut place = file.as_os_str().to_owned();
25    place.push("/");
26    place.push(name);
27    place.into()
28}
29
30/// The file and the table a path inside a file of tables names, the inverse of
31/// [`place`]: `shop.db/orders` is the table `orders` of `shop.db`, and `shop.db/a/b` the
32/// table `a/b`. `None` for a path that is there, or that is inside no such file.
33pub fn split(path: &Path) -> Option<(PathBuf, String)> {
34    split_inside(path, |file| holder(file).is_some())
35}
36
37/// The file and the variant a path inside a file of a format spec's variants names:
38/// `day.itch/add` is the variant `add` of `day.itch`, as the spec writes it. `None` for
39/// a path that is there, inside no such file, or naming no variant of it.
40pub fn split_variant(path: &Path, formats: &crate::formats::Registry) -> Option<(PathBuf, String)> {
41    if formats.is_empty() {
42        return None;
43    }
44    let (file, name) = split_inside(path, |file| formats.variants_of(file).is_some())?;
45    let (_, tables) = variants(&file, formats)?;
46    let found = tables.iter().find(|t| t.name == name).or_else(|| {
47        let mut alike = tables.iter().filter(|t| t.name.eq_ignore_ascii_case(&name));
48        match (alike.next(), alike.next()) {
49            (Some(one), None) => Some(one),
50            _ => None,
51        }
52    })?;
53    Some((file, found.name.clone()))
54}
55
56/// The variants of the records of `file`, as the format spec whose glob names it reads
57/// them, each a table: the spec's name, and its variants with their columns. `None`
58/// unless the spec reads several. Its name is all that is read.
59pub fn variants(file: &Path, formats: &crate::formats::Registry) -> Option<(String, Vec<Table>)> {
60    let spec = formats.variants_of(file)?;
61    Some((spec.name.clone(), variant_tables(&spec)))
62}
63
64/// The variants `spec` reads records as, each a table with its columns, from the spec
65/// alone.
66pub fn variant_tables(spec: &crate::formats::Spec) -> Vec<Table> {
67    let named = |fields: &[crate::formats::Field]| {
68        fields
69            .iter()
70            .filter_map(|f| f.name.clone())
71            .collect::<Vec<_>>()
72    };
73    let common = named(&spec.records.fields);
74    spec.records
75        .variants
76        .iter()
77        .map(|v| {
78            let columns = common.iter().cloned().chain(named(&v.fields));
79            Table::plain(&v.name, "record type", columns)
80        })
81        .collect()
82}
83
84/// [`split`], for files `holds` says are files of tables.
85fn split_inside(path: &Path, holds: impl Fn(&Path) -> bool) -> Option<(PathBuf, String)> {
86    // A path ending in `..` is a table so named: Windows resolves it before it looks,
87    // and `app.db/..` is the directory the database is in.
88    if path.file_name().is_some() && path.exists() {
89        return None;
90    }
91    let file = path
92        .ancestors()
93        .skip(1)
94        .take_while(|p| !p.as_os_str().is_empty())
95        // Windows resolves `..` before it looks, so `app.db/a/..` would be the
96        // database itself and the table `b` rather than `a/../b`.
97        .find(|p| p.file_name().is_some() && p.is_file())?;
98    if !holds(file) {
99        return None;
100    }
101    // What follows the file's name, less the one separator after it, as written: a
102    // parent is a prefix of the path's own text.
103    let rest = path.to_str()?.strip_prefix(file.to_str()?)?;
104    let mut chars = rest.chars();
105    chars.next().filter(|c| std::path::is_separator(*c))?;
106    let name = chars.as_str().trim_end_matches(std::path::is_separator);
107    (!name.is_empty()).then(|| (file.to_path_buf(), name.to_string()))
108}
109
110/// The tables of `file`, a file of `format`, as the home screen lists them. Cheap: a
111/// database's schema, an archive's directory and its arrays' headers.
112pub fn tables(file: &Path, format: FileFormat) -> color_eyre::Result<Vec<Table>> {
113    match crate::formats::readers::of(format).tables {
114        Some(tables) => tables(file),
115        None => Err(color_eyre::eyre::eyre!(
116            "A {} file holds one table.",
117            format.name()
118        )),
119    }
120    .map_err(|e| crate::error_display::in_file(file, e))
121}
122
123/// The table `wanted` names among `tables`, or the file's one table of its own when
124/// nothing is named. `display` names the file in errors, and `empty` what to say of a
125/// file of none.
126pub fn pick(
127    tables: Vec<Table>,
128    wanted: Option<&str>,
129    display: &Path,
130    empty: &str,
131) -> color_eyre::Result<Pick> {
132    use crate::error_display::FileError;
133    let own: Vec<&Table> = tables.iter().filter(|t| !t.internal).collect();
134    let names = || {
135        const SHOWN: usize = 20;
136        let mut names: Vec<&str> = own.iter().take(SHOWN).map(|t| t.name.as_str()).collect();
137        let more = own.len().saturating_sub(SHOWN);
138        let more = format!("and {more} more");
139        if own.len() > SHOWN {
140            names.push(&more);
141        }
142        names.join(", ")
143    };
144    if let Some(wanted) = wanted {
145        // Exactly as written first, then without regard to case when that is one.
146        let found = tables.iter().find(|t| t.name == wanted).or_else(|| {
147            let mut alike = tables
148                .iter()
149                .filter(|t| t.name.eq_ignore_ascii_case(wanted));
150            match (alike.next(), alike.next()) {
151                (Some(one), None) => Some(one),
152                _ => None,
153            }
154        });
155        return match found {
156            Some(table) => Ok(Pick::One(table.clone())),
157            None if own.is_empty() => Err(FileError::new(
158                display,
159                format!("no table \"{wanted}\"; the file holds no tables."),
160            )
161            .into()),
162            None => Err(FileError::new(
163                display,
164                format!(
165                    "no table \"{wanted}\". --table names one of its tables: {}.",
166                    names()
167                ),
168            )
169            .into()),
170        };
171    }
172    match own.as_slice() {
173        [] => Err(FileError::new(display, format!("the file holds no tables.{empty}")).into()),
174        [one] => Ok(Pick::One((*one).clone())),
175        _ => Ok(Pick::Several(tables)),
176    }
177}
178
179/// What a reader that decodes its table from the file found, carried from the scan to
180/// the dataset: a window read straight from the file, its row count, its Info panel
181/// tab, its other tables, notes and units.
182#[derive(Default)]
183pub struct Opened {
184    /// Rows read straight from the source, and how many it holds, so neither a page
185    /// deep in the table nor the count builds the frame's row index.
186    pub window: Option<(
187        std::sync::Arc<dyn crate::formats::pushdown::Windowed>,
188        usize,
189    )>,
190    pub detail: Option<std::sync::Arc<crate::formats::text_formats::Detail>>,
191    pub other_tables: Vec<String>,
192    pub notes: Vec<crate::notes::Note>,
193    /// Each column's unit, where the file says one.
194    pub units: Vec<(String, String)>,
195    /// Lines still being indexed behind the first rows: the dataset indexes the rest.
196    pub indexing: Option<std::sync::Arc<crate::formats::lines::Lines>>,
197    /// The lines of several files, whose rows `#` numbers by their line in their own
198    /// file.
199    pub numbering: Option<std::sync::Arc<crate::formats::lines::Lines>>,
200}
201
202impl Opened {
203    /// The open of `picked`, one of a file's `tables`: its Info panel tab, the file's
204    /// other tables, and `notes`, each said of `scope` ("the log").
205    pub fn for_table(
206        detail: crate::formats::text_formats::Detail,
207        tables: &[Table],
208        picked: &str,
209        notes: Vec<String>,
210        scope: &str,
211    ) -> Self {
212        Self {
213            detail: Some(std::sync::Arc::new(detail)),
214            other_tables: others(tables, picked),
215            notes: notes
216                .into_iter()
217                .map(|n| crate::formats::text_formats::note(n, scope.to_string()))
218                .collect(),
219            ..Default::default()
220        }
221    }
222
223    /// The scan of `lf`, the table opened, with this reported beside it.
224    pub(crate) fn scan(
225        self,
226        input: crate::formats::readers::ScanIn<'_>,
227        lf: polars::prelude::LazyFrame,
228    ) -> crate::loading::scan::Scan {
229        input.report.opened = Some(std::sync::Arc::new(self));
230        lf.into()
231    }
232}
233
234/// The scan of a file of several `tables` when `--table` named none: the list to pick
235/// from.
236pub(crate) fn several(
237    input: &crate::formats::readers::ScanIn<'_>,
238    tables: Vec<Table>,
239) -> crate::loading::scan::Scan {
240    crate::loading::scan::Scan::Tables {
241        file: input.path().to_path_buf(),
242        tables: tables
243            .into_iter()
244            .filter(|t| !t.internal)
245            .map(|t| t.name)
246            .collect(),
247        format: input.format,
248    }
249}
250
251impl std::fmt::Debug for Opened {
252    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
253        f.debug_struct("Opened")
254            .field("rows", &self.window.as_ref().map(|(_, rows)| rows))
255            .field("other_tables", &self.other_tables)
256            .finish_non_exhaustive()
257    }
258}
259
260/// The other tables of a file, each as `--table` names it, for the Info panel.
261pub fn others(tables: &[Table], opened: &str) -> Vec<String> {
262    tables
263        .iter()
264        .filter(|t| !t.internal && t.name != opened)
265        .map(|t| t.name.clone())
266        .collect()
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272
273    #[test]
274    fn a_place_inside_a_file_reads_back_whole() {
275        let dir = tempfile::tempdir().unwrap();
276        let db = dir.path().join("app.db");
277        let mut head = crate::formats::sqlite::MAGIC.to_vec();
278        head.resize(100, 0);
279        std::fs::write(&db, head).unwrap();
280        for name in ["users", "a/b", "/etc", "a/../b"] {
281            let place = place(&db, name);
282            assert_eq!(
283                split(&place),
284                Some((db.clone(), name.to_string())),
285                "{name}"
286            );
287        }
288        assert_eq!(split(&db), None);
289        let text = dir.path().join("notes.txt");
290        std::fs::write(&text, "hello").unwrap();
291        assert_eq!(split(&text.join("users")), None);
292    }
293
294    #[test]
295    fn a_place_inside_a_file_of_variants_names_one() {
296        let spec = crate::formats::Spec::parse(
297            r#"name = "acme.v"
298match = { glob = ["*.v"] }
299[records]
300framing = "length_prefixed"
301size = "len"
302type = "kind"
303fields = [{ name = "len", type = "u1" }, { name = "kind", type = "u1" }]
304[[variants]]
305name = "Add"
306when = 1
307fields = [{ name = "a", type = "u1" }]
308[[variants]]
309name = "exec"
310when = 2
311fields = [{ name = "b", type = "u1" }]"#,
312            None,
313        )
314        .unwrap();
315        let formats = crate::formats::Registry::of(vec![spec]);
316        let dir = tempfile::tempdir().unwrap();
317        let file = dir.path().join("day.v");
318        std::fs::write(&file, [3u8, 1, 7]).unwrap();
319        assert_eq!(
320            split_variant(&place(&file, "add"), &formats),
321            Some((file.clone(), "Add".to_string())),
322            "as the spec writes it"
323        );
324        assert_eq!(split_variant(&place(&file, "nope"), &formats), None);
325        assert_eq!(split_variant(&file, &formats), None);
326        let other = dir.path().join("day.w");
327        std::fs::write(&other, [0u8]).unwrap();
328        assert_eq!(split_variant(&place(&other, "exec"), &formats), None);
329        let (spec, tables) = variants(&file, &formats).unwrap();
330        assert_eq!(spec, "acme.v");
331        assert_eq!(tables[1].columns.len(), 3);
332    }
333
334    #[test]
335    fn pick_names_what_is_there() {
336        let t = |name: &str| Table::plain(name, "array", Vec::<String>::new());
337        let display = Path::new("run.npz");
338        let several = vec![t("x"), t("y")];
339        assert!(matches!(
340            pick(several.clone(), None, display, "").unwrap(),
341            Pick::Several(_)
342        ));
343        assert_eq!(
344            pick(several.clone(), Some("Y"), display, "").unwrap(),
345            Pick::One(t("y"))
346        );
347        let missing = pick(several, Some("z"), display, "")
348            .unwrap_err()
349            .to_string();
350        assert_eq!(
351            missing,
352            "\"run.npz\": No table \"z\". --table names one of its tables: x, y."
353        );
354        let empty = pick(Vec::new(), None, display, "").unwrap_err().to_string();
355        assert_eq!(empty, "\"run.npz\": The file holds no tables.");
356    }
357}