Skip to main content

datui_lib/
catalog.rs

1//! Catalogs: named datasets, local or remote, one TOML file each.
2//!
3//! A catalog's top level holds `label` and `description`; every table in it is one
4//! dataset, keyed by a short id. `catalog.toml` in the config directory is the user's
5//! own, the one file datui writes (Ctrl+D on home); `catalogs = [...]` in the config
6//! lists others; the `examples` catalog is bundled. Each is a section of the home screen.
7//!
8//! Parsed with `toml_edit` rather than `toml`: it keeps the file's order, which is the
9//! order the home screen lists, and the place of every key, which an error names.
10
11use std::path::{Path, PathBuf};
12
13use crate::config::{CloudConnectionConfig, expand_path, is_valid_source_id};
14
15/// The bundled catalog's id.
16pub const EXAMPLES: &str = "examples";
17/// The bundled catalog's id before 0.4.0: a `home.hide` naming it is warned about.
18pub const OLD_EXAMPLES_ID: &str = "public";
19/// The id of the user's own catalog, `catalog.toml`.
20pub const MINE: &str = "mine";
21/// The user's own catalog, in the config directory.
22pub const MINE_FILE: &str = "catalog.toml";
23/// The directory beside `catalog.toml` whose `*.toml` files are catalogs.
24pub const FOLDER: &str = "catalogs";
25/// The label of `catalog.toml` when it gives none.
26pub const MINE_LABEL: &str = "My datasets";
27
28const BUNDLED: &str = include_str!("public_catalog.toml");
29
30const DATASET_KEYS: &str = "name, path, url, auth, connection, description, publisher, \
31     license, homepage, documentation, size, columns, bookmarks";
32const COLUMN_KEYS: &str = "description, unit, values";
33const AUTH_VALUES: &str = "auto or anonymous";
34
35/// Where a catalog came from, which decides what datui may do to it.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum Origin {
38    /// `catalog.toml`: Ctrl+D adds to it and forgets from it.
39    Mine,
40    /// A file in the config directory's `catalogs/`: read, never written.
41    Folder,
42    /// A file named in `catalogs`: read, never written.
43    Listed,
44    /// The `examples` catalog datui ships.
45    Bundled,
46}
47
48/// One catalog file.
49#[derive(Debug, Clone, PartialEq)]
50pub struct Catalog {
51    /// What `home.hide` names: `mine`, `examples`, or a listed file's stem.
52    pub id: String,
53    /// The section's title.
54    pub label: String,
55    pub description: String,
56    pub origin: Origin,
57    /// The file it was read from; none for the bundled one.
58    pub file: Option<PathBuf>,
59    pub datasets: Vec<Dataset>,
60}
61
62/// One dataset of a catalog: a local `path` or a remote `url`, never both.
63#[derive(Debug, Clone, Default, PartialEq)]
64pub struct Dataset {
65    /// The table's key: short, unique in its catalog.
66    pub id: String,
67    /// Where its table starts in the file, for errors.
68    pub line: usize,
69    /// The row's label.
70    pub name: String,
71    pub path: Option<String>,
72    pub url: Option<String>,
73    pub auth: Option<String>,
74    pub connection: Option<String>,
75    pub description: String,
76    pub publisher: String,
77    pub license: String,
78    pub homepage: String,
79    /// The publisher's documentation of the columns: the source of `columns`.
80    pub documentation: String,
81    /// About how many bytes an HTTP(S) file is, shown until it is measured.
82    pub size: Option<u64>,
83    /// What each column means, by its name as the data spells it, in file order.
84    pub columns: Vec<(String, ColumnNote)>,
85    /// Places inside a directory dataset to start from, by name, in file order.
86    pub bookmarks: Vec<(String, String)>,
87    /// The directory of the file that names it: a relative `path` is relative to it.
88    pub base: Option<PathBuf>,
89}
90
91/// A column's note: what it means, its unit, and what its codes stand for.
92#[derive(Debug, Clone, Default, PartialEq)]
93pub struct ColumnNote {
94    pub description: String,
95    pub unit: String,
96    /// Code to meaning, in file order. `""` is what a blank or null value means.
97    pub values: Vec<(String, String)>,
98    /// The type a spec declares for the column (`f64`), as the type row names it.
99    pub ty: String,
100}
101
102impl ColumnNote {
103    /// The note as one line: `description (unit)`; with a declared type, `f64 · deg
104    /// description`, as the type row pairs a type and a unit.
105    pub fn about(&self) -> String {
106        if !self.ty.is_empty() {
107            let mut typed = self.ty.clone();
108            if !self.unit.is_empty() {
109                typed = format!("{typed} {} {}", crate::glyphs::get().middot, self.unit);
110            }
111            if self.description.is_empty() {
112                return typed;
113            }
114            return format!("{typed}  {}", self.description);
115        }
116        match (self.description.is_empty(), self.unit.is_empty()) {
117            (false, false) => format!("{} ({})", self.description, self.unit),
118            (false, true) => self.description.clone(),
119            (true, false) => self.unit.clone(),
120            (true, true) => String::new(),
121        }
122    }
123}
124
125/// A mistake in a catalog file: where, and what to do about it.
126#[derive(Debug, Clone, PartialEq)]
127pub struct CatalogError {
128    pub line: Option<usize>,
129    pub message: String,
130}
131
132impl CatalogError {
133    fn at(line: usize, message: impl Into<String>) -> Self {
134        Self {
135            line: Some(line),
136            message: message.into(),
137        }
138    }
139
140    /// `file:line: message`, the way a compiler names a mistake.
141    pub fn in_file(&self, file: &str) -> String {
142        match self.line {
143            Some(line) => format!("{file}:{line}: {}", self.message),
144            None => format!("{file}: {}", self.message),
145        }
146    }
147}
148
149/// Where a dataset URL lives, as far as reading it is concerned.
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151pub(crate) enum UrlPlace {
152    /// S3, Google Cloud or Azure, with its connection kind.
153    ObjectStore(&'static str),
154    Http,
155}
156
157/// Where a dataset URL is, when datui can read it: a place in S3, Google Cloud or Azure
158/// (a file or a directory), or a data file on a web server, which has no listing.
159pub(crate) fn dataset_url_place(url: &str) -> Option<UrlPlace> {
160    if url.chars().any(char::is_whitespace) {
161        return None;
162    }
163    match crate::source::input_source(Path::new(url)) {
164        crate::source::InputSource::Azure(_) => Some(UrlPlace::ObjectStore("azure")),
165        crate::source::InputSource::S3(rest) | crate::source::InputSource::Gcs(rest) => {
166            let host = rest.split('/').next().unwrap_or("");
167            let kind = if url.to_ascii_lowercase().starts_with("s3") {
168                "s3"
169            } else {
170                "gcs"
171            };
172            (!host.is_empty() && !host.contains('@')).then_some(UrlPlace::ObjectStore(kind))
173        }
174        crate::source::InputSource::Http(_) => {
175            let (_, rest) = url.split_once("://")?;
176            let (host, path) = rest.split_once('/')?;
177            let path = path.split(['?', '#']).next().unwrap_or("");
178            (!host.is_empty()
179                && !host.contains('@')
180                && crate::discover::is_data_file(Path::new(path)))
181            .then_some(UrlPlace::Http)
182        }
183        crate::source::InputSource::Local(_) => None,
184    }
185}
186
187/// Whether a dataset URL is in an object store, and so browsed as well as opened.
188pub fn is_object_store_dataset(url: &str) -> bool {
189    matches!(dataset_url_place(url), Some(UrlPlace::ObjectStore(_)))
190}
191
192/// The line `offset` is on, from 1.
193fn line_of(text: &str, offset: usize) -> usize {
194    text[..offset.min(text.len())].matches('\n').count() + 1
195}
196
197/// The line `key` of `table` is written on, or `fallback` when it has no place (a key
198/// made by a dotted key further up).
199fn key_line(text: &str, table: &dyn toml_edit::TableLike, key: &str, fallback: usize) -> usize {
200    table
201        .key(key)
202        .and_then(toml_edit::Key::span)
203        .map(|span| line_of(text, span.start))
204        .unwrap_or(fallback)
205}
206
207/// A TOML syntax error, with the fix for the one mistake a catalog invites: a column
208/// written as an inline table, then given a `[id.columns.X.values]` table of its own.
209fn syntax_error(text: &str, error: &toml_edit::TomlError) -> CatalogError {
210    let line = error.span().map(|span| line_of(text, span.start));
211    let message = error.message().trim_end().to_string();
212    let header = line
213        .and_then(|l| text.lines().nth(l - 1))
214        .map(str::trim)
215        .unwrap_or("");
216    let values_table = header.starts_with('[')
217        && header.contains(".columns.")
218        && header.trim_end_matches(']').ends_with(".values");
219    let message = if values_table {
220        let column = header
221            .trim_matches(['[', ']'])
222            .rsplit_once(".values")
223            .and_then(|(head, _)| head.rsplit_once(".columns."))
224            .map(|(_, column)| column.to_string())
225            .unwrap_or_else(|| "X".to_string());
226        format!(
227            "{message}. {header} cannot add to a column written as an inline table \
228             (columns.{column} = {{ ... }}). Write the column with dotted keys instead: \
229             columns.{column}.description = \"...\""
230        )
231    } else {
232        message
233    };
234    CatalogError { line, message }
235}
236
237fn string_of(item: &toml_edit::Item, what: &str, line: usize) -> Result<String, CatalogError> {
238    item.as_str().map(str::to_string).ok_or_else(|| {
239        CatalogError::at(
240            line,
241            format!("{what} must be a string, not {}", item.type_name()),
242        )
243    })
244}
245
246/// Read a catalog's text. `id` is what `home.hide` will name it; `file` is where a
247/// relative `path` is anchored, and is named by errors.
248pub fn parse(
249    text: &str,
250    id: &str,
251    origin: Origin,
252    file: Option<&Path>,
253) -> Result<Catalog, CatalogError> {
254    let doc = toml_edit::Document::parse(text).map_err(|e| syntax_error(text, &e))?;
255    let root = doc.as_table();
256    let base = file.and_then(Path::parent).map(Path::to_path_buf);
257    let mut catalog = Catalog {
258        id: id.to_string(),
259        label: String::new(),
260        description: String::new(),
261        origin,
262        file: file.map(Path::to_path_buf),
263        datasets: Vec::new(),
264    };
265    for (key, item) in root.iter() {
266        let line = key_line(text, root, key, 1);
267        match key {
268            "label" => catalog.label = string_of(item, "label", line)?.trim().to_string(),
269            "description" => catalog.description = string_of(item, "description", line)?,
270            _ => {
271                let Some(table) = item.as_table_like() else {
272                    return Err(CatalogError::at(
273                        line,
274                        format!(
275                            "\"{key}\" is not a catalog key. The top level holds label, \
276                             description, and one [id] table per dataset"
277                        ),
278                    ));
279                };
280                let mut dataset = dataset(text, key, table, line)?;
281                dataset.base = base.clone();
282                catalog.datasets.push(dataset);
283            }
284        }
285    }
286    if catalog.label.is_empty() {
287        catalog.label = match origin {
288            Origin::Mine => MINE_LABEL.to_string(),
289            _ => id.to_string(),
290        };
291    }
292    check_unique(&catalog)?;
293    Ok(catalog)
294}
295
296/// One `[id]` table.
297fn dataset(
298    text: &str,
299    id: &str,
300    table: &dyn toml_edit::TableLike,
301    line: usize,
302) -> Result<Dataset, CatalogError> {
303    let what = format!("[{id}]");
304    if !is_valid_source_id(id) {
305        return Err(CatalogError::at(
306            line,
307            format!(
308                "{what}: a dataset's id is lowercase letters, digits and '-', up to 40 \
309                 characters. Its label goes in name = \"...\""
310            ),
311        ));
312    }
313    let mut dataset = Dataset {
314        id: id.to_string(),
315        line,
316        ..Default::default()
317    };
318    for (key, item) in table.iter() {
319        let at = key_line(text, table, key, line);
320        let field = format!("{what} {key}");
321        match key {
322            "name" => dataset.name = string_of(item, &field, at)?.trim().to_string(),
323            "path" => dataset.path = Some(string_of(item, &field, at)?),
324            "url" => dataset.url = Some(string_of(item, &field, at)?),
325            "auth" => dataset.auth = Some(string_of(item, &field, at)?),
326            "connection" => dataset.connection = Some(string_of(item, &field, at)?),
327            "description" => dataset.description = string_of(item, &field, at)?,
328            "publisher" => dataset.publisher = string_of(item, &field, at)?,
329            "license" => dataset.license = string_of(item, &field, at)?,
330            "homepage" => dataset.homepage = string_of(item, &field, at)?,
331            "documentation" => dataset.documentation = string_of(item, &field, at)?,
332            "size" => {
333                let size = item.as_integer().filter(|n| *n >= 0).ok_or_else(|| {
334                    CatalogError::at(at, format!("{field} must be a number of bytes"))
335                })?;
336                dataset.size = Some(size as u64);
337            }
338            "columns" => dataset.columns = columns(text, &what, item, at)?,
339            "bookmarks" => {
340                let Some(marks) = item.as_table_like() else {
341                    return Err(CatalogError::at(
342                        at,
343                        format!("{field}: write each as bookmarks.\"Name\" = \"path/\""),
344                    ));
345                };
346                for (name, place) in marks.iter() {
347                    let at = key_line(text, marks, name, at);
348                    let place = string_of(place, &format!("{what} bookmarks.\"{name}\""), at)?;
349                    dataset.bookmarks.push((name.trim().to_string(), place));
350                }
351            }
352            "codebook" => {
353                return Err(CatalogError::at(
354                    at,
355                    format!("{field}: the link is documentation = \"https://...\" now"),
356                ));
357            }
358            "suggested" => {
359                return Err(CatalogError::at(
360                    at,
361                    format!("{field}: places are bookmarks.\"Name\" = \"path/\" now"),
362                ));
363            }
364            _ => {
365                return Err(CatalogError::at(
366                    at,
367                    format!("{what}: unknown key '{key}'. Expected one of: {DATASET_KEYS}"),
368                ));
369            }
370        }
371    }
372    check_dataset(&dataset).map_err(|message| CatalogError::at(line, message))?;
373    Ok(dataset)
374}
375
376fn columns(
377    text: &str,
378    what: &str,
379    item: &toml_edit::Item,
380    line: usize,
381) -> Result<Vec<(String, ColumnNote)>, CatalogError> {
382    let Some(table) = item.as_table_like() else {
383        return Err(CatalogError::at(
384            line,
385            format!("{what} columns: write each as columns.NAME = {{ description = \"...\" }}"),
386        ));
387    };
388    let mut out = Vec::new();
389    for (column, item) in table.iter() {
390        let at = key_line(text, table, column, line);
391        let what = format!("{what} column \"{column}\"");
392        let Some(fields) = item.as_table_like() else {
393            return Err(CatalogError::at(
394                at,
395                format!("{what}: write it as columns.{column} = {{ description = \"...\" }}"),
396            ));
397        };
398        let mut note = ColumnNote::default();
399        for (key, item) in fields.iter() {
400            let at = key_line(text, fields, key, at);
401            match key {
402                "description" => {
403                    note.description = string_of(item, &format!("{what} description"), at)?
404                        .trim()
405                        .to_string();
406                }
407                "unit" => {
408                    note.unit = string_of(item, &format!("{what} unit"), at)?
409                        .trim()
410                        .to_string();
411                }
412                "values" => {
413                    let Some(values) = item.as_table_like() else {
414                        return Err(CatalogError::at(
415                            at,
416                            format!("{what}: values is a table of code = \"meaning\""),
417                        ));
418                    };
419                    for (code, meaning) in values.iter() {
420                        let at = key_line(text, values, code, at);
421                        let meaning = string_of(meaning, &format!("{what} value \"{code}\""), at)?;
422                        if meaning.trim().is_empty() {
423                            return Err(CatalogError::at(
424                                at,
425                                format!("{what}: value \"{code}\" has no meaning"),
426                            ));
427                        }
428                        note.values.push((code.to_string(), meaning));
429                    }
430                }
431                _ => {
432                    return Err(CatalogError::at(
433                        at,
434                        format!("{what}: unknown key '{key}'. Expected one of: {COLUMN_KEYS}"),
435                    ));
436                }
437            }
438        }
439        if note.description.is_empty() && note.unit.is_empty() && note.values.is_empty() {
440            return Err(CatalogError::at(
441                at,
442                format!("{what}: says nothing. Give a description, unit or values"),
443            ));
444        }
445        out.push((column.to_string(), note));
446    }
447    Ok(out)
448}
449
450/// The rules one dataset keeps on its own: where it is, how it is read, what its
451/// documentation and bookmarks say. Connections are checked against the config in
452/// [`Catalog::check_connections`].
453fn check_dataset(dataset: &Dataset) -> Result<(), String> {
454    let what = format!("[{}]", dataset.id);
455    if dataset.name.is_empty() {
456        return Err(format!("{what}: give it a name = \"...\" for its row"));
457    }
458    if !dataset.documentation.is_empty() && !dataset.documentation.starts_with("https://") {
459        return Err(format!(
460            "{what}: documentation \"{}\" is not an https:// link",
461            dataset.documentation
462        ));
463    }
464    if !dataset.homepage.is_empty()
465        && !(dataset.homepage.starts_with("https://") || dataset.homepage.starts_with("http://"))
466    {
467        return Err(format!(
468            "{what}: homepage \"{}\" is not an http(s):// link",
469            dataset.homepage
470        ));
471    }
472    let place = match (&dataset.path, &dataset.url) {
473        (None, None) => return Err(format!("{what}: say where it is with path or url")),
474        (Some(_), Some(_)) => {
475            return Err(format!(
476                "{what}: path and url both say where it is. Use one"
477            ));
478        }
479        (Some(path), None) => {
480            if path.trim().is_empty() {
481                return Err(format!("{what}: path is blank"));
482            }
483            if path.contains("://") {
484                return Err(format!("{what}: \"{path}\" is a URL. Use url = \"{path}\""));
485            }
486            for (field, set) in [
487                ("auth", dataset.auth.is_some()),
488                ("connection", dataset.connection.is_some()),
489            ] {
490                if set {
491                    return Err(format!(
492                        "{what}: {field} applies only to a url; a path is read as a file"
493                    ));
494                }
495            }
496            if dataset.size.is_some() {
497                return Err(format!(
498                    "{what}: size applies only to an http(s) url; a local file is measured"
499                ));
500            }
501            None
502        }
503        (None, Some(url)) => Some(dataset_url_place(url).ok_or_else(|| {
504            if crate::source::split_source_id(url).0.is_some() {
505                format!(
506                    "{what}: name the connection with connection = \"...\" rather than in \
507                     the URL"
508                )
509            } else if url.starts_with("http://") || url.starts_with("https://") {
510                format!(
511                    "{what}: \"{url}\" is not a data file. An HTTP server has no listing, so \
512                     a web URL must name a file datui reads, such as .csv or .parquet"
513                )
514            } else {
515                format!("{what}: \"{url}\" is not an s3://, gs://, Azure or HTTP(S) URL")
516            }
517        })?),
518    };
519    if let Some(place) = place {
520        if let Some(auth) = dataset.auth.as_deref()
521            && !matches!(auth, "auto" | "anonymous")
522        {
523            return Err(format!(
524                "{what}: auth \"{auth}\" is not valid. Expected {AUTH_VALUES}"
525            ));
526        }
527        if place == UrlPlace::Http {
528            if dataset.connection.is_some() {
529                return Err(format!(
530                    "{what}: connection applies only to s3://, gs:// and Azure URLs. A web \
531                     URL is read with no login"
532                ));
533            }
534        } else if dataset.size.is_some() {
535            return Err(format!(
536                "{what}: size applies only to an http(s) url; a store gives its own"
537            ));
538        }
539        if dataset.auth.is_some() && dataset.connection.is_some() {
540            return Err(format!(
541                "{what}: auth and connection both say how to read it. Use one"
542            ));
543        }
544    }
545    if dataset.bookmarks.is_empty() {
546        return Ok(());
547    }
548    if !matches!(place, None | Some(UrlPlace::ObjectStore(_))) {
549        return Err(format!(
550            "{what}: bookmarks apply only to a local path or an s3://, gs:// or Azure url"
551        ));
552    }
553    let mut names = std::collections::HashSet::new();
554    for (name, path) in &dataset.bookmarks {
555        if name.is_empty() {
556            return Err(format!("{what}: every bookmark needs a name"));
557        }
558        if !names.insert(name.as_str()) {
559            return Err(format!(
560                "{what} bookmark \"{name}\": the name is used twice"
561            ));
562        }
563        let path = path.trim();
564        if path.is_empty()
565            || path.starts_with('/')
566            || path.contains("://")
567            || path.contains('\\')
568            || path.split('/').any(|part| part == "..")
569        {
570            return Err(format!(
571                "{what} bookmark \"{name}\": path \"{path}\" must be relative to the dataset \
572                 and stay inside it"
573            ));
574        }
575    }
576    Ok(())
577}
578
579/// No two datasets of one catalog share a name or a location.
580fn check_unique(catalog: &Catalog) -> Result<(), CatalogError> {
581    let mut names = std::collections::HashMap::new();
582    let mut places = std::collections::HashMap::new();
583    for dataset in &catalog.datasets {
584        if let Some(first) = names.insert(dataset.name.as_str(), dataset.id.as_str()) {
585            return Err(CatalogError::at(
586                dataset.line,
587                format!(
588                    "[{}]: name \"{}\" is [{first}]'s too. Give each its own",
589                    dataset.id, dataset.name
590                ),
591            ));
592        }
593        if let Some(first) = places.insert(dataset.place_key(), dataset.id.as_str()) {
594            return Err(CatalogError::at(
595                dataset.line,
596                format!(
597                    "[{}]: \"{}\" is listed as [{first}] already",
598                    dataset.id,
599                    dataset.location_text()
600                ),
601            ));
602        }
603    }
604    Ok(())
605}
606
607impl Dataset {
608    /// What a chart export credits as its source: the entry's name, publisher and
609    /// license, as the catalog gives them (`NYC flights (2013), nycflights13 (CC0)`).
610    pub fn credit(&self) -> String {
611        let mut line = self.name.trim().to_string();
612        let publisher = self.publisher.trim();
613        if !publisher.is_empty() {
614            if !line.is_empty() {
615                line.push_str(", ");
616            }
617            line.push_str(publisher);
618        }
619        let license = self.license.trim();
620        if !license.is_empty() {
621            line.push_str(&format!(" ({license})"));
622        }
623        line.trim().to_string()
624    }
625
626    /// The local path with `~` and `$VAR` expanded, anchored at the catalog's directory
627    /// when it is relative.
628    pub fn local_path(&self) -> Option<PathBuf> {
629        let path = expand_path(self.path.as_deref()?);
630        Some(match &self.base {
631            Some(base) if path.is_relative() => base.join(path),
632            _ => path,
633        })
634    }
635
636    /// Where it is: the local path, expanded, or the URL.
637    pub fn location(&self) -> PathBuf {
638        self.local_path()
639            .or_else(|| self.url.as_deref().map(PathBuf::from))
640            .unwrap_or_default()
641    }
642
643    /// The path or URL as written.
644    pub fn location_text(&self) -> &str {
645        self.path.as_deref().or(self.url.as_deref()).unwrap_or("")
646    }
647
648    /// What two entries naming the same data have in common.
649    pub fn place_key(&self) -> String {
650        match (&self.path, &self.url) {
651            (Some(_), _) => format!(
652                "path:{}",
653                crate::config::path_place(&self.local_path().unwrap_or_default()).display()
654            ),
655            (None, Some(url)) => url_key(url),
656            (None, None) => String::new(),
657        }
658    }
659
660    /// Where a bookmark is: its path under the dataset's.
661    pub fn bookmark_location(&self, path: &str) -> PathBuf {
662        let rel = path.trim().trim_start_matches("./");
663        if let Some(local) = self.local_path() {
664            return local.join(rel);
665        }
666        PathBuf::from(format!(
667            "{}/{rel}",
668            self.url.as_deref().unwrap_or("").trim_end_matches('/')
669        ))
670    }
671
672    /// Whether a dataset in an object store, and how it is read: `auto`, `anonymous`,
673    /// or a connection's name. `None` for a path or a web file.
674    pub fn object_store_auth(&self) -> Option<crate::config::DatasetAuth> {
675        let url = self.url.as_deref()?;
676        if !is_object_store_dataset(url) {
677            return None;
678        }
679        Some(match (self.connection.as_deref(), self.auth.as_deref()) {
680            (Some(connection), _) => crate::config::DatasetAuth::Connection(connection.to_string()),
681            (None, Some("anonymous")) => crate::config::DatasetAuth::Anonymous,
682            _ => crate::config::DatasetAuth::Auto,
683        })
684    }
685
686    /// Whether this is an HTTP(S) file.
687    pub fn is_web_file(&self) -> bool {
688        self.url
689            .as_deref()
690            .is_some_and(|url| dataset_url_place(url) == Some(UrlPlace::Http))
691    }
692}
693
694impl Catalog {
695    /// Every `connection` a dataset names is one of `connections`, of the URL's kind.
696    pub fn check_connections(
697        &self,
698        connections: &[CloudConnectionConfig],
699    ) -> Result<(), CatalogError> {
700        for dataset in &self.datasets {
701            let (Some(connection), Some(url)) =
702                (dataset.connection.as_deref(), dataset.url.as_deref())
703            else {
704                continue;
705            };
706            let what = format!("[{}]", dataset.id);
707            let fail = |message: String| CatalogError::at(dataset.line, message);
708            let Some(configured) = connections.iter().find(|c| c.name == connection) else {
709                let names: Vec<&str> = connections.iter().map(|c| c.name.as_str()).collect();
710                return Err(fail(format!(
711                    "{what}: no [[cloud.connections]] entry in the config is named \
712                     \"{connection}\"{}",
713                    if names.is_empty() {
714                        String::new()
715                    } else {
716                        format!(". Connections: {}", names.join(", "))
717                    }
718                )));
719            };
720            let Some(UrlPlace::ObjectStore(kind)) = dataset_url_place(url) else {
721                continue;
722            };
723            let connection_kind = configured.kind.as_deref().unwrap_or("");
724            if connection_kind != kind {
725                return Err(fail(format!(
726                    "{what}: connection \"{connection}\" is kind = \"{connection_kind}\", \
727                     which does not read {kind} URLs"
728                )));
729            }
730            if let (Some(account), Some((url_account, _, _))) = (
731                configured.account.as_deref(),
732                crate::source::azure_parts(url),
733            ) && !account.eq_ignore_ascii_case(&url_account)
734            {
735                return Err(fail(format!(
736                    "{what}: connection \"{connection}\" signs in to account \"{account}\", \
737                     but the URL is in \"{url_account}\""
738                )));
739            }
740        }
741        Ok(())
742    }
743
744    /// The file's name for errors: its path, `examples` for the bundled one, or the
745    /// file its id names.
746    pub fn file_name(&self) -> String {
747        match (&self.file, self.origin) {
748            (Some(file), _) => file.display().to_string(),
749            (None, Origin::Bundled) => EXAMPLES.to_string(),
750            (None, _) => format!("{}.toml", self.id),
751        }
752    }
753
754    /// The dataset at `location`, by place.
755    pub fn dataset_at(&self, location: &Path) -> Option<&Dataset> {
756        let key = place_key_of(location);
757        self.datasets.iter().find(|d| d.place_key() == key)
758    }
759}
760
761/// The place key of a path or URL, as [`Dataset::place_key`] makes one.
762pub fn place_key_of(location: &Path) -> String {
763    let text = location.to_string_lossy();
764    if matches!(
765        crate::source::input_source(location),
766        crate::source::InputSource::Local(_)
767    ) {
768        format!("path:{}", crate::config::path_place(location).display())
769    } else {
770        url_key(&text)
771    }
772}
773
774/// A URL's place key: the place, whichever source a `s3://<id>@bucket` spelling names.
775fn url_key(url: &str) -> String {
776    let (_, plain) = crate::source::split_source_id(url);
777    format!("url:{}", crate::source::canonical_cloud_place(&plain))
778}
779
780/// The bundled `examples` catalog.
781pub fn bundled() -> Catalog {
782    static CATALOG: std::sync::OnceLock<Catalog> = std::sync::OnceLock::new();
783    CATALOG
784        .get_or_init(|| {
785            parse(BUNDLED, EXAMPLES, Origin::Bundled, None)
786                .unwrap_or_else(|e| panic!("{}", e.in_file("public_catalog.toml")))
787        })
788        .clone()
789}
790
791/// The bundled catalog's text, comments and all.
792pub fn bundled_text() -> &'static str {
793    BUNDLED
794}
795
796/// A catalog file left out because it has a mistake: shown as its section's callout, so
797/// one broken team file never keeps datui from starting.
798#[derive(Debug, Clone, PartialEq)]
799pub struct Broken {
800    /// The id the file would have had: what `home.hide` names.
801    pub id: String,
802    pub origin: Origin,
803    pub file: PathBuf,
804    pub line: Option<usize>,
805    pub message: String,
806}
807
808impl Broken {
809    /// `path:line: message`, for stderr and the log.
810    pub fn full(&self) -> String {
811        CatalogError {
812            line: self.line,
813            message: self.message.clone(),
814        }
815        .in_file(&self.file.display().to_string())
816    }
817
818    /// `▲ name.toml:3 message`, for the section's one line.
819    pub fn callout(&self) -> String {
820        let name = self
821            .file
822            .file_name()
823            .map(|n| n.to_string_lossy().into_owned())
824            .unwrap_or_default();
825        let place = match self.line {
826            Some(line) => format!("{name}:{line}"),
827            None => name,
828        };
829        format!("{} {place} {}", crate::glyphs::get().warning, self.message)
830    }
831}
832
833/// Read the catalog in `file`, or say what is wrong with it. `Ok(None)` when there is
834/// no such file.
835pub fn load(file: &Path, id: &str, origin: Origin) -> Result<Option<Catalog>, Broken> {
836    let broken = |line, message| Broken {
837        id: id.to_string(),
838        origin,
839        file: file.to_path_buf(),
840        line,
841        message,
842    };
843    let text = match std::fs::read_to_string(file) {
844        Ok(text) => text,
845        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
846        Err(e) => return Err(broken(None, format!("cannot be read: {e}"))),
847    };
848    parse(&text, id, origin, Some(file))
849        .map(Some)
850        .map_err(|e| broken(e.line, e.message))
851}
852
853/// Read the catalog in `file`. `None` when there is no such file.
854pub fn read(file: &Path, id: &str, origin: Origin) -> color_eyre::Result<Option<Catalog>> {
855    let text = match std::fs::read_to_string(file) {
856        Ok(text) => text,
857        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
858        Err(e) => {
859            return Err(color_eyre::eyre::eyre!(
860                "Failed to read catalog {}: {e}",
861                file.display()
862            ));
863        }
864    };
865    parse(&text, id, origin, Some(file))
866        .map(Some)
867        .map_err(|e| color_eyre::eyre::eyre!("{}", e.in_file(&file.display().to_string())))
868}
869
870/// A listed catalog file's id: its file name without `.toml`.
871pub fn id_of_file(file: &Path) -> String {
872    file.file_stem()
873        .map(|s| s.to_string_lossy().into_owned())
874        .unwrap_or_default()
875}
876
877/// A dataset id for `name`, not among `taken`: lowercase words joined by `-`.
878pub fn id_for(name: &str, taken: &[&str]) -> String {
879    let mut base = String::new();
880    for c in name.chars() {
881        if c.is_ascii_alphanumeric() {
882            base.push(c.to_ascii_lowercase());
883        } else if !base.ends_with('-') && !base.is_empty() {
884            base.push('-');
885        }
886    }
887    let mut base: String = base.trim_end_matches('-').chars().take(34).collect();
888    base = base.trim_end_matches('-').to_string();
889    if base.is_empty() {
890        base = "dataset".to_string();
891    }
892    if !taken.contains(&base.as_str()) {
893        return base;
894    }
895    (2..)
896        .map(|n| format!("{base}-{n}"))
897        .find(|id| !taken.contains(&id.as_str()))
898        .expect("an unused id")
899}
900
901/// What Ctrl+D writes for a row.
902#[derive(Debug, Clone, Default, PartialEq)]
903pub struct NewDataset {
904    pub name: String,
905    pub path: Option<String>,
906    pub url: Option<String>,
907    pub auth: Option<String>,
908    pub connection: Option<String>,
909    pub description: String,
910    pub size: Option<u64>,
911}
912
913impl NewDataset {
914    /// The dataset this would be in a catalog in `dir`.
915    fn as_dataset(&self, dir: Option<&Path>) -> Dataset {
916        Dataset {
917            id: "new".to_string(),
918            name: self.name.trim().to_string(),
919            path: self.path.clone(),
920            url: self.url.clone(),
921            auth: self.auth.clone(),
922            connection: self.connection.clone(),
923            description: self.description.clone(),
924            size: self.size,
925            base: dir.map(Path::to_path_buf),
926            ..Default::default()
927        }
928    }
929
930    /// Why this cannot be a catalog entry, if it cannot.
931    pub fn check(&self) -> Result<(), String> {
932        check_dataset(&self.as_dataset(None))
933            .map_err(|e| e.strip_prefix("[new]: ").map(str::to_string).unwrap_or(e))
934    }
935
936    /// Where it is, as a place key.
937    pub fn place_key(&self) -> String {
938        self.as_dataset(None).place_key()
939    }
940}
941
942/// What a new `catalog.toml` starts with.
943pub const MINE_TEMPLATE: &str = "\
944# catalog.toml, beside config.toml: your catalog, \"My datasets\" on the home screen.
945# Ctrl+D on the home screen adds the selected row here; Ctrl+D on one of these rows
946# forgets it. Your edits, comments and layout are kept: datui catalog check catalog.toml
947#
948# Each [table] is one dataset; its key is a short id (lowercase letters, digits, -):
949#
950#   [sales]
951#   name = \"Sales\"
952#   path = \"~/datasets/sales.parquet\"
953#   columns.amount = { description = \"Net of returns\", unit = \"USD\" }
954#
955# The bundled \"Example datasets\" catalog is separate:
956#   hide all of it      [home] hide = [\"examples\"]               (config.toml)
957#   hide some entries   [home] hide = [\"examples/nyc-taxis\"]     (config.toml)
958#   make it your own    datui catalog show examples > examples.toml
959#                       then move examples.toml into catalogs/ here, and edit it:
960#                       it replaces the bundled one
961#
962# Any other *.toml in catalogs/ here is a catalog too, named by its file name.
963
964label = \"My datasets\"
965";
966
967/// Write `text` to `file` whole: into a sibling, then renamed over it, so a reader
968/// never sees half a file.
969fn write_whole(file: &Path, text: &str) -> color_eyre::Result<()> {
970    if let Some(dir) = file.parent() {
971        std::fs::create_dir_all(dir)?;
972    }
973    // Its own temporary name, so two datui writing at once cannot share one.
974    let mut temp = file.as_os_str().to_owned();
975    temp.push(format!(".{}.tmp", std::process::id()));
976    let temp = PathBuf::from(temp);
977    std::fs::write(&temp, text)?;
978    if let Err(e) = std::fs::rename(&temp, file) {
979        let _ = std::fs::remove_file(&temp);
980        return Err(e.into());
981    }
982    Ok(())
983}
984
985/// Append `dataset` to the catalog in `file`, creating the file when there is none.
986/// Returns its id. The rest of the file is kept as written.
987pub fn add(file: &Path, dataset: &NewDataset) -> color_eyre::Result<String> {
988    let text = match std::fs::read_to_string(file) {
989        Ok(text) => text,
990        Err(e) if e.kind() == std::io::ErrorKind::NotFound => MINE_TEMPLATE.to_string(),
991        Err(e) => return Err(e.into()),
992    };
993    // A file that does not read is not written over: the user's text is in it.
994    let existing = parse(&text, MINE, Origin::Mine, Some(file))
995        .map_err(|e| color_eyre::eyre::eyre!("{}", e.in_file(&file.display().to_string())))?;
996    let candidate = dataset.as_dataset(file.parent());
997    check_dataset(&candidate).map_err(|e| color_eyre::eyre::eyre!("{e}"))?;
998    if let Some(there) = existing
999        .datasets
1000        .iter()
1001        .find(|d| d.place_key() == candidate.place_key())
1002    {
1003        return Err(color_eyre::eyre::eyre!(
1004            "{} is in the catalog already, as {}",
1005            candidate.location_text(),
1006            there.name
1007        ));
1008    }
1009    // Names are unique in a catalog: a second `data.csv` is named by where it is.
1010    let mut name = candidate.name.clone();
1011    if existing.datasets.iter().any(|d| d.name == name) {
1012        name = candidate.location_text().to_string();
1013    }
1014    let mut doc: toml_edit::DocumentMut = text.parse()?;
1015    // The top-level keys are not ids either.
1016    let mut taken: Vec<String> = doc.iter().map(|(k, _)| k.to_string()).collect();
1017    taken.extend(["label".to_string(), "description".to_string()]);
1018    let taken: Vec<&str> = taken.iter().map(String::as_str).collect();
1019    let id = id_for(&dataset.name, &taken);
1020    let mut table = toml_edit::Table::new();
1021    table.insert("name", toml_edit::value(name.as_str()));
1022    for (key, value) in [
1023        ("path", &dataset.path),
1024        ("url", &dataset.url),
1025        ("auth", &dataset.auth),
1026        ("connection", &dataset.connection),
1027    ] {
1028        if let Some(value) = value {
1029            table.insert(key, toml_edit::value(value.as_str()));
1030        }
1031    }
1032    if let Some(size) = dataset.size {
1033        table.insert("size", toml_edit::value(size as i64));
1034    }
1035    if !dataset.description.is_empty() {
1036        table.insert(
1037            "description",
1038            toml_edit::value(dataset.description.as_str()),
1039        );
1040    }
1041    table.decor_mut().set_prefix("\n");
1042    doc.insert(&id, toml_edit::Item::Table(table));
1043    let mut out = doc.to_string();
1044    if !out.ends_with('\n') {
1045        out.push('\n');
1046    }
1047    // Never write a file the next start cannot read.
1048    parse(&out, MINE, Origin::Mine, Some(file)).map_err(|e| {
1049        color_eyre::eyre::eyre!("not added: {}", e.in_file(&file.display().to_string()))
1050    })?;
1051    write_whole(file, &out)?;
1052    Ok(id)
1053}
1054
1055/// Remove the dataset `id` from the catalog in `file`: its table and every table under
1056/// it, and nothing else.
1057pub fn forget(file: &Path, id: &str) -> color_eyre::Result<()> {
1058    let text = std::fs::read_to_string(file)?;
1059    // A file that does not read is never written: the user's text is in it.
1060    parse(&text, MINE, Origin::Mine, Some(file))
1061        .map_err(|e| color_eyre::eyre::eyre!("{}", e.in_file(&file.display().to_string())))?;
1062    let mut doc: toml_edit::DocumentMut = text.parse()?;
1063    if doc.remove(id).is_none() {
1064        return Err(color_eyre::eyre::eyre!("{} has no [{id}]", file.display()));
1065    }
1066    write_whole(file, &doc.to_string())
1067}
1068
1069/// Add each directory of `places` to the catalog in `file`, named by where it is, unless
1070/// the file lists it already: what Ctrl+D kept in the cache before 0.4.0. Returns how
1071/// many were added.
1072pub fn move_places(file: &Path, places: &[PathBuf]) -> color_eyre::Result<usize> {
1073    let mut added = 0;
1074    for place in places {
1075        let listed = match read(file, MINE, Origin::Mine)? {
1076            Some(catalog) => catalog.dataset_at(place).is_some(),
1077            None => false,
1078        };
1079        if listed {
1080            continue;
1081        }
1082        let text = crate::home::display_path(place);
1083        add(
1084            file,
1085            &NewDataset {
1086                name: text.clone(),
1087                path: Some(text),
1088                ..Default::default()
1089            },
1090        )?;
1091        added += 1;
1092    }
1093    Ok(added)
1094}
1095
1096/// Columns padded to their widest cell, two spaces apart.
1097pub(crate) fn table(rows: &[Vec<String>]) -> String {
1098    let columns = rows.first().map(Vec::len).unwrap_or(0);
1099    let widths: Vec<usize> = (0..columns)
1100        .map(|c| rows.iter().map(|r| r[c].chars().count()).max().unwrap_or(0))
1101        .collect();
1102    let mut out = String::new();
1103    for row in rows {
1104        let mut line = String::new();
1105        for (c, cell) in row.iter().enumerate() {
1106            if c + 1 == row.len() {
1107                line.push_str(cell);
1108            } else {
1109                line.push_str(&format!("{cell:<w$}  ", w = widths[c]));
1110            }
1111        }
1112        out.push_str(line.trim_end());
1113        out.push('\n');
1114    }
1115    out
1116}
1117
1118/// What `datui catalog ACTION` prints, and its exit code. `config` is the configuration
1119/// in effect, or why it could not be read.
1120pub fn command(
1121    action: &datui_cli::CatalogAction,
1122    config: color_eyre::Result<crate::config::AppConfig>,
1123) -> (String, i32) {
1124    use datui_cli::CatalogAction;
1125    use datui_cli::exit::{FAILURE, SUCCESS};
1126    match action {
1127        CatalogAction::Show { name } => {
1128            let config = match config {
1129                Ok(config) => config,
1130                Err(e) => return (format!("{e}\n"), FAILURE),
1131            };
1132            let catalogs = config.catalogs();
1133            let Some(name) = name else {
1134                let mut rows = vec![vec![
1135                    "ID".to_string(),
1136                    "LABEL".to_string(),
1137                    "DATASETS".to_string(),
1138                    "FROM".to_string(),
1139                    "FILE".to_string(),
1140                ]];
1141                for catalog in &catalogs {
1142                    let hidden = if config.home.hide.contains(&catalog.id) {
1143                        " (hidden by home.hide)"
1144                    } else {
1145                        ""
1146                    };
1147                    rows.push(vec![
1148                        catalog.id.clone(),
1149                        format!("{}{hidden}", catalog.label),
1150                        catalog.datasets.len().to_string(),
1151                        match catalog.origin {
1152                            Origin::Mine => MINE_FILE,
1153                            Origin::Folder => "catalogs/",
1154                            Origin::Listed => "catalogs = [...]",
1155                            Origin::Bundled => crate::home::BUNDLED_ORIGIN,
1156                        }
1157                        .to_string(),
1158                        catalog
1159                            .file
1160                            .as_deref()
1161                            .map(|f| f.display().to_string())
1162                            .unwrap_or_else(|| "-".to_string()),
1163                    ]);
1164                }
1165                for broken in &config.broken_catalogs {
1166                    rows.push(vec![
1167                        broken.id.clone(),
1168                        format!("{} not read", crate::glyphs::get().warning),
1169                        "-".to_string(),
1170                        "-".to_string(),
1171                        broken.full(),
1172                    ]);
1173                }
1174                return (table(&rows), SUCCESS);
1175            };
1176            if name == MINE && !catalogs.iter().any(|c| c.id == MINE) {
1177                return (
1178                    format!(
1179                        "No {MINE_FILE} yet. Ctrl+D on a home row, or datui config init, \
1180                         writes it\n"
1181                    ),
1182                    FAILURE,
1183                );
1184            }
1185            let Some(catalog) = catalogs.iter().find(|c| c.id == *name) else {
1186                let ids: Vec<&str> = catalogs.iter().map(|c| c.id.as_str()).collect();
1187                let renamed = if name == OLD_EXAMPLES_ID {
1188                    format!(" (`{OLD_EXAMPLES_ID}` is now `{EXAMPLES}`)")
1189                } else {
1190                    String::new()
1191                };
1192                return (
1193                    format!(
1194                        "No catalog is named {name}{renamed}. Catalogs: {}\n",
1195                        ids.join(", ")
1196                    ),
1197                    FAILURE,
1198                );
1199            };
1200            match &catalog.file {
1201                None => (bundled_text().to_string(), SUCCESS),
1202                Some(file) => match std::fs::read_to_string(file) {
1203                    Ok(text) => (text, SUCCESS),
1204                    Err(e) => (format!("{}: {e}\n", file.display()), FAILURE),
1205                },
1206            }
1207        }
1208        CatalogAction::Check { file } => {
1209            let text = match std::fs::read_to_string(file) {
1210                Ok(text) => text,
1211                Err(e) => return (format!("{}: {e}\n", file.display()), FAILURE),
1212            };
1213            let name = file.display().to_string();
1214            let origin = if file.file_name().is_some_and(|n| n == MINE_FILE) {
1215                Origin::Mine
1216            } else {
1217                Origin::Listed
1218            };
1219            let parsed = parse(&text, &id_of_file(file), origin, Some(file)).and_then(|c| {
1220                // Connections are the config's: with no config to read, they go unchecked.
1221                match &config {
1222                    Ok(config) => c.check_connections(&config.cloud.connections).map(|()| c),
1223                    Err(_) => Ok(c),
1224                }
1225            });
1226            match parsed {
1227                Err(e) => (format!("{}\n", e.in_file(&name)), FAILURE),
1228                Ok(catalog) => {
1229                    let mut out = format!(
1230                        "{name}: {}, {} dataset{}\n",
1231                        catalog.label,
1232                        catalog.datasets.len(),
1233                        if catalog.datasets.len() == 1 { "" } else { "s" }
1234                    );
1235                    let rows: Vec<Vec<String>> = catalog
1236                        .datasets
1237                        .iter()
1238                        .map(|d| {
1239                            vec![
1240                                format!("  {}", d.id),
1241                                d.name.clone(),
1242                                d.location_text().to_string(),
1243                            ]
1244                        })
1245                        .collect();
1246                    out.push_str(&table(&rows));
1247                    (out, SUCCESS)
1248                }
1249            }
1250        }
1251    }
1252}
1253
1254#[cfg(test)]
1255mod tests {
1256    use super::*;
1257
1258    fn error_of(text: &str) -> String {
1259        parse(text, "t", Origin::Listed, None)
1260            .unwrap_err()
1261            .in_file("t.toml")
1262    }
1263
1264    /// A chart export credits the entry: its name, publisher and license, as given.
1265    #[test]
1266    fn an_entry_credits_its_name_publisher_and_license() {
1267        let entry = |publisher: &str, license: &str| Dataset {
1268            name: "NYC flights (2013)".to_string(),
1269            publisher: publisher.to_string(),
1270            license: license.to_string(),
1271            ..Default::default()
1272        };
1273        assert_eq!(
1274            entry("nycflights13", "CC0").credit(),
1275            "NYC flights (2013), nycflights13 (CC0)"
1276        );
1277        assert_eq!(entry(" NOAA ", "").credit(), "NYC flights (2013), NOAA");
1278        assert_eq!(entry("", "").credit(), "NYC flights (2013)");
1279    }
1280
1281    #[test]
1282    fn the_bundled_catalog_reads_in_file_order() {
1283        let catalog = bundled();
1284        assert_eq!(catalog.label, "Example datasets");
1285        assert_eq!(catalog.datasets[0].id, "nyc-flights");
1286        let noaa = catalog.datasets.iter().find(|d| d.id == "noaa").unwrap();
1287        assert_eq!(noaa.columns[0].0, "ID");
1288        let element = &noaa.columns.iter().find(|(c, _)| c == "ELEMENT").unwrap().1;
1289        assert_eq!(element.values[0].0, "PRCP");
1290        assert_eq!(noaa.bookmarks[0].0, "Daily highs, 2024");
1291    }
1292
1293    #[test]
1294    fn a_values_table_after_an_inline_column_says_how_to_write_it() {
1295        let text = "[w]\nname = \"W\"\nurl = \"s3://b/w/\"\n\
1296                    columns.FLAG = { description = \"Flag\" }\n\n\
1297                    [w.columns.FLAG.values]\nS = \"spatial\"\n";
1298        let error = error_of(text);
1299        assert!(error.starts_with("t.toml:6: "), "{error}");
1300        assert!(
1301            error.contains("columns.FLAG.description = \"...\""),
1302            "{error}"
1303        );
1304        let dotted = "[w]\nname = \"W\"\nurl = \"s3://b/w/\"\n\
1305                      columns.FLAG.description = \"Flag\"\n\n\
1306                      [w.columns.FLAG.values]\nS = \"spatial\"\n";
1307        let catalog = parse(dotted, "t", Origin::Listed, None).unwrap();
1308        assert_eq!(
1309            catalog.datasets[0].columns[0].1.values,
1310            [("S".to_string(), "spatial".to_string())]
1311        );
1312    }
1313
1314    #[test]
1315    fn a_mistake_is_named_at_its_line_with_the_fix() {
1316        assert_eq!(
1317            error_of("label = \"x\"\n\n[a]\nname = \"A\"\n"),
1318            "t.toml:3: [a]: say where it is with path or url"
1319        );
1320        let error = error_of("[a]\nname = \"A\"\npath = \"/x\"\ncodebook = \"https://x\"\n");
1321        assert!(error.starts_with("t.toml:4: "), "{error}");
1322        assert!(error.contains("documentation"), "{error}");
1323        let error = error_of("[\"A b\"]\nname = \"A\"\npath = \"/x\"\n");
1324        assert!(error.contains("lowercase"), "{error}");
1325        let error =
1326            error_of("[a]\nname = \"A\"\npath = \"/x\"\n[b]\nname = \"A\"\npath = \"/y\"\n");
1327        assert!(error.starts_with("t.toml:4: [b]: name \"A\""), "{error}");
1328        let error =
1329            error_of("[a]\nname = \"A\"\npath = \"/x\"\n[b]\nname = \"B\"\npath = \"/x/\"\n");
1330        assert!(error.contains("listed as [a]"), "{error}");
1331        let error = error_of(
1332            "[a]\nname = \"A\"\nurl = \"https://x.org/a.csv\"\nbookmarks.\"B\" = \"b/\"\n",
1333        );
1334        assert!(error.contains("bookmarks apply only"), "{error}");
1335        let error = error_of("[a]\nname = \"A\"\npath = \"/x\"\nbookmarks.\"Up\" = \"../b/\"\n");
1336        assert!(error.contains("stay inside"), "{error}");
1337        let error = error_of("stray = 1\n");
1338        assert!(error.starts_with("t.toml:1: "), "{error}");
1339    }
1340
1341    #[test]
1342    fn ids_come_from_names_and_never_repeat() {
1343        assert_eq!(
1344            id_for("NOAA daily weather (GHCN-D)", &[]),
1345            "noaa-daily-weather-ghcn-d"
1346        );
1347        assert_eq!(id_for("~/Downloads", &[]), "downloads");
1348        assert_eq!(id_for("Sales", &["sales"]), "sales-2");
1349        assert_eq!(id_for("Sales", &["sales", "sales-2"]), "sales-3");
1350        assert_eq!(id_for("→", &[]), "dataset");
1351        assert!(is_valid_source_id(&id_for(&"x".repeat(80), &[])));
1352    }
1353
1354    #[test]
1355    fn adding_and_forgetting_keep_the_rest_of_the_file() {
1356        let dir = tempfile::tempdir().unwrap();
1357        let file = dir.path().join(MINE_FILE);
1358        let id = add(
1359            &file,
1360            &NewDataset {
1361                name: "Sales".into(),
1362                path: Some("/data/sales.parquet".into()),
1363                ..Default::default()
1364            },
1365        )
1366        .unwrap();
1367        assert_eq!(id, "sales");
1368        let mut text = std::fs::read_to_string(&file).unwrap();
1369        assert!(
1370            text.starts_with(MINE_TEMPLATE),
1371            "the header is kept whole: {text}"
1372        );
1373        text.push_str("\n# kept\n[noaa]\nname = \"NOAA\"\nurl = \"s3://noaa-ghcn-pds/parquet/\"\ncolumns.ELEMENT.description = \"What\"\n\n[noaa.columns.ELEMENT.values]\nTMAX = \"High\"\n");
1374        std::fs::write(&file, &text).unwrap();
1375        let again = add(
1376            &file,
1377            &NewDataset {
1378                name: "Sales".into(),
1379                path: Some("/data/sales2.parquet".into()),
1380                ..Default::default()
1381            },
1382        )
1383        .unwrap();
1384        assert_eq!(again, "sales-2");
1385        forget(&file, "noaa").unwrap();
1386        let text = std::fs::read_to_string(&file).unwrap();
1387        assert!(!text.contains("[noaa"), "{text}");
1388        assert!(text.starts_with(MINE_TEMPLATE), "{text}");
1389        let catalog = read(&file, MINE, Origin::Mine).unwrap().unwrap();
1390        let ids: Vec<&str> = catalog.datasets.iter().map(|d| d.id.as_str()).collect();
1391        assert_eq!(ids, ["sales", "sales-2"]);
1392        assert_eq!(catalog.label, MINE_LABEL);
1393    }
1394
1395    #[test]
1396    fn remembered_places_move_into_the_catalog_once() {
1397        let cache_dir = tempfile::tempdir().unwrap();
1398        let config_dir = tempfile::tempdir().unwrap();
1399        let cache = crate::cache::CacheManager::with_dir(cache_dir.path().to_path_buf());
1400        // Absolute on every platform: a Windows path needs its drive.
1401        let places = [
1402            cache_dir.path().join("lake"),
1403            cache_dir.path().join("share"),
1404        ];
1405        cache.save_remembered_places(&places).unwrap();
1406        let taken = cache.load_remembered_places();
1407        assert_eq!(taken, places);
1408        let file = config_dir.path().join(MINE_FILE);
1409        assert_eq!(move_places(&file, &taken).unwrap(), 2);
1410        assert_eq!(move_places(&file, &taken).unwrap(), 0, "never twice");
1411        let catalog = read(&file, MINE, Origin::Mine).unwrap().unwrap();
1412        let paths: Vec<PathBuf> = catalog.datasets.iter().map(Dataset::location).collect();
1413        assert_eq!(paths, places);
1414    }
1415
1416    #[test]
1417    fn show_prints_the_bundled_file_and_check_names_the_line() {
1418        use datui_cli::CatalogAction;
1419        let config = crate::config::AppConfig::default();
1420        let (text, code) = command(
1421            &CatalogAction::Show {
1422                name: Some(EXAMPLES.into()),
1423            },
1424            Ok(config.clone()),
1425        );
1426        assert_eq!(code, 0);
1427        assert_eq!(text, BUNDLED);
1428        let (list, code) = command(&CatalogAction::Show { name: None }, Ok(config.clone()));
1429        assert_eq!(code, 0);
1430        assert!(
1431            list.contains("examples") && list.contains("comes with datui"),
1432            "{list}"
1433        );
1434        // A catalog the config hides is listed, and says why it is not on home.
1435        let mut hiding = config.clone();
1436        hiding.home.hide = vec![EXAMPLES.to_string()];
1437        let (list, _) = command(&CatalogAction::Show { name: None }, Ok(hiding));
1438        assert!(list.contains("(hidden by home.hide)"), "{list}");
1439        // The id before 0.4.0 says what it is now.
1440        let (text, code) = command(
1441            &CatalogAction::Show {
1442                name: Some(OLD_EXAMPLES_ID.into()),
1443            },
1444            Ok(config.clone()),
1445        );
1446        assert_ne!(code, 0);
1447        assert!(text.contains("`public` is now `examples`"), "{text}");
1448        let dir = tempfile::tempdir().unwrap();
1449        let file = dir.path().join("team.toml");
1450        std::fs::write(
1451            &file,
1452            "label = \"Team\"\n\n[a]\nname = \"A\"\npath = \"a.csv\"\n",
1453        )
1454        .unwrap();
1455        let (text, code) = command(
1456            &CatalogAction::Check { file: file.clone() },
1457            Ok(config.clone()),
1458        );
1459        assert_eq!(code, 0, "{text}");
1460        assert!(text.contains("Team, 1 dataset"), "{text}");
1461        std::fs::write(
1462            &file,
1463            "[a]\nname = \"A\"\nurl = \"s3://b/a/\"\nconnection = \"lab\"\n",
1464        )
1465        .unwrap();
1466        let (text, code) = command(&CatalogAction::Check { file: file.clone() }, Ok(config));
1467        assert_eq!(code, 1);
1468        assert!(
1469            text.contains("team.toml:1: [a]: no [[cloud.connections]]"),
1470            "{text}"
1471        );
1472    }
1473
1474    #[test]
1475    fn an_added_row_never_breaks_the_file() {
1476        let dir = tempfile::tempdir().unwrap();
1477        let file = dir.path().join(MINE_FILE);
1478        let new = |name: &str, path: &str| NewDataset {
1479            name: name.into(),
1480            path: Some(path.into()),
1481            ..Default::default()
1482        };
1483        // Not the top level's keys.
1484        assert_eq!(
1485            add(&file, &new("Description", "/d")).unwrap(),
1486            "description-2"
1487        );
1488        assert_eq!(add(&file, &new("Label", "/l")).unwrap(), "label-2");
1489        // A name that is another's once trimmed is named by where it is.
1490        add(&file, &new("Sales", "/a.csv")).unwrap();
1491        add(&file, &new(" Sales ", "/b.csv")).unwrap();
1492        let catalog = read(&file, MINE, Origin::Mine).unwrap().unwrap();
1493        let names: Vec<&str> = catalog.datasets.iter().map(|d| d.name.as_str()).collect();
1494        assert_eq!(names, ["Description", "Label", "Sales", "/b.csv"]);
1495    }
1496
1497    #[test]
1498    fn a_url_through_a_named_source_is_the_same_place() {
1499        assert_eq!(
1500            place_key_of(Path::new("s3://lab@bucket/dir/")),
1501            place_key_of(Path::new("s3://bucket/dir"))
1502        );
1503    }
1504
1505    #[test]
1506    fn a_relative_path_is_relative_to_the_catalog() {
1507        let catalog = parse(
1508            "[a]\nname = \"A\"\npath = \"data/a.csv\"\n",
1509            "t",
1510            Origin::Listed,
1511            Some(Path::new("/team/t.toml")),
1512        )
1513        .unwrap();
1514        assert_eq!(
1515            catalog.datasets[0].local_path().unwrap(),
1516            Path::new("/team/data/a.csv")
1517        );
1518    }
1519}