1pub(crate) mod view_apply;
2pub(crate) mod view_keys;
3
4use color_eyre::Result;
5use serde::{Deserialize, Serialize};
6use std::collections::HashSet;
7use std::collections::hash_map::DefaultHasher;
8use std::fs;
9use std::hash::{Hash, Hasher};
10use std::path::{Path, PathBuf};
11use std::time::SystemTime;
12
13use polars::prelude::Schema;
14
15use crate::app::modals::filter_modal::FilterStatement;
16use crate::app::modals::pivot_melt_modal::{MeltSpec, PivotSpec, ReshapeSource};
17use crate::config::ConfigManager;
18
19mod time_serde {
21 use serde::{Deserialize, Deserializer, Serialize, Serializer};
22 use std::time::{SystemTime, UNIX_EPOCH};
23
24 pub fn serialize<S>(time: &SystemTime, serializer: S) -> Result<S::Ok, S::Error>
25 where
26 S: Serializer,
27 {
28 let duration = time.duration_since(UNIX_EPOCH).map_err(|e| {
29 serde::ser::Error::custom(format!("Failed to serialize SystemTime: {}", e))
30 })?;
31 duration.as_secs().serialize(serializer)
32 }
33
34 pub fn deserialize<'de, D>(deserializer: D) -> Result<SystemTime, D::Error>
35 where
36 D: Deserializer<'de>,
37 {
38 let secs = u64::deserialize(deserializer)?;
39 Ok(UNIX_EPOCH + std::time::Duration::from_secs(secs))
40 }
41
42 pub mod option {
43 use super::*;
44
45 pub fn serialize<S>(time: &Option<SystemTime>, serializer: S) -> Result<S::Ok, S::Error>
46 where
47 S: Serializer,
48 {
49 match time {
50 Some(time) => super::serialize(time, serializer),
51 None => serializer.serialize_none(),
52 }
53 }
54
55 pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<SystemTime>, D::Error>
56 where
57 D: Deserializer<'de>,
58 {
59 Option::<u64>::deserialize(deserializer)?
60 .map(|secs| Ok(UNIX_EPOCH + std::time::Duration::from_secs(secs)))
61 .transpose()
62 }
63 }
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct SavedView {
70 #[serde(default)]
71 pub id: String,
72 #[serde(default)]
73 pub name: String,
74 pub description: Option<String>,
75 #[serde(with = "time_serde", default = "SystemTime::now")]
76 pub created: SystemTime,
77 #[serde(with = "time_serde::option")]
78 #[serde(skip_serializing_if = "Option::is_none")]
79 #[serde(default)]
80 pub last_used: Option<SystemTime>,
81 #[serde(default)]
82 pub usage_count: usize,
83 #[serde(skip_serializing_if = "Option::is_none")]
84 pub last_matched_file: Option<PathBuf>,
85 #[serde(default)]
86 pub match_criteria: MatchCriteria,
87 pub settings: ViewSettings,
88}
89
90#[derive(Debug, Clone, Default, Serialize, Deserialize)]
91pub struct MatchCriteria {
92 #[serde(skip_serializing_if = "Option::is_none")]
93 pub exact_path: Option<PathBuf>,
94 #[serde(skip_serializing_if = "Option::is_none")]
95 pub relative_path: Option<String>,
96 #[serde(skip_serializing_if = "Option::is_none")]
97 pub path_pattern: Option<String>,
98 #[serde(skip_serializing_if = "Option::is_none")]
99 pub filename_pattern: Option<String>,
100 #[serde(skip_serializing_if = "Option::is_none")]
101 pub schema_columns: Option<Vec<String>>,
102 #[serde(skip_serializing_if = "Option::is_none")]
103 pub schema_types: Option<Vec<String>>,
104 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub table: Option<String>,
109}
110
111impl MatchCriteria {
112 fn unmangle_urls(&mut self) {
115 if let Some(url) = self.exact_path.as_deref().and_then(unmangled_url) {
116 self.exact_path = Some(url);
117 }
118 if let Some(relative) = self.relative_path.take() {
119 if crate::cloud::source::is_remote_url(Path::new(&relative)) {
120 self.exact_path
121 .get_or_insert_with(|| PathBuf::from(&relative));
122 } else {
123 self.relative_path = Some(relative);
124 }
125 }
126 }
127}
128
129fn unmangled_url(path: &Path) -> Option<PathBuf> {
132 let text = path.to_string_lossy();
133 let scheme_end = text.find("://")?;
134 let start = text[..scheme_end].rfind(['/', '\\'])? + 1;
135 let url = PathBuf::from(&text[start..]);
136 crate::cloud::source::is_remote_url(&url).then_some(url)
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct ViewSettings {
141 #[serde(skip_serializing_if = "Option::is_none")]
142 pub query: Option<String>,
143 #[serde(skip_serializing_if = "Option::is_none")]
144 #[serde(default)]
145 pub sql_query: Option<String>,
146 #[serde(skip_serializing_if = "Option::is_none")]
147 #[serde(default)]
148 pub fuzzy_query: Option<String>,
149 pub filters: Vec<FilterStatement>,
150 pub sort_columns: Vec<String>,
151 #[serde(default)]
154 #[serde(skip_serializing_if = "Vec::is_empty")]
155 pub sort_descending: Vec<bool>,
156 pub sort_ascending: bool,
157 pub column_order: Vec<String>,
158 pub locked_columns_count: usize,
159 #[serde(skip_serializing_if = "Option::is_none")]
160 #[serde(default)]
161 pub pivot: Option<PivotSpec>,
162 #[serde(skip_serializing_if = "Option::is_none")]
163 #[serde(default)]
164 pub melt: Option<MeltSpec>,
165 #[serde(skip_serializing_if = "Option::is_none")]
169 #[serde(default)]
170 pub reshape_source: Option<ReshapeSource>,
171 #[serde(default, skip_serializing_if = "Vec::is_empty")]
174 pub columns: Vec<crate::formats::column_types::ColumnChange>,
175 #[serde(default, skip_serializing_if = "Option::is_none")]
178 pub sample: Option<SavedSample>,
179 #[serde(default, skip_serializing_if = "Option::is_none")]
181 pub chart: Option<SavedChart>,
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize)]
186pub struct SavedChart {
187 #[serde(flatten)]
189 pub spec: crate::chart::chart_modal::ChartSpec,
190 pub histogram_bins: usize,
191 pub heatmap_bins: usize,
192 pub bandwidth: f64,
194 pub range: crate::chart::chart_data::ValueRange,
195 pub bar_order: crate::chart::chart_data::BarOrder,
196 pub share: bool,
198 pub y_starts_at_zero: bool,
199 pub log_scale: bool,
200 pub legend: bool,
201 pub grid: bool,
202 #[serde(default, skip_serializing_if = "Option::is_none")]
205 pub rows: Option<usize>,
206 #[serde(default, skip_serializing_if = "Option::is_none")]
208 pub seed: Option<u64>,
209 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub export: Option<SavedChartExport>,
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize)]
216pub struct SavedChartExport {
217 pub format: crate::chart::chart_export::ChartExportFormat,
218 pub style: crate::chart::chart_export::ExportStyle,
219 pub size: crate::chart::chart_export::SizePreset,
220 pub width: u32,
221 pub height: u32,
222 pub dpi: f32,
223 pub legend: crate::chart::chart_export::LegendPlace,
224 pub point_opacity: crate::chart::chart_export::PointOpacity,
225 pub point_size: crate::chart::chart_export::PointSize,
226 pub line_width: crate::chart::chart_export::LineWidth,
227 pub y_from_zero: bool,
228 #[serde(default, skip_serializing_if = "String::is_empty")]
229 pub title: String,
230 #[serde(default, skip_serializing_if = "String::is_empty")]
231 pub description: String,
232 #[serde(default, skip_serializing_if = "String::is_empty")]
233 pub notes: String,
234 #[serde(default, skip_serializing_if = "String::is_empty")]
235 pub source: String,
236 #[serde(default, skip_serializing_if = "String::is_empty")]
237 pub byline: String,
238 pub recipe: bool,
240}
241
242#[derive(Debug, Clone, Serialize, Deserialize)]
245pub struct SavedSample {
246 pub scope: String,
249 pub method: String,
251 #[serde(default, skip_serializing_if = "Option::is_none")]
253 pub per: Option<String>,
254 pub rows: usize,
256 pub seed: u64,
257 #[serde(default, skip_serializing_if = "Option::is_none")]
260 pub path: Option<crate::analysis::table_sample::DrawPath>,
261 #[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::analysis::sampling::Sample,
272 path: Option<crate::analysis::table_sample::DrawPath>,
273 through: Option<ViewSettings>,
274 ) -> Self {
275 use crate::analysis::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::analysis::sampling::Sample> {
295 use crate::analysis::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::analysis::sampling::Sample {
311 scope: crate::analysis::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);
340
341fn views_lock_left(began: std::time::Instant) -> std::time::Duration {
343 VIEWS_LOCK_TIMEOUT.saturating_sub(began.elapsed())
344}
345
346pub struct ViewManager {
347 config: ConfigManager,
348 views: Vec<SavedView>,
349 pub(crate) views_dir: PathBuf,
350 pub broken_views: Vec<BrokenView>,
351}
352
353pub struct Views {
358 ready: std::cell::OnceCell<ViewManager>,
359 pending: std::cell::RefCell<Option<std::sync::mpsc::Receiver<ViewManager>>>,
360}
361
362impl Views {
363 pub fn read_in_background() -> Self {
365 let (tx, rx) = std::sync::mpsc::channel();
366 let pending = std::thread::Builder::new()
367 .name("datui-views".into())
368 .spawn(move || {
369 let _ = tx.send(ViewManager::load_or_empty());
370 })
371 .map(|_| rx)
372 .ok();
373 Self {
374 ready: std::cell::OnceCell::new(),
375 pending: std::cell::RefCell::new(pending),
376 }
377 }
378
379 #[cfg(test)]
382 pub(crate) fn waiting_on(rx: std::sync::mpsc::Receiver<ViewManager>) -> Self {
383 Self {
384 ready: std::cell::OnceCell::new(),
385 pending: std::cell::RefCell::new(Some(rx)),
386 }
387 }
388
389 #[cfg(test)]
391 pub(crate) fn is_read(&self) -> bool {
392 self.ready.get().is_some()
393 }
394
395 fn manager(&self) -> &ViewManager {
396 self.ready.get_or_init(|| {
397 self.pending
398 .borrow_mut()
399 .take()
400 .and_then(|rx| rx.recv().ok())
401 .unwrap_or_else(ViewManager::load_or_empty)
403 })
404 }
405}
406
407impl From<ViewManager> for Views {
408 fn from(manager: ViewManager) -> Self {
409 Self {
410 ready: std::cell::OnceCell::from(manager),
411 pending: std::cell::RefCell::new(None),
412 }
413 }
414}
415
416impl std::ops::Deref for Views {
417 type Target = ViewManager;
418
419 fn deref(&self) -> &ViewManager {
420 self.manager()
421 }
422}
423
424impl std::ops::DerefMut for Views {
425 fn deref_mut(&mut self) -> &mut ViewManager {
426 self.manager();
427 self.ready.get_mut().expect("read just now")
428 }
429}
430
431impl ViewManager {
432 pub fn load_or_empty() -> Self {
435 let config = ConfigManager::new(crate::APP_NAME).unwrap_or_else(|_| ConfigManager {
436 config_dir: std::env::temp_dir().join(crate::APP_NAME).join("config"),
437 });
438 Self::new(&config).unwrap_or_else(|_| {
439 let last_resort = ConfigManager {
440 config_dir: std::env::temp_dir().join("datui_config"),
441 };
442 Self::new(&last_resort).unwrap_or_else(|_| Self::empty(&last_resort))
443 })
444 }
445
446 pub fn new(config: &ConfigManager) -> Result<Self> {
449 let views_dir = config.config_dir().join("views");
452
453 let mut manager = Self {
454 config: config.clone(),
455 views: Vec::new(),
456 views_dir,
457 broken_views: Vec::new(),
458 };
459
460 manager.load_views()?;
463 Ok(manager)
464 }
465
466 pub fn empty(config: &ConfigManager) -> Self {
469 Self {
470 config: config.clone(),
471 views: Vec::new(),
472 views_dir: config.config_dir().join("views"),
473 broken_views: Vec::new(),
474 }
475 }
476
477 pub fn load_views(&mut self) -> Result<()> {
478 self.views.clear();
479 self.broken_views.clear();
480
481 if !self.views_dir.exists() {
482 return Ok(());
483 }
484
485 let began = std::time::Instant::now();
488 let queue = crate::cache::lock_file(&self.views_queue(), VIEWS_LOCK_TIMEOUT)
489 .ok()
490 .flatten();
491 let _lock = crate::cache::lock_file_shared(&self.views_lock(), views_lock_left(began))
492 .ok()
493 .flatten();
494 drop(queue);
495 let entries = fs::read_dir(&self.views_dir)?;
496 for entry in entries {
497 let entry = entry?;
498 let path = entry.path();
499
500 if path.is_file()
501 && path.extension().and_then(|s| s.to_str()) == Some("json")
502 && let Ok(content) = fs::read_to_string(&path)
503 {
504 match serde_json::from_str::<SavedView>(&content) {
505 Ok(mut view) => {
506 view.match_criteria.unmangle_urls();
507 self.views.push(view);
508 }
509 Err(e) => {
510 let filename = path
511 .file_stem()
512 .and_then(|s| s.to_str())
513 .unwrap_or("unknown")
514 .to_string();
515 self.broken_views.push(BrokenView {
516 filename,
517 error: e.to_string(),
518 });
519 }
520 }
521 }
522 }
523
524 Ok(())
525 }
526
527 fn views_lock(&self) -> PathBuf {
528 self.views_dir.join("views.lock")
529 }
530
531 fn views_queue(&self) -> PathBuf {
534 self.views_dir.join("views.queue")
535 }
536
537 fn view_path(&self, id: &str) -> PathBuf {
538 self.views_dir.join(format!("view_{id}.json"))
539 }
540
541 fn locked<T>(&self, work: impl FnOnce() -> Result<T>) -> Result<T> {
544 self.config.ensure_config_dir()?;
545 fs::create_dir_all(&self.views_dir)?;
546 let busy = || color_eyre::eyre::eyre!("another datui is saving views; try again");
547 let began = std::time::Instant::now();
548 let queue =
549 crate::cache::lock_file(&self.views_queue(), VIEWS_LOCK_TIMEOUT)?.ok_or_else(busy)?;
550 let lock = crate::cache::lock_file(&self.views_lock(), views_lock_left(began))?
551 .ok_or_else(busy)?;
552 drop(queue);
553 let result = work();
554 drop(lock);
555 result
556 }
557
558 fn read_stored(&self, id: &str) -> Result<Option<SavedView>> {
561 let text = match fs::read_to_string(self.view_path(id)) {
562 Ok(text) => text,
563 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
564 Err(e) => return Err(e.into()),
565 };
566 let mut view: SavedView = serde_json::from_str(&text)?;
567 view.match_criteria.unmangle_urls();
568 Ok(Some(view))
569 }
570
571 fn write_stored(&self, view: &SavedView) -> Result<()> {
572 let json = serde_json::to_string_pretty(view)?;
573 crate::cache::atomic_write(&self.view_path(&view.id), json.as_bytes())?;
574 Ok(())
575 }
576
577 pub fn save_view(&self, view: &SavedView) -> Result<()> {
580 self.locked(|| self.write_stored(view))
581 }
582
583 pub fn delete_view(&mut self, id: &str) -> Result<()> {
584 let file_path = self.view_path(id);
585 if file_path.exists() {
586 self.locked(|| match fs::remove_file(&file_path) {
587 Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e.into()),
588 _ => Ok(()),
589 })?;
590 }
591
592 self.views.retain(|t| t.id != id);
593 Ok(())
594 }
595
596 pub fn record_use(&mut self, id: &str, file: &Path) -> Result<()> {
599 let stored = self.locked(|| {
600 let Some(mut stored) = self.read_stored(id)? else {
601 return Ok(None);
602 };
603 stored.last_used = Some(SystemTime::now());
604 stored.usage_count += 1;
605 stored.last_matched_file = Some(file.to_path_buf());
606 self.write_stored(&stored)?;
607 Ok(Some(stored))
608 })?;
609 self.adopt(id, stored);
610 Ok(())
611 }
612
613 fn adopt(&mut self, id: &str, stored: Option<SavedView>) {
615 match stored {
616 Some(stored) => match self.views.iter_mut().find(|t| t.id == id) {
617 Some(existing) => *existing = stored,
618 None => self.views.push(stored),
619 },
620 None => self.views.retain(|t| t.id != id),
621 }
622 }
623
624 pub fn find_relevant_views<'a>(
625 &self,
626 dataset: impl Into<Dataset<'a>>,
627 schema: &Schema,
628 ) -> Vec<(SavedView, f64)> {
629 let dataset = dataset.into();
630 let mut results: Vec<(SavedView, f64)> = self
631 .views
632 .iter()
633 .map(|view| {
634 let score = calculate_relevance(view, dataset, schema);
635 (view.clone(), score)
636 })
637 .collect();
638
639 results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
640
641 results
642 }
643
644 pub fn get_most_relevant<'a>(
647 &self,
648 dataset: impl Into<Dataset<'a>>,
649 schema: &Schema,
650 ) -> Option<(SavedView, MatchReason)> {
651 let dataset = dataset.into();
652 self.find_relevant_views(dataset, schema)
653 .into_iter()
654 .find_map(|(view, _)| {
655 let reason = match_reason(&view, dataset, schema)?;
656 Some((view, reason))
657 })
658 }
659
660 pub fn suggest_name(&self, path: Option<&Path>, query: Option<&str>) -> String {
663 let base = path
664 .and_then(|p| p.file_stem())
665 .and_then(|s| s.to_str())
666 .map(str::to_string)
667 .filter(|s| !s.trim().is_empty())
668 .or_else(|| query.map(|q| q.split_whitespace().take(4).collect::<Vec<_>>().join(" ")))
669 .filter(|s| !s.trim().is_empty())
670 .unwrap_or_else(|| "view".to_string());
671
672 if !self.view_exists(&base) {
673 return base;
674 }
675 (2..)
676 .map(|n| format!("{base} {n}"))
677 .find(|name| !self.view_exists(name))
678 .expect("some numbered name is free")
679 }
680
681 pub fn view_exists(&self, name: &str) -> bool {
682 self.views.iter().any(|t| t.name == name)
683 }
684
685 pub fn get_view_by_name(&self, name: &str) -> Option<&SavedView> {
686 self.views.iter().find(|t| t.name == name)
687 }
688
689 pub fn get_view_by_id(&self, id: &str) -> Option<&SavedView> {
690 self.views.iter().find(|t| t.id == id)
691 }
692
693 pub fn all_views(&self) -> &[SavedView] {
694 &self.views
695 }
696
697 pub fn create_view(
698 &mut self,
699 name: String,
700 description: Option<String>,
701 match_criteria: MatchCriteria,
702 settings: ViewSettings,
703 ) -> Result<SavedView> {
704 let mut hasher = DefaultHasher::new();
706 name.hash(&mut hasher);
707 SystemTime::now()
708 .duration_since(SystemTime::UNIX_EPOCH)
709 .unwrap_or_default()
710 .as_secs()
711 .hash(&mut hasher);
712 let id = format!("{:016x}", hasher.finish());
713
714 let view = SavedView {
715 id,
716 name,
717 description,
718 created: SystemTime::now(),
719 last_used: None,
720 usage_count: 0,
721 last_matched_file: None,
722 match_criteria,
723 settings,
724 };
725
726 self.save_view(&view)?;
727
728 self.load_views()?;
730
731 Ok(view)
732 }
733
734 pub fn update_view(&mut self, edited: &SavedView) -> Result<()> {
738 let base = self.get_view_by_id(&edited.id).cloned();
739 let stored = self.locked(|| {
740 let Some(mut stored) = self.read_stored(&edited.id)? else {
741 return Ok(None);
742 };
743 merge_edit(&mut stored, base.as_ref(), edited);
744 self.write_stored(&stored)?;
745 Ok(Some(stored))
746 })?;
747 let deleted = stored.is_none();
748 self.adopt(&edited.id, stored);
749 if deleted {
750 return Err(color_eyre::eyre::eyre!(
751 "view \"{}\" was deleted by another datui",
752 edited.name
753 ));
754 }
755 Ok(())
756 }
757
758 pub fn remove_all_views(&mut self) -> Result<()> {
759 if self.views_dir.exists() {
761 self.locked(|| {
762 for entry in fs::read_dir(&self.views_dir)? {
763 let entry = entry?;
764 let path = entry.path();
765 if path.is_file()
766 && path
767 .file_name()
768 .and_then(|n| n.to_str())
769 .map(|s| s.starts_with("view_") && s.ends_with(".json"))
770 .unwrap_or(false)
771 {
772 fs::remove_file(&path)?;
773 }
774 }
775 Ok(())
776 })?;
777 }
778
779 self.views.clear();
780
781 Ok(())
782 }
783}
784
785fn merge_edit(stored: &mut SavedView, base: Option<&SavedView>, edited: &SavedView) {
788 fn changed<T: Serialize>(base: Option<&T>, edited: &T) -> bool {
789 base.is_none_or(|base| serde_json::to_value(base).ok() != serde_json::to_value(edited).ok())
790 }
791 if changed(base.map(|b| &b.name), &edited.name) {
792 stored.name = edited.name.clone();
793 }
794 if changed(base.map(|b| &b.description), &edited.description) {
795 stored.description = edited.description.clone();
796 }
797 if changed(base.map(|b| &b.match_criteria), &edited.match_criteria) {
798 stored.match_criteria = edited.match_criteria.clone();
799 }
800 if changed(base.map(|b| &b.settings), &edited.settings) {
801 stored.settings = edited.settings.clone();
802 }
803}
804
805#[derive(Debug, Clone, Copy, PartialEq, Eq)]
808pub enum MatchReason {
809 SameFile,
810 SameColumns,
811 Glob,
812}
813
814impl MatchReason {
815 pub fn as_str(&self) -> &'static str {
816 match self {
817 MatchReason::SameFile => "same file",
818 MatchReason::SameColumns => "same columns",
819 MatchReason::Glob => "glob",
820 }
821 }
822}
823
824#[derive(Debug, Clone, Copy)]
827pub struct Dataset<'a> {
828 pub path: &'a Path,
829 pub table: Option<&'a str>,
830}
831
832impl<'a> From<&'a Path> for Dataset<'a> {
833 fn from(path: &'a Path) -> Self {
834 Self { path, table: None }
835 }
836}
837
838impl<'a> From<&'a PathBuf> for Dataset<'a> {
839 fn from(path: &'a PathBuf) -> Self {
840 Self::from(path.as_path())
841 }
842}
843
844impl<'a> Dataset<'a> {
845 fn place_for(self, criteria: &MatchCriteria) -> Option<&'a Path> {
848 let table_fits = criteria
849 .table
850 .as_deref()
851 .is_none_or(|saved| self.table == Some(saved));
852 (table_fits && has_a_path(self.path)).then_some(self.path)
853 }
854}
855
856pub fn exact_location(path: &Path) -> PathBuf {
859 if crate::cloud::source::is_remote_url(path) {
860 return path.to_path_buf();
861 }
862 let absolute = if path.is_absolute() {
863 path.to_path_buf()
864 } else {
865 match std::env::current_dir() {
866 Ok(cwd) => cwd.join(path),
867 Err(_) => return path.to_path_buf(),
868 }
869 };
870 if crate::home::is_network_path(&absolute) {
873 return absolute;
874 }
875 crate::canonical::canonicalize(&absolute)
876 .ok()
877 .or_else(|| canonical_prefix(&absolute))
878 .unwrap_or(absolute)
879}
880
881fn canonical_prefix(path: &Path) -> Option<PathBuf> {
884 let (there, canonical) = path
885 .ancestors()
886 .skip(1)
887 .take_while(|p| !p.as_os_str().is_empty())
888 .find_map(|p| Some((p, crate::canonical::canonicalize(p).ok()?)))?;
889 Some(canonical.join(path.strip_prefix(there).ok()?))
890}
891
892pub fn relative_location(path: &Path) -> Option<String> {
895 if crate::cloud::source::is_remote_url(path) {
896 return None;
897 }
898 let cwd = exact_location(&std::env::current_dir().ok()?);
899 let relative = exact_location(path)
900 .strip_prefix(&cwd)
901 .ok()?
902 .to_string_lossy()
903 .into_owned();
904 (!relative.is_empty()).then_some(relative)
905}
906
907pub fn exact_path_matches<'a>(criteria: &MatchCriteria, dataset: impl Into<Dataset<'a>>) -> bool {
910 let (Some(stored), Some(file_path)) = (
911 criteria.exact_path.as_deref(),
912 dataset.into().place_for(criteria),
913 ) else {
914 return false;
915 };
916 if crate::cloud::source::is_remote_url(stored) || crate::cloud::source::is_remote_url(file_path)
917 {
918 return url_key(stored) == url_key(file_path);
919 }
920 stored == file_path || stored == exact_location(file_path)
921}
922
923fn has_a_path(file_path: &Path) -> bool {
926 !crate::loading::stdin::is_stdin(file_path)
927}
928
929fn url_key(path: &Path) -> String {
930 let text = path.to_string_lossy();
931 let text = text.trim_end_matches('/');
932 match text.split_once("://") {
933 Some((scheme, rest)) => format!("{}://{rest}", scheme.to_ascii_lowercase()),
934 None => text.to_string(),
935 }
936}
937
938pub fn relative_path_matches<'a>(
940 criteria: &MatchCriteria,
941 dataset: impl Into<Dataset<'a>>,
942) -> bool {
943 let Some(file_path) = dataset.into().place_for(criteria) else {
944 return false;
945 };
946 criteria.relative_path.as_deref().is_some_and(|stored| {
947 relative_location(file_path).is_some_and(|rel| Path::new(&rel) == Path::new(stored))
948 })
949}
950
951pub fn path_pattern_matches<'a>(criteria: &MatchCriteria, dataset: impl Into<Dataset<'a>>) -> bool {
954 let Some(file_path) = dataset.into().place_for(criteria) else {
955 return false;
956 };
957 criteria.path_pattern.as_deref().is_some_and(|pattern| {
958 let fits = |p: &Path| {
959 p.to_str()
960 .is_some_and(|text| matches_pattern(text, pattern))
961 };
962 fits(file_path) || fits(&exact_location(file_path))
963 })
964}
965
966pub fn filename_pattern_matches<'a>(
968 criteria: &MatchCriteria,
969 dataset: impl Into<Dataset<'a>>,
970) -> bool {
971 let Some(file_path) = dataset.into().place_for(criteria) else {
972 return false;
973 };
974 criteria.filename_pattern.as_deref().is_some_and(|pattern| {
975 file_path
976 .file_name()
977 .and_then(|name| name.to_str())
978 .is_some_and(|name| matches_pattern(name, pattern))
979 })
980}
981
982pub fn match_reason<'a>(
984 view: &SavedView,
985 dataset: impl Into<Dataset<'a>>,
986 schema: &Schema,
987) -> Option<MatchReason> {
988 let dataset = dataset.into();
989 let criteria = &view.match_criteria;
990 if exact_path_matches(criteria, dataset) || relative_path_matches(criteria, dataset) {
991 return Some(MatchReason::SameFile);
992 }
993 if let Some(required) = &criteria.schema_columns
994 && !required.is_empty()
995 {
996 let file_cols: HashSet<&str> = schema.iter_names().map(|s| s.as_str()).collect();
997 if required.iter().all(|col| file_cols.contains(col.as_str())) {
998 return Some(MatchReason::SameColumns);
999 }
1000 }
1001 if path_pattern_matches(criteria, dataset) || filename_pattern_matches(criteria, dataset) {
1002 return Some(MatchReason::Glob);
1003 }
1004 None
1005}
1006
1007fn calculate_relevance(view: &SavedView, dataset: Dataset<'_>, schema: &Schema) -> f64 {
1008 let mut score = 0.0;
1009
1010 let exact_path_match = exact_path_matches(&view.match_criteria, dataset);
1011 let relative_path_match = relative_path_matches(&view.match_criteria, dataset);
1012
1013 let exact_schema_match = if let Some(required_cols) = &view.match_criteria.schema_columns {
1015 let file_cols: HashSet<&str> = schema.iter_names().map(|s| s.as_str()).collect();
1016 let required_cols_set: HashSet<&str> = required_cols.iter().map(|s| s.as_str()).collect();
1017
1018 required_cols_set.is_subset(&file_cols) && file_cols.len() == required_cols_set.len()
1020 } else {
1021 false
1022 };
1023
1024 if exact_path_match && exact_schema_match {
1026 return 2000.0;
1027 }
1028
1029 if exact_path_match {
1031 return 1000.0;
1032 }
1033
1034 if relative_path_match && exact_schema_match {
1036 return 1950.0;
1037 }
1038
1039 if relative_path_match {
1041 return 950.0;
1042 }
1043
1044 if exact_schema_match {
1046 return 900.0;
1047 }
1048
1049 if let Some(pattern) = &view.match_criteria.path_pattern
1052 && path_pattern_matches(&view.match_criteria, dataset)
1053 {
1054 score += 50.0;
1055 score += pattern_specificity_bonus(pattern);
1056 }
1057
1058 if let Some(pattern) = &view.match_criteria.filename_pattern
1060 && filename_pattern_matches(&view.match_criteria, dataset)
1061 {
1062 score += 30.0;
1063 score += pattern_specificity_bonus(pattern);
1064 }
1065
1066 if let Some(required_cols) = &view.match_criteria.schema_columns {
1068 let file_cols: HashSet<&str> = schema.iter_names().map(|s| s.as_str()).collect();
1069 let matching_count = required_cols
1070 .iter()
1071 .filter(|col| file_cols.contains(col.as_str()))
1072 .count();
1073 score += (matching_count as f64) * 2.0; }
1078
1079 score += (view.usage_count.min(10) as f64) * 1.0;
1081 if let Some(last_used) = view.last_used
1082 && let Ok(duration) = SystemTime::now().duration_since(last_used)
1083 {
1084 let days_since = duration.as_secs() / 86400;
1085 if days_since <= 7 {
1086 score += 5.0;
1087 } else if days_since <= 30 {
1088 score += 2.0;
1089 }
1090 }
1091 score
1094}
1095
1096fn pattern_specificity_bonus(pattern: &str) -> f64 {
1097 let wildcard_count = pattern.matches('*').count() + pattern.matches('?').count();
1099 match wildcard_count {
1100 0 => 10.0, 1 => 5.0, 2 => 3.0, 3 => 1.0, _ => 0.0, }
1106}
1107
1108fn matches_pattern(text: &str, pattern: &str) -> bool {
1111 if pattern == "*" {
1112 return true;
1113 }
1114
1115 let pattern_parts: Vec<&str> = pattern.split('*').collect();
1117
1118 if pattern_parts.len() == 1 {
1119 return text == pattern;
1121 }
1122
1123 let mut text_pos = 0;
1125 for (i, part) in pattern_parts.iter().enumerate() {
1126 if part.is_empty() {
1127 continue;
1128 }
1129
1130 if i == 0 {
1131 if !text.starts_with(part) {
1133 return false;
1134 }
1135 text_pos = part.len();
1136 } else if i == pattern_parts.len() - 1 {
1137 return text[text_pos..].ends_with(part);
1139 } else {
1140 if let Some(pos) = text[text_pos..].find(part) {
1142 text_pos += pos + part.len();
1143 } else {
1144 return false;
1145 }
1146 }
1147 }
1148
1149 true
1150}
1151
1152#[cfg(test)]
1153mod tests;