Skip to main content

libsurfer/
file_history.rs

1use camino::Utf8PathBuf;
2#[cfg(all(not(target_arch = "wasm32"), not(test)))]
3use serde::{Deserialize, Serialize};
4
5#[cfg(all(not(target_arch = "wasm32"), not(test)))]
6const FILE_HISTORY_FILE: &str = "file_history.ron";
7
8#[cfg(all(not(target_arch = "wasm32"), not(test)))]
9#[derive(Debug, Default, Serialize, Deserialize)]
10struct StoredFileHistory {
11    files: Vec<String>,
12}
13
14#[derive(Debug, Default)]
15pub struct FileHistory {
16    files: Vec<Utf8PathBuf>,
17    max_entries: usize,
18}
19
20impl FileHistory {
21    #[must_use]
22    pub fn load(max_entries: usize) -> Self {
23        let mut history = Self {
24            files: Vec::new(),
25            max_entries,
26        };
27        history.load_from_disk();
28        history.truncate_to_limit();
29        history
30    }
31
32    #[must_use]
33    pub fn files(&self) -> &[Utf8PathBuf] {
34        &self.files
35    }
36
37    #[must_use]
38    pub fn display_labels(&self) -> Vec<String> {
39        disambiguated_labels(&self.files)
40    }
41
42    pub fn add(&mut self, file: Utf8PathBuf) {
43        if self.max_entries == 0 || is_connection_entry(&file) {
44            return;
45        }
46
47        self.files.retain(|path| path != &file);
48        self.files.insert(0, file.clone());
49        self.truncate_to_limit();
50        self.save_to_disk();
51    }
52
53    fn truncate_to_limit(&mut self) {
54        self.files.truncate(self.max_entries);
55    }
56
57    #[cfg(all(not(target_arch = "wasm32"), not(test)))]
58    fn load_from_disk(&mut self) {
59        let Some(path) = storage_path() else {
60            return;
61        };
62
63        let Ok(content) = std::fs::read_to_string(path) else {
64            return;
65        };
66
67        let Ok(stored) = ron::from_str::<StoredFileHistory>(&content) else {
68            return;
69        };
70
71        self.files = stored
72            .files
73            .into_iter()
74            .map(Utf8PathBuf::from)
75            .collect::<Vec<_>>();
76    }
77
78    #[cfg(any(target_arch = "wasm32", test))]
79    fn load_from_disk(&mut self) {}
80
81    #[cfg(all(not(target_arch = "wasm32"), not(test)))]
82    fn save_to_disk(&self) {
83        let Some(path) = storage_path() else {
84            return;
85        };
86        let Some(parent) = path.parent() else {
87            return;
88        };
89
90        if std::fs::create_dir_all(parent).is_err() {
91            return;
92        }
93
94        let stored = StoredFileHistory {
95            files: self
96                .files
97                .iter()
98                .map(std::string::ToString::to_string)
99                .collect(),
100        };
101        let Ok(ron) =
102            ron::Options::default().to_string_pretty(&stored, ron::ser::PrettyConfig::default())
103        else {
104            return;
105        };
106
107        let _ = std::fs::write(path, ron);
108    }
109
110    #[cfg(any(target_arch = "wasm32", test))]
111    fn save_to_disk(&self) {}
112}
113
114#[cfg(all(not(target_arch = "wasm32"), not(test)))]
115fn storage_path() -> Option<std::path::PathBuf> {
116    crate::config::PROJECT_DIR
117        .as_ref()
118        .map(|dirs| dirs.data_local_dir().join(FILE_HISTORY_FILE))
119}
120
121fn is_connection_entry(path: &Utf8PathBuf) -> bool {
122    let value = path.as_str();
123    value.starts_with("http://")
124        || value.starts_with("https://")
125        || value.starts_with("ws://")
126        || value.starts_with("wss://")
127        || value.starts_with("cxxrtl+tcp://")
128}
129
130fn disambiguated_labels(paths: &[Utf8PathBuf]) -> Vec<String> {
131    let segments: Vec<Vec<String>> = paths
132        .iter()
133        .map(|path| {
134            path.as_str()
135                .split(['/', '\\'])
136                .filter(|segment| !segment.is_empty())
137                .map(str::to_string)
138                .collect::<Vec<_>>()
139        })
140        .collect();
141
142    let mut labels = segments
143        .iter()
144        .map(|parts| {
145            parts
146                .last()
147                .cloned()
148                .unwrap_or_else(|| String::from("<unknown>"))
149        })
150        .collect::<Vec<_>>();
151
152    let mut groups = std::collections::HashMap::<String, Vec<usize>>::new();
153    for (idx, parts) in segments.iter().enumerate() {
154        if let Some(name) = parts.last() {
155            groups.entry(name.clone()).or_default().push(idx);
156        }
157    }
158
159    for indexes in groups.values() {
160        if indexes.len() <= 1 {
161            continue;
162        }
163
164        let max_depth = indexes
165            .iter()
166            .map(|idx| segments[*idx].len())
167            .max()
168            .unwrap_or(1);
169
170        let mut found_unique_depth = None;
171        for depth in 2..=max_depth {
172            let mut seen = std::collections::HashSet::new();
173            let all_unique = indexes.iter().all(|idx| {
174                let candidate = label_for_depth(&segments[*idx], depth);
175                seen.insert(candidate)
176            });
177            if all_unique {
178                found_unique_depth = Some(depth);
179                break;
180            }
181        }
182
183        if let Some(depth) = found_unique_depth {
184            for idx in indexes {
185                labels[*idx] = label_for_depth(&segments[*idx], depth);
186            }
187        } else {
188            for idx in indexes {
189                labels[*idx] = paths[*idx].to_string();
190            }
191        }
192    }
193
194    labels
195}
196
197fn label_for_depth(parts: &[String], depth: usize) -> String {
198    let start = parts.len().saturating_sub(depth);
199    parts[start..].join("/")
200}
201
202#[cfg(test)]
203mod tests {
204    use super::FileHistory;
205    use super::disambiguated_labels;
206    use camino::Utf8PathBuf;
207
208    #[test]
209    fn keeps_most_recent_on_top() {
210        let mut history = FileHistory::load(3);
211
212        history.add(Utf8PathBuf::from("a.vcd"));
213        history.add(Utf8PathBuf::from("b.vcd"));
214        history.add(Utf8PathBuf::from("a.vcd"));
215
216        assert_eq!(
217            history.files(),
218            [Utf8PathBuf::from("a.vcd"), Utf8PathBuf::from("b.vcd")]
219        );
220    }
221
222    #[test]
223    fn respects_max_entries() {
224        let mut history = FileHistory::load(2);
225
226        history.add(Utf8PathBuf::from("a.vcd"));
227        history.add(Utf8PathBuf::from("b.vcd"));
228        history.add(Utf8PathBuf::from("c.vcd"));
229
230        assert_eq!(
231            history.files(),
232            [Utf8PathBuf::from("c.vcd"), Utf8PathBuf::from("b.vcd")]
233        );
234    }
235
236    #[test]
237    fn ignores_connection_entries() {
238        let mut history = FileHistory::load(5);
239
240        history.add(Utf8PathBuf::from("https://surver.example/status"));
241        history.add(Utf8PathBuf::from("ws://surver.example/socket"));
242        history.add(Utf8PathBuf::from("wave.vcd"));
243
244        assert_eq!(history.files(), [Utf8PathBuf::from("wave.vcd")]);
245    }
246
247    #[test]
248    fn disambiguates_duplicate_file_names() {
249        let paths = vec![
250            Utf8PathBuf::from("C:/work/a/top.vcd"),
251            Utf8PathBuf::from("C:/work/b/top.vcd"),
252            Utf8PathBuf::from("C:/work/c/other.vcd"),
253        ];
254
255        let labels = disambiguated_labels(&paths);
256
257        assert_eq!(labels[0], "a/top.vcd");
258        assert_eq!(labels[1], "b/top.vcd");
259        assert_eq!(labels[2], "other.vcd");
260    }
261}