A file-based task manager

ADD: fuzzy finding, fix issues with id, refactor error handling

+156 -85
+31 -22
Cargo.lock
··· 232 232 233 233 [[package]] 234 234 name = "clap" 235 - version = "4.5.18" 235 + version = "4.5.19" 236 236 source = "registry+https://github.com/rust-lang/crates.io-index" 237 - checksum = "b0956a43b323ac1afaffc053ed5c4b7c1f1800bacd1683c353aabbb752515dd3" 237 + checksum = "7be5744db7978a28d9df86a214130d106a89ce49644cbc4e3f0c22c3fba30615" 238 238 dependencies = [ 239 239 "clap_builder", 240 240 "clap_derive", ··· 242 242 243 243 [[package]] 244 244 name = "clap_builder" 245 - version = "4.5.18" 245 + version = "4.5.19" 246 246 source = "registry+https://github.com/rust-lang/crates.io-index" 247 - checksum = "4d72166dd41634086d5803a47eb71ae740e61d84709c36f3c34110173db3961b" 247 + checksum = "a5fbc17d3ef8278f55b282b2a2e75ae6f6c7d4bb70ed3d0382375104bfafdb4b" 248 248 dependencies = [ 249 249 "anstream", 250 250 "anstyle", ··· 254 254 255 255 [[package]] 256 256 name = "clap_complete" 257 - version = "4.5.29" 257 + version = "4.5.32" 258 258 source = "registry+https://github.com/rust-lang/crates.io-index" 259 - checksum = "8937760c3f4c60871870b8c3ee5f9b30771f792a7045c48bcbba999d7d6b3b8e" 259 + checksum = "74a01f4f9ee6c066d42a1c8dedf0dcddad16c72a8981a309d6398de3a75b0c39" 260 260 dependencies = [ 261 261 "clap", 262 262 ] ··· 528 528 529 529 [[package]] 530 530 name = "hashbrown" 531 - version = "0.14.5" 531 + version = "0.15.0" 532 532 source = "registry+https://github.com/rust-lang/crates.io-index" 533 - checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" 533 + checksum = "1e087f84d4f86bf4b218b927129862374b72199ae7d8657835f1e89000eea4fb" 534 534 535 535 [[package]] 536 536 name = "heck" ··· 561 561 562 562 [[package]] 563 563 name = "indexmap" 564 - version = "2.5.0" 564 + version = "2.6.0" 565 565 source = "registry+https://github.com/rust-lang/crates.io-index" 566 - checksum = "68b900aa2f7301e21c36462b170ee99994de34dff39a4a6a528e80e7376d07e5" 566 + checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da" 567 567 dependencies = [ 568 568 "equivalent", 569 569 "hashbrown", ··· 682 682 683 683 [[package]] 684 684 name = "once_cell" 685 - version = "1.19.0" 685 + version = "1.20.1" 686 686 source = "registry+https://github.com/rust-lang/crates.io-index" 687 - checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" 687 + checksum = "82881c4be219ab5faaf2ad5e5e5ecdff8c66bd7402ca3160975c93b24961afd1" 688 + dependencies = [ 689 + "portable-atomic", 690 + ] 688 691 689 692 [[package]] 690 693 name = "parking" ··· 754 757 ] 755 758 756 759 [[package]] 760 + name = "portable-atomic" 761 + version = "1.9.0" 762 + source = "registry+https://github.com/rust-lang/crates.io-index" 763 + checksum = "cc9c68a3f6da06753e9335d63e27f6b9754dd1920d941135b7ea8224f141adb2" 764 + 765 + [[package]] 757 766 name = "proc-macro2" 758 767 version = "1.0.86" 759 768 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 773 782 774 783 [[package]] 775 784 name = "redox_syscall" 776 - version = "0.5.6" 785 + version = "0.5.7" 777 786 source = "registry+https://github.com/rust-lang/crates.io-index" 778 - checksum = "355ae415ccd3a04315d3f8246e86d67689ea74d88d915576e1589a351062a13b" 787 + checksum = "9b6dfecf2c74bce2466cabf93f6664d6998a69eb21e39f4207930065b27b771f" 779 788 dependencies = [ 780 789 "bitflags", 781 790 ] ··· 807 816 808 817 [[package]] 809 818 name = "serde" 810 - version = "1.0.203" 819 + version = "1.0.210" 811 820 source = "registry+https://github.com/rust-lang/crates.io-index" 812 - checksum = "7253ab4de971e72fb7be983802300c30b5a7f0c2e56fab8abfc6a214307c0094" 821 + checksum = "c8e3592472072e6e22e0a54d5904d9febf8508f65fb8552499a1abc7d1078c3a" 813 822 dependencies = [ 814 823 "serde_derive", 815 824 ] 816 825 817 826 [[package]] 818 827 name = "serde_derive" 819 - version = "1.0.203" 828 + version = "1.0.210" 820 829 source = "registry+https://github.com/rust-lang/crates.io-index" 821 - checksum = "500cbc0ebeb6f46627f50f3f5811ccf6bf00643be300b4c3eabc0ef55dc5b5ba" 830 + checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f" 822 831 dependencies = [ 823 832 "proc-macro2", 824 833 "quote", ··· 920 929 921 930 [[package]] 922 931 name = "syn" 923 - version = "2.0.77" 932 + version = "2.0.79" 924 933 source = "registry+https://github.com/rust-lang/crates.io-index" 925 - checksum = "9f35bcdf61fd8e7be6caf75f429fdca8beb3ed76584befb503b1569faee373ed" 934 + checksum = "89132cd0bf050864e1d38dc3bbc07a0eb8e7530af26344d3d2bbbef83499f590" 926 935 dependencies = [ 927 936 "proc-macro2", 928 937 "quote", ··· 951 960 952 961 [[package]] 953 962 name = "tempfile" 954 - version = "3.12.0" 963 + version = "3.13.0" 955 964 source = "registry+https://github.com/rust-lang/crates.io-index" 956 - checksum = "04cbcdd0c794ebb0d4cf35e88edd2f7d2c4c3e9a5a6dab322839b321c6a87a64" 965 + checksum = "f0f2c9fc62d0beef6951ccffd757e241266a2c833136efbe35af6cd2567dca5b" 957 966 dependencies = [ 958 967 "cfg-if", 959 968 "fastrand",
+4
src/errors.rs
··· 20 20 Parse(String), 21 21 #[error("Error parsing bytes as utf-8: {0}")] 22 22 FromUtf8(#[from] FromUtf8Error), 23 + #[error("No tasks on stack")] 24 + NoTasks, 25 + #[error("No task selected.")] 26 + NotSelected, 23 27 #[allow(dead_code)] 24 28 #[error("An unexpected error occurred: {0}")] 25 29 Oops(Box<dyn std::error::Error>),
+82 -58
src/main.rs
··· 4 4 mod util; 5 5 mod workspace; 6 6 use clap_complete::{generate, Shell}; 7 + use errors::Result; 7 8 use std::io; 8 9 use std::path::PathBuf; 10 + use std::process::exit; 9 11 use std::{env::current_dir, io::Read}; 10 - use workspace::{Id, Workspace}; 12 + use workspace::{Id, TaskIdentifier, Workspace}; 11 13 12 14 //use smol; 13 15 //use iocraft::prelude::*; ··· 114 116 } 115 117 116 118 #[derive(Args)] 117 - #[group(required = true, multiple = false)] 119 + #[group(required = false, multiple = false)] 118 120 struct TaskId { 121 + /// The ID of the task to select as a plain integer. 119 122 #[arg(short = 't', value_name = "ID")] 120 123 id: Option<u32>, 121 124 125 + /// The ID of the task to select with the 'tsk-' prefix. 122 126 #[arg(short = 'T', value_name = "TSK-ID", value_parser = value_parser!(String))] 123 127 tsk_id: Option<Id>, 124 128 125 - /// If no option is specified 126 - #[arg(short = 'r', value_name = "RELATIVE")] 127 - relative_id: Option<u32> 129 + /// Selects a task relative to the top of the stack. 130 + /// If no option is specified, the task selected will be the top of the stack. 131 + #[arg(short = 'r', value_name = "RELATIVE", default_value_t = 0)] 132 + relative_id: u32, 133 + 134 + /// Use fuzzy finding to search for and select a task. 135 + /// Does not support searching task bodies or archived tasks. 136 + #[arg(short = 'f', value_name = "FIND", default_value_t = false)] 137 + find: bool, 138 + } 139 + 140 + impl From<TaskId> for TaskIdentifier { 141 + fn from(value: TaskId) -> Self { 142 + if let Some(id) = value.id.map(Id::from).or(value.tsk_id) { 143 + TaskIdentifier::Id(id) 144 + } else { 145 + if value.find { 146 + TaskIdentifier::Find 147 + } else { 148 + TaskIdentifier::Relative(value.relative_id) 149 + } 150 + } 151 + } 128 152 } 129 153 130 154 fn main() { 131 155 let cli = Cli::parse(); 132 156 let dir = cli.dir.unwrap_or(default_dir()); 133 - match cli.command { 157 + let result = match cli.command { 134 158 Commands::Init => command_init(dir), 135 159 Commands::Push { edit, body, title } => command_push(dir, edit, body, title), 136 160 Commands::List { all, count } => command_list(dir, all, count), ··· 138 162 Commands::Edit { task_id } => command_edit(dir, task_id), 139 163 Commands::Completion { shell } => command_completion(shell), 140 164 Commands::Drop => command_drop(dir), 141 - Commands::Find { full_id, .. } => command_search(dir, full_id), 142 - Commands::Rot => Workspace::from_path(dir).unwrap().rot().unwrap(), 143 - Commands::Tor => Workspace::from_path(dir).unwrap().tor().unwrap(), 165 + Commands::Find { full_id, .. } => command_find(dir, full_id), 166 + Commands::Rot => Workspace::from_path(dir).unwrap().rot(), 167 + Commands::Tor => Workspace::from_path(dir).unwrap().tor(), 144 168 Commands::Reprioritize { task_id } => command_reprioritize(dir, task_id), 169 + }; 170 + match result { 171 + Ok(_) => exit(0), 172 + Err(e) => { 173 + eprintln!("{e}"); 174 + exit(1); 175 + } 145 176 } 146 177 } 147 178 148 - fn command_init(dir: PathBuf) { 149 - Workspace::init(dir).expect("Init failed") 179 + fn command_init(dir: PathBuf) -> Result<()> { 180 + Workspace::init(dir) 150 181 } 151 182 152 - fn command_push(dir: PathBuf, edit: bool, body: Option<String>, title: Title) { 153 - let workspace = Workspace::from_path(dir).expect("Unable to find .tsk dir"); 183 + fn command_push(dir: PathBuf, edit: bool, body: Option<String>, title: Title) -> Result<()> { 184 + let workspace = Workspace::from_path(dir)?; 154 185 let mut title = if let Some(title) = title.title { 155 186 title 156 187 } else if let Some(title) = title.title_simple { ··· 164 195 // add newline so you can type directly in the shell 165 196 eprintln!(""); 166 197 body.clear(); 167 - std::io::stdin() 168 - .read_to_string(&mut body) 169 - .expect("Failed to read stdin"); 198 + std::io::stdin().read_to_string(&mut body)?; 170 199 } 171 200 if edit { 172 - let new_content = open_editor(format!("{title}\n\n{body}")).expect("Failed to edit file"); 201 + let new_content = open_editor(format!("{title}\n\n{body}"))?; 173 202 if let Some(content) = new_content.split_once("\n") { 174 203 title = content.0.to_string(); 175 204 body = content.1.to_string(); 176 205 } 177 206 } 178 - let task = workspace 179 - .new_task(title, body) 180 - .expect("Failed to create task"); 181 - workspace 182 - .push_task(task) 183 - .expect("Failed to push task to stack"); 207 + let task = workspace.new_task(title, body)?; 208 + workspace.push_task(task) 184 209 } 185 210 186 - fn command_list(dir: PathBuf, all: bool, count: usize) { 211 + fn command_list(dir: PathBuf, all: bool, count: usize) -> Result<()> { 187 212 let workspace = Workspace::from_path(dir).expect("Unable to find .tsk dir"); 188 213 let stack = if all { 189 - workspace.read_stack().expect("Failed to read index") 214 + workspace.read_stack()? 190 215 } else { 191 - workspace.read_stack().expect("Failed to read index") 216 + workspace.read_stack()? 192 217 }; 193 218 if stack.empty() { 194 219 println!("*No tasks*"); 220 + exit(0); 195 221 } else { 196 222 if !all { 197 223 for stack_item in stack.into_iter().take(count) { ··· 203 229 } 204 230 } 205 231 } 232 + Ok(()) 206 233 } 207 234 208 - fn command_swap(dir: PathBuf) { 209 - let workspace = Workspace::from_path(dir).expect("Unable to find .tsk dir"); 210 - workspace.swap_top().expect("swap to work"); 235 + fn command_swap(dir: PathBuf) -> Result<()> { 236 + let workspace = Workspace::from_path(dir)?; 237 + workspace.swap_top()?; 238 + Ok(()) 211 239 } 212 240 213 - fn command_edit(dir: PathBuf, id: TaskId) { 214 - let workspace = Workspace::from_path(dir).expect("Unable to find .tsk dir"); 215 - let tsk_id: Option<Id> = id.id.map(Id::from).or(id.tsk_id); 216 - let mut task = if let Some(id) = tsk_id { 217 - workspace.task(id.into()).expect("To read task from disk") 218 - } else { 219 - let mut stack = workspace.read_stack().expect("to read stack"); 220 - let stack_item = stack.pop().expect("No tasks on stack."); 221 - workspace.task(stack_item.id).expect("couldn't read task") 222 - }; 223 - let new_content = open_editor(format!("{}\n\n{}", task.title.trim(), task.body.trim())) 224 - .expect("Failed to edit file"); 241 + fn command_edit(dir: PathBuf, id: TaskId) -> Result<()> { 242 + let workspace = Workspace::from_path(dir)?; 243 + let id: TaskIdentifier = id.into(); 244 + let mut task = workspace.task(id)?; 245 + let new_content = open_editor(format!("{}\n\n{}", task.title.trim(), task.body.trim()))?; 225 246 if let Some((title, body)) = new_content.split_once("\n") { 226 247 task.title = title.to_string(); 227 248 task.body = body.to_string(); 228 - task.save().expect("Failed to save task"); 249 + task.save()?; 229 250 } 251 + Ok(()) 230 252 } 231 253 232 - fn command_completion(shell: Shell) { 233 - generate(shell, &mut Cli::command(), "tsk", &mut io::stdout()) 254 + fn command_completion(shell: Shell) -> Result<()> { 255 + generate(shell, &mut Cli::command(), "tsk", &mut io::stdout()); 256 + Ok(()) 234 257 } 235 258 236 - fn command_drop(dir: PathBuf) { 237 - if let Some(id) = Workspace::from_path(dir) 238 - .expect("Unable to find .tsk dir") 239 - .drop() 240 - .expect("Unable to drop task.") 241 - { 242 - println!("Dropped {id}") 259 + fn command_drop(dir: PathBuf) -> Result<()> { 260 + if let Some(id) = Workspace::from_path(dir)?.drop()? { 261 + eprint!("Dropped "); 262 + println!("{id}"); 263 + } else { 264 + eprintln!("No task to drop."); 265 + exit(1); 243 266 } 267 + Ok(()) 244 268 } 245 269 246 - fn command_search(dir: PathBuf, full_id: bool) { 247 - let id = Workspace::from_path(dir).unwrap().search().unwrap(); 270 + fn command_find(dir: PathBuf, full_id: bool) -> Result<()> { 271 + let id = Workspace::from_path(dir).unwrap().search(None).unwrap(); 248 272 if let Some(id) = id { 249 273 if full_id { 250 274 println!("{id}"); ··· 253 277 println!("{}", id.0); 254 278 } 255 279 } else { 256 - eprintln!("No task to drop.") 280 + eprintln!("No task to drop."); 281 + exit(1); 257 282 } 283 + Ok(()) 258 284 } 259 285 260 - fn command_reprioritize(dir: PathBuf, task_id: TaskId) { 286 + fn command_reprioritize(dir: PathBuf, task_id: TaskId) -> Result<()> { 261 287 // unwrap is safe here because clap will ensure we have at least one of these 262 - let tsk_id: Id = task_id.id.map(Id::from).or(task_id.tsk_id).unwrap(); 263 288 Workspace::from_path(dir) 264 289 .unwrap() 265 - .reprioritize(tsk_id) 266 - .unwrap() 290 + .reprioritize(task_id.into()) 267 291 }
+4 -1
src/stack.rs
··· 175 175 pub fn iter(&self) -> Iter<StackItem> { 176 176 self.all.iter() 177 177 } 178 + 179 + pub fn get(&self, index: usize) -> Option<&StackItem> { 180 + self.all.get(index) 181 + } 178 182 } 179 - 180 183 181 184 impl IntoIterator for TaskStack { 182 185 type Item = StackItem;
+35 -4
src/workspace.rs
··· 46 46 } 47 47 } 48 48 49 + pub enum TaskIdentifier { 50 + Id(Id), 51 + Relative(u32), 52 + Find, 53 + } 54 + 55 + impl From<Id> for TaskIdentifier { 56 + fn from(value: Id) -> Self { 57 + TaskIdentifier::Id(value) 58 + } 59 + } 60 + 49 61 pub struct Workspace { 50 62 /// The path to the workspace root, excluding the .tsk directory. This should *contain* the 51 63 /// .tsk directory. ··· 77 89 Ok(Self { path: tsk_dir }) 78 90 } 79 91 92 + fn resolve(&self, identifier: TaskIdentifier) -> Result<Id> { 93 + match identifier { 94 + TaskIdentifier::Id(id) => Ok(id), 95 + TaskIdentifier::Relative(r) => { 96 + let stack = self.read_stack()?; 97 + let stack_item = stack.get(r as usize).ok_or(Error::NoTasks)?; 98 + Ok(stack_item.id) 99 + } 100 + TaskIdentifier::Find => self.search(None)?.ok_or(Error::NotSelected), 101 + } 102 + } 103 + 80 104 pub fn next_id(&self) -> Result<Id> { 81 105 let mut file = util::flopen(self.path.join("next"), FlockArg::LockExclusive)?; 82 106 let mut buf = String::new(); ··· 110 134 }) 111 135 } 112 136 113 - pub fn task(&self, id: Id) -> Result<Task> { 137 + pub fn task(&self, identifier: TaskIdentifier) -> Result<Task> { 138 + let id = self.resolve(identifier)?; 139 + 114 140 let file = util::flopen( 115 141 self.path.join("tasks").join(format!("tsk-{}.tsk", id.0)), 116 142 FlockArg::LockExclusive, ··· 198 224 } 199 225 } 200 226 201 - pub fn search(&self) -> Result<Option<Id>> { 202 - let stack = self.read_stack()?; 227 + pub fn search(&self, stack: Option<TaskStack>) -> Result<Option<Id>> { 228 + let stack = if let Some(stack) = stack { 229 + stack 230 + } else { 231 + self.read_stack()? 232 + }; 203 233 Ok(fzf::select(stack)?.map(|si| si.id)) 204 234 } 205 235 206 - pub fn reprioritize(&self, id: Id) -> Result<()> { 236 + pub fn reprioritize(&self, identifier: TaskIdentifier) -> Result<()> { 237 + let id = self.resolve(identifier)?; 207 238 let mut stack = self.read_stack()?; 208 239 let index = &stack.iter().map(|i| i.id).position(|i| i == id); 209 240 if let Some(index) = index {