A file-based task manager
1#![allow(dead_code)] 2//! The Task stack. Tasks created with `push` end up at the top here. It is invalid for a task that 3//! has been completed/archived to be on the stack. 4 5use crate::errors::{Error, Result}; 6use crate::util; 7use std::collections::VecDeque; 8use std::fmt::Display; 9use std::io::{self, BufRead, BufReader, Seek, Write}; 10use std::str::FromStr; 11use std::time::{Duration, SystemTime, UNIX_EPOCH}; 12use std::{fs::File, path::PathBuf}; 13 14use nix::fcntl::{Flock, FlockArg}; 15 16use crate::workspace::{Id, Task}; 17 18const TASKSFOLDER: &str = "tasks"; 19const INDEXFILE: &str = "index"; 20 21pub struct StackItem { 22 pub id: Id, 23 pub title: String, 24 pub modify_time: SystemTime, 25} 26 27impl Display for StackItem { 28 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 29 // .trim is used here on the title because there may be a newline in here if we read the 30 // title from the task file. 31 write!( 32 f, 33 // NOTE: we do NOT print the access time. 34 "{}\t{}", 35 self.id, 36 self.title.trim(), 37 ) 38 } 39} 40 41impl TryFrom<Task> for StackItem { 42 type Error = Error; 43 44 fn try_from(value: Task) -> std::result::Result<Self, Self::Error> { 45 let modify_time = value.file.metadata()?.modified()?; 46 Ok(Self { 47 id: value.id, 48 // replace tabs with spaces, they're not valid in StackItem titles. 49 title: value.title.replace("\t", " "), 50 modify_time, 51 }) 52 } 53} 54 55fn eof() -> Error { 56 Error::Io(io::Error::new( 57 io::ErrorKind::UnexpectedEof, 58 "Unexpected end of file", 59 )) 60} 61 62impl FromStr for StackItem { 63 type Err = Error; 64 65 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> { 66 let mut parts = s.trim().split("\t"); 67 let id: Id = parts 68 .next() 69 .ok_or(Error::Parse(format!( 70 "Incomplete index line. Missing tsk ID" 71 )))? 72 .parse()?; 73 let title: String = parts 74 .next() 75 .ok_or(Error::Parse(format!( 76 "Incomplete index line. Missing title." 77 )))? 78 .trim() 79 .to_string(); 80 // parse the timestamp as an integer 81 let index_epoch: u64 = parts.next().unwrap_or("0").parse()?; 82 // get a usable system time from the UNIX epoch, defaulting to the UNIX_EPOCH if there's 83 // any failures. This means that if there's errors, we will always read the title and 84 // modify_time from the task file. 85 let modify_time = UNIX_EPOCH 86 .checked_add(Duration::from_secs(index_epoch)) 87 .unwrap_or(UNIX_EPOCH); 88 Ok(Self { 89 id, 90 title, 91 modify_time, 92 }) 93 } 94} 95 96impl StackItem { 97 /// Parses a [`StackItem`] from a string. The expected format is a tab-delimited line with the 98 /// files: task id title 99 fn from_line(workspace_path: &PathBuf, line: String) -> Result<Self> { 100 let mut stack_item: StackItem = line.parse()?; 101 102 let task = util::flopen( 103 workspace_path 104 .join(TASKSFOLDER) 105 .join(stack_item.id.to_filename()), 106 FlockArg::LockExclusive, 107 )?; 108 let task_modify_time = task.metadata()?.modified()?; 109 // if the task file has been modified since we last looked at it, re-read the title and 110 // metadata 111 if (task_modify_time - Duration::from_secs(1)) > stack_item.modify_time { 112 stack_item.title.clear(); 113 BufReader::new(&*task).read_line(&mut stack_item.title)?; 114 stack_item.modify_time = task_modify_time; 115 } 116 Ok(stack_item) 117 } 118} 119 120pub struct TaskStack { 121 /// All items within the stack 122 all: VecDeque<StackItem>, 123 file: Flock<File>, 124} 125 126impl TaskStack { 127 pub fn from_tskdir(workspace_path: &PathBuf) -> Result<Self> { 128 let file = util::flopen(workspace_path.join(INDEXFILE), FlockArg::LockExclusive)?; 129 let index = BufReader::new(&*file).lines(); 130 let mut all = VecDeque::new(); 131 for line in index { 132 let stack_item = StackItem::from_line(workspace_path, line?)?; 133 all.push_back(stack_item); 134 } 135 Ok(Self { all, file }) 136 } 137 138 /// Saves the task stack to disk. 139 pub fn save(mut self) -> Result<()> { 140 // Clear the file 141 self.file.seek(std::io::SeekFrom::Start(0))?; 142 self.file.set_len(0)?; 143 for item in self.all.iter() { 144 self.file.write_all(format!("{item}\n").as_bytes())?; 145 } 146 Ok(()) 147 } 148 149 pub fn push(&mut self, item: StackItem) { 150 self.all.push_front(item); 151 } 152 153 pub fn pop(&mut self) -> Option<StackItem> { 154 self.all.pop_front() 155 } 156 157 pub fn swap(&mut self) { 158 let tip = self.all.pop_front(); 159 let second = self.all.pop_front(); 160 if tip.is_some() && second.is_some() { 161 self.all.push_front(tip.unwrap()); 162 self.all.push_front(second.unwrap()); 163 } 164 } 165 166 pub fn empty(&self) -> bool { 167 self.all.is_empty() 168 } 169} 170 171impl IntoIterator for TaskStack { 172 type Item = StackItem; 173 174 type IntoIter = std::collections::vec_deque::IntoIter<Self::Item>; 175 176 fn into_iter(self) -> Self::IntoIter { 177 self.all.into_iter() 178 } 179}