Skip to main content

ashlar_fs/
recent.rs

1//! A most-recently-used list of files (File ▸ Open Recent).
2
3use std::path::{Component, Path, PathBuf};
4
5use serde::{Deserialize, Serialize};
6
7use crate::settings::Settings;
8
9/// Entries kept unless set with [`RecentFiles::new`].
10pub const DEFAULT_CAP: usize = 10;
11
12/// Most recent first, no duplicates, at most `cap` entries.
13#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
14pub struct RecentFiles {
15    paths: Vec<PathBuf>,
16    cap: usize,
17}
18
19impl Default for RecentFiles {
20    fn default() -> Self {
21        RecentFiles::new(DEFAULT_CAP)
22    }
23}
24
25impl RecentFiles {
26    /// An empty list keeping at most `cap` entries (at least 1).
27    pub fn new(cap: usize) -> RecentFiles {
28        RecentFiles { paths: Vec::new(), cap: cap.max(1) }
29    }
30
31    /// Move `path` to the front (adding it if new), dropping the oldest beyond the cap. Paths
32    /// that differ only by `.` components or trailing separators are the same entry.
33    pub fn push(&mut self, path: impl AsRef<Path>) {
34        let p = normalize(path.as_ref());
35        if p.as_os_str().is_empty() {
36            return;
37        }
38        self.paths.retain(|q| *q != p);
39        self.paths.insert(0, p);
40        self.paths.truncate(self.cap);
41    }
42
43    /// Remove `path`; true when it was listed.
44    pub fn remove(&mut self, path: impl AsRef<Path>) -> bool {
45        let p = normalize(path.as_ref());
46        let before = self.paths.len();
47        self.paths.retain(|q| *q != p);
48        self.paths.len() != before
49    }
50
51    /// Drop the entries `exists` says are gone (pass `Path::exists`, or a `FileOps` check).
52    /// Returns how many were dropped.
53    pub fn prune(&mut self, exists: impl Fn(&Path) -> bool) -> usize {
54        let before = self.paths.len();
55        self.paths.retain(|p| exists(p));
56        before - self.paths.len()
57    }
58
59    pub fn clear(&mut self) {
60        self.paths.clear();
61    }
62
63    pub fn paths(&self) -> &[PathBuf] {
64        &self.paths
65    }
66
67    pub fn cap(&self) -> usize {
68        self.cap
69    }
70
71    /// Change the cap, dropping the oldest entries beyond it.
72    pub fn set_cap(&mut self, cap: usize) {
73        self.cap = cap.max(1);
74        self.paths.truncate(self.cap);
75    }
76
77    /// Read the list stored under `key` (a list of path strings), keeping this list's cap. Bad
78    /// entries are skipped; a missing or malformed value gives an empty list.
79    pub fn load(settings: &Settings, key: &str, cap: usize) -> RecentFiles {
80        let mut r = RecentFiles::new(cap);
81        let items: Vec<serde_json::Value> = settings.get(key).unwrap_or_default();
82        for s in items.iter().rev().filter_map(|v| v.as_str()) {
83            r.push(s);
84        }
85        r
86    }
87
88    /// Store the list under `key` as a list of path strings.
89    pub fn store(&self, settings: &mut Settings, key: &str) {
90        let v: Vec<String> = self.paths.iter().map(|p| p.to_string_lossy().into_owned()).collect();
91        settings.set_value(key, serde_json::Value::from(v));
92    }
93}
94
95/// Lexical clean-up only (no file system access, so symlinks are not resolved): drops `.`
96/// components and trailing separators.
97fn normalize(p: &Path) -> PathBuf {
98    p.components().filter(|c| !matches!(c, Component::CurDir)).collect()
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104    use crate::backend::{ConfigStore, MemoryConfig};
105    use std::sync::Arc;
106
107    #[test]
108    fn mru_order_dedupe_and_cap() {
109        let mut r = RecentFiles::new(3);
110        r.push("/a");
111        r.push("/b");
112        r.push("/c");
113        r.push("/a/"); // same as /a
114        assert_eq!(r.paths(), [PathBuf::from("/a"), "/c".into(), "/b".into()]);
115        r.push("/d");
116        assert_eq!(r.paths(), [PathBuf::from("/d"), "/a".into(), "/c".into()]);
117        r.push("/./c");
118        assert_eq!(r.paths()[0], PathBuf::from("/c"));
119        r.push("");
120        assert_eq!(r.paths().len(), 3);
121        assert!(r.remove("/a"));
122        assert!(!r.remove("/a"));
123        r.set_cap(1);
124        assert_eq!(r.paths(), [PathBuf::from("/c")]);
125        assert_eq!(RecentFiles::new(0).cap(), 1);
126    }
127
128    #[test]
129    fn prune_missing() {
130        let mut r = RecentFiles::new(5);
131        for p in ["/keep1", "/gone", "/keep2"] {
132            r.push(p);
133        }
134        assert_eq!(r.prune(|p| p.to_string_lossy().contains("keep")), 1);
135        assert_eq!(r.paths(), [PathBuf::from("/keep2"), "/keep1".into()]);
136    }
137
138    #[test]
139    fn round_trip_through_settings_and_hostile_values() {
140        let store: Arc<dyn ConfigStore> = Arc::new(MemoryConfig::new());
141        let mut s = Settings::load(store, "s.json", &[]);
142        let mut r = RecentFiles::new(3);
143        r.push("/x");
144        r.push("/y");
145        r.store(&mut s, "recent");
146        assert_eq!(RecentFiles::load(&s, "recent", 3), r);
147        s.set_value("recent", serde_json::json!(["/1", 5, null, "/2", "/3", "/4", "/1"]));
148        let back = RecentFiles::load(&s, "recent", 3);
149        assert_eq!(back.paths(), [PathBuf::from("/1"), "/2".into(), "/3".into()]);
150        s.set_value("recent", serde_json::json!({"not": "a list"}));
151        assert!(RecentFiles::load(&s, "recent", 3).paths().is_empty());
152    }
153}