Skip to main content

datui_lib/
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::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::readers::sniff_file(path, crate::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            .map(|name| (name, String::new()))
72            .collect::<Vec<_>>()
73    };
74    let common = named(&spec.records.fields);
75    spec.records
76        .variants
77        .iter()
78        .map(|v| Table {
79            name: v.name.clone(),
80            kind: "record type".to_string(),
81            internal: false,
82            columns: common.iter().cloned().chain(named(&v.fields)).collect(),
83        })
84        .collect()
85}
86
87/// [`split`], for files `holds` says are files of tables.
88fn split_inside(path: &Path, holds: impl Fn(&Path) -> bool) -> Option<(PathBuf, String)> {
89    // A path ending in `..` is a table so named: Windows resolves it before it looks,
90    // and `app.db/..` is the directory the database is in.
91    if path.file_name().is_some() && path.exists() {
92        return None;
93    }
94    let file = path
95        .ancestors()
96        .skip(1)
97        .take_while(|p| !p.as_os_str().is_empty())
98        // Windows resolves `..` before it looks, so `app.db/a/..` would be the
99        // database itself and the table `b` rather than `a/../b`.
100        .find(|p| p.file_name().is_some() && p.is_file())?;
101    if !holds(file) {
102        return None;
103    }
104    // What follows the file's name, less the one separator after it, as written: a
105    // parent is a prefix of the path's own text.
106    let rest = path.to_str()?.strip_prefix(file.to_str()?)?;
107    let mut chars = rest.chars();
108    chars.next().filter(|c| std::path::is_separator(*c))?;
109    let name = chars.as_str().trim_end_matches(std::path::is_separator);
110    (!name.is_empty()).then(|| (file.to_path_buf(), name.to_string()))
111}
112
113/// The tables of `file`, a file of `format`, as the home screen lists them. Cheap: a
114/// database's schema, an archive's directory and its arrays' headers.
115pub fn tables(file: &Path, format: FileFormat) -> color_eyre::Result<Vec<Table>> {
116    match crate::readers::of(format).tables {
117        Some(tables) => tables(file),
118        None => Err(color_eyre::eyre::eyre!(
119            "A {} file holds one table.",
120            format.name()
121        )),
122    }
123    .map_err(|e| crate::error_display::in_file(file, e))
124}
125
126/// The table `wanted` names among `tables`, or the file's one table of its own when
127/// nothing is named. `display` names the file in errors, and `empty` what to say of a
128/// file of none.
129pub fn pick(
130    tables: Vec<Table>,
131    wanted: Option<&str>,
132    display: &Path,
133    empty: &str,
134) -> color_eyre::Result<Pick> {
135    use crate::error_display::FileError;
136    let own: Vec<&Table> = tables.iter().filter(|t| !t.internal).collect();
137    let names = || {
138        const SHOWN: usize = 20;
139        let mut names: Vec<&str> = own.iter().take(SHOWN).map(|t| t.name.as_str()).collect();
140        let more = own.len().saturating_sub(SHOWN);
141        let more = format!("and {more} more");
142        if own.len() > SHOWN {
143            names.push(&more);
144        }
145        names.join(", ")
146    };
147    if let Some(wanted) = wanted {
148        // Exactly as written first, then without regard to case when that is one.
149        let found = tables.iter().find(|t| t.name == wanted).or_else(|| {
150            let mut alike = tables
151                .iter()
152                .filter(|t| t.name.eq_ignore_ascii_case(wanted));
153            match (alike.next(), alike.next()) {
154                (Some(one), None) => Some(one),
155                _ => None,
156            }
157        });
158        return match found {
159            Some(table) => Ok(Pick::One(table.clone())),
160            None if own.is_empty() => Err(FileError::new(
161                display,
162                format!("no table \"{wanted}\"; the file holds no tables."),
163            )
164            .into()),
165            None => Err(FileError::new(
166                display,
167                format!(
168                    "no table \"{wanted}\". --table names one of its tables: {}.",
169                    names()
170                ),
171            )
172            .into()),
173        };
174    }
175    match own.as_slice() {
176        [] => Err(FileError::new(display, format!("the file holds no tables.{empty}")).into()),
177        [one] => Ok(Pick::One((*one).clone())),
178        _ => Ok(Pick::Several(tables)),
179    }
180}
181
182/// What a reader that decodes its table from the file found, carried from the scan to
183/// the dataset: a window read straight from the file, its row count, its Info panel
184/// tab, its other tables, notes and units.
185#[derive(Default)]
186pub struct Opened {
187    /// Rows read straight from the source, and how many it holds, so neither a page
188    /// deep in the table nor the count builds the frame's row index.
189    pub window: Option<(std::sync::Arc<dyn crate::pushdown::Windowed>, usize)>,
190    pub detail: Option<std::sync::Arc<crate::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::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::lines::Lines>>,
200}
201
202impl std::fmt::Debug for Opened {
203    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
204        f.debug_struct("Opened")
205            .field("rows", &self.window.as_ref().map(|(_, rows)| rows))
206            .field("other_tables", &self.other_tables)
207            .finish_non_exhaustive()
208    }
209}
210
211/// The other tables of a file, each as `--table` names it, for the Info panel.
212pub fn others(tables: &[Table], opened: &str) -> Vec<String> {
213    tables
214        .iter()
215        .filter(|t| !t.internal && t.name != opened)
216        .map(|t| t.name.clone())
217        .collect()
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    #[test]
225    fn a_place_inside_a_file_reads_back_whole() {
226        let dir = tempfile::tempdir().unwrap();
227        let db = dir.path().join("app.db");
228        let mut head = crate::sqlite::MAGIC.to_vec();
229        head.resize(100, 0);
230        std::fs::write(&db, head).unwrap();
231        for name in ["users", "a/b", "/etc", "a/../b"] {
232            let place = place(&db, name);
233            assert_eq!(
234                split(&place),
235                Some((db.clone(), name.to_string())),
236                "{name}"
237            );
238        }
239        assert_eq!(split(&db), None);
240        let text = dir.path().join("notes.txt");
241        std::fs::write(&text, "hello").unwrap();
242        assert_eq!(split(&text.join("users")), None);
243    }
244
245    #[test]
246    fn a_place_inside_a_file_of_variants_names_one() {
247        let spec = crate::formats::Spec::parse(
248            r#"name = "acme.v"
249match = { glob = ["*.v"] }
250[records]
251framing = "length_prefixed"
252size = "len"
253type = "kind"
254fields = [{ name = "len", type = "u1" }, { name = "kind", type = "u1" }]
255[[variants]]
256name = "Add"
257when = 1
258fields = [{ name = "a", type = "u1" }]
259[[variants]]
260name = "exec"
261when = 2
262fields = [{ name = "b", type = "u1" }]"#,
263            None,
264        )
265        .unwrap();
266        let formats = crate::formats::Registry::of(vec![spec]);
267        let dir = tempfile::tempdir().unwrap();
268        let file = dir.path().join("day.v");
269        std::fs::write(&file, [3u8, 1, 7]).unwrap();
270        assert_eq!(
271            split_variant(&place(&file, "add"), &formats),
272            Some((file.clone(), "Add".to_string())),
273            "as the spec writes it"
274        );
275        assert_eq!(split_variant(&place(&file, "nope"), &formats), None);
276        assert_eq!(split_variant(&file, &formats), None);
277        let other = dir.path().join("day.w");
278        std::fs::write(&other, [0u8]).unwrap();
279        assert_eq!(split_variant(&place(&other, "exec"), &formats), None);
280        let (spec, tables) = variants(&file, &formats).unwrap();
281        assert_eq!(spec, "acme.v");
282        assert_eq!(tables[1].columns.len(), 3);
283    }
284
285    #[test]
286    fn pick_names_what_is_there() {
287        let t = |name: &str| Table {
288            name: name.into(),
289            kind: "array".into(),
290            internal: false,
291            columns: Vec::new(),
292        };
293        let display = Path::new("run.npz");
294        let several = vec![t("x"), t("y")];
295        assert!(matches!(
296            pick(several.clone(), None, display, "").unwrap(),
297            Pick::Several(_)
298        ));
299        assert_eq!(
300            pick(several.clone(), Some("Y"), display, "").unwrap(),
301            Pick::One(t("y"))
302        );
303        let missing = pick(several, Some("z"), display, "")
304            .unwrap_err()
305            .to_string();
306        assert_eq!(
307            missing,
308            "\"run.npz\": No table \"z\". --table names one of its tables: x, y."
309        );
310        let empty = pick(Vec::new(), None, display, "").unwrap_err().to_string();
311        assert_eq!(empty, "\"run.npz\": The file holds no tables.");
312    }
313}