1use color_eyre::Result;
2use serde::{Deserialize, Serialize};
3use std::collections::HashSet;
4use std::collections::hash_map::DefaultHasher;
5use std::fs;
6use std::hash::{Hash, Hasher};
7use std::path::{Path, PathBuf};
8use std::time::SystemTime;
9
10use polars::prelude::Schema;
11
12use crate::config::ConfigManager;
13use crate::filter_modal::FilterStatement;
14use crate::pivot_melt_modal::{MeltSpec, PivotSpec, ReshapeSource};
15
16mod time_serde {
18 use serde::{Deserialize, Deserializer, Serialize, Serializer};
19 use std::time::{SystemTime, UNIX_EPOCH};
20
21 pub fn serialize<S>(time: &SystemTime, serializer: S) -> Result<S::Ok, S::Error>
22 where
23 S: Serializer,
24 {
25 let duration = time.duration_since(UNIX_EPOCH).map_err(|e| {
26 serde::ser::Error::custom(format!("Failed to serialize SystemTime: {}", e))
27 })?;
28 duration.as_secs().serialize(serializer)
29 }
30
31 pub fn deserialize<'de, D>(deserializer: D) -> Result<SystemTime, D::Error>
32 where
33 D: Deserializer<'de>,
34 {
35 let secs = u64::deserialize(deserializer)?;
36 Ok(UNIX_EPOCH + std::time::Duration::from_secs(secs))
37 }
38
39 pub mod option {
40 use super::*;
41
42 pub fn serialize<S>(time: &Option<SystemTime>, serializer: S) -> Result<S::Ok, S::Error>
43 where
44 S: Serializer,
45 {
46 match time {
47 Some(time) => super::serialize(time, serializer),
48 None => serializer.serialize_none(),
49 }
50 }
51
52 pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<SystemTime>, D::Error>
53 where
54 D: Deserializer<'de>,
55 {
56 Option::<u64>::deserialize(deserializer)?
57 .map(|secs| Ok(UNIX_EPOCH + std::time::Duration::from_secs(secs)))
58 .transpose()
59 }
60 }
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct SavedView {
67 #[serde(default)]
68 pub id: String,
69 #[serde(default)]
70 pub name: String,
71 pub description: Option<String>,
72 #[serde(with = "time_serde", default = "SystemTime::now")]
73 pub created: SystemTime,
74 #[serde(with = "time_serde::option")]
75 #[serde(skip_serializing_if = "Option::is_none")]
76 #[serde(default)]
77 pub last_used: Option<SystemTime>,
78 #[serde(default)]
79 pub usage_count: usize,
80 #[serde(skip_serializing_if = "Option::is_none")]
81 pub last_matched_file: Option<PathBuf>,
82 #[serde(default)]
83 pub match_criteria: MatchCriteria,
84 pub settings: ViewSettings,
85}
86
87#[derive(Debug, Clone, Default, Serialize, Deserialize)]
88pub struct MatchCriteria {
89 #[serde(skip_serializing_if = "Option::is_none")]
90 pub exact_path: Option<PathBuf>,
91 #[serde(skip_serializing_if = "Option::is_none")]
92 pub relative_path: Option<String>,
93 #[serde(skip_serializing_if = "Option::is_none")]
94 pub path_pattern: Option<String>,
95 #[serde(skip_serializing_if = "Option::is_none")]
96 pub filename_pattern: Option<String>,
97 #[serde(skip_serializing_if = "Option::is_none")]
98 pub schema_columns: Option<Vec<String>>,
99 #[serde(skip_serializing_if = "Option::is_none")]
100 pub schema_types: Option<Vec<String>>,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub table: Option<String>,
106}
107
108impl MatchCriteria {
109 fn unmangle_urls(&mut self) {
113 if let Some(url) = self.exact_path.as_deref().and_then(unmangled_url) {
114 self.exact_path = Some(url);
115 }
116 if let Some(relative) = self.relative_path.take() {
117 if crate::source::is_remote_url(Path::new(&relative)) {
118 self.exact_path
119 .get_or_insert_with(|| PathBuf::from(&relative));
120 } else {
121 self.relative_path = Some(relative);
122 }
123 }
124 }
125}
126
127fn unmangled_url(path: &Path) -> Option<PathBuf> {
130 let text = path.to_string_lossy();
131 let scheme_end = text.find("://")?;
132 let start = text[..scheme_end].rfind(['/', '\\'])? + 1;
133 let url = PathBuf::from(&text[start..]);
134 crate::source::is_remote_url(&url).then_some(url)
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct ViewSettings {
139 #[serde(skip_serializing_if = "Option::is_none")]
140 pub query: Option<String>,
141 #[serde(skip_serializing_if = "Option::is_none")]
142 #[serde(default)]
143 pub sql_query: Option<String>,
144 #[serde(skip_serializing_if = "Option::is_none")]
145 #[serde(default)]
146 pub fuzzy_query: Option<String>,
147 pub filters: Vec<FilterStatement>,
148 pub sort_columns: Vec<String>,
149 #[serde(default)]
152 #[serde(skip_serializing_if = "Vec::is_empty")]
153 pub sort_descending: Vec<bool>,
154 pub sort_ascending: bool,
155 pub column_order: Vec<String>,
156 pub locked_columns_count: usize,
157 #[serde(skip_serializing_if = "Option::is_none")]
158 #[serde(default)]
159 pub pivot: Option<PivotSpec>,
160 #[serde(skip_serializing_if = "Option::is_none")]
161 #[serde(default)]
162 pub melt: Option<MeltSpec>,
163 #[serde(skip_serializing_if = "Option::is_none")]
167 #[serde(default)]
168 pub reshape_source: Option<ReshapeSource>,
169 #[serde(default, skip_serializing_if = "Vec::is_empty")]
172 pub columns: Vec<crate::column_types::ColumnChange>,
173 #[serde(default, skip_serializing_if = "Option::is_none")]
176 pub sample: Option<SavedSample>,
177 #[serde(default, skip_serializing_if = "Option::is_none")]
179 pub chart: Option<SavedChart>,
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize)]
184pub struct SavedChart {
185 #[serde(flatten)]
187 pub spec: crate::chart_modal::ChartSpec,
188 pub histogram_bins: usize,
189 pub heatmap_bins: usize,
190 pub bandwidth: f64,
192 pub range: crate::chart_data::ValueRange,
193 pub bar_order: crate::chart_data::BarOrder,
194 pub share: bool,
196 pub y_starts_at_zero: bool,
197 pub log_scale: bool,
198 pub legend: bool,
199 pub grid: bool,
200 #[serde(default, skip_serializing_if = "Option::is_none")]
203 pub rows: Option<usize>,
204 #[serde(default, skip_serializing_if = "Option::is_none")]
206 pub seed: Option<u64>,
207 #[serde(default, skip_serializing_if = "Option::is_none")]
209 pub export: Option<SavedChartExport>,
210}
211
212#[derive(Debug, Clone, Serialize, Deserialize)]
214pub struct SavedChartExport {
215 pub format: crate::chart_export::ChartExportFormat,
216 pub style: crate::chart_export::ExportStyle,
217 pub size: crate::chart_export::SizePreset,
218 pub width: u32,
219 pub height: u32,
220 pub dpi: f32,
221 pub legend: crate::chart_export::LegendPlace,
222 pub point_opacity: crate::chart_export::PointOpacity,
223 pub point_size: crate::chart_export::PointSize,
224 pub line_width: crate::chart_export::LineWidth,
225 pub y_from_zero: bool,
226 #[serde(default, skip_serializing_if = "String::is_empty")]
227 pub title: String,
228 #[serde(default, skip_serializing_if = "String::is_empty")]
229 pub description: String,
230 #[serde(default, skip_serializing_if = "String::is_empty")]
231 pub notes: String,
232 #[serde(default, skip_serializing_if = "String::is_empty")]
233 pub source: String,
234 #[serde(default, skip_serializing_if = "String::is_empty")]
235 pub byline: String,
236 pub recipe: bool,
238}
239
240#[derive(Debug, Clone, Serialize, Deserialize)]
243pub struct SavedSample {
244 pub scope: String,
247 pub method: String,
249 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub per: Option<String>,
252 pub rows: usize,
254 pub seed: u64,
255 #[serde(default, skip_serializing_if = "Option::is_none")]
259 pub path: Option<crate::table_sample::DrawPath>,
260 #[serde(default, skip_serializing_if = "Option::is_none")]
264 pub through: Option<Box<ViewSettings>>,
265}
266
267impl SavedSample {
268 pub fn of(
271 sample: &crate::sampling::Sample,
272 path: Option<crate::table_sample::DrawPath>,
273 through: Option<ViewSettings>,
274 ) -> Self {
275 use crate::sampling::SampleMethod;
276 let (method, per) = match &sample.method {
277 SampleMethod::Spread => ("random", None),
278 SampleMethod::PerPartition { column } => ("per value", Some(column.clone())),
279 SampleMethod::FirstRows => ("first rows", None),
280 SampleMethod::EveryRow => ("every row", None),
281 };
282 Self {
283 scope: sample.scope.command(),
284 method: method.to_string(),
285 per,
286 rows: sample.rows,
287 seed: sample.seed,
288 path,
289 through: through.map(Box::new),
290 }
291 }
292
293 pub fn sample(&self) -> Result<crate::sampling::Sample> {
295 use crate::sampling::SampleMethod;
296 let method = match (self.method.as_str(), &self.per) {
297 ("random", _) => SampleMethod::Spread,
298 ("per value", Some(column)) => SampleMethod::PerPartition {
299 column: column.clone(),
300 },
301 ("first rows", _) => SampleMethod::FirstRows,
302 ("every row", _) => SampleMethod::EveryRow,
303 (other, _) => {
304 return Err(color_eyre::eyre::eyre!(
305 "the view's sample has no method {other:?}; it is random, per value \
306 (with per), first rows or every row"
307 ));
308 }
309 };
310 Ok(crate::sampling::Sample {
311 scope: crate::data_quality::QualityScope::parse_command(&self.scope)?,
312 method,
313 rows: self.rows.max(1),
314 seed: self.seed,
315 })
316 }
317}
318
319impl ViewSettings {
320 pub fn sort_directions(&self) -> Vec<bool> {
323 if self.sort_descending.len() == self.sort_columns.len() {
324 self.sort_descending.clone()
325 } else {
326 vec![!self.sort_ascending; self.sort_columns.len()]
327 }
328 }
329}
330
331#[derive(Debug, Clone)]
332pub struct BrokenView {
333 pub filename: String,
334 pub error: String,
335}
336
337const VIEWS_LOCK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
341
342fn views_lock_left(began: std::time::Instant) -> std::time::Duration {
344 VIEWS_LOCK_TIMEOUT.saturating_sub(began.elapsed())
345}
346
347pub struct ViewManager {
348 config: ConfigManager,
349 views: Vec<SavedView>,
350 pub(crate) views_dir: PathBuf,
351 pub broken_views: Vec<BrokenView>,
352}
353
354pub struct Views {
363 ready: std::cell::OnceCell<ViewManager>,
364 pending: std::cell::RefCell<Option<std::sync::mpsc::Receiver<ViewManager>>>,
365}
366
367impl Views {
368 pub fn read_in_background() -> Self {
370 let (tx, rx) = std::sync::mpsc::channel();
371 let pending = std::thread::Builder::new()
372 .name("datui-views".into())
373 .spawn(move || {
374 let _ = tx.send(ViewManager::load_or_empty());
375 })
376 .map(|_| rx)
377 .ok();
378 Self {
379 ready: std::cell::OnceCell::new(),
380 pending: std::cell::RefCell::new(pending),
381 }
382 }
383
384 #[cfg(test)]
387 pub(crate) fn waiting_on(rx: std::sync::mpsc::Receiver<ViewManager>) -> Self {
388 Self {
389 ready: std::cell::OnceCell::new(),
390 pending: std::cell::RefCell::new(Some(rx)),
391 }
392 }
393
394 #[cfg(test)]
396 pub(crate) fn is_read(&self) -> bool {
397 self.ready.get().is_some()
398 }
399
400 fn manager(&self) -> &ViewManager {
401 self.ready.get_or_init(|| {
402 self.pending
403 .borrow_mut()
404 .take()
405 .and_then(|rx| rx.recv().ok())
406 .unwrap_or_else(ViewManager::load_or_empty)
408 })
409 }
410}
411
412impl From<ViewManager> for Views {
413 fn from(manager: ViewManager) -> Self {
414 Self {
415 ready: std::cell::OnceCell::from(manager),
416 pending: std::cell::RefCell::new(None),
417 }
418 }
419}
420
421impl std::ops::Deref for Views {
422 type Target = ViewManager;
423
424 fn deref(&self) -> &ViewManager {
425 self.manager()
426 }
427}
428
429impl std::ops::DerefMut for Views {
430 fn deref_mut(&mut self) -> &mut ViewManager {
431 self.manager();
432 self.ready.get_mut().expect("read just now")
433 }
434}
435
436impl ViewManager {
437 pub fn load_or_empty() -> Self {
441 let config = ConfigManager::new(crate::APP_NAME).unwrap_or_else(|_| ConfigManager {
442 config_dir: std::env::temp_dir().join(crate::APP_NAME).join("config"),
443 });
444 Self::new(&config).unwrap_or_else(|_| {
445 let last_resort = ConfigManager {
446 config_dir: std::env::temp_dir().join("datui_config"),
447 };
448 Self::new(&last_resort).unwrap_or_else(|_| Self::empty(&last_resort))
449 })
450 }
451
452 pub fn new(config: &ConfigManager) -> Result<Self> {
455 let views_dir = config.config_dir().join("views");
458
459 let mut manager = Self {
460 config: config.clone(),
461 views: Vec::new(),
462 views_dir,
463 broken_views: Vec::new(),
464 };
465
466 manager.load_views()?;
469 Ok(manager)
470 }
471
472 pub fn empty(config: &ConfigManager) -> Self {
475 Self {
476 config: config.clone(),
477 views: Vec::new(),
478 views_dir: config.config_dir().join("views"),
479 broken_views: Vec::new(),
480 }
481 }
482
483 pub fn load_views(&mut self) -> Result<()> {
484 self.views.clear();
485 self.broken_views.clear();
486
487 if !self.views_dir.exists() {
489 return Ok(());
490 }
491
492 let began = std::time::Instant::now();
497 let queue = crate::cache::lock_file(&self.views_queue(), VIEWS_LOCK_TIMEOUT)
498 .ok()
499 .flatten();
500 let _lock = crate::cache::lock_file_shared(&self.views_lock(), views_lock_left(began))
501 .ok()
502 .flatten();
503 drop(queue);
504 let entries = fs::read_dir(&self.views_dir)?;
505 for entry in entries {
506 let entry = entry?;
507 let path = entry.path();
508
509 if path.is_file()
510 && path.extension().and_then(|s| s.to_str()) == Some("json")
511 && let Ok(content) = fs::read_to_string(&path)
512 {
513 match serde_json::from_str::<SavedView>(&content) {
514 Ok(mut view) => {
515 view.match_criteria.unmangle_urls();
516 self.views.push(view);
517 }
518 Err(e) => {
519 let filename = path
520 .file_stem()
521 .and_then(|s| s.to_str())
522 .unwrap_or("unknown")
523 .to_string();
524 self.broken_views.push(BrokenView {
525 filename,
526 error: e.to_string(),
527 });
528 }
529 }
530 }
531 }
532
533 Ok(())
534 }
535
536 fn views_lock(&self) -> PathBuf {
537 self.views_dir.join("views.lock")
538 }
539
540 fn views_queue(&self) -> PathBuf {
543 self.views_dir.join("views.queue")
544 }
545
546 fn view_path(&self, id: &str) -> PathBuf {
547 self.views_dir.join(format!("view_{id}.json"))
548 }
549
550 fn locked<T>(&self, work: impl FnOnce() -> Result<T>) -> Result<T> {
553 self.config.ensure_config_dir()?;
554 fs::create_dir_all(&self.views_dir)?;
555 let busy = || color_eyre::eyre::eyre!("another datui is saving views; try again");
556 let began = std::time::Instant::now();
557 let queue =
558 crate::cache::lock_file(&self.views_queue(), VIEWS_LOCK_TIMEOUT)?.ok_or_else(busy)?;
559 let lock = crate::cache::lock_file(&self.views_lock(), views_lock_left(began))?
560 .ok_or_else(busy)?;
561 drop(queue);
562 let result = work();
563 drop(lock);
564 result
565 }
566
567 fn read_stored(&self, id: &str) -> Result<Option<SavedView>> {
570 let text = match fs::read_to_string(self.view_path(id)) {
571 Ok(text) => text,
572 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
573 Err(e) => return Err(e.into()),
574 };
575 let mut view: SavedView = serde_json::from_str(&text)?;
576 view.match_criteria.unmangle_urls();
577 Ok(Some(view))
578 }
579
580 fn write_stored(&self, view: &SavedView) -> Result<()> {
581 let json = serde_json::to_string_pretty(view)?;
582 crate::cache::atomic_write(&self.view_path(&view.id), json.as_bytes())?;
583 Ok(())
584 }
585
586 pub fn save_view(&self, view: &SavedView) -> Result<()> {
589 self.locked(|| self.write_stored(view))
590 }
591
592 pub fn delete_view(&mut self, id: &str) -> Result<()> {
593 let file_path = self.view_path(id);
594 if file_path.exists() {
595 self.locked(|| match fs::remove_file(&file_path) {
596 Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e.into()),
597 _ => Ok(()),
598 })?;
599 }
600
601 self.views.retain(|t| t.id != id);
602 Ok(())
603 }
604
605 pub fn record_use(&mut self, id: &str, file: &Path) -> Result<()> {
609 let stored = self.locked(|| {
610 let Some(mut stored) = self.read_stored(id)? else {
611 return Ok(None);
612 };
613 stored.last_used = Some(SystemTime::now());
614 stored.usage_count += 1;
615 stored.last_matched_file = Some(file.to_path_buf());
616 self.write_stored(&stored)?;
617 Ok(Some(stored))
618 })?;
619 self.adopt(id, stored);
620 Ok(())
621 }
622
623 fn adopt(&mut self, id: &str, stored: Option<SavedView>) {
625 match stored {
626 Some(stored) => match self.views.iter_mut().find(|t| t.id == id) {
627 Some(existing) => *existing = stored,
628 None => self.views.push(stored),
629 },
630 None => self.views.retain(|t| t.id != id),
631 }
632 }
633
634 pub fn find_relevant_views<'a>(
635 &self,
636 dataset: impl Into<Dataset<'a>>,
637 schema: &Schema,
638 ) -> Vec<(SavedView, f64)> {
639 let dataset = dataset.into();
640 let mut results: Vec<(SavedView, f64)> = self
641 .views
642 .iter()
643 .map(|view| {
644 let score = calculate_relevance(view, dataset, schema);
645 (view.clone(), score)
646 })
647 .collect();
648
649 results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
651
652 results
653 }
654
655 pub fn get_most_relevant<'a>(
660 &self,
661 dataset: impl Into<Dataset<'a>>,
662 schema: &Schema,
663 ) -> Option<(SavedView, MatchReason)> {
664 let dataset = dataset.into();
665 self.find_relevant_views(dataset, schema)
666 .into_iter()
667 .find_map(|(view, _)| {
668 let reason = match_reason(&view, dataset, schema)?;
669 Some((view, reason))
670 })
671 }
672
673 pub fn suggest_name(&self, path: Option<&Path>, query: Option<&str>) -> String {
677 let base = path
678 .and_then(|p| p.file_stem())
679 .and_then(|s| s.to_str())
680 .map(str::to_string)
681 .filter(|s| !s.trim().is_empty())
682 .or_else(|| query.map(|q| q.split_whitespace().take(4).collect::<Vec<_>>().join(" ")))
683 .filter(|s| !s.trim().is_empty())
684 .unwrap_or_else(|| "view".to_string());
685
686 if !self.view_exists(&base) {
687 return base;
688 }
689 (2..)
690 .map(|n| format!("{base} {n}"))
691 .find(|name| !self.view_exists(name))
692 .expect("some numbered name is free")
693 }
694
695 pub fn view_exists(&self, name: &str) -> bool {
696 self.views.iter().any(|t| t.name == name)
697 }
698
699 pub fn get_view_by_name(&self, name: &str) -> Option<&SavedView> {
700 self.views.iter().find(|t| t.name == name)
701 }
702
703 pub fn get_view_by_id(&self, id: &str) -> Option<&SavedView> {
704 self.views.iter().find(|t| t.id == id)
705 }
706
707 pub fn all_views(&self) -> &[SavedView] {
708 &self.views
709 }
710
711 pub fn create_view(
712 &mut self,
713 name: String,
714 description: Option<String>,
715 match_criteria: MatchCriteria,
716 settings: ViewSettings,
717 ) -> Result<SavedView> {
718 let mut hasher = DefaultHasher::new();
720 name.hash(&mut hasher);
721 SystemTime::now()
722 .duration_since(SystemTime::UNIX_EPOCH)
723 .unwrap_or_default()
724 .as_secs()
725 .hash(&mut hasher);
726 let id = format!("{:016x}", hasher.finish());
727
728 let view = SavedView {
729 id,
730 name,
731 description,
732 created: SystemTime::now(),
733 last_used: None,
734 usage_count: 0,
735 last_matched_file: None,
736 match_criteria,
737 settings,
738 };
739
740 self.save_view(&view)?;
742
743 self.load_views()?;
745
746 Ok(view)
747 }
748
749 pub fn update_view(&mut self, edited: &SavedView) -> Result<()> {
754 let base = self.get_view_by_id(&edited.id).cloned();
755 let stored = self.locked(|| {
756 let Some(mut stored) = self.read_stored(&edited.id)? else {
757 return Ok(None);
758 };
759 merge_edit(&mut stored, base.as_ref(), edited);
760 self.write_stored(&stored)?;
761 Ok(Some(stored))
762 })?;
763 let deleted = stored.is_none();
764 self.adopt(&edited.id, stored);
765 if deleted {
766 return Err(color_eyre::eyre::eyre!(
767 "view \"{}\" was deleted by another datui",
768 edited.name
769 ));
770 }
771 Ok(())
772 }
773
774 pub fn remove_all_views(&mut self) -> Result<()> {
775 if self.views_dir.exists() {
777 self.locked(|| {
778 for entry in fs::read_dir(&self.views_dir)? {
779 let entry = entry?;
780 let path = entry.path();
781 if path.is_file()
782 && path
783 .file_name()
784 .and_then(|n| n.to_str())
785 .map(|s| s.starts_with("view_") && s.ends_with(".json"))
786 .unwrap_or(false)
787 {
788 fs::remove_file(&path)?;
789 }
790 }
791 Ok(())
792 })?;
793 }
794
795 self.views.clear();
797
798 Ok(())
799 }
800}
801
802fn merge_edit(stored: &mut SavedView, base: Option<&SavedView>, edited: &SavedView) {
805 fn changed<T: Serialize>(base: Option<&T>, edited: &T) -> bool {
806 base.is_none_or(|base| serde_json::to_value(base).ok() != serde_json::to_value(edited).ok())
807 }
808 if changed(base.map(|b| &b.name), &edited.name) {
809 stored.name = edited.name.clone();
810 }
811 if changed(base.map(|b| &b.description), &edited.description) {
812 stored.description = edited.description.clone();
813 }
814 if changed(base.map(|b| &b.match_criteria), &edited.match_criteria) {
815 stored.match_criteria = edited.match_criteria.clone();
816 }
817 if changed(base.map(|b| &b.settings), &edited.settings) {
818 stored.settings = edited.settings.clone();
819 }
820}
821
822#[derive(Debug, Clone, Copy, PartialEq, Eq)]
826pub enum MatchReason {
827 SameFile,
828 SameColumns,
829 Glob,
830}
831
832impl MatchReason {
833 pub fn as_str(&self) -> &'static str {
834 match self {
835 MatchReason::SameFile => "same file",
836 MatchReason::SameColumns => "same columns",
837 MatchReason::Glob => "glob",
838 }
839 }
840}
841
842#[derive(Debug, Clone, Copy)]
846pub struct Dataset<'a> {
847 pub path: &'a Path,
848 pub table: Option<&'a str>,
849}
850
851impl<'a> From<&'a Path> for Dataset<'a> {
852 fn from(path: &'a Path) -> Self {
853 Self { path, table: None }
854 }
855}
856
857impl<'a> From<&'a PathBuf> for Dataset<'a> {
858 fn from(path: &'a PathBuf) -> Self {
859 Self::from(path.as_path())
860 }
861}
862
863impl<'a> Dataset<'a> {
864 fn place_for(self, criteria: &MatchCriteria) -> Option<&'a Path> {
868 let table_fits = criteria
869 .table
870 .as_deref()
871 .is_none_or(|saved| self.table == Some(saved));
872 (table_fits && has_a_path(self.path)).then_some(self.path)
873 }
874}
875
876pub fn exact_location(path: &Path) -> PathBuf {
880 if crate::source::is_remote_url(path) {
881 return path.to_path_buf();
882 }
883 let absolute = if path.is_absolute() {
884 path.to_path_buf()
885 } else {
886 match std::env::current_dir() {
887 Ok(cwd) => cwd.join(path),
888 Err(_) => return path.to_path_buf(),
889 }
890 };
891 if crate::home::is_network_path(&absolute) {
894 return absolute;
895 }
896 crate::canonical::canonicalize(&absolute)
897 .ok()
898 .or_else(|| canonical_prefix(&absolute))
899 .unwrap_or(absolute)
900}
901
902fn canonical_prefix(path: &Path) -> Option<PathBuf> {
906 let (there, canonical) = path
907 .ancestors()
908 .skip(1)
909 .take_while(|p| !p.as_os_str().is_empty())
910 .find_map(|p| Some((p, crate::canonical::canonicalize(p).ok()?)))?;
911 Some(canonical.join(path.strip_prefix(there).ok()?))
912}
913
914pub fn relative_location(path: &Path) -> Option<String> {
917 if crate::source::is_remote_url(path) {
918 return None;
919 }
920 let cwd = exact_location(&std::env::current_dir().ok()?);
921 let relative = exact_location(path)
922 .strip_prefix(&cwd)
923 .ok()?
924 .to_string_lossy()
925 .into_owned();
926 (!relative.is_empty()).then_some(relative)
927}
928
929pub fn exact_path_matches<'a>(criteria: &MatchCriteria, dataset: impl Into<Dataset<'a>>) -> bool {
933 let (Some(stored), Some(file_path)) = (
934 criteria.exact_path.as_deref(),
935 dataset.into().place_for(criteria),
936 ) else {
937 return false;
938 };
939 if crate::source::is_remote_url(stored) || crate::source::is_remote_url(file_path) {
940 return url_key(stored) == url_key(file_path);
941 }
942 stored == file_path || stored == exact_location(file_path)
943}
944
945fn has_a_path(file_path: &Path) -> bool {
948 !crate::stdin::is_stdin(file_path)
949}
950
951fn url_key(path: &Path) -> String {
952 let text = path.to_string_lossy();
953 let text = text.trim_end_matches('/');
954 match text.split_once("://") {
955 Some((scheme, rest)) => format!("{}://{rest}", scheme.to_ascii_lowercase()),
956 None => text.to_string(),
957 }
958}
959
960pub fn relative_path_matches<'a>(
962 criteria: &MatchCriteria,
963 dataset: impl Into<Dataset<'a>>,
964) -> bool {
965 let Some(file_path) = dataset.into().place_for(criteria) else {
966 return false;
967 };
968 criteria.relative_path.as_deref().is_some_and(|stored| {
969 relative_location(file_path).is_some_and(|rel| Path::new(&rel) == Path::new(stored))
970 })
971}
972
973pub fn path_pattern_matches<'a>(criteria: &MatchCriteria, dataset: impl Into<Dataset<'a>>) -> bool {
977 let Some(file_path) = dataset.into().place_for(criteria) else {
978 return false;
979 };
980 criteria.path_pattern.as_deref().is_some_and(|pattern| {
981 let fits = |p: &Path| {
982 p.to_str()
983 .is_some_and(|text| matches_pattern(text, pattern))
984 };
985 fits(file_path) || fits(&exact_location(file_path))
986 })
987}
988
989pub fn filename_pattern_matches<'a>(
991 criteria: &MatchCriteria,
992 dataset: impl Into<Dataset<'a>>,
993) -> bool {
994 let Some(file_path) = dataset.into().place_for(criteria) else {
995 return false;
996 };
997 criteria.filename_pattern.as_deref().is_some_and(|pattern| {
998 file_path
999 .file_name()
1000 .and_then(|name| name.to_str())
1001 .is_some_and(|name| matches_pattern(name, pattern))
1002 })
1003}
1004
1005pub fn criteria_match<'a>(
1010 view: &SavedView,
1011 dataset: impl Into<Dataset<'a>>,
1012 schema: &Schema,
1013) -> bool {
1014 match_reason(view, dataset, schema).is_some()
1015}
1016
1017pub fn match_reason<'a>(
1022 view: &SavedView,
1023 dataset: impl Into<Dataset<'a>>,
1024 schema: &Schema,
1025) -> Option<MatchReason> {
1026 let dataset = dataset.into();
1027 let criteria = &view.match_criteria;
1028 if exact_path_matches(criteria, dataset) || relative_path_matches(criteria, dataset) {
1029 return Some(MatchReason::SameFile);
1030 }
1031 if let Some(required) = &criteria.schema_columns
1032 && !required.is_empty()
1033 {
1034 let file_cols: HashSet<&str> = schema.iter_names().map(|s| s.as_str()).collect();
1035 if required.iter().all(|col| file_cols.contains(col.as_str())) {
1036 return Some(MatchReason::SameColumns);
1037 }
1038 }
1039 if path_pattern_matches(criteria, dataset) || filename_pattern_matches(criteria, dataset) {
1040 return Some(MatchReason::Glob);
1041 }
1042 None
1043}
1044
1045fn calculate_relevance(view: &SavedView, dataset: Dataset<'_>, schema: &Schema) -> f64 {
1046 let mut score = 0.0;
1047
1048 let exact_path_match = exact_path_matches(&view.match_criteria, dataset);
1049 let relative_path_match = relative_path_matches(&view.match_criteria, dataset);
1050
1051 let exact_schema_match = if let Some(required_cols) = &view.match_criteria.schema_columns {
1053 let file_cols: HashSet<&str> = schema.iter_names().map(|s| s.as_str()).collect();
1054 let required_cols_set: HashSet<&str> = required_cols.iter().map(|s| s.as_str()).collect();
1055
1056 required_cols_set.is_subset(&file_cols) && file_cols.len() == required_cols_set.len()
1058 } else {
1059 false
1060 };
1061
1062 if exact_path_match && exact_schema_match {
1064 return 2000.0;
1065 }
1066
1067 if exact_path_match {
1069 return 1000.0;
1070 }
1071
1072 if relative_path_match && exact_schema_match {
1074 return 1950.0;
1075 }
1076
1077 if relative_path_match {
1079 return 950.0;
1080 }
1081
1082 if exact_schema_match {
1084 return 900.0;
1085 }
1086
1087 if let Some(pattern) = &view.match_criteria.path_pattern
1090 && path_pattern_matches(&view.match_criteria, dataset)
1091 {
1092 score += 50.0;
1093 score += pattern_specificity_bonus(pattern);
1094 }
1095
1096 if let Some(pattern) = &view.match_criteria.filename_pattern
1098 && filename_pattern_matches(&view.match_criteria, dataset)
1099 {
1100 score += 30.0;
1101 score += pattern_specificity_bonus(pattern);
1102 }
1103
1104 if let Some(required_cols) = &view.match_criteria.schema_columns {
1106 let file_cols: HashSet<&str> = schema.iter_names().map(|s| s.as_str()).collect();
1107 let matching_count = required_cols
1108 .iter()
1109 .filter(|col| file_cols.contains(col.as_str()))
1110 .count();
1111 score += (matching_count as f64) * 2.0; }
1116
1117 score += (view.usage_count.min(10) as f64) * 1.0;
1119 if let Some(last_used) = view.last_used
1120 && let Ok(duration) = SystemTime::now().duration_since(last_used)
1121 {
1122 let days_since = duration.as_secs() / 86400;
1123 if days_since <= 7 {
1124 score += 5.0;
1125 } else if days_since <= 30 {
1126 score += 2.0;
1127 }
1128 }
1129 score
1134}
1135
1136fn pattern_specificity_bonus(pattern: &str) -> f64 {
1137 let wildcard_count = pattern.matches('*').count() + pattern.matches('?').count();
1139 match wildcard_count {
1140 0 => 10.0, 1 => 5.0, 2 => 3.0, 3 => 1.0, _ => 0.0, }
1146}
1147
1148fn matches_pattern(text: &str, pattern: &str) -> bool {
1152 if pattern == "*" {
1153 return true;
1154 }
1155
1156 let pattern_parts: Vec<&str> = pattern.split('*').collect();
1158
1159 if pattern_parts.len() == 1 {
1160 return text == pattern;
1162 }
1163
1164 let mut text_pos = 0;
1166 for (i, part) in pattern_parts.iter().enumerate() {
1167 if part.is_empty() {
1168 continue;
1169 }
1170
1171 if i == 0 {
1172 if !text.starts_with(part) {
1174 return false;
1175 }
1176 text_pos = part.len();
1177 } else if i == pattern_parts.len() - 1 {
1178 return text[text_pos..].ends_with(part);
1180 } else {
1181 if let Some(pos) = text[text_pos..].find(part) {
1183 text_pos += pos + part.len();
1184 } else {
1185 return false;
1186 }
1187 }
1188 }
1189
1190 true
1191}
1192
1193#[cfg(test)]
1194mod tests {
1195 use super::*;
1196
1197 #[test]
1199 fn test_settings_deserialize_without_sql_fuzzy() {
1200 let json = r#"{
1201 "query": "select a",
1202 "filters": [],
1203 "sort_columns": [],
1204 "sort_ascending": true,
1205 "column_order": ["a", "b"],
1206 "locked_columns_count": 0
1207 }"#;
1208 let settings: ViewSettings = serde_json::from_str(json).unwrap();
1209 assert_eq!(settings.query, Some("select a".to_string()));
1210 assert_eq!(settings.sql_query, None);
1211 assert_eq!(settings.fuzzy_query, None);
1212 assert!(settings.reshape_source.is_none());
1213 }
1214
1215 #[test]
1218 fn test_reshape_source_is_written_only_when_present() {
1219 let mut settings = a_view("t", no_criteria()).settings;
1220 let json = serde_json::to_string(&settings).unwrap();
1221 assert!(!json.contains("reshape_source"), "{json}");
1222
1223 settings.reshape_source = Some(ReshapeSource {
1224 sql_query: Some("SELECT * FROM df".to_string()),
1225 ..ReshapeSource::default()
1226 });
1227 let json = serde_json::to_string(&settings).unwrap();
1228 assert!(
1229 json.contains(r#""reshape_source":{"sql_query":"SELECT * FROM df"}"#),
1230 "{json}"
1231 );
1232 let back: ViewSettings = serde_json::from_str(&json).unwrap();
1233 assert_eq!(
1234 back.reshape_source.and_then(|s| s.sql_query).as_deref(),
1235 Some("SELECT * FROM df")
1236 );
1237 }
1238
1239 fn a_view(name: &str, criteria: MatchCriteria) -> SavedView {
1240 SavedView {
1241 id: name.to_string(),
1242 name: name.to_string(),
1243 description: None,
1244 created: SystemTime::now(),
1245 last_used: Some(SystemTime::now()),
1246 usage_count: 10,
1247 last_matched_file: None,
1248 match_criteria: criteria,
1249 settings: ViewSettings {
1250 chart: None,
1251 sample: None,
1252 query: None,
1253 sql_query: None,
1254 fuzzy_query: None,
1255 filters: Vec::new(),
1256 sort_columns: Vec::new(),
1257 sort_descending: Vec::new(),
1258 sort_ascending: true,
1259 column_order: Vec::new(),
1260 locked_columns_count: 0,
1261 pivot: None,
1262 melt: None,
1263 reshape_source: None,
1264 columns: Vec::new(),
1265 },
1266 }
1267 }
1268
1269 fn no_criteria() -> MatchCriteria {
1270 MatchCriteria {
1271 exact_path: None,
1272 relative_path: None,
1273 path_pattern: None,
1274 filename_pattern: None,
1275 schema_columns: None,
1276 schema_types: None,
1277 table: None,
1278 }
1279 }
1280
1281 #[test]
1284 fn path_criteria_fit_only_the_saved_table() {
1285 let url = Path::new("https://example.com/shop.db");
1286 let schema = Schema::default();
1287 let on = |table| Dataset {
1288 path: url,
1289 table: Some(table),
1290 };
1291 let view = a_view(
1292 "orders",
1293 MatchCriteria {
1294 exact_path: Some(url.to_path_buf()),
1295 filename_pattern: Some("shop.db".to_string()),
1296 table: Some("orders".to_string()),
1297 ..no_criteria()
1298 },
1299 );
1300 assert_eq!(
1301 match_reason(&view, on("orders"), &schema),
1302 Some(MatchReason::SameFile)
1303 );
1304 assert_eq!(match_reason(&view, on("customers"), &schema), None);
1305 assert_eq!(match_reason(&view, url, &schema), None, "no table named");
1306 assert!(!filename_pattern_matches(
1307 &view.match_criteria,
1308 on("customers")
1309 ));
1310
1311 let older = a_view(
1312 "older",
1313 MatchCriteria {
1314 exact_path: Some(url.to_path_buf()),
1315 ..no_criteria()
1316 },
1317 );
1318 assert_eq!(
1319 match_reason(&older, on("customers"), &schema),
1320 Some(MatchReason::SameFile)
1321 );
1322 }
1323
1324 #[cfg(unix)]
1327 #[test]
1328 fn a_table_inside_a_file_is_located_through_the_file() {
1329 let dir = tempfile::tempdir().unwrap();
1330 let real = dir.path().join("real");
1331 fs::create_dir(&real).unwrap();
1332 fs::write(real.join("shop.db"), b"").unwrap();
1333 let link = dir.path().join("link");
1334 std::os::unix::fs::symlink(&real, &link).unwrap();
1335 let resolved = crate::canonical::canonicalize(&real).unwrap();
1336 assert_eq!(
1337 exact_location(&link.join("shop.db").join("orders")),
1338 resolved.join("shop.db").join("orders")
1339 );
1340 }
1341
1342 #[test]
1345 fn usage_alone_is_not_a_match() {
1346 use polars::prelude::DataType;
1347 let schema = Schema::from_iter([("a".into(), DataType::Int64)]);
1348 let path = Path::new("/data/other.parquet");
1349
1350 let unrelated = a_view(
1351 "well used, fits nothing",
1352 MatchCriteria {
1353 filename_pattern: Some("sales_*.csv".to_string()),
1354 ..no_criteria()
1355 },
1356 );
1357 assert!(!criteria_match(&unrelated, path, &schema));
1358 assert!(calculate_relevance(&unrelated, path.into(), &schema) > 0.0);
1359
1360 let fits = a_view(
1361 "fits by schema",
1362 MatchCriteria {
1363 schema_columns: Some(vec!["a".to_string()]),
1364 ..no_criteria()
1365 },
1366 );
1367 assert!(criteria_match(&fits, path, &schema));
1368 }
1369
1370 #[test]
1373 fn stdin_matches_by_schema_only() {
1374 use polars::prelude::DataType;
1375 let schema = Schema::from_iter([("a".into(), DataType::Int64)]);
1376 let stdin = Path::new(crate::stdin::PATH);
1377 let by_path = a_view(
1378 "every path",
1379 MatchCriteria {
1380 exact_path: Some(PathBuf::from("-")),
1381 relative_path: Some("-".to_string()),
1382 path_pattern: Some("*".to_string()),
1383 filename_pattern: Some("*".to_string()),
1384 ..no_criteria()
1385 },
1386 );
1387 assert_eq!(match_reason(&by_path, stdin, &schema), None);
1388 assert!(calculate_relevance(&by_path, stdin.into(), &schema) < 50.0);
1389 let by_schema = a_view(
1390 "by schema",
1391 MatchCriteria {
1392 schema_columns: Some(vec!["a".to_string()]),
1393 ..no_criteria()
1394 },
1395 );
1396 assert_eq!(
1397 match_reason(&by_schema, stdin, &schema),
1398 Some(MatchReason::SameColumns)
1399 );
1400 }
1401
1402 #[test]
1405 fn schema_criterion_is_a_subset_test() {
1406 use polars::prelude::DataType;
1407 let schema = Schema::from_iter([
1408 ("a".into(), DataType::Int64),
1409 ("b".into(), DataType::String),
1410 ]);
1411 let view = a_view(
1412 "wants a and b",
1413 MatchCriteria {
1414 schema_columns: Some(vec!["a".into(), "b".into()]),
1415 ..no_criteria()
1416 },
1417 );
1418 assert!(criteria_match(&view, Path::new("/x.parquet"), &schema));
1419
1420 let narrower = Schema::from_iter([("a".into(), DataType::Int64)]);
1421 assert!(!criteria_match(&view, Path::new("/x.parquet"), &narrower));
1422 }
1423
1424 #[test]
1425 fn test_matches_pattern() {
1426 assert!(matches_pattern("test.csv", "test.csv"));
1427 assert!(matches_pattern("test.csv", "*.csv"));
1428 assert!(matches_pattern("sales_2024.csv", "sales_*.csv"));
1429 assert!(matches_pattern(
1430 "/data/reports/sales.csv",
1431 "/data/reports/*.csv"
1432 ));
1433 assert!(!matches_pattern("test.txt", "*.csv"));
1434 assert!(!matches_pattern("sales.csv", "sales_*.csv"));
1435 }
1436
1437 #[test]
1440 fn match_reason_names_the_strongest_criterion() {
1441 use polars::prelude::DataType;
1442 let schema = Schema::from_iter([("a".into(), DataType::Int64)]);
1443 let path = Path::new("/data/sales_2024.csv");
1444
1445 let by_path = a_view(
1446 "by path",
1447 MatchCriteria {
1448 exact_path: Some(path.to_path_buf()),
1449 schema_columns: Some(vec!["a".into()]),
1450 filename_pattern: Some("sales_*.csv".into()),
1451 ..no_criteria()
1452 },
1453 );
1454 assert_eq!(
1455 match_reason(&by_path, path, &schema),
1456 Some(MatchReason::SameFile)
1457 );
1458
1459 let by_schema = a_view(
1460 "by schema",
1461 MatchCriteria {
1462 schema_columns: Some(vec!["a".into()]),
1463 filename_pattern: Some("sales_*.csv".into()),
1464 ..no_criteria()
1465 },
1466 );
1467 assert_eq!(
1468 match_reason(&by_schema, path, &schema),
1469 Some(MatchReason::SameColumns)
1470 );
1471
1472 let by_pattern = a_view(
1473 "by pattern",
1474 MatchCriteria {
1475 filename_pattern: Some("sales_*.csv".into()),
1476 ..no_criteria()
1477 },
1478 );
1479 assert_eq!(
1480 match_reason(&by_pattern, path, &schema),
1481 Some(MatchReason::Glob)
1482 );
1483
1484 let fits_nothing = a_view(
1485 "fits nothing",
1486 MatchCriteria {
1487 filename_pattern: Some("other_*.csv".into()),
1488 ..no_criteria()
1489 },
1490 );
1491 assert_eq!(match_reason(&fits_nothing, path, &schema), None);
1492 assert!(!criteria_match(&fits_nothing, path, &schema));
1493 }
1494
1495 #[test]
1498 fn a_remote_path_matches_the_same_url() {
1499 use polars::prelude::DataType;
1500 let schema = Schema::from_iter([("DATA_VALUE".into(), DataType::Int64)]);
1501 let url = Path::new("s3://noaa-ghcn-pds/parquet/by_year/YEAR=2024/ELEMENT=TMAX/");
1502 assert_eq!(exact_location(url), url);
1503 assert_eq!(relative_location(url), None);
1504
1505 let view = a_view(
1506 "tmax",
1507 MatchCriteria {
1508 exact_path: Some(exact_location(url)),
1509 relative_path: relative_location(url),
1510 ..no_criteria()
1511 },
1512 );
1513 assert_eq!(
1514 match_reason(&view, url, &schema),
1515 Some(MatchReason::SameFile)
1516 );
1517 let unslashed = Path::new("s3://noaa-ghcn-pds/parquet/by_year/YEAR=2024/ELEMENT=TMAX");
1518 assert_eq!(
1519 match_reason(&view, unslashed, &schema),
1520 Some(MatchReason::SameFile)
1521 );
1522 let upper = Path::new("S3://noaa-ghcn-pds/parquet/by_year/YEAR=2024/ELEMENT=TMAX");
1523 assert_eq!(
1524 match_reason(&view, upper, &schema),
1525 Some(MatchReason::SameFile)
1526 );
1527 let key_case = Path::new("s3://noaa-ghcn-pds/parquet/by_year/YEAR=2024/element=TMAX/");
1528 assert_eq!(match_reason(&view, key_case, &schema), None);
1529 let other_year = Path::new("s3://noaa-ghcn-pds/parquet/by_year/YEAR=2023/ELEMENT=TMAX/");
1530 assert_eq!(match_reason(&view, other_year, &schema), None);
1531 assert!(calculate_relevance(&view, url.into(), &schema) >= 1000.0);
1532 }
1533
1534 #[test]
1537 fn a_relative_local_path_matches_its_absolute_path() {
1538 let schema = Schema::default();
1539 let opened = Path::new("Cargo.toml");
1540 let absolute = crate::canonical::canonicalize(opened).unwrap();
1541 assert_eq!(exact_location(opened), absolute);
1542 assert_eq!(relative_location(opened).as_deref(), Some("Cargo.toml"));
1543
1544 let by_exact = a_view(
1545 "exact",
1546 MatchCriteria {
1547 exact_path: Some(absolute.clone()),
1548 ..no_criteria()
1549 },
1550 );
1551 assert_eq!(
1552 match_reason(&by_exact, opened, &schema),
1553 Some(MatchReason::SameFile)
1554 );
1555 let by_relative = a_view(
1556 "relative",
1557 MatchCriteria {
1558 relative_path: Some("Cargo.toml".into()),
1559 ..no_criteria()
1560 },
1561 );
1562 assert_eq!(
1563 match_reason(&by_relative, opened, &schema),
1564 Some(MatchReason::SameFile)
1565 );
1566 let by_pattern = a_view(
1568 "pattern",
1569 MatchCriteria {
1570 path_pattern: Some(format!(
1571 "{}{}*.toml",
1572 absolute.parent().unwrap().display(),
1573 std::path::MAIN_SEPARATOR
1574 )),
1575 ..no_criteria()
1576 },
1577 );
1578 assert_eq!(
1579 match_reason(&by_pattern, opened, &schema),
1580 Some(MatchReason::Glob)
1581 );
1582 assert!(calculate_relevance(&by_pattern, opened.into(), &schema) >= 50.0);
1583 }
1584
1585 #[test]
1589 fn a_view_saved_with_a_mangled_url_loads_with_the_url() {
1590 let dir = tempfile::tempdir().unwrap();
1591 let config = ConfigManager::with_dir(dir.path().to_path_buf());
1592 let views = dir.path().join("views");
1593 fs::create_dir_all(&views).unwrap();
1594 let json = r#"{
1595 "id": "old",
1596 "name": "old",
1597 "description": null,
1598 "created": 1790000000,
1599 "usage_count": 0,
1600 "match_criteria": {
1601 "exact_path": "/home/me/work/s3://noaa-ghcn-pds/parquet/by_year/YEAR=2024/ELEMENT=TMAX/",
1602 "relative_path": "s3://noaa-ghcn-pds/parquet/by_year/YEAR=2024/ELEMENT=TMAX",
1603 "filename_pattern": "ELEMENT=TMAX",
1604 "schema_columns": ["day", "high_c"]
1605 },
1606 "settings": {
1607 "sql_query": "SELECT 1 AS day, 2 AS high_c FROM df",
1608 "filters": [],
1609 "sort_columns": [],
1610 "sort_ascending": true,
1611 "column_order": [],
1612 "locked_columns_count": 0
1613 }
1614 }"#;
1615 fs::write(views.join("view_old.json"), json).unwrap();
1616
1617 let manager = ViewManager::new(&config).unwrap();
1618 assert!(manager.broken_views.is_empty());
1619 let view = manager.get_view_by_id("old").unwrap();
1620 let url = "s3://noaa-ghcn-pds/parquet/by_year/YEAR=2024/ELEMENT=TMAX/";
1621 assert_eq!(
1622 view.match_criteria.exact_path.as_deref(),
1623 Some(Path::new(url))
1624 );
1625 assert_eq!(view.match_criteria.relative_path, None);
1626 assert_eq!(
1627 match_reason(view, Path::new(url), &Schema::default()),
1628 Some(MatchReason::SameFile)
1629 );
1630 }
1631
1632 #[test]
1633 fn unmangled_url_finds_the_url_behind_a_local_prefix() {
1634 assert_eq!(
1635 unmangled_url(Path::new("/work/gs://bucket/a.parquet")),
1636 Some(PathBuf::from("gs://bucket/a.parquet"))
1637 );
1638 assert_eq!(
1639 unmangled_url(Path::new(r"C:\work\https://example.com/a.csv")),
1640 Some(PathBuf::from("https://example.com/a.csv"))
1641 );
1642 assert_eq!(unmangled_url(Path::new("s3://bucket/a.parquet")), None);
1643 assert_eq!(unmangled_url(Path::new("/data/a.csv")), None);
1644 assert_eq!(unmangled_url(Path::new("/data/odd://name.csv")), None);
1645 }
1646
1647 #[test]
1648 fn test_pattern_specificity_bonus() {
1649 assert_eq!(pattern_specificity_bonus("test.csv"), 10.0);
1650 assert_eq!(pattern_specificity_bonus("*.csv"), 5.0);
1651 assert_eq!(pattern_specificity_bonus("sales_*.csv"), 5.0);
1652 }
1653}