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    /// Append `path` to the list and switch the cursor to it.
114    pub fn append_and_switch(&mut self, path: PathBuf) -> &Path {
115        self.paths.push(path);
116        self.current_index = self.paths.len() - 1;
117        self.paths[self.current_index].as_path()
118    }
119
120    /// Delete the current entry and move the cursor to the next file (or
121    /// back to the previous if we were at the end). Returns the new
122    /// current path. Errors with `WouldEmpty` when only one file remains.
123    pub fn delete_current(&mut self) -> Result<&Path, FileSetError> {
124        if self.paths.len() <= 1 {
125            return Err(FileSetError::WouldEmpty);
126        }
127        self.paths.remove(self.current_index);
128        if self.current_index >= self.paths.len() {
129            self.current_index = self.paths.len() - 1;
130        }
131        Ok(self.paths[self.current_index].as_path())
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    fn fs(names: &[&str]) -> FileSet {
140        FileSet::new(names.iter().map(PathBuf::from).collect())
141    }
142
143    #[test]
144    fn new_with_paths_sets_current_zero() {
145        let f = fs(&["a.log", "b.log", "c.log"]);
146        assert_eq!(f.current_index(), 0);
147        assert_eq!(f.current(), Some(Path::new("a.log")));
148    }
149
150    #[test]
151    fn len_reports_total() {
152        let f = fs(&["a.log", "b.log", "c.log"]);
153        assert_eq!(f.len(), 3);
154    }
155
156    #[test]
157    fn next_advances_index() {
158        let mut f = fs(&["a.log", "b.log", "c.log"]);
159        assert_eq!(f.next().unwrap(), Path::new("b.log"));
160        assert_eq!(f.current_index(), 1);
161        assert_eq!(f.next().unwrap(), Path::new("c.log"));
162        assert_eq!(f.current_index(), 2);
163    }
164
165    #[test]
166    fn next_at_last_returns_no_next_file_error() {
167        let mut f = fs(&["a.log"]);
168        assert_eq!(f.next().unwrap_err(), FileSetError::NoNextFile);
169        assert_eq!(f.current_index(), 0);
170    }
171
172    #[test]
173    fn prev_decrements_index() {
174        let mut f = fs(&["a.log", "b.log"]);
175        f.next().unwrap();
176        assert_eq!(f.prev().unwrap(), Path::new("a.log"));
177        assert_eq!(f.current_index(), 0);
178    }
179
180    #[test]
181    fn prev_at_first_returns_no_previous_file_error() {
182        let mut f = fs(&["a.log", "b.log"]);
183        assert_eq!(f.prev().unwrap_err(), FileSetError::NoPreviousFile);
184        assert_eq!(f.current_index(), 0);
185    }
186
187    #[test]
188    fn first_resets_to_zero() {
189        let mut f = fs(&["a.log", "b.log", "c.log"]);
190        f.next().unwrap();
191        f.next().unwrap();
192        assert_eq!(f.first(), Some(Path::new("a.log")));
193        assert_eq!(f.current_index(), 0);
194    }
195
196    #[test]
197    fn last_jumps_to_count_minus_one() {
198        let mut f = fs(&["a.log", "b.log", "c.log"]);
199        assert_eq!(f.last(), Some(Path::new("c.log")));
200        assert_eq!(f.current_index(), 2);
201    }
202
203    #[test]
204    fn append_and_switch_grows_list_and_moves_cursor() {
205        let mut f = fs(&["a.log"]);
206        let new_path = f.append_and_switch(PathBuf::from("b.log"));
207        assert_eq!(new_path, Path::new("b.log"));
208        assert_eq!(f.len(), 2);
209        assert_eq!(f.current_index(), 1);
210    }
211
212    #[test]
213    fn delete_current_drops_entry_and_advances() {
214        let mut f = fs(&["a.log", "b.log", "c.log"]);
215        f.next().unwrap();  // now at b.log
216        let new_path = f.delete_current().unwrap();
217        assert_eq!(new_path, Path::new("c.log"));
218        assert_eq!(f.len(), 2);
219        assert_eq!(f.current_index(), 1);
220    }
221
222    #[test]
223    fn delete_current_at_end_moves_back() {
224        let mut f = fs(&["a.log", "b.log"]);
225        f.next().unwrap();  // at b.log (last)
226        let new_path = f.delete_current().unwrap();
227        assert_eq!(new_path, Path::new("a.log"));
228        assert_eq!(f.len(), 1);
229        assert_eq!(f.current_index(), 0);
230    }
231
232    #[test]
233    fn delete_current_at_start_stays_at_zero() {
234        let mut f = fs(&["a.log", "b.log", "c.log"]);
235        // cursor is at index 0 (a.log)
236        let new_path = f.delete_current().unwrap();
237        assert_eq!(new_path, Path::new("b.log"));
238        assert_eq!(f.len(), 2);
239        assert_eq!(f.current_index(), 0);
240    }
241
242    #[test]
243    fn delete_current_with_single_file_returns_would_empty_error() {
244        let mut f = fs(&["a.log"]);
245        assert_eq!(f.delete_current().unwrap_err(), FileSetError::WouldEmpty);
246        assert_eq!(f.len(), 1);
247    }
248
249    #[test]
250    fn empty_fileset_returns_none_for_current() {
251        let f = FileSet::new(Vec::new());
252        assert_eq!(f.current(), None);
253        assert!(f.is_empty());
254        assert_eq!(f.len(), 0);
255    }
256
257    #[test]
258    fn set_current_index_changes_cursor() {
259        let mut f = fs(&["a.log", "b.log", "c.log"]);
260        f.set_current_index(2);
261        assert_eq!(f.current(), Some(Path::new("c.log")));
262        f.set_current_index(99);  // clamp
263        assert_eq!(f.current_index(), 2);
264    }
265
266    #[test]
267    fn next_on_empty_returns_no_next_file_error() {
268        let mut f = FileSet::new(Vec::new());
269        assert_eq!(f.next().unwrap_err(), FileSetError::NoNextFile);
270    }
271
272    #[test]
273    fn prev_on_empty_returns_no_previous_file_error() {
274        let mut f = FileSet::new(Vec::new());
275        assert_eq!(f.prev().unwrap_err(), FileSetError::NoPreviousFile);
276    }
277}