Skip to main content

datui_lib/
view.rs

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
16// Custom serialization for SystemTime (convert to/from seconds since epoch)
17mod 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/// Every field but the settings may be left out, so an exported chart's recipe, which
64/// carries only the settings, reads as a view.
65#[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    /// The table of a file of tables the view was saved on (`orders` of `shop.db`,
102    /// whether opened as `shop.db/orders` or with `--table orders`). Its path criteria
103    /// fit only that table: a URL or standard input names the file alone.
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub table: Option<String>,
106}
107
108impl MatchCriteria {
109    /// Repair the paths of a view saved before URLs were told apart from local paths:
110    /// its exact path was the working directory joined to the URL, and its relative
111    /// path the URL itself. Both become the URL, as the exact path.
112    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
127/// The URL inside `<working directory>/s3://bucket/key`, or None when `path` is not
128/// a URL behind a local prefix.
129fn 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    /// Per entry of `sort_columns`, whether it runs descending. Empty in views
150    /// saved before per-column directions existed; `sort_ascending` then covers all.
151    #[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    /// The query, filters and sort the pivot or melt ran over, replayed before it. With
164    /// a reshape, `query`, `filters` and the sort are what ran on its result. Views
165    /// saved before this existed have none: their reshape ran over the data as loaded.
166    #[serde(skip_serializing_if = "Option::is_none")]
167    #[serde(default)]
168    pub reshape_source: Option<ReshapeSource>,
169    /// Column types and columns made from others, as a spec's `[columns]` entries:
170    /// `{ "name": "zip", "type": "str" }`. Applied after the query, before the filters.
171    #[serde(default, skip_serializing_if = "Vec::is_empty")]
172    pub columns: Vec<crate::column_types::ColumnChange>,
173    /// The view's sample: drawn again from its seed when the view is applied, under
174    /// the query, filters and sort above. Its settings only, never its rows.
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub sample: Option<SavedSample>,
177    /// The chart drawn of the view, and how it was last exported: `c` brings it back.
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub chart: Option<SavedChart>,
180}
181
182/// A view's chart: what is charted, and the options it is drawn with.
183#[derive(Debug, Clone, Serialize, Deserialize)]
184pub struct SavedChart {
185    /// The type, X, Y with its aggregate, and Color, as Vega-Lite names them.
186    #[serde(flatten)]
187    pub spec: crate::chart_modal::ChartSpec,
188    pub histogram_bins: usize,
189    pub heatmap_bins: usize,
190    /// The KDE's bandwidth, as a factor of its rule of thumb.
191    pub bandwidth: f64,
192    pub range: crate::chart_data::ValueRange,
193    pub bar_order: crate::chart_data::BarOrder,
194    /// A histogram's bars as each group's share of its rows.
195    pub share: bool,
196    pub y_starts_at_zero: bool,
197    pub log_scale: bool,
198    pub legend: bool,
199    pub grid: bool,
200    /// Rows the chart reads when the view has no sample of its own: up to this many,
201    /// spread across the table. `None` reads every row.
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub rows: Option<usize>,
204    /// The seed the chart's own sample is drawn with.
205    #[serde(default, skip_serializing_if = "Option::is_none")]
206    pub seed: Option<u64>,
207    /// How the chart was last exported: everything but where.
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub export: Option<SavedChartExport>,
210}
211
212/// A chart export's settings, as a view keeps them.
213#[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    /// Whether the file carries its recipe.
237    pub recipe: bool,
238}
239
240/// A view's sample as a view keeps it: which rows, how they are picked, how many,
241/// and the seed that draws the same ones again.
242#[derive(Debug, Clone, Serialize, Deserialize)]
243pub struct SavedSample {
244    /// Which rows, as the Sample form's scope command says them: `view`, `source`,
245    /// `rows 1..5000`, `files 1,3`, `partition year=2024`, `time date=2024-01..2024-02`.
246    pub scope: String,
247    /// `random`, `per value`, `first rows` or `every row`.
248    pub method: String,
249    /// The column an equal-per-value sample splits by.
250    #[serde(default, skip_serializing_if = "Option::is_none")]
251    pub per: Option<String>,
252    /// Rows to keep: in all, or per value.
253    pub rows: usize,
254    pub seed: u64,
255    /// How a random sample of a stream was drawn: a reservoir, or row by row with
256    /// chance `rows / of`. Drawn the same way again, the same seed keeps the same
257    /// rows, whatever is counted by then.
258    #[serde(default, skip_serializing_if = "Option::is_none")]
259    pub path: Option<crate::table_sample::DrawPath>,
260    /// The view the sample was drawn through, when it was drawn from a view's rows
261    /// rather than the source: its query, filters, sort, column types and reshape,
262    /// replayed before the sample is drawn.
263    #[serde(default, skip_serializing_if = "Option::is_none")]
264    pub through: Option<Box<ViewSettings>>,
265}
266
267impl SavedSample {
268    /// `sample` as a view keeps it, drawn `through` the view given, the way `path`
269    /// says.
270    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    /// The sample this draws, or why it cannot be read.
294    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    /// The per-column directions this view's sort runs. A view saved before
321    /// per-column directions existed has none; `sort_ascending` then covers all.
322    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
337/// How long a read or a write of the views waits for another instance, the queue and
338/// the lock together. Long enough for a busy machine; it bounds the wait on a wedged
339/// peer, which a save on the UI thread pays, and is not meant to be met.
340const VIEWS_LOCK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
341
342/// What is left of [`VIEWS_LOCK_TIMEOUT`] after `began`.
343fn 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
354/// The saved views, read on a worker from the moment `run` starts.
355///
356/// Reading them enumerates and parses a directory, which is a stall on a slow mount
357/// and tens of milliseconds for a few thousand views; neither belongs in front of the
358/// first frame. Nothing needs them until a dataset's schema is known (`--view` and
359/// auto-apply meet it there) or the views list opens, so the first use waits for the
360/// read if it is still going — a view the user asked for is never skipped by rows
361/// shown without it. Derefs to the [`ViewManager`].
362pub struct Views {
363    ready: std::cell::OnceCell<ViewManager>,
364    pending: std::cell::RefCell<Option<std::sync::mpsc::Receiver<ViewManager>>>,
365}
366
367impl Views {
368    /// Start reading the views on a worker.
369    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    /// Views that arrive on `rx` whenever the test sends them: a reader as slow as the
385    /// test likes.
386    #[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    /// Whether the read has been waited for yet.
395    #[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                // No worker, or it died: read them here rather than go without.
407                .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    /// The views in the config directory, or none: a directory that cannot be read
438    /// falls back to a temporary one, as the app always has, so startup never fails
439    /// on it.
440    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    /// Creates a view manager that loads views from disk. Use `empty()` when
453    /// config dirs are unavailable to avoid panicking on startup.
454    pub fn new(config: &ConfigManager) -> Result<Self> {
455        // Don't create directories on startup - be sensitive to constrained environments
456        // Directories will be created lazily when actually needed (e.g., saving views)
457        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        // Only try to load views if the directory exists
467        // Don't create it if it doesn't exist
468        manager.load_views()?;
469        Ok(manager)
470    }
471
472    /// Creates an empty in-memory view manager (no disk load). Use when
473    /// `new()` fails so the app can start without panicking; save may fail later.
474    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        // Load all view files
488        if !self.views_dir.exists() {
489            return Ok(());
490        }
491
492        // Listed under the views lock, shared. A save renames a temp file over the
493        // view, and a listing taken meanwhile on btrfs can miss the view entirely: the
494        // new name takes a later directory slot than the listing reaches. A directory
495        // that will not take the lock file (read-only) is listed without it.
496        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    /// Taken briefly before the views' lock, and held by a writer while it waits for
541    /// it: readers that keep listing cannot starve a writer of the lock.
542    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    /// Run `work` holding the views' lock, which every write and delete takes, so
551    /// another instance cannot land between reading a view and writing it back.
552    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    /// The view as stored now, `None` when it is gone (deleted, perhaps by another
568    /// instance). A file that does not parse is an error: it is not ours to replace.
569    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    /// Write `view` as it is, under the lock and by rename: a crash or a reader
587    /// mid-write never meets a half-written view.
588    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    /// Count a use of the view: its stored file is bumped, not this instance's copy
606    /// written back, so an edit made by another instance since this one read the
607    /// views is kept, and a view deleted elsewhere stays deleted.
608    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    /// Replace this instance's copy of a view with the stored one, or drop it.
624    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        // Sort by relevance score (highest first)
650        results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
651
652        results
653    }
654
655    /// The best view whose own criteria match this file — not merely the
656    /// best-scored one. Scores mix in usage and recency, so with no gate the
657    /// most-used view "matches" every dataset ever opened; `V` applying it
658    /// silently is how views lose the user's trust.
659    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    /// A name for a view saved from this state: the file stem, or failing
674    /// that the query's first words — something the user will recognize in
675    /// the list, never a serial number. Numbered past the first collision.
676    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        // Generate unique ID based on name and timestamp
719        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        // Save the view
741        self.save_view(&view)?;
742
743        // Reload views to include the new one
744        self.load_views()?;
745
746        Ok(view)
747    }
748
749    /// Save an edit of a view. Only what the edit changed from this instance's copy
750    /// is written, over the view as stored now, so another instance's edit to other
751    /// fields and its usage counts survive. A view deleted elsewhere is not brought
752    /// back: that is an error, and the view leaves this instance too.
753    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        // Delete all view files
776        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        // Clear in-memory list
796        self.views.clear();
797
798        Ok(())
799    }
800}
801
802/// Apply to `stored` the fields `edited` changed from `base`, the copy the edit began
803/// from. Without a base every edited field is taken. Usage is never an edit.
804fn 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/// Why a view's criteria fit the open file, in the words the list annotates
823/// rows with. The strongest reason wins: the same file beats the same columns
824/// beats a glob.
825#[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/// What a view is matched against: where the dataset was read from, and which table
843/// of it. Standard input's `-` is a path no path criterion fits, so data piped in or a
844/// frame handed over is matched by its columns alone.
845#[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    /// The path the view's path criteria are compared with, or None when none can fit:
865    /// the dataset has no path, or it is another table than the one the view was saved
866    /// on. A view saved before tables were recorded fits any.
867    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
876/// A dataset's location as a view records it: a URL as written, a local path made
877/// absolute and resolved. The save form offers this and matching compares with it,
878/// so the two are spelled alike.
879pub 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    // Matching runs on the interface thread, where a stalled network mount must
892    // not be touched; such a path is compared as spelled.
893    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
902/// A path nothing on disk has, such as a table inside its file (`shop.db/orders`),
903/// with the part that is there resolved: opened through a link or from the home
904/// screen, it is spelled alike.
905fn 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
914/// A local dataset's path relative to the working directory, when it is under it.
915/// A URL has none: it names the same data wherever datui runs.
916pub 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
929/// Whether the view's exact path names the dataset at `file_path`. A URL compares as
930/// text less any trailing slash, which is how a directory's URL may or may not end,
931/// and with its scheme in either case, as datui opens it.
932pub 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
945/// Whether `file_path` is a path a path criterion can fit: not standard input's `-`,
946/// which views match by its columns alone.
947fn 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
960/// Whether the view's relative path names the dataset at `file_path`.
961pub 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
973/// Whether the view's path pattern fits `file_path`, as opened or as the save form
974/// spells it: a file opened by a relative path or through a link is still under the
975/// resolved directory its pattern was suggested from.
976pub 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
989/// Whether the view's filename pattern fits the name of `file_path`.
990pub 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
1005/// Whether the view's own criteria match this file: a path or pattern hit,
1006/// or every schema column the view asks for present. Distinct from the
1007/// relevance score, which also carries usage and recency and so is never zero
1008/// for a view that has been used — a ranking, not a claim of fit.
1009pub 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
1017/// The strongest criterion of the view's that fits this file, or None when
1018/// none does. This is the same test `criteria_match` gates on, kept in one
1019/// place so the list's "why it matches" annotation can never disagree with
1020/// what `V` and auto-apply do.
1021pub 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    // Check for exact schema match
1052    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        // All required columns present AND no extra columns (exact match)
1057        required_cols_set.is_subset(&file_cols) && file_cols.len() == required_cols_set.len()
1058    } else {
1059        false
1060    };
1061
1062    // Exact path (absolute) + exact schema: highest priority (2000 points)
1063    if exact_path_match && exact_schema_match {
1064        return 2000.0;
1065    }
1066
1067    // Exact path (absolute) only: very high priority (1000 points)
1068    if exact_path_match {
1069        return 1000.0;
1070    }
1071
1072    // Relative path + exact schema: very high priority (1950 points)
1073    if relative_path_match && exact_schema_match {
1074        return 1950.0;
1075    }
1076
1077    // Relative path only: very high priority (950 points)
1078    if relative_path_match {
1079        return 950.0;
1080    }
1081
1082    // Exact schema only (without path matches): very high priority (900 points)
1083    if exact_schema_match {
1084        return 900.0;
1085    }
1086
1087    // For non-exact matches, sum components
1088    // Path pattern match
1089    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    // Filename pattern match
1097    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    // Partial schema matching (only if not exact match)
1105    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; // 2 points per matching column
1112
1113        // Optional: type matching bonus (if types are specified)
1114        // This would require comparing schema types, which is more complex
1115    }
1116
1117    // Usage statistics
1118    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    // No penalty for age since creation: a view is not worse for being old,
1130    // and last-used recency above already separates the live from the stale.
1131    // Charged anyway, a year-old view that fit showed a negative "score".
1132
1133    score
1134}
1135
1136fn pattern_specificity_bonus(pattern: &str) -> f64 {
1137    // More specific patterns (fewer wildcards) get higher bonuses
1138    let wildcard_count = pattern.matches('*').count() + pattern.matches('?').count();
1139    match wildcard_count {
1140        0 => 10.0, // No wildcards (most specific)
1141        1 => 5.0,  // One wildcard
1142        2 => 3.0,  // Two wildcards
1143        3 => 1.0,  // Three wildcards
1144        _ => 0.0,  // Many wildcards (less specific)
1145    }
1146}
1147
1148/// Glob-like matching: `*` matches any sequence; anything else matches itself.
1149/// (`?` gets no special treatment: it is rare in names, and a single-character
1150/// wildcard is not worth a second wildcard rule in a five-field matcher.)
1151fn matches_pattern(text: &str, pattern: &str) -> bool {
1152    if pattern == "*" {
1153        return true;
1154    }
1155
1156    // Simple wildcard matching
1157    let pattern_parts: Vec<&str> = pattern.split('*').collect();
1158
1159    if pattern_parts.len() == 1 {
1160        // No wildcards, exact match
1161        return text == pattern;
1162    }
1163
1164    // Has wildcards - check if text matches pattern parts
1165    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            // First part must match start
1173            if !text.starts_with(part) {
1174                return false;
1175            }
1176            text_pos = part.len();
1177        } else if i == pattern_parts.len() - 1 {
1178            // Last part must match end
1179            return text[text_pos..].ends_with(part);
1180        } else {
1181            // Middle parts must appear in order
1182            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    /// Old view JSON without sql_query or fuzzy_query deserializes; those fields default to None.
1198    #[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    /// A reshape's source is written only when there is one, and only what it holds, so
1216    /// a view without one reads the same to an older datui.
1217    #[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    /// A view's path criteria fit only the table it was saved on; one saved before
1282    /// tables were recorded fits any, as it always did.
1283    #[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    /// A table inside its file is spelled with the file's resolved path, so the place
1325    /// a link or a relative path names matches the one the home screen lists.
1326    #[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    /// Usage and recency raise the score but are not a match: a well-used
1343    /// view whose criteria fit nothing must never be what `V` applies.
1344    #[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    /// Data piped in matches by its columns alone: no path or pattern fits `-`, even
1371    /// a pattern of `*` or a view saved with that name as its path.
1372    #[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    /// A view's schema criterion asks for its columns to be present; a file
1403    /// with extra columns still fits ("similar table"), a file missing one does not.
1404    #[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    /// The list's annotation names the strongest criterion that fits: the
1438    /// same file beats the same columns beats a pattern.
1439    #[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    /// A URL is recorded as written and matches itself, with or without the
1496    /// trailing slash a directory's URL may carry; another URL is another file.
1497    #[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    /// A local file opened by a relative path is the same file as its absolute,
1535    /// resolved path, and has a path relative to the working directory.
1536    #[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        // The save form suggests a pattern from the resolved directory.
1567        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    /// Views saved before URLs were told apart from local paths carry the working
1586    /// directory joined to the URL, and the URL as the relative path. They still
1587    /// load, with the URL as their exact path, and match it.
1588    #[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}