A file-based task manager

ADD: basic stack management

+260 -127
+10
Cargo.lock
··· 930 930 ] 931 931 932 932 [[package]] 933 + name = "tabwriter" 934 + version = "1.4.0" 935 + source = "registry+https://github.com/rust-lang/crates.io-index" 936 + checksum = "a327282c4f64f6dc37e3bba4c2b6842cc3a992f204fa58d917696a89f691e5f6" 937 + dependencies = [ 938 + "unicode-width", 939 + ] 940 + 941 + [[package]] 933 942 name = "taffy" 934 943 version = "0.5.2" 935 944 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1012 1021 "nix", 1013 1022 "smallstr", 1014 1023 "smol", 1024 + "tabwriter", 1015 1025 "thiserror", 1016 1026 "xattr", 1017 1027 ]
+1
Cargo.toml
··· 12 12 nix = { version = "0.29.0", features = ["fs"] } 13 13 smallstr = { version = "0.3.0", features = ["std"] } 14 14 smol = "2.0.2" 15 + tabwriter = "1.4.0" 15 16 thiserror = "1.0.64" 16 17 xattr = "1.3.1"
-51
src/buffer.rs
··· 1 - use std::{ 2 - collections::BinaryHeap, 3 - sync::{Condvar, Mutex}, 4 - }; 5 - struct PooledHandle<'a, T: Ord + Default> { 6 - parent: &'a Pool<T>, 7 - item: Option<T>, 8 - } 9 - 10 - struct Pool<T: Ord + Default>(Mutex<BinaryHeap<T>>, Condvar); 11 - 12 - impl<'a, T: Ord + Default> Pool<T> { 13 - pub fn give(&self, item: T) { 14 - self.0.lock().expect("Poisoned lock").push(item) 15 - } 16 - 17 - /// Retrieves 18 - pub fn take(&self) -> Option<T> { 19 - self.0.lock().expect("Poisoned lock").pop() 20 - } 21 - 22 - pub fn lease(&self) -> PooledHandle<'a, T> { 23 - let guard = self.0.lock().expect("Poisoned lock"); 24 - match guard.pop() { 25 - Some(item) => PooledHandle { 26 - parent: self, 27 - item: Some(item), 28 - }, 29 - None => { 30 - let mut item = None; 31 - while item.is_none() { 32 - let guard = self.1.wait(guard).expect("Poisoned lock"); 33 - item = guard.pop(); 34 - } 35 - 36 - }, 37 - } 38 - } 39 - } 40 - 41 - impl<T: Ord> AsRef<T> for PooledHandle<'_, T> { 42 - fn as_ref(&self) -> &T { 43 - &self.item 44 - } 45 - } 46 - 47 - impl<T: Ord> Drop for PooledHandle<'_, T> { 48 - fn drop(&mut self) { 49 - self.parent.give(self.item) 50 - } 51 - }
+1
src/errors.rs
··· 16 16 ParseId(#[from] std::num::ParseIntError), 17 17 #[error("General parsing error: {0}")] 18 18 Parse(String), 19 + #[allow(dead_code)] 19 20 #[error("An unexpected error occurred: {0}")] 20 21 Oops(Box<dyn std::error::Error>), 21 22 }
+69 -27
src/main.rs
··· 1 1 mod errors; 2 - mod workspace; 3 2 mod stack; 4 3 mod util; 5 - mod buffer; 4 + mod workspace; 6 5 use std::path::PathBuf; 7 6 use std::{env::current_dir, io::Read}; 8 7 use workspace::Workspace; ··· 47 46 #[command(flatten)] 48 47 title: Title, 49 48 }, 49 + List { 50 + /// Whether to list all tasks in the task stack. If specified, -c / count is ignored. 51 + #[arg(short = 'a', default_value_t = false)] 52 + all: bool, 53 + #[arg(short = 'c', default_value_t = 10)] 54 + count: usize, 55 + }, 56 + 57 + Swap, 50 58 } 51 59 52 60 #[derive(Args)] ··· 64 72 fn main() { 65 73 let cli = Cli::parse(); 66 74 match cli.command { 67 - Commands::Init => Workspace::init(cli.dir.unwrap_or(default_dir())).expect("Init failed"), 75 + Commands::Init => command_init(cli.dir.unwrap_or(default_dir())), 68 76 Commands::Push { edit, body, title } => { 69 - let title = if let Some(title) = title.title { 70 - title 71 - } else if let Some(title) = title.title_simple { 72 - let joined = title.join(" "); 73 - joined 74 - } else { 75 - "".to_string() 76 - }; 77 - let mut body = body.unwrap_or_default(); 78 - if body == "-" { 79 - // add newline so you can type directly in the shell 80 - eprintln!(""); 81 - body.clear(); 82 - std::io::stdin() 83 - .read_to_string(&mut body) 84 - .expect("Failed to read stdin"); 85 - } 86 - if edit { 87 - body = open_editor(format!("{title}\n\n{body}")).expect("Failed to edit file"); 88 - } 89 - Workspace::from_path(cli.dir.unwrap_or(default_dir())) 90 - .expect("Unable to find .tsk dir") 91 - .new_task(title, body) 92 - .expect("Failed to create task"); 77 + command_push(cli.dir.unwrap_or(default_dir()), edit, body, title) 93 78 } 79 + Commands::List { all, count } => command_list(cli.dir.unwrap_or(default_dir()), all, count), 80 + Commands::Swap => command_swap(cli.dir.unwrap_or(default_dir())) 94 81 } 95 82 } 83 + 84 + fn command_init(dir: PathBuf) { 85 + Workspace::init(dir).expect("Init failed") 86 + } 87 + 88 + fn command_push(dir: PathBuf, edit: bool, body: Option<String>, title: Title) { 89 + let workspace = Workspace::from_path(dir).expect("Unable to find .tsk dir"); 90 + let title = if let Some(title) = title.title { 91 + title 92 + } else if let Some(title) = title.title_simple { 93 + let joined = title.join(" "); 94 + joined 95 + } else { 96 + "".to_string() 97 + }; 98 + let mut body = body.unwrap_or_default(); 99 + if body == "-" { 100 + // add newline so you can type directly in the shell 101 + eprintln!(""); 102 + body.clear(); 103 + std::io::stdin() 104 + .read_to_string(&mut body) 105 + .expect("Failed to read stdin"); 106 + } 107 + if edit { 108 + body = open_editor(format!("{title}\n\n{body}")).expect("Failed to edit file"); 109 + } 110 + let task = workspace 111 + .new_task(title, body) 112 + .expect("Failed to create task"); 113 + workspace 114 + .push_task(task) 115 + .expect("Failed to push task to stack"); 116 + } 117 + 118 + fn command_list(dir: PathBuf, all: bool, count: usize) { 119 + let workspace = Workspace::from_path(dir).expect("Unable to find .tsk dir"); 120 + let stack = if all { 121 + workspace.read_stack(None).expect("Failed to read index") 122 + } else { 123 + workspace 124 + .read_stack(Some(count)) 125 + .expect("Failed to read index") 126 + }; 127 + if stack.empty() { 128 + println!("*No tasks*"); 129 + } else { 130 + println!("{}", stack); 131 + } 132 + } 133 + 134 + fn command_swap(dir: PathBuf) { 135 + let workspace = Workspace::from_path(dir).expect("Unable to find .tsk dir"); 136 + workspace.swap_top().expect("swap to work"); 137 + }
+144 -33
src/stack.rs
··· 1 + #![allow(dead_code)] 1 2 //! The Task stack. Tasks created with `push` end up at the top here. It is invalid for a task that 2 3 //! has been completed/archived to be on the stack. 3 4 4 5 use crate::errors::{Error, Result}; 5 6 use crate::util; 6 - use std::io::{self, BufRead, Read}; 7 + use std::collections::VecDeque; 8 + use std::fmt::Display; 9 + use std::io::{self, BufRead, BufReader, Seek, Write}; 10 + use std::time::{Duration, SystemTime, UNIX_EPOCH}; 7 11 use std::{fs::File, path::PathBuf}; 8 12 9 13 use nix::fcntl::{Flock, FlockArg}; 10 14 11 - use crate::workspace::{Id, Workspace}; 15 + use crate::workspace::{Id, Task}; 12 16 13 - struct StackItem { 17 + const TASKSFOLDER: &str = "tasks"; 18 + const INDEXFILE: &str = "index"; 19 + 20 + pub(crate) struct StackItem { 14 21 id: Id, 15 22 title: String, 16 - next: Id, 23 + modify_time: SystemTime, 24 + } 25 + 26 + impl Display for StackItem { 27 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 28 + // .trim is used here on the title because there may be a newline in here if we read the 29 + // title from the task file. 30 + write!( 31 + f, 32 + // NOTE: we do NOT print the access time. 33 + "{}\t{}", 34 + self.id, 35 + self.title.trim(), 36 + ) 37 + } 38 + } 39 + 40 + impl TryFrom<Task> for StackItem { 41 + type Error = Error; 42 + 43 + fn try_from(value: Task) -> std::result::Result<Self, Self::Error> { 44 + let modify_time = value.file.metadata()?.modified()?; 45 + Ok(Self { 46 + id: value.id, 47 + // replace tabs with spaces, they're not valid in StackItem titles. 48 + title: value.title.replace("\t", " "), 49 + modify_time, 50 + }) 51 + } 17 52 } 18 53 19 54 fn eof() -> Error { ··· 24 59 } 25 60 26 61 impl StackItem { 27 - fn from_reader(workspace_path: &PathBuf, reader: &mut impl BufRead) -> Result<Self> { 28 - let mut buf = String::new(); 29 - reader.read_line(&mut buf)?; 30 - if buf.is_empty() { 31 - return Err(Error::Io(io::Error::new( 32 - io::ErrorKind::UnexpectedEof, 33 - "Empty line", 34 - ))); 62 + /// Parses a [`StackItem`] from a string. The expected format is a tab-delimited line with the 63 + /// files: task id title 64 + fn from_line(workspace_path: &PathBuf, line: String) -> Result<Self> { 65 + let mut parts = line.split("\t"); 66 + let id: Id = parts 67 + .next() 68 + .ok_or(Error::Parse(format!( 69 + "Incomplete index line. Missing tsk ID" 70 + )))? 71 + .parse()?; 72 + let mut title: String = parts 73 + .next() 74 + .ok_or(Error::Parse(format!( 75 + "Incomplete index line. Missing title." 76 + )))? 77 + .trim() 78 + .to_string(); 79 + // parse the timestamp as an integer 80 + let index_epoch: u64 = parts.next().unwrap_or("0").parse()?; 81 + // get a usable system time from the UNIX epoch, defaulting to the UNIX_EPOCH if there's 82 + // any failures. This means that if there's errors, we will always read the title and 83 + // modify_time from the task file. 84 + let mut modify_time = UNIX_EPOCH 85 + .checked_add(Duration::from_secs(index_epoch)) 86 + .unwrap_or(UNIX_EPOCH); 87 + let modify_epoch = modify_time 88 + .duration_since(UNIX_EPOCH) 89 + .expect("We're before the dawn of time!?") 90 + .as_secs(); 91 + let task = util::flopen( 92 + workspace_path.join(TASKSFOLDER).join(id.to_string()), 93 + FlockArg::LockExclusive, 94 + )?; 95 + let task_modify_time = task.metadata()?.modified()?; 96 + // if the task file has been modified since we last looked at it, re-read the title and 97 + // metadata 98 + if modify_epoch > index_epoch { 99 + title.clear(); 100 + BufReader::new(&*task).read_line(&mut title)?; 101 + modify_time = task_modify_time; 35 102 } 36 - let (id, next) = Self::parse(&buf)?; 37 - let title = util::flopen(workspace_path.join("tasks").join(id), mode) 38 - todo!(); 39 - } 40 - 41 - fn parse(line: &str) -> Result<(Id, Id)> { 42 - let mut split = line.split("->"); 43 - let curr = split.next().ok_or(eof())?; 44 - let next = split.next().ok_or(eof())?; 45 - if let Some(rest) = split.next() { 46 - Err(Error::Parse(format!( 47 - "Got unexpected data in index item: {rest}" 48 - ))) 49 - } else { 50 - Ok((curr.parse()?, next.parse()?)) 51 - } 103 + Ok(Self { 104 + id, 105 + title, 106 + modify_time, 107 + }) 52 108 } 53 109 } 54 110 55 111 pub struct TaskStack { 56 - /// The index into `all` that is the top of the stack 57 - top: usize, 58 - all: Vec<StackItem>, 112 + /// All items within the stack 113 + all: VecDeque<StackItem>, 59 114 file: Flock<File>, 60 115 } 61 116 117 + impl Display for TaskStack { 118 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 119 + for task in self.all.iter() { 120 + write!(f, "{task}\n")?; 121 + } 122 + Ok(()) 123 + } 124 + } 125 + 62 126 impl TaskStack { 63 - fn from_tskdir(path: &PathBuf) -> Result<Self> { 64 - todo!() 127 + pub fn from_tskdir(workspace_path: &PathBuf, count: Option<usize>) -> 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 + if let Some(count) = count { 132 + for line in index.take(count) { 133 + let line = line?; 134 + let stack_item = StackItem::from_line(workspace_path, line)?; 135 + all.push_back(stack_item); 136 + } 137 + } else { 138 + for line in index { 139 + let stack_item = StackItem::from_line(workspace_path, line?)?; 140 + all.push_back(stack_item); 141 + } 142 + }; 143 + Ok(Self { all, file }) 144 + } 145 + 146 + /// Saves the task stack to disk. 147 + pub fn save(mut self) -> Result<()> { 148 + // Clear the file 149 + self.file.seek(std::io::SeekFrom::Start(0))?; 150 + self.file.set_len(0)?; 151 + for item in self.all.iter() { 152 + self.file.write_all(format!("{item}\n").as_bytes())?; 153 + } 154 + Ok(()) 155 + } 156 + 157 + pub fn push(&mut self, item: StackItem) { 158 + self.all.push_front(item); 159 + } 160 + 161 + pub fn pop(&mut self) -> Option<StackItem> { 162 + self.all.pop_front() 163 + } 164 + 165 + pub fn swap(&mut self) { 166 + let tip = self.all.pop_front(); 167 + let second = self.all.pop_front(); 168 + if tip.is_some() && second.is_some() { 169 + self.all.push_front(tip.unwrap()); 170 + self.all.push_front(second.unwrap()); 171 + } 172 + } 173 + 174 + pub fn empty(&self) -> bool { 175 + self.all.is_empty() 65 176 } 66 177 }
+35 -16
src/workspace.rs
··· 4 4 use crate::errors::{Error, Result}; 5 5 use crate::stack::TaskStack; 6 6 use crate::util; 7 + use std::fmt::Display; 7 8 use std::fs::File; 8 9 use std::io::{Read, Seek}; 9 10 use std::path::PathBuf; ··· 19 20 type Err = Error; 20 21 21 22 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> { 22 - s.strip_prefix("tsk-") 23 - .ok_or(Self::Err::Parse("expected tsk- prefix ".to_string()))?; 23 + let s = s 24 + .strip_prefix("tsk-") 25 + .ok_or(Self::Err::Parse(format!("expected tsk- prefix. Got {s}")))?; 24 26 Ok(Self(s.parse()?)) 27 + } 28 + } 29 + 30 + impl Display for Id { 31 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 32 + write!(f, "tsk-{}", self.0) 33 + } 34 + } 35 + 36 + impl Id { 37 + pub fn to_string(&self) -> String { 38 + format!("tsk-{}.tsk", self.0) 25 39 } 26 40 } 27 41 ··· 90 104 }) 91 105 } 92 106 93 - fn read_stack(&self) -> Result<TaskStack> { 94 - let mut index = String::new(); 95 - let mut cache = String::new(); 96 - let mut index_file = util::flopen(self.path.join(INDEXFILE), FlockArg::LockExclusive)?; 97 - let mut cache_file = util::flopen(self.path.join(TITLECACHEFILE), FlockArg::LockShared)?; 98 - index_file.read_to_string(&mut index)?; 99 - for line in index.lines() { 100 - 101 - } 107 + pub fn read_stack(&self, count: Option<usize>) -> Result<TaskStack> { 108 + TaskStack::from_tskdir(&self.path, count) 109 + } 110 + 111 + pub fn push_task(&self, task: Task) -> Result<()> { 112 + let mut stack = TaskStack::from_tskdir(&self.path, None)?; 113 + stack.push(task.try_into()?); 114 + stack.save()?; 115 + Ok(()) 116 + } 102 117 103 - todo!(); 118 + pub fn swap_top(&self) -> Result<()> { 119 + let mut stack = TaskStack::from_tskdir(&self.path, None)?; 120 + stack.swap(); 121 + stack.save()?; 122 + Ok(()) 104 123 } 105 124 } 106 125 107 126 pub struct Task { 108 - id: Id, 109 - title: String, 110 - body: String, 111 - file: Flock<File>, 127 + pub id: Id, 128 + pub title: String, 129 + pub body: String, 130 + pub file: Flock<File>, 112 131 } 113 132 114 133 #[cfg(test)]