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