Skip to main content

datui_lib/view/
mod.rs

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
19// Custom serialization for SystemTime (convert to/from seconds since epoch)
20mod 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/// Every field but the settings may be left out, so an exported chart's recipe, which
67/// carries only the settings, reads as a view.
68#[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    /// The table within a file of tables the view was saved on (`orders` of `shop.db`,
105    /// however opened); its path criteria fit only that table. URLs and stdin name the file
106    /// alone.
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub table: Option<String>,
109}
110
111impl MatchCriteria {
112    /// Repair paths of a view saved before URLs were distinguished from local paths (exact
113    /// path was cwd joined to the URL, relative path the URL): both become the URL, exact.
114    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
129/// The URL inside `<working directory>/s3://bucket/key`, or None when `path` is not
130/// a URL behind a local prefix.
131fn 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    /// Per entry of `sort_columns`, whether it runs descending. Empty in views
152    /// saved before per-column directions existed; `sort_ascending` then covers all.
153    #[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    /// The query, filters and sort a pivot or melt ran over, replayed before it; `query`,
166    /// `filters` and sort then ran on its result. Older views have none (reshape over the
167    /// data as loaded).
168    #[serde(skip_serializing_if = "Option::is_none")]
169    #[serde(default)]
170    pub reshape_source: Option<ReshapeSource>,
171    /// Column types and columns made from others, as a spec's `[columns]` entries:
172    /// `{ "name": "zip", "type": "str" }`. Applied after the query, before the filters.
173    #[serde(default, skip_serializing_if = "Vec::is_empty")]
174    pub columns: Vec<crate::formats::column_types::ColumnChange>,
175    /// The view's sample: drawn again from its seed when the view is applied, under
176    /// the query, filters and sort above. Its settings only, never its rows.
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub sample: Option<SavedSample>,
179    /// The chart drawn of the view, and how it was last exported: `c` brings it back.
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub chart: Option<SavedChart>,
182}
183
184/// A view's chart: what is charted, and the options it is drawn with.
185#[derive(Debug, Clone, Serialize, Deserialize)]
186pub struct SavedChart {
187    /// The type, X, Y with its aggregate, and Color, as Vega-Lite names them.
188    #[serde(flatten)]
189    pub spec: crate::chart::chart_modal::ChartSpec,
190    pub histogram_bins: usize,
191    pub heatmap_bins: usize,
192    /// The KDE's bandwidth, as a factor of its rule of thumb.
193    pub bandwidth: f64,
194    pub range: crate::chart::chart_data::ValueRange,
195    pub bar_order: crate::chart::chart_data::BarOrder,
196    /// A histogram's bars as each group's share of its rows.
197    pub share: bool,
198    pub y_starts_at_zero: bool,
199    pub log_scale: bool,
200    pub legend: bool,
201    pub grid: bool,
202    /// Rows the chart reads when the view has no sample of its own: up to this many,
203    /// spread across the table. `None` reads every row.
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub rows: Option<usize>,
206    /// The seed the chart's own sample is drawn with.
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub seed: Option<u64>,
209    /// How the chart was last exported: everything but where.
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub export: Option<SavedChartExport>,
212}
213
214/// A chart export's settings, as a view keeps them.
215#[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    /// Whether the file carries its recipe.
239    pub recipe: bool,
240}
241
242/// A view's sample as a view keeps it: which rows, how they are picked, how many,
243/// and the seed that draws the same ones again.
244#[derive(Debug, Clone, Serialize, Deserialize)]
245pub struct SavedSample {
246    /// Which rows, as the Sample form's scope command says them: `view`, `source`,
247    /// `rows 1..5000`, `files 1,3`, `partition year=2024`, `time date=2024-01..2024-02`.
248    pub scope: String,
249    /// `random`, `per value`, `first rows` or `every row`.
250    pub method: String,
251    /// The column an equal-per-value sample splits by.
252    #[serde(default, skip_serializing_if = "Option::is_none")]
253    pub per: Option<String>,
254    /// Rows to keep: in all, or per value.
255    pub rows: usize,
256    pub seed: u64,
257    /// How a stream's random sample was drawn (reservoir, or row by row with chance
258    /// `rows / of`); redrawn this way, the seed gives the same rows whatever is counted.
259    #[serde(default, skip_serializing_if = "Option::is_none")]
260    pub path: Option<crate::analysis::table_sample::DrawPath>,
261    /// The view a sample was drawn through (its query, filters, sort, types and reshape),
262    /// replayed before drawing.
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::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    /// The sample this draws, or why it cannot be read.
294    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    /// 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 views read or write waits for another instance (queue and lock): bounds
338/// a wedged peer, paid by a save on the UI thread, not expected to be reached.
339const VIEWS_LOCK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
340
341/// What is left of [`VIEWS_LOCK_TIMEOUT`] after `began`.
342fn 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
353/// The saved views, read on a worker from `run`'s start (a directory parse that can
354/// stall on slow mounts). Nothing needs them until a schema is known (`--view`,
355/// auto-apply) or the list opens; first use waits if still reading, so a requested view
356/// is never skipped. Derefs to the [`ViewManager`].
357pub struct Views {
358    ready: std::cell::OnceCell<ViewManager>,
359    pending: std::cell::RefCell<Option<std::sync::mpsc::Receiver<ViewManager>>>,
360}
361
362impl Views {
363    /// Start reading the views on a worker.
364    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    /// Views that arrive on `rx` whenever the test sends them: a reader as slow as the
380    /// test likes.
381    #[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    /// Whether the read has been waited for yet.
390    #[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                // No worker, or it died: read them here rather than go without.
402                .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    /// The config directory's views, or none: an unreadable directory falls back to a
433    /// temporary one, so startup never fails on it.
434    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    /// Creates a view manager that loads views from disk. Use `empty()` when
447    /// config dirs are unavailable to avoid panicking on startup.
448    pub fn new(config: &ConfigManager) -> Result<Self> {
449        // Don't create directories on startup - be sensitive to constrained environments
450        // Directories will be created lazily when actually needed (e.g., saving views)
451        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        // Only try to load views if the directory exists
461        // Don't create it if it doesn't exist
462        manager.load_views()?;
463        Ok(manager)
464    }
465
466    /// Creates an empty in-memory view manager (no disk load). Use when
467    /// `new()` fails so the app can start without panicking; save may fail later.
468    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        // Listed under the shared views lock: a save renames a temp file over the view, and a
486        // concurrent listing on btrfs can miss it. A read-only directory is listed unlocked.
487        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    /// Taken briefly before the views' lock, and held by a writer while it waits for
532    /// it: readers that keep listing cannot starve a writer of the lock.
533    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    /// Run `work` holding the views' lock, which every write and delete takes, so
542    /// another instance cannot land between reading a view and writing it back.
543    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    /// The view as stored now, `None` when it is gone (deleted, perhaps by another
559    /// instance). A file that does not parse is an error: it is not ours to replace.
560    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    /// Write `view` as it is, under the lock and by rename: a crash or a reader
578    /// mid-write never meets a half-written view.
579    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    /// Count a use of the view by bumping its stored file (not writing this copy back), so
597    /// other instances' edits and deletions survive.
598    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    /// Replace this instance's copy of a view with the stored one, or drop it.
614    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    /// The best view whose own criteria match this file, not the best-scored (usage and
645    /// recency would make the most-used view "match" everything).
646    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    /// A recognizable name for a view saved from this state: the file stem, else the
661    /// query's first words; numbered past the first collision.
662    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        // Generate unique ID based on name and timestamp
705        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        // Reload views to include the new one
729        self.load_views()?;
730
731        Ok(view)
732    }
733
734    /// Save an edit of a view: only what changed from this instance's copy, over the stored
735    /// view, so other instances' edits and usage survive. A view deleted elsewhere is an
736    /// error and leaves this instance too.
737    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        // Delete all view files
760        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
785/// Apply to `stored` the fields `edited` changed from `base`, the copy the edit began
786/// from. Without a base every edited field is taken. Usage is never an edit.
787fn 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/// Why a view's criteria fit the open file, worded as the list annotates rows; the
806/// strongest wins (same file over same columns over a glob).
807#[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/// What a view is matched against: the dataset's location and table. Stdin's `-` fits
825/// no path criterion, so piped data and handed frames match by columns alone.
826#[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    /// The path compared with the view's path criteria, or `None` when none can fit (no
846    /// path, or another table than saved on; views predating table recording fit any).
847    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
856/// A dataset location as a view records it (URL as written, local path absolute and
857/// resolved), shared by the save form and matching.
858pub 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    // Matching runs on the interface thread, where a stalled network mount must
871    // not be touched; such a path is compared as spelled.
872    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
881/// A path nothing on disk has (a table inside its file, `shop.db/orders`) with its
882/// existing part resolved, so links and home spell it alike.
883fn 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
892/// A local dataset's path relative to the working directory, when it is under it.
893/// A URL has none: it names the same data wherever datui runs.
894pub 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
907/// Whether the view's exact path names `file_path`. URLs compare as text, ignoring a
908/// trailing slash, with the scheme either way.
909pub 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
923/// Whether `file_path` is a path a path criterion can fit: not standard input's `-`,
924/// which views match by its columns alone.
925fn 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
938/// Whether the view's relative path names the dataset at `file_path`.
939pub 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
951/// Whether the view's path pattern fits `file_path`, as opened or as the save form
952/// spells it (relative or linked paths still sit under the resolved directory).
953pub 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
966/// Whether the view's filename pattern fits the name of `file_path`.
967pub 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
982/// The strongest fitting criterion, or `None`: one test, so the list's annotation never disagrees with `V` and auto-apply.
983pub 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    // Check for exact schema match
1014    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        // All required columns present AND no extra columns (exact match)
1019        required_cols_set.is_subset(&file_cols) && file_cols.len() == required_cols_set.len()
1020    } else {
1021        false
1022    };
1023
1024    // Exact path (absolute) + exact schema: highest priority (2000 points)
1025    if exact_path_match && exact_schema_match {
1026        return 2000.0;
1027    }
1028
1029    // Exact path (absolute) only: very high priority (1000 points)
1030    if exact_path_match {
1031        return 1000.0;
1032    }
1033
1034    // Relative path + exact schema: very high priority (1950 points)
1035    if relative_path_match && exact_schema_match {
1036        return 1950.0;
1037    }
1038
1039    // Relative path only: very high priority (950 points)
1040    if relative_path_match {
1041        return 950.0;
1042    }
1043
1044    // Exact schema only (without path matches): very high priority (900 points)
1045    if exact_schema_match {
1046        return 900.0;
1047    }
1048
1049    // For non-exact matches, sum components
1050    // Path pattern match
1051    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    // Filename pattern match
1059    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    // Partial schema matching (only if not exact match)
1067    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; // 2 points per matching column
1074
1075        // Optional: type matching bonus (if types are specified)
1076        // This would require comparing schema types, which is more complex
1077    }
1078
1079    // Usage statistics
1080    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    // No age penalty: recency above already separates live from stale views.
1092
1093    score
1094}
1095
1096fn pattern_specificity_bonus(pattern: &str) -> f64 {
1097    // More specific patterns (fewer wildcards) get higher bonuses
1098    let wildcard_count = pattern.matches('*').count() + pattern.matches('?').count();
1099    match wildcard_count {
1100        0 => 10.0, // No wildcards (most specific)
1101        1 => 5.0,  // One wildcard
1102        2 => 3.0,  // Two wildcards
1103        3 => 1.0,  // Three wildcards
1104        _ => 0.0,  // Many wildcards (less specific)
1105    }
1106}
1107
1108/// Glob-like matching: `*` matches any sequence, anything else itself (`?` is not
1109/// special).
1110fn matches_pattern(text: &str, pattern: &str) -> bool {
1111    if pattern == "*" {
1112        return true;
1113    }
1114
1115    // Simple wildcard matching
1116    let pattern_parts: Vec<&str> = pattern.split('*').collect();
1117
1118    if pattern_parts.len() == 1 {
1119        // No wildcards, exact match
1120        return text == pattern;
1121    }
1122
1123    // Has wildcards - check if text matches pattern parts
1124    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            // First part must match start
1132            if !text.starts_with(part) {
1133                return false;
1134            }
1135            text_pos = part.len();
1136        } else if i == pattern_parts.len() - 1 {
1137            // Last part must match end
1138            return text[text_pos..].ends_with(part);
1139        } else {
1140            // Middle parts must appear in order
1141            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;