1use std::path::{Path, PathBuf};
12
13use crate::config::{CloudConnectionConfig, expand_path, is_valid_source_id};
14
15pub const EXAMPLES: &str = "examples";
17pub const OLD_EXAMPLES_ID: &str = "public";
19pub const MINE: &str = "mine";
21pub const MINE_FILE: &str = "catalog.toml";
23pub const FOLDER: &str = "catalogs";
25pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum Origin {
38 Mine,
40 Folder,
42 Listed,
44 Bundled,
46}
47
48#[derive(Debug, Clone, PartialEq)]
50pub struct Catalog {
51 pub id: String,
53 pub label: String,
55 pub description: String,
56 pub origin: Origin,
57 pub file: Option<PathBuf>,
59 pub datasets: Vec<Dataset>,
60}
61
62#[derive(Debug, Clone, Default, PartialEq)]
64pub struct Dataset {
65 pub id: String,
67 pub line: usize,
69 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 pub documentation: String,
81 pub size: Option<u64>,
83 pub columns: Vec<(String, ColumnNote)>,
85 pub bookmarks: Vec<(String, String)>,
87 pub base: Option<PathBuf>,
89}
90
91#[derive(Debug, Clone, Default, PartialEq)]
93pub struct ColumnNote {
94 pub description: String,
95 pub unit: String,
96 pub values: Vec<(String, String)>,
98 pub ty: String,
100}
101
102impl ColumnNote {
103 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#[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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151pub(crate) enum UrlPlace {
152 ObjectStore(&'static str),
154 Http,
155}
156
157pub(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
188pub fn is_object_store_dataset(url: &str) -> bool {
190 matches!(dataset_url_place(url), Some(UrlPlace::ObjectStore(_)))
191}
192
193fn line_of(text: &str, offset: usize) -> usize {
195 text[..offset.min(text.len())].matches('\n').count() + 1
196}
197
198fn 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
208fn 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
247pub 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
297fn 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
451fn 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
580fn 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 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 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 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 pub fn location_text(&self) -> &str {
646 self.path.as_deref().or(self.url.as_deref()).unwrap_or("")
647 }
648
649 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 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 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 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 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 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
755pub 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
768fn 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
777pub 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
788pub fn bundled_text() -> &'static str {
790 BUNDLED
791}
792
793#[derive(Debug, Clone, PartialEq)]
796pub struct Broken {
797 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 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 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
830pub 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
850pub 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
867pub 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
874pub 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#[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 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 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
934pub 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
959fn 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 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
977pub 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 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 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 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 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
1047pub fn forget(file: &Path, id: &str) -> color_eyre::Result<()> {
1050 let text = std::fs::read_to_string(file)?;
1051 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
1061pub 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
1088pub(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
1110pub 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 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 #[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 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 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 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 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 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}