Skip to main content

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::num::NonZeroU32;
22use std::path::Path;
23use std::result;
24
25#[doc(inline)]
26use crate::error::{Error, PathError};
27#[doc(inline)]
28use crate::file;
29
30/// Represent the stack file on disk.
31#[derive(Debug)]
32pub struct Stack(String);
33
34impl Stack {
35    /// Creates a [`Stack`] object wrapping the supplied file.
36    ///
37    /// # Errors
38    ///
39    /// - Return [`PathError::FilenameMissing`] if the `file` has no filename.
40    /// - Return [`PathError::InvalidPath`] if the path part of `file` is not a valid path.
41    /// - Return [`PathError::InvalidStackPath`] if stack path is invalid.
42    pub fn new(file: &str) -> result::Result<Self, PathError> {
43        file::canonical_filename(file, file::FileKind::StackFile).map(Self)
44    }
45
46    /// Open the stack file for reading, return a [`File`].
47    ///
48    /// # Errors
49    ///
50    /// - Return [`PathError::FileAccess`] if unable to open the file.
51    pub fn open(&self) -> result::Result<File, PathError> {
52        File::open(&self.0).map_err(|e| PathError::FileAccess(self.clone_file(), e.to_string()))
53    }
54
55    // Clone the filename
56    fn clone_file(&self) -> String { self.0.clone() }
57
58    /// Return `true` if the timelog file exists
59    pub fn exists(&self) -> bool { Path::new(&self.0).exists() }
60
61    /// Truncates the stack file, removing all items from the stack.
62    ///
63    /// # Errors
64    ///
65    /// - Return [`PathError::FileAccess`] if the stack file is not accessible.
66    pub fn clear(&self) -> result::Result<(), PathError> {
67        fs::remove_file(&self.0).map_err(|e| PathError::FileAccess(self.clone_file(), e.to_string()))
68    }
69
70    /// Adds a new event to the stack file.
71    ///
72    /// # Errors
73    ///
74    /// - Return [`PathError::FileAccess`] if the stack file cannot be opened or created.
75    /// - Return [`PathError::FileWrite`] if the stack file cannot be written.
76    pub fn push(&self, task: &str) -> result::Result<(), PathError> {
77        let file = file::append_open(&self.0)?;
78        let mut stream = io::BufWriter::new(file);
79        writeln!(&mut stream, "{task}")
80            .map_err(|e| PathError::FileWrite(self.clone_file(), e.to_string()))?;
81        stream
82            .flush()
83            .map_err(|e| PathError::FileWrite(self.clone_file(), e.to_string()))?;
84        Ok(())
85    }
86
87    /// Remove the most recent task from the stack file and return the task string.
88    pub fn pop(&self) -> Option<String> {
89        let mut file = file::rw_open(&self.0).ok()?;
90        file::pop_last_line(&mut file)
91    }
92
93    /// Remove one or more tasks from the stack file.
94    ///
95    /// Remove `num` 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, num: NonZeroU32) -> crate::Result<()> {
101        (0..num.get())
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: NonZeroU32) -> crate::Result<()> {
114        let file = self.open()?;
115        let unum = num.get() 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 assert2::{assert, let_assert};
192    use nzliteral::nzliteral;
193    use tempfile::TempDir;
194
195    use super::*;
196
197    #[test]
198    fn test_new_missing_file() {
199        let_assert!(Err(err) = Stack::new(""));
200        assert!(err == PathError::FilenameMissing);
201    }
202
203    #[test]
204    fn test_new_bad_path() {
205        let mut stackdir = TempDir::new()
206            .expect("Cannot make tempdir")
207            .path()
208            .to_path_buf();
209        stackdir.push("foo");
210        stackdir.push("stack.txt");
211
212        let_assert!(Some(file) = stackdir.as_path().to_str());
213        let_assert!(Err(e) = Stack::new(file));
214        assert!(e == PathError::InvalidPath(
215            file.to_string(),
216            "No such file or directory (os error 2)".to_string()
217        ));
218    }
219
220    #[test]
221    fn test_new() {
222        let_assert!(Ok(tmpdir) = TempDir::new());
223        let mut path = tmpdir.path().to_path_buf();
224        path.push("stack.txt");
225
226        let_assert!(Some(filename) = path.to_str());
227        assert!(Stack::new(filename).is_ok());
228    }
229
230    #[test]
231    fn test_push() {
232        let_assert!(Ok(tmpdir) = TempDir::new());
233        let mut path = tmpdir.path().to_path_buf();
234        path.push("stack.txt");
235
236        let_assert!(Some(filename) = path.to_str());
237        let_assert!(Ok(stack) = Stack::new(filename));
238        assert!(!Path::new(filename).exists());
239
240        let task = "+house @todo change filters";
241        assert!(stack.push(task).is_ok());
242        let path = Path::new(filename);
243        assert!(path.is_file());
244
245        // test file length and handle newline lengths
246        let_assert!(Ok(filelen) = path.metadata().map(|m| m.len() as usize));
247        assert!(filelen == task.len() + 1);
248
249        assert!(stack.push(task).is_ok());
250        let_assert!(Ok(filelen) = path.metadata().map(|m| m.len() as usize));
251        assert!(filelen == 2 * task.len() + 2);
252    }
253
254    #[test]
255    fn test_pop() {
256        let_assert!(Ok(tmpdir) = TempDir::new());
257        let mut path = tmpdir.path().to_path_buf();
258        path.push("stack.txt");
259
260        let_assert!(Some(filename) = path.to_str());
261        let_assert!(Ok(mut file) = File::create(filename));
262        let_assert!(Ok(_) = file.write_all(b"+home @todo first\n+home @todo second\n"));
263
264        let_assert!(Ok(stack) = Stack::new(filename));
265        let_assert!(Some(line) = stack.pop());
266        assert!(line == String::from("+home @todo second"));
267        let_assert!(Some(line) = stack.pop());
268        assert!(line == String::from("+home @todo first"));
269
270        let path = Path::new(filename);
271        assert!(path.is_file());
272        // test file length and handle newline lengths
273        let_assert!(Ok(filelen) = path.metadata().map(|m| m.len() as usize));
274        assert!(filelen == 0);
275    }
276
277    #[test]
278    fn test_clear() {
279        let_assert!(Ok(tmpdir) = TempDir::new());
280        let mut path = tmpdir.path().to_path_buf();
281        path.push("stack.txt");
282
283        let_assert!(Some(filename) = path.to_str());
284        let_assert!(Ok(mut file) = File::create(filename));
285        let_assert!(Ok(_) = file.write_all(b"+home @todo first\n+home @todo second\n"));
286
287        let_assert!(Ok(stack) = Stack::new(filename));
288        assert!(stack.clear().is_ok());
289        assert!(!Path::new(filename).exists());
290    }
291
292    #[test]
293    fn test_drop_1() {
294        let_assert!(Ok(tmpdir) = TempDir::new());
295        let mut path = tmpdir.path().to_path_buf();
296        path.push("stack.txt");
297
298        let_assert!(Some(filename) = path.to_str());
299        let_assert!(Ok(mut file) = File::create(filename));
300        let_assert!(Ok(_) = file.write_all(b"+home @todo first\n+home @todo second\n"), "Cannot fill file");
301
302        let_assert!(Ok(stack) = Stack::new(filename));
303        assert!(stack.drop(nzliteral!(1u32)).is_ok());
304        let_assert!(Some(line) = stack.pop());
305        assert!(line == String::from("+home @todo first"));
306    }
307
308    #[test]
309    fn test_drop_2() {
310        let_assert!(Ok(tmpdir) = TempDir::new());
311        let mut path = tmpdir.path().to_path_buf();
312        path.push("stack.txt");
313
314        let_assert!(Some(filename) = path.to_str());
315        let_assert!(Ok(mut file) = File::create(filename));
316        let_assert!(Ok(_) = file.write_all(b"+home @todo first\n+home @todo second\n+home @todo third\n"));
317
318        let_assert!(Ok(stack) = Stack::new(filename));
319        assert!(stack.drop(nzliteral!(2u32)).is_ok());
320        let_assert!(Some(line) = stack.pop());
321        assert!(line == String::from("+home @todo first"));
322    }
323
324    #[test]
325    fn test_keep_1() {
326        let_assert!(Ok(tmpdir) = TempDir::new());
327        let mut path = tmpdir.path().to_path_buf();
328        path.push("stack.txt");
329
330        let_assert!(Some(filename) = path.to_str());
331        let_assert!(Ok(mut file) = File::create(filename));
332        let_assert!(Ok(_) = file.write_all(b"+home @todo first\n+home @todo second\n"));
333
334        let_assert!(Ok(stack) = Stack::new(filename));
335        assert!(stack.keep(nzliteral!(1u32)).is_ok());
336        let_assert!(Some(line) = stack.pop());
337        assert!(line == String::from("+home @todo second"));
338        assert!(stack.pop().is_none());
339    }
340
341    #[test]
342    fn test_keep_2() {
343        let_assert!(Ok(tmpdir) = TempDir::new());
344        let mut path = tmpdir.path().to_path_buf();
345        path.push("stack.txt");
346
347        let_assert!(Some(filename) = path.to_str());
348        let_assert!(Ok(mut file) = File::create(filename));
349        let_assert!(Ok(_) = file.write_all(b"+home @todo first\n+home @todo second\n+home @todo third\n"));
350
351        let_assert!(Ok(stack) = Stack::new(filename));
352        assert!(stack.keep(nzliteral!(2u32)).is_ok());
353        let_assert!(Some(line) = stack.pop());
354        assert!(line == String::from("+home @todo third"));
355        let_assert!(Some(line) = stack.pop());
356        assert!(line == String::from("+home @todo second"));
357        assert!(stack.pop().is_none());
358    }
359
360    #[test]
361    fn test_list() {
362        let_assert!(Ok(tmpdir) = TempDir::new());
363        let mut path = tmpdir.path().to_path_buf();
364        path.push("stack.txt");
365
366        let_assert!(Some(filename) = path.to_str());
367        let_assert!(Ok(mut file) = File::create(filename));
368        let_assert!(Ok(_) = file.write_all(b"+home @todo first\n+home @todo second\n+home @todo third\n"));
369
370        let_assert!(Ok(stack) = Stack::new(filename));
371        assert!(stack.list() == String::from(
372            "+home @todo third\n+home @todo second\n+home @todo first\n"
373        ));
374    }
375
376    #[test]
377    fn test_top() {
378        let_assert!(Ok(tmpdir) = TempDir::new());
379        let mut path = tmpdir.path().to_path_buf();
380        path.push("stack.txt");
381
382        let_assert!(Some(filename) = path.to_str());
383        let_assert!(Ok(mut file) = File::create(filename));
384        let_assert!(Ok(_) = file.write_all(b"+home @todo first\n+home @todo second\n+home @todo third\n"));
385
386        let_assert!(Ok(stack) = Stack::new(filename));
387        let_assert!(Ok(line) = stack.top());
388        assert!(line == String::from("+home @todo third"));
389    }
390
391    #[test]
392    fn test_top_empty() {
393        let_assert!(Ok(tmpdir) = TempDir::new());
394        let mut path = tmpdir.path().to_path_buf();
395        path.push("stack.txt");
396
397        let_assert!(Some(filename) = path.to_str());
398        let_assert!(Ok(_) = File::create(filename));
399
400        let_assert!(Ok(stack) = Stack::new(filename));
401        let_assert!(Ok(line) = stack.top());
402        assert!(line == String::new());
403    }
404}