timelog/
stack.rs

1//! Interface to the stack file for the timelog application.
2//!
3//! # Examples
4//!
5//! ```rust
6//! use timelog::stack::Stack;
7//! # fn main() -> Result<(), timelog::Error> {
8//! let stack = Stack::new("./stack.txt" )?;
9//!
10//! stack.push("+Project @Task More detail");
11//! let task = stack.pop().expect("Can't pop task");
12//! println!("{:?}", task);
13//! stack.clear();
14//! #   Ok(())
15//! # }
16//! ```
17
18use std::fs::{self, File};
19use std::io;
20use std::io::prelude::*;
21use std::path::Path;
22use std::result;
23
24#[doc(inline)]
25use crate::error::{Error, PathError};
26#[doc(inline)]
27use crate::file;
28
29/// Represent the stack file on disk.
30#[derive(Debug)]
31pub struct Stack(String);
32
33impl Stack {
34    /// Creates a [`Stack`] object wrapping the supplied file.
35    ///
36    /// # Errors
37    ///
38    /// - Return [`PathError::FilenameMissing`] if the `file` has no filename.
39    /// - Return [`PathError::InvalidPath`] if the path part of `file` is not a valid path.
40    /// - Return [`PathError::InvalidStackPath`] if stack path is invalid.
41    pub fn new(file: &str) -> result::Result<Self, PathError> {
42        file::canonical_filename(file, file::FileKind::StackFile).map(Self)
43    }
44
45    /// Open the stack file for reading, return a [`File`].
46    ///
47    /// # Errors
48    ///
49    /// - Return [`PathError::FileAccess`] if unable to open the file.
50    pub fn open(&self) -> result::Result<File, PathError> {
51        File::open(&self.0).map_err(|e| PathError::FileAccess(self.clone_file(), e.to_string()))
52    }
53
54    // Clone the filename
55    fn clone_file(&self) -> String { self.0.clone() }
56
57    /// Return `true` if the timelog file exists
58    pub fn exists(&self) -> bool { Path::new(&self.0).exists() }
59
60    /// Truncates the stack file, removing all items from the stack.
61    ///
62    /// # Errors
63    ///
64    /// - Return [`PathError::FileAccess`] if the stack file is not accessible.
65    pub fn clear(&self) -> result::Result<(), PathError> {
66        fs::remove_file(&self.0).map_err(|e| PathError::FileAccess(self.clone_file(), e.to_string()))
67    }
68
69    /// Adds a new event to the stack file.
70    ///
71    /// # Errors
72    ///
73    /// - Return [`PathError::FileAccess`] if the stack file cannot be opened or created.
74    /// - Return [`PathError::FileWrite`] if the stack file cannot be written.
75    pub fn push(&self, task: &str) -> result::Result<(), PathError> {
76        let file = file::append_open(&self.0)?;
77        let mut stream = io::BufWriter::new(file);
78        writeln!(&mut stream, "{task}")
79            .map_err(|e| PathError::FileWrite(self.clone_file(), e.to_string()))?;
80        stream
81            .flush()
82            .map_err(|e| PathError::FileWrite(self.clone_file(), e.to_string()))?;
83        Ok(())
84    }
85
86    /// Remove the most recent task from the stack file and return the task string.
87    pub fn pop(&self) -> Option<String> {
88        let mut file = file::rw_open(&self.0).ok()?;
89        file::pop_last_line(&mut file)
90    }
91
92    /// Remove one or more tasks from the stack file.
93    ///
94    /// If `arg` is 0, remove one item.
95    /// If `arg` is a positive number, remove that many items from the stack.
96    ///
97    /// # Errors
98    ///
99    /// - Return [`Error::StackPop`] if attempts to pop more items than exist in the stack file.
100    pub fn drop(&self, arg: u32) -> crate::Result<()> {
101        (0..std::cmp::max(1, arg))
102            .try_for_each(|_| self.pop().map(|_| ()))
103            .ok_or(Error::StackPop)
104    }
105
106    /// Remove everything except the top `num` tasks from the stack.
107    ///
108    /// # Errors
109    ///
110    /// - Return [`PathError::FileAccess`] if the stack file cannot be opened or created.
111    /// - Return [`PathError::FileWrite`] if the stack file cannot be written.
112    /// - Return [`PathError::RenameFailure`] if the stack file cannot be renamed.
113    pub fn keep(&self, num: u32) -> crate::Result<()> {
114        let file = self.open()?;
115        let unum = num as usize;
116        let len = io::BufReader::new(file).lines().count();
117
118        if len > unum {
119            let backfile = format!("{}-bak", self.0);
120            let outfile = file::append_open(&backfile)?;
121            let mut stream = io::BufWriter::new(outfile);
122            let reader = io::BufReader::new(self.open()?);
123            for line in reader.lines().skip(len - unum).flatten() {
124                writeln!(&mut stream, "{line}")
125                    .map_err(|e| PathError::FileWrite(self.clone_file(), e.to_string()))?;
126            }
127            stream
128                .flush()
129                .map_err(|e| PathError::FileWrite(self.clone_file(), e.to_string()))?;
130
131            fs::rename(&backfile, self.clone_file())
132                .map_err(|e| PathError::RenameFailure(self.clone_file(), e.to_string()))?;
133        }
134        Ok(())
135    }
136
137    /// Process the stack top-down, passing the index and lines to the supplied function.
138    ///
139    /// # Errors
140    ///
141    /// - Return [`PathError::FileAccess`] if the stack file cannot be opened or created.
142    /// - Return [`PathError::FileWrite`] if the stack file cannot be written.
143    /// - Return [`PathError::RenameFailure`] if the stack file cannot be renamed.
144    pub fn process_down_stack<F>(&self, mut func: F) -> crate::Result<()>
145    where
146        F: FnMut(usize, &str)
147    {
148        let file = self.open()?;
149        let lines: Vec<String> = io::BufReader::new(file).lines().map_while(Result::ok).collect();
150        for (i, ln) in lines.iter().rev().enumerate() {
151            func(i, ln);
152        }
153        Ok(())
154    }
155
156    /// Format the stack as a [`String`].
157    ///
158    /// The stack will be formatted such that the most recent item is listed
159    /// first.
160    pub fn list(&self) -> String {
161        let mut output = String::new();
162        let Ok(_) = self.process_down_stack(|_, l| {
163                output.push_str(l);
164                output.push('\n');
165            })
166        else {
167            return String::new()
168        };
169
170        output
171    }
172
173    /// Return the top of the stack as a [`String`].
174    ///
175    /// # Errors
176    ///
177    /// - Return [`PathError::FileAccess`] if the stack file cannot be opened or created.
178    /// - Return [`PathError::FileWrite`] if the stack file cannot be written.
179    /// - Return [`PathError::RenameFailure`] if the stack file cannot be renamed.
180    pub fn top(&self) -> crate::Result<String> {
181        let file = self.open()?;
182        let reader = io::BufReader::new(file).lines().map_while(Result::ok);
183        Ok(reader.last().unwrap_or_default())
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use std::path::Path;
190
191    use spectral::prelude::*;
192    use tempfile::TempDir;
193
194    use super::*;
195
196    #[test]
197    fn test_new_missing_file() {
198        assert_that!(Stack::new("")).is_err_containing(PathError::FilenameMissing);
199    }
200
201    #[test]
202    fn test_new_bad_path() {
203        let mut stackdir = TempDir::new()
204            .expect("Cannot make tempdir")
205            .path()
206            .to_path_buf();
207        stackdir.push("foo");
208        stackdir.push("stack.txt");
209
210        let file = stackdir.as_path().to_str().unwrap();
211        assert_that!(Stack::new(file)).is_err_containing(PathError::InvalidPath(
212            file.to_string(),
213            "No such file or directory (os error 2)".to_string()
214        ));
215    }
216
217    #[test]
218    fn test_new() {
219        let tmpdir = TempDir::new().expect("Cannot make tempfile");
220        let mut path = tmpdir.path().to_path_buf();
221        path.push("stack.txt");
222
223        let filename = path.to_str().unwrap();
224        assert_that!(Stack::new(filename)).is_ok();
225    }
226
227    #[test]
228    fn test_push() {
229        let tmpdir = TempDir::new().expect("Cannot make tempfile");
230        let mut path = tmpdir.path().to_path_buf();
231        path.push("stack.txt");
232
233        let filename = path.to_str().unwrap();
234        let stack = Stack::new(filename).expect("Cannot create stack");
235        assert_that!(Path::new(filename).exists()).is_false();
236
237        let task = "+house @todo change filters";
238        assert_that!(stack.push(task)).is_ok();
239        let path = Path::new(filename);
240        assert_that!(path.is_file()).is_true();
241
242        // test file length and handle newline lengths
243        let filelen = path.metadata().expect("metadata fail").len() as usize;
244        assert_that!(filelen).is_equal_to(task.len() + 1);
245
246        assert_that!(stack.push(task)).is_ok();
247        let filelen = path.metadata().expect("metadata fail").len() as usize;
248        assert_that!(filelen).is_equal_to(2 * task.len() + 2);
249    }
250
251    #[test]
252    fn test_pop() {
253        let tmpdir = TempDir::new().expect("Cannot make tempfile");
254        let mut path = tmpdir.path().to_path_buf();
255        path.push("stack.txt");
256
257        let filename = path.to_str().unwrap();
258        let mut file = File::create(filename).expect("Cannot create file");
259        file.write_all(b"+home @todo first\n+home @todo second\n")
260            .expect("Cannot fill file");
261
262        let stack = Stack::new(filename).expect("Cannot create stack");
263        assert_that!(stack.pop()).contains(String::from("+home @todo second"));
264        assert_that!(stack.pop()).contains(String::from("+home @todo first"));
265
266        let path = Path::new(filename);
267        assert_that!(path.is_file()).is_true();
268        // test file length and handle newline lengths
269        let filelen = path.metadata().expect("metadata fail").len() as usize;
270        assert_that!(filelen).is_equal_to(0);
271    }
272
273    #[test]
274    fn test_clear() {
275        let tmpdir = TempDir::new().expect("Cannot make tempfile");
276        let mut path = tmpdir.path().to_path_buf();
277        path.push("stack.txt");
278
279        let filename = path.to_str().unwrap();
280        let mut file = File::create(filename).expect("Cannot create file");
281        file.write_all(b"+home @todo first\n+home @todo second\n")
282            .expect("Cannot fill file");
283
284        let stack = Stack::new(filename).expect("Cannot create stack");
285        assert_that!(stack.clear()).is_ok();
286        assert_that!(Path::new(filename).exists()).is_false();
287    }
288
289    #[test]
290    fn test_drop_0() {
291        let tmpdir = TempDir::new().expect("Cannot make tempfile");
292        let mut path = tmpdir.path().to_path_buf();
293        path.push("stack.txt");
294
295        let filename = path.to_str().unwrap();
296        let mut file = File::create(filename).expect("Cannot create file");
297        file.write_all(b"+home @todo first\n+home @todo second\n")
298            .expect("Cannot fill file");
299
300        let stack = Stack::new(filename).expect("Cannot create stack");
301        assert_that!(stack.drop(0)).is_ok();
302        assert_that!(stack.pop()).contains(String::from("+home @todo first"));
303    }
304
305    #[test]
306    fn test_drop_1() {
307        let tmpdir = TempDir::new().expect("Cannot make tempfile");
308        let mut path = tmpdir.path().to_path_buf();
309        path.push("stack.txt");
310
311        let filename = path.to_str().unwrap();
312        let mut file = File::create(filename).expect("Cannot create file");
313        file.write_all(b"+home @todo first\n+home @todo second\n")
314            .expect("Cannot fill file");
315
316        let stack = Stack::new(filename).expect("Cannot create stack");
317        assert_that!(stack.drop(1)).is_ok();
318        assert_that!(stack.pop()).contains(String::from("+home @todo first"));
319    }
320
321    #[test]
322    fn test_drop_2() {
323        let tmpdir = TempDir::new().expect("Cannot make tempfile");
324        let mut path = tmpdir.path().to_path_buf();
325        path.push("stack.txt");
326
327        let filename = path.to_str().unwrap();
328        let mut file = File::create(filename).expect("Cannot create file");
329        file.write_all(b"+home @todo first\n+home @todo second\n+home @todo third\n")
330            .expect("Cannot fill file");
331
332        let stack = Stack::new(filename).expect("Cannot create stack");
333        assert_that!(stack.drop(2)).is_ok();
334        assert_that!(stack.pop()).contains(String::from("+home @todo first"));
335    }
336
337    #[test]
338    fn test_list() {
339        let tmpdir = TempDir::new().expect("Cannot make tempfile");
340        let mut path = tmpdir.path().to_path_buf();
341        path.push("stack.txt");
342
343        let filename = path.to_str().unwrap();
344        let mut file = File::create(filename).expect("Cannot create file");
345        file.write_all(b"+home @todo first\n+home @todo second\n+home @todo third\n")
346            .expect("Cannot fill file");
347
348        let stack = Stack::new(filename).expect("Cannot create stack");
349        assert_that!(stack.list()).is_equal_to(String::from(
350            "+home @todo third\n+home @todo second\n+home @todo first\n"
351        ));
352    }
353
354    #[test]
355    fn test_top() {
356        let tmpdir = TempDir::new().expect("Cannot make tempfile");
357        let mut path = tmpdir.path().to_path_buf();
358        path.push("stack.txt");
359
360        let filename = path.to_str().unwrap();
361        let mut file = File::create(filename).expect("Cannot create file");
362        file.write_all(b"+home @todo first\n+home @todo second\n+home @todo third\n")
363            .expect("Cannot fill file");
364
365        let stack = Stack::new(filename).expect("Cannot create stack");
366        assert_that!(stack.top()).contains(String::from("+home @todo third"));
367    }
368
369    #[test]
370    fn test_top_empty() {
371        let tmpdir = TempDir::new().expect("Cannot make tempfile");
372        let mut path = tmpdir.path().to_path_buf();
373        path.push("stack.txt");
374
375        let filename = path.to_str().unwrap();
376        File::create(filename).expect("Cannot create file");
377
378        let stack = Stack::new(filename).expect("Cannot create stack");
379        assert_that!(stack.top()).contains(String::new());
380    }
381}