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