Skip to main content

tess/
file_set.rs

1//! Owns the multi-file working set: a list of paths, a current-index
2//! cursor, and the navigation primitives that the colon-prompt dispatch
3//! consumes (`:n`, `:p`, `:e`, `:d`, `:x`, `:t`).
4//!
5//! Does NOT own `Source` instances long-term — those are constructed on
6//! demand by `main::open_source_for_path` and dropped on switch, so a
7//! 100-file invocation doesn't mmap 100 files at once.
8
9use std::path::{Path, PathBuf};
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum FileSetError {
13    NoNextFile,
14    NoPreviousFile,
15    WouldEmpty,
16}
17
18impl std::fmt::Display for FileSetError {
19    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
20        match self {
21            FileSetError::NoNextFile => write!(f, "no next file"),
22            FileSetError::NoPreviousFile => write!(f, "no previous file"),
23            FileSetError::WouldEmpty => write!(f, "cannot remove last file"),
24        }
25    }
26}
27
28#[derive(Debug, Clone)]
29pub struct FileSet {
30    paths: Vec<PathBuf>,
31    current_index: usize,
32}
33
34impl FileSet {
35    /// Construct with the initial path list. `paths` must be non-empty
36    /// for navigation to make sense; an empty list is technically valid
37    /// (stdin-source startup uses an empty FileSet) but all navigation
38    /// methods then return errors or `None`.
39    pub fn new(paths: Vec<PathBuf>) -> Self {
40        Self { paths, current_index: 0 }
41    }
42
43    pub fn current(&self) -> Option<&Path> {
44        self.paths.get(self.current_index).map(|p| p.as_path())
45    }
46
47    /// Total number of files in the set.
48    pub fn len(&self) -> usize {
49        self.paths.len()
50    }
51
52    pub fn current_index(&self) -> usize {
53        self.current_index
54    }
55
56    pub fn is_empty(&self) -> bool {
57        self.paths.is_empty()
58    }
59
60    /// Set the cursor directly. Out-of-range indices are clamped to the
61    /// last entry (or no-op if the list is empty).
62    pub fn set_current_index(&mut self, index: usize) {
63        if self.paths.is_empty() {
64            return;
65        }
66        self.current_index = index.min(self.paths.len() - 1);
67    }
68
69    /// Advance to the next file. Returns the new current path on success
70    /// or `NoNextFile` if already at the last entry.
71    #[allow(clippy::should_implement_trait)]
72    pub fn next(&mut self) -> Result<&Path, FileSetError> {
73        if self.current_index + 1 >= self.paths.len() {
74            return Err(FileSetError::NoNextFile);
75        }
76        self.current_index += 1;
77        Ok(self.paths[self.current_index].as_path())
78    }
79
80    /// Move to the previous file. Returns the new current path on success
81    /// or `NoPreviousFile` if already at the first entry.
82    pub fn prev(&mut self) -> Result<&Path, FileSetError> {
83        if self.current_index == 0 {
84            return Err(FileSetError::NoPreviousFile);
85        }
86        self.current_index -= 1;
87        Ok(self.paths[self.current_index].as_path())
88    }
89
90    /// Jump to the first file. Returns the current path after the move,
91    /// or `None` if the list is empty. Returns `Option` (not `Result`)
92    /// because jumping to the boundary is always idempotent — there's
93    /// no "no first file" failure mode like there is for `next`.
94    pub fn first(&mut self) -> Option<&Path> {
95        if self.paths.is_empty() {
96            return None;
97        }
98        self.current_index = 0;
99        Some(self.paths[0].as_path())
100    }
101
102    /// Jump to the last file. Returns the current path after the move,
103    /// or `None` if the list is empty. See `first` for the rationale
104    /// behind `Option` rather than `Result`.
105    pub fn last(&mut self) -> Option<&Path> {
106        if self.paths.is_empty() {
107            return None;
108        }
109        self.current_index = self.paths.len() - 1;
110        Some(self.paths[self.current_index].as_path())
111    }
112
113    /// Borrow the i-th entry, or None if out of range.
114    pub fn nth(&self, i: usize) -> Option<&Path> {
115        self.paths.get(i).map(|p| p.as_path())
116    }
117
118    /// Append `path` to the list and switch the cursor to it.
119    pub fn append_and_switch(&mut self, path: PathBuf) -> &Path {
120        self.paths.push(path);
121        self.current_index = self.paths.len() - 1;
122        self.paths[self.current_index].as_path()
123    }
124
125    /// Delete the current entry and move the cursor to the next file (or
126    /// back to the previous if we were at the end). Returns the new
127    /// current path. Errors with `WouldEmpty` when only one file remains.
128    pub fn delete_current(&mut self) -> Result<&Path, FileSetError> {
129        if self.paths.len() <= 1 {
130            return Err(FileSetError::WouldEmpty);
131        }
132        self.paths.remove(self.current_index);
133        if self.current_index >= self.paths.len() {
134            self.current_index = self.paths.len() - 1;
135        }
136        Ok(self.paths[self.current_index].as_path())
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    fn fs(names: &[&str]) -> FileSet {
145        FileSet::new(names.iter().map(PathBuf::from).collect())
146    }
147
148    #[test]
149    fn new_with_paths_sets_current_zero() {
150        let f = fs(&["a.log", "b.log", "c.log"]);
151        assert_eq!(f.current_index(), 0);
152        assert_eq!(f.current(), Some(Path::new("a.log")));
153    }
154
155    #[test]
156    fn len_reports_total() {
157        let f = fs(&["a.log", "b.log", "c.log"]);
158        assert_eq!(f.len(), 3);
159    }
160
161    #[test]
162    fn next_advances_index() {
163        let mut f = fs(&["a.log", "b.log", "c.log"]);
164        assert_eq!(f.next().unwrap(), Path::new("b.log"));
165        assert_eq!(f.current_index(), 1);
166        assert_eq!(f.next().unwrap(), Path::new("c.log"));
167        assert_eq!(f.current_index(), 2);
168    }
169
170    #[test]
171    fn next_at_last_returns_no_next_file_error() {
172        let mut f = fs(&["a.log"]);
173        assert_eq!(f.next().unwrap_err(), FileSetError::NoNextFile);
174        assert_eq!(f.current_index(), 0);
175    }
176
177    #[test]
178    fn prev_decrements_index() {
179        let mut f = fs(&["a.log", "b.log"]);
180        f.next().unwrap();
181        assert_eq!(f.prev().unwrap(), Path::new("a.log"));
182        assert_eq!(f.current_index(), 0);
183    }
184
185    #[test]
186    fn prev_at_first_returns_no_previous_file_error() {
187        let mut f = fs(&["a.log", "b.log"]);
188        assert_eq!(f.prev().unwrap_err(), FileSetError::NoPreviousFile);
189        assert_eq!(f.current_index(), 0);
190    }
191
192    #[test]
193    fn first_resets_to_zero() {
194        let mut f = fs(&["a.log", "b.log", "c.log"]);
195        f.next().unwrap();
196        f.next().unwrap();
197        assert_eq!(f.first(), Some(Path::new("a.log")));
198        assert_eq!(f.current_index(), 0);
199    }
200
201    #[test]
202    fn last_jumps_to_count_minus_one() {
203        let mut f = fs(&["a.log", "b.log", "c.log"]);
204        assert_eq!(f.last(), Some(Path::new("c.log")));
205        assert_eq!(f.current_index(), 2);
206    }
207
208    #[test]
209    fn append_and_switch_grows_list_and_moves_cursor() {
210        let mut f = fs(&["a.log"]);
211        let new_path = f.append_and_switch(PathBuf::from("b.log"));
212        assert_eq!(new_path, Path::new("b.log"));
213        assert_eq!(f.len(), 2);
214        assert_eq!(f.current_index(), 1);
215    }
216
217    #[test]
218    fn delete_current_drops_entry_and_advances() {
219        let mut f = fs(&["a.log", "b.log", "c.log"]);
220        f.next().unwrap();  // now at b.log
221        let new_path = f.delete_current().unwrap();
222        assert_eq!(new_path, Path::new("c.log"));
223        assert_eq!(f.len(), 2);
224        assert_eq!(f.current_index(), 1);
225    }
226
227    #[test]
228    fn delete_current_at_end_moves_back() {
229        let mut f = fs(&["a.log", "b.log"]);
230        f.next().unwrap();  // at b.log (last)
231        let new_path = f.delete_current().unwrap();
232        assert_eq!(new_path, Path::new("a.log"));
233        assert_eq!(f.len(), 1);
234        assert_eq!(f.current_index(), 0);
235    }
236
237    #[test]
238    fn delete_current_at_start_stays_at_zero() {
239        let mut f = fs(&["a.log", "b.log", "c.log"]);
240        // cursor is at index 0 (a.log)
241        let new_path = f.delete_current().unwrap();
242        assert_eq!(new_path, Path::new("b.log"));
243        assert_eq!(f.len(), 2);
244        assert_eq!(f.current_index(), 0);
245    }
246
247    #[test]
248    fn delete_current_with_single_file_returns_would_empty_error() {
249        let mut f = fs(&["a.log"]);
250        assert_eq!(f.delete_current().unwrap_err(), FileSetError::WouldEmpty);
251        assert_eq!(f.len(), 1);
252    }
253
254    #[test]
255    fn empty_fileset_returns_none_for_current() {
256        let f = FileSet::new(Vec::new());
257        assert_eq!(f.current(), None);
258        assert!(f.is_empty());
259        assert_eq!(f.len(), 0);
260    }
261
262    #[test]
263    fn set_current_index_changes_cursor() {
264        let mut f = fs(&["a.log", "b.log", "c.log"]);
265        f.set_current_index(2);
266        assert_eq!(f.current(), Some(Path::new("c.log")));
267        f.set_current_index(99);  // clamp
268        assert_eq!(f.current_index(), 2);
269    }
270
271    #[test]
272    fn nth_returns_path_or_none() {
273        let f = fs(&["a.log", "b.log"]);
274        assert_eq!(f.nth(0), Some(Path::new("a.log")));
275        assert_eq!(f.nth(1), Some(Path::new("b.log")));
276        assert_eq!(f.nth(2), None);
277    }
278
279    #[test]
280    fn next_on_empty_returns_no_next_file_error() {
281        let mut f = FileSet::new(Vec::new());
282        assert_eq!(f.next().unwrap_err(), FileSetError::NoNextFile);
283    }
284
285    #[test]
286    fn prev_on_empty_returns_no_previous_file_error() {
287        let mut f = FileSet::new(Vec::new());
288        assert_eq!(f.prev().unwrap_err(), FileSetError::NoPreviousFile);
289    }
290}