Skip to main content

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