A file-based task manager

ADD: .git-like parent search, searching

+227 -85
+10
src/errors.rs
··· 1 + use std::{convert::Infallible, string::FromUtf8Error}; 2 + 1 3 use thiserror::Error as ThisError; 2 4 3 5 pub type Result<T> = std::result::Result<T, Error>; ··· 16 18 ParseId(#[from] std::num::ParseIntError), 17 19 #[error("General parsing error: {0}")] 18 20 Parse(String), 21 + #[error("Error parsing bytes as utf-8: {0}")] 22 + FromUtf8(#[from] FromUtf8Error), 19 23 #[allow(dead_code)] 20 24 #[error("An unexpected error occurred: {0}")] 21 25 Oops(Box<dyn std::error::Error>), 22 26 } 27 + 28 + impl From<Infallible> for Error { 29 + fn from(_: Infallible) -> Self { 30 + unreachable!(); 31 + } 32 + }
+31
src/fzf.rs
··· 1 + use crate::errors::{Error, Result}; 2 + use std::fmt::Display; 3 + use std::io::Write; 4 + use std::process::{Command, Stdio}; 5 + use std::str::FromStr; 6 + 7 + /// Sends each item as a line to stdin to the `fzf` command and returns the selected item's string 8 + /// representation as output 9 + pub fn select<I>(input: impl IntoIterator<Item = I>) -> Result<Option<I>> 10 + where 11 + I: Display + FromStr, 12 + Error: From<<I as FromStr>::Err>, 13 + { 14 + let mut child = Command::new("fzf") 15 + .args(["-d", "\t"]) 16 + .stderr(Stdio::inherit()) 17 + .stdin(Stdio::piped()) 18 + .stdout(Stdio::piped()) 19 + .spawn()?; 20 + // unwrap: this can never fail 21 + let child_in = child.stdin.as_mut().unwrap(); 22 + for item in input.into_iter() { 23 + write!(child_in, "{}\n", item.to_string())?; 24 + } 25 + let output = child.wait_with_output()?; 26 + if output.stdout.is_empty() { 27 + Ok(None) 28 + } else { 29 + Ok(Some(String::from_utf8(output.stdout)?.parse()?)) 30 + } 31 + }
+74 -19
src/main.rs
··· 1 1 mod errors; 2 + mod fzf; 2 3 mod stack; 3 4 mod util; 4 5 mod workspace; ··· 6 7 use std::io; 7 8 use std::path::PathBuf; 8 9 use std::{env::current_dir, io::Read}; 9 - use workspace::Workspace; 10 + use workspace::{Id, Workspace}; 10 11 11 12 //use smol; 12 13 //use iocraft::prelude::*; 13 - use clap::{Args, CommandFactory, Parser, Subcommand}; 14 + use clap::{value_parser, Args, CommandFactory, Parser, Subcommand}; 14 15 use edit::edit as open_editor; 15 16 16 17 fn default_dir() -> PathBuf { ··· 30 31 31 32 #[derive(Subcommand)] 32 33 enum Commands { 34 + /// Initializes a .tsk workspace in the current effective directory, which defaults to PWD. 33 35 Init, 34 36 /// Creates a new task, automatically assigning it a unique identifider and persisting 35 37 Push { ··· 56 58 count: usize, 57 59 }, 58 60 61 + /// Swaps the top two tasks on the stack. If there are less than 2 tasks on the stack, there is 62 + /// no effect. 59 63 Swap, 60 64 65 + /// Open up an editor to modify the task with the given ID. 61 66 Edit { 62 - #[arg(short = 't')] 63 - task_id: Option<u32>, 67 + #[command(flatten)] 68 + task_id: TaskId, 64 69 }, 65 70 71 + /// Generates completion for a given shell. 66 72 Completion { 67 73 #[arg(short = 's')] 68 74 shell: Shell, 69 75 }, 70 - /* 71 - Drop { 72 - #[arg(short = 't')] 73 - task_id: Option<u32>, 74 - } 75 - */ 76 + 77 + /// Use fuzzy finding with `fzf` to search for a task 78 + Find { 79 + /// Include the contents of tasks in the search criteria. 80 + #[arg(short = 'b', default_value_t = false)] 81 + search_body: bool, 82 + /// Include archived tasks in the search criteria. Combine with `-b` to include archived 83 + /// bodies in the search criteria. 84 + #[arg(short = 'a', default_value_t = false)] 85 + search_archived: bool, 86 + }, 87 + 88 + /// Drops the task on the top of the stack and archives it. 89 + Drop, 76 90 } 77 91 78 92 #[derive(Args)] 79 - #[group(required = true, multiple = false)] 93 + #[group(required = false, multiple = false)] 80 94 struct Title { 81 95 /// The title of the task. This is useful for when you also wish to specify the body of the 82 96 /// task as an argument (ie. with -b). ··· 87 101 title_simple: Option<Vec<String>>, 88 102 } 89 103 104 + #[derive(Args)] 105 + #[group(required = true, multiple = false)] 106 + struct TaskId { 107 + #[arg(short = 't', value_name = "ID")] 108 + id: Option<u32>, 109 + 110 + #[arg(short = 'T', value_name = "TSK-ID", value_parser = value_parser!(String))] 111 + tsk_id: Option<Id>, 112 + } 113 + 90 114 fn main() { 91 115 let cli = Cli::parse(); 92 116 match cli.command { ··· 98 122 Commands::Swap => command_swap(cli.dir.unwrap_or(default_dir())), 99 123 Commands::Edit { task_id } => command_edit(cli.dir.unwrap_or(default_dir()), task_id), 100 124 Commands::Completion { shell } => command_completion(shell), 125 + Commands::Drop => command_drop(cli.dir.unwrap_or(default_dir())), 126 + Commands::Find { 127 + search_body, 128 + search_archived, 129 + } => command_search(cli.dir.unwrap_or(default_dir())), 101 130 } 102 131 } 103 132 ··· 142 171 fn command_list(dir: PathBuf, all: bool, count: usize) { 143 172 let workspace = Workspace::from_path(dir).expect("Unable to find .tsk dir"); 144 173 let stack = if all { 145 - workspace.read_stack(None).expect("Failed to read index") 174 + workspace.read_stack().expect("Failed to read index") 146 175 } else { 147 - workspace 148 - .read_stack(Some(count)) 149 - .expect("Failed to read index") 176 + workspace.read_stack().expect("Failed to read index") 150 177 }; 151 178 if stack.empty() { 152 179 println!("*No tasks*"); 153 180 } else { 154 - println!("{}", stack); 181 + if !all { 182 + for stack_item in stack.into_iter().take(count) { 183 + println!("{stack_item}"); 184 + } 185 + } else { 186 + for stack_item in stack.into_iter() { 187 + println!("{stack_item}"); 188 + } 189 + } 155 190 } 156 191 } 157 192 ··· 160 195 workspace.swap_top().expect("swap to work"); 161 196 } 162 197 163 - fn command_edit(dir: PathBuf, id: Option<u32>) { 198 + fn command_edit(dir: PathBuf, id: TaskId) { 164 199 let workspace = Workspace::from_path(dir).expect("Unable to find .tsk dir"); 165 - let mut task = if let Some(id) = id { 200 + let tsk_id: Option<Id> = id.id.map(Id::from).or(id.tsk_id); 201 + let mut task = if let Some(id) = tsk_id { 166 202 workspace.task(id.into()).expect("To read task from disk") 167 203 } else { 168 - let mut stack = workspace.read_stack(Some(1)).expect("to read stack"); 204 + let mut stack = workspace.read_stack().expect("to read stack"); 169 205 let stack_item = stack.pop().expect("No tasks on stack."); 170 206 workspace.task(stack_item.id).expect("couldn't read task") 171 207 }; ··· 181 217 fn command_completion(shell: Shell) { 182 218 generate(shell, &mut Cli::command(), "tsk", &mut io::stdout()) 183 219 } 220 + 221 + fn command_drop(dir: PathBuf) { 222 + if let Some(id) = Workspace::from_path(dir) 223 + .expect("Unable to find .tsk dir") 224 + .drop() 225 + .expect("Unable to drop task.") { 226 + println!("Dropped {id}") 227 + } 228 + } 229 + 230 + fn command_search(dir: PathBuf) { 231 + let id = Workspace::from_path(dir).unwrap().search().unwrap(); 232 + if let Some(id) = id { 233 + eprint!("Dropping "); 234 + println!("{id}"); 235 + } else { 236 + eprintln!("No task to drop.") 237 + } 238 + }
+46 -44
src/stack.rs
··· 7 7 use std::collections::VecDeque; 8 8 use std::fmt::Display; 9 9 use std::io::{self, BufRead, BufReader, Seek, Write}; 10 + use std::str::FromStr; 10 11 use std::time::{Duration, SystemTime, UNIX_EPOCH}; 11 12 use std::{fs::File, path::PathBuf}; 12 13 ··· 17 18 const TASKSFOLDER: &str = "tasks"; 18 19 const INDEXFILE: &str = "index"; 19 20 20 - pub(crate) struct StackItem { 21 + pub struct StackItem { 21 22 pub id: Id, 22 23 pub title: String, 23 24 pub modify_time: SystemTime, ··· 58 59 )) 59 60 } 60 61 61 - impl StackItem { 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"); 62 + impl 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"); 66 67 let id: Id = parts 67 68 .next() 68 69 .ok_or(Error::Parse(format!( 69 70 "Incomplete index line. Missing tsk ID" 70 71 )))? 71 72 .parse()?; 72 - let mut title: String = parts 73 + let title: String = parts 73 74 .next() 74 75 .ok_or(Error::Parse(format!( 75 76 "Incomplete index line. Missing title." ··· 81 82 // get a usable system time from the UNIX epoch, defaulting to the UNIX_EPOCH if there's 82 83 // any failures. This means that if there's errors, we will always read the title and 83 84 // modify_time from the task file. 84 - let mut modify_time = UNIX_EPOCH 85 + let modify_time = UNIX_EPOCH 85 86 .checked_add(Duration::from_secs(index_epoch)) 86 87 .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(); 88 + Ok(Self { 89 + id, 90 + title, 91 + modify_time, 92 + }) 93 + } 94 + } 95 + 96 + impl 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 + 91 102 let task = util::flopen( 92 - workspace_path.join(TASKSFOLDER).join(id.to_string()), 103 + workspace_path 104 + .join(TASKSFOLDER) 105 + .join(stack_item.id.to_filename()), 93 106 FlockArg::LockExclusive, 94 107 )?; 95 108 let task_modify_time = task.metadata()?.modified()?; 96 109 // if the task file has been modified since we last looked at it, re-read the title and 97 110 // metadata 98 - if modify_epoch > index_epoch { 99 - title.clear(); 100 - BufReader::new(&*task).read_line(&mut title)?; 101 - modify_time = task_modify_time; 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; 102 115 } 103 - Ok(Self { 104 - id, 105 - title, 106 - modify_time, 107 - }) 116 + Ok(stack_item) 108 117 } 109 118 } 110 119 ··· 114 123 file: Flock<File>, 115 124 } 116 125 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 - 126 126 impl TaskStack { 127 - pub fn from_tskdir(workspace_path: &PathBuf, count: Option<usize>) -> Result<Self> { 127 + pub fn from_tskdir(workspace_path: &PathBuf) -> Result<Self> { 128 128 let file = util::flopen(workspace_path.join(INDEXFILE), FlockArg::LockExclusive)?; 129 129 let index = BufReader::new(&*file).lines(); 130 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 - }; 131 + for line in index { 132 + let stack_item = StackItem::from_line(workspace_path, line?)?; 133 + all.push_back(stack_item); 134 + } 143 135 Ok(Self { all, file }) 144 136 } 145 137 ··· 175 167 self.all.is_empty() 176 168 } 177 169 } 170 + 171 + impl 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 + }
+25 -1
src/util.rs
··· 1 1 use crate::errors::{Error, Result}; 2 + use std::fs; 3 + use std::os::unix::fs::MetadataExt; 2 4 use std::{ 3 5 fs::{File, OpenOptions}, 4 - path::PathBuf, 6 + path::{Path, PathBuf}, 5 7 }; 6 8 7 9 use nix::fcntl::{Flock, FlockArg}; ··· 14 16 .open(path)?; 15 17 Ok(Flock::lock(file, mode).map_err(|(_, errno)| Error::Lock(errno))?) 16 18 } 19 + 20 + /// Recursively searches upwards for a directory 21 + pub fn find_parent_with_dir( 22 + dir: PathBuf, 23 + searching_for: impl AsRef<Path>, 24 + ) -> Result<Option<PathBuf>> { 25 + // Create a new pathbuf to modify, we slap a segment onto the end but then pop it off right 26 + // away 27 + let mut d = dir.join(&searching_for); 28 + while d.pop() { 29 + let check = d.join(&searching_for); 30 + eprintln!("Searching {check:?}"); 31 + if check.exists() { 32 + if fs::metadata(&check)?.dev() != fs::metadata(&dir)?.dev() { 33 + // we hit a filesystem boundary 34 + return Ok(None); 35 + } 36 + return Ok(Some(check)); 37 + } 38 + } 39 + Ok(None) 40 + }
+41 -21
src/workspace.rs
··· 3 3 4 4 use crate::errors::{Error, Result}; 5 5 use crate::stack::TaskStack; 6 - use crate::util; 6 + use crate::{fzf, util}; 7 7 use std::fmt::Display; 8 - use std::fs::File; 8 + use std::fs::{self, File}; 9 9 use std::io::{BufRead as _, BufReader, Read, Seek, SeekFrom}; 10 10 use std::path::PathBuf; 11 11 use std::str::FromStr; ··· 14 14 const INDEXFILE: &str = "index"; 15 15 const TITLECACHEFILE: &str = "cache"; 16 16 /// A unique identifier for a task. When referenced in text, it is prefixed with `tsk-`. 17 + #[derive(Clone, Copy, Debug, Eq, PartialEq)] 17 18 pub struct Id(u32); 18 19 19 20 impl FromStr for Id { ··· 40 41 } 41 42 42 43 impl Id { 43 - pub fn to_string(&self) -> String { 44 + pub fn to_filename(&self) -> String { 44 45 format!("tsk-{}.tsk", self.0) 45 46 } 46 47 } ··· 60 61 std::fs::create_dir(&tsk_dir)?; 61 62 // Create the tasks directory 62 63 std::fs::create_dir(&tsk_dir.join("tasks"))?; 64 + // Create the archive directory 65 + std::fs::create_dir(&tsk_dir.join("archive"))?; 63 66 let mut next = OpenOptions::new() 64 67 .read(true) 65 68 .write(true) ··· 70 73 } 71 74 72 75 pub fn from_path(path: PathBuf) -> Result<Self> { 73 - // TODO: recursively walk up the path until we find a .tsk dir or error if we can't find 74 - // one / cross a filesystem boundary 75 - let tsk_dir = path.join(".tsk"); 76 - if !tsk_dir.exists() { 77 - return Err(Error::Uninitialized); 78 - } else { 79 - Ok(Self { path: tsk_dir }) 80 - } 76 + let tsk_dir = util::find_parent_with_dir(path, ".tsk")?.ok_or(Error::Uninitialized)?; 77 + Ok(Self { path: tsk_dir }) 81 78 } 82 79 83 80 pub fn next_id(&self) -> Result<Id> { ··· 87 84 let id = buf.trim().parse::<u32>()?; 88 85 // reset the files contents 89 86 file.set_len(0)?; 90 - // TODO: figure out if this is necessary 91 87 file.seek(SeekFrom::Start(0))?; 92 88 // store the *next* if 93 89 file.write_all(format!("{}\n", id + 1).as_bytes())?; ··· 95 91 } 96 92 97 93 pub fn new_task(&self, title: String, body: String) -> Result<Task> { 98 - // TODO: we could improperly increment the id if the task is not written to disk/errors 94 + // WARN: we could improperly increment the id if the task is not written to disk/errors. 95 + // But who cares 99 96 let id = self.next_id()?; 100 - let mut file = util::flopen( 101 - self.path.join("tasks").join(format!("tsk-{}.tsk", id.0)), 102 - FlockArg::LockExclusive, 103 - )?; 97 + let task_path = self.path.join("tasks").join(format!("tsk-{}.tsk", id.0)); 98 + let mut file = util::flopen(task_path.clone(), FlockArg::LockExclusive)?; 104 99 file.write_all(format!("{title}\n\n{body}").as_bytes())?; 100 + // create a hardlink to the archive dir 101 + fs::hard_link( 102 + task_path, 103 + self.path.join("archive").join(format!("tsk-{}.tsk", id.0)), 104 + )?; 105 105 Ok(Task { 106 106 id, 107 107 title, ··· 129 129 }) 130 130 } 131 131 132 - pub fn read_stack(&self, count: Option<usize>) -> Result<TaskStack> { 133 - TaskStack::from_tskdir(&self.path, count) 132 + pub fn read_stack(&self) -> Result<TaskStack> { 133 + TaskStack::from_tskdir(&self.path) 134 134 } 135 135 136 136 pub fn push_task(&self, task: Task) -> Result<()> { 137 - let mut stack = TaskStack::from_tskdir(&self.path, None)?; 137 + let mut stack = TaskStack::from_tskdir(&self.path)?; 138 138 stack.push(task.try_into()?); 139 139 stack.save()?; 140 140 Ok(()) 141 141 } 142 142 143 143 pub fn swap_top(&self) -> Result<()> { 144 - let mut stack = TaskStack::from_tskdir(&self.path, None)?; 144 + let mut stack = TaskStack::from_tskdir(&self.path)?; 145 145 stack.swap(); 146 146 stack.save()?; 147 147 Ok(()) 148 + } 149 + 150 + pub fn drop(&self) -> Result<Option<Id>> { 151 + let mut stack = self.read_stack()?; 152 + if let Some(stack_item) = stack.pop() { 153 + let task_path = self 154 + .path 155 + .join("tasks") 156 + .join(format!("{}.tsk", stack_item.id)); 157 + fs::remove_file(task_path)?; 158 + stack.save()?; 159 + Ok(Some(stack_item.id)) 160 + } else { 161 + Ok(None) 162 + } 163 + } 164 + 165 + pub fn search(&self) -> Result<Option<Id>> { 166 + let stack = self.read_stack()?; 167 + Ok(fzf::select(stack)?.map(|si| si.id)) 148 168 } 149 169 } 150 170