···11+use crate::errors::{Error, Result};
22+use std::fmt::Display;
33+use std::io::Write;
44+use std::process::{Command, Stdio};
55+use std::str::FromStr;
66+77+/// Sends each item as a line to stdin to the `fzf` command and returns the selected item's string
88+/// representation as output
99+pub fn select<I>(input: impl IntoIterator<Item = I>) -> Result<Option<I>>
1010+where
1111+ I: Display + FromStr,
1212+ Error: From<<I as FromStr>::Err>,
1313+{
1414+ let mut child = Command::new("fzf")
1515+ .args(["-d", "\t"])
1616+ .stderr(Stdio::inherit())
1717+ .stdin(Stdio::piped())
1818+ .stdout(Stdio::piped())
1919+ .spawn()?;
2020+ // unwrap: this can never fail
2121+ let child_in = child.stdin.as_mut().unwrap();
2222+ for item in input.into_iter() {
2323+ write!(child_in, "{}\n", item.to_string())?;
2424+ }
2525+ let output = child.wait_with_output()?;
2626+ if output.stdout.is_empty() {
2727+ Ok(None)
2828+ } else {
2929+ Ok(Some(String::from_utf8(output.stdout)?.parse()?))
3030+ }
3131+}
+74-19
src/main.rs
···11mod errors;
22+mod fzf;
23mod stack;
34mod util;
45mod workspace;
···67use std::io;
78use std::path::PathBuf;
89use std::{env::current_dir, io::Read};
99-use workspace::Workspace;
1010+use workspace::{Id, Workspace};
10111112//use smol;
1213//use iocraft::prelude::*;
1313-use clap::{Args, CommandFactory, Parser, Subcommand};
1414+use clap::{value_parser, Args, CommandFactory, Parser, Subcommand};
1415use edit::edit as open_editor;
15161617fn default_dir() -> PathBuf {
···30313132#[derive(Subcommand)]
3233enum Commands {
3434+ /// Initializes a .tsk workspace in the current effective directory, which defaults to PWD.
3335 Init,
3436 /// Creates a new task, automatically assigning it a unique identifider and persisting
3537 Push {
···5658 count: usize,
5759 },
58606161+ /// Swaps the top two tasks on the stack. If there are less than 2 tasks on the stack, there is
6262+ /// no effect.
5963 Swap,
60646565+ /// Open up an editor to modify the task with the given ID.
6166 Edit {
6262- #[arg(short = 't')]
6363- task_id: Option<u32>,
6767+ #[command(flatten)]
6868+ task_id: TaskId,
6469 },
65707171+ /// Generates completion for a given shell.
6672 Completion {
6773 #[arg(short = 's')]
6874 shell: Shell,
6975 },
7070- /*
7171- Drop {
7272- #[arg(short = 't')]
7373- task_id: Option<u32>,
7474- }
7575- */
7676+7777+ /// Use fuzzy finding with `fzf` to search for a task
7878+ Find {
7979+ /// Include the contents of tasks in the search criteria.
8080+ #[arg(short = 'b', default_value_t = false)]
8181+ search_body: bool,
8282+ /// Include archived tasks in the search criteria. Combine with `-b` to include archived
8383+ /// bodies in the search criteria.
8484+ #[arg(short = 'a', default_value_t = false)]
8585+ search_archived: bool,
8686+ },
8787+8888+ /// Drops the task on the top of the stack and archives it.
8989+ Drop,
7690}
77917892#[derive(Args)]
7979-#[group(required = true, multiple = false)]
9393+#[group(required = false, multiple = false)]
8094struct Title {
8195 /// The title of the task. This is useful for when you also wish to specify the body of the
8296 /// task as an argument (ie. with -b).
···87101 title_simple: Option<Vec<String>>,
88102}
89103104104+#[derive(Args)]
105105+#[group(required = true, multiple = false)]
106106+struct TaskId {
107107+ #[arg(short = 't', value_name = "ID")]
108108+ id: Option<u32>,
109109+110110+ #[arg(short = 'T', value_name = "TSK-ID", value_parser = value_parser!(String))]
111111+ tsk_id: Option<Id>,
112112+}
113113+90114fn main() {
91115 let cli = Cli::parse();
92116 match cli.command {
···98122 Commands::Swap => command_swap(cli.dir.unwrap_or(default_dir())),
99123 Commands::Edit { task_id } => command_edit(cli.dir.unwrap_or(default_dir()), task_id),
100124 Commands::Completion { shell } => command_completion(shell),
125125+ Commands::Drop => command_drop(cli.dir.unwrap_or(default_dir())),
126126+ Commands::Find {
127127+ search_body,
128128+ search_archived,
129129+ } => command_search(cli.dir.unwrap_or(default_dir())),
101130 }
102131}
103132···142171fn command_list(dir: PathBuf, all: bool, count: usize) {
143172 let workspace = Workspace::from_path(dir).expect("Unable to find .tsk dir");
144173 let stack = if all {
145145- workspace.read_stack(None).expect("Failed to read index")
174174+ workspace.read_stack().expect("Failed to read index")
146175 } else {
147147- workspace
148148- .read_stack(Some(count))
149149- .expect("Failed to read index")
176176+ workspace.read_stack().expect("Failed to read index")
150177 };
151178 if stack.empty() {
152179 println!("*No tasks*");
153180 } else {
154154- println!("{}", stack);
181181+ if !all {
182182+ for stack_item in stack.into_iter().take(count) {
183183+ println!("{stack_item}");
184184+ }
185185+ } else {
186186+ for stack_item in stack.into_iter() {
187187+ println!("{stack_item}");
188188+ }
189189+ }
155190 }
156191}
157192···160195 workspace.swap_top().expect("swap to work");
161196}
162197163163-fn command_edit(dir: PathBuf, id: Option<u32>) {
198198+fn command_edit(dir: PathBuf, id: TaskId) {
164199 let workspace = Workspace::from_path(dir).expect("Unable to find .tsk dir");
165165- let mut task = if let Some(id) = id {
200200+ let tsk_id: Option<Id> = id.id.map(Id::from).or(id.tsk_id);
201201+ let mut task = if let Some(id) = tsk_id {
166202 workspace.task(id.into()).expect("To read task from disk")
167203 } else {
168168- let mut stack = workspace.read_stack(Some(1)).expect("to read stack");
204204+ let mut stack = workspace.read_stack().expect("to read stack");
169205 let stack_item = stack.pop().expect("No tasks on stack.");
170206 workspace.task(stack_item.id).expect("couldn't read task")
171207 };
···181217fn command_completion(shell: Shell) {
182218 generate(shell, &mut Cli::command(), "tsk", &mut io::stdout())
183219}
220220+221221+fn command_drop(dir: PathBuf) {
222222+ if let Some(id) = Workspace::from_path(dir)
223223+ .expect("Unable to find .tsk dir")
224224+ .drop()
225225+ .expect("Unable to drop task.") {
226226+ println!("Dropped {id}")
227227+ }
228228+}
229229+230230+fn command_search(dir: PathBuf) {
231231+ let id = Workspace::from_path(dir).unwrap().search().unwrap();
232232+ if let Some(id) = id {
233233+ eprint!("Dropping ");
234234+ println!("{id}");
235235+ } else {
236236+ eprintln!("No task to drop.")
237237+ }
238238+}
+46-44
src/stack.rs
···77use std::collections::VecDeque;
88use std::fmt::Display;
99use std::io::{self, BufRead, BufReader, Seek, Write};
1010+use std::str::FromStr;
1011use std::time::{Duration, SystemTime, UNIX_EPOCH};
1112use std::{fs::File, path::PathBuf};
1213···1718const TASKSFOLDER: &str = "tasks";
1819const INDEXFILE: &str = "index";
19202020-pub(crate) struct StackItem {
2121+pub struct StackItem {
2122 pub id: Id,
2223 pub title: String,
2324 pub modify_time: SystemTime,
···5859 ))
5960}
60616161-impl StackItem {
6262- /// Parses a [`StackItem`] from a string. The expected format is a tab-delimited line with the
6363- /// files: task id title
6464- fn from_line(workspace_path: &PathBuf, line: String) -> Result<Self> {
6565- let mut parts = line.split("\t");
6262+impl FromStr for StackItem {
6363+ type Err = Error;
6464+6565+ fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
6666+ let mut parts = s.trim().split("\t");
6667 let id: Id = parts
6768 .next()
6869 .ok_or(Error::Parse(format!(
6970 "Incomplete index line. Missing tsk ID"
7071 )))?
7172 .parse()?;
7272- let mut title: String = parts
7373+ let title: String = parts
7374 .next()
7475 .ok_or(Error::Parse(format!(
7576 "Incomplete index line. Missing title."
···8182 // get a usable system time from the UNIX epoch, defaulting to the UNIX_EPOCH if there's
8283 // any failures. This means that if there's errors, we will always read the title and
8384 // modify_time from the task file.
8484- let mut modify_time = UNIX_EPOCH
8585+ let modify_time = UNIX_EPOCH
8586 .checked_add(Duration::from_secs(index_epoch))
8687 .unwrap_or(UNIX_EPOCH);
8787- let modify_epoch = modify_time
8888- .duration_since(UNIX_EPOCH)
8989- .expect("We're before the dawn of time!?")
9090- .as_secs();
8888+ Ok(Self {
8989+ id,
9090+ title,
9191+ modify_time,
9292+ })
9393+ }
9494+}
9595+9696+impl StackItem {
9797+ /// Parses a [`StackItem`] from a string. The expected format is a tab-delimited line with the
9898+ /// files: task id title
9999+ fn from_line(workspace_path: &PathBuf, line: String) -> Result<Self> {
100100+ let mut stack_item: StackItem = line.parse()?;
101101+91102 let task = util::flopen(
9292- workspace_path.join(TASKSFOLDER).join(id.to_string()),
103103+ workspace_path
104104+ .join(TASKSFOLDER)
105105+ .join(stack_item.id.to_filename()),
93106 FlockArg::LockExclusive,
94107 )?;
95108 let task_modify_time = task.metadata()?.modified()?;
96109 // if the task file has been modified since we last looked at it, re-read the title and
97110 // metadata
9898- if modify_epoch > index_epoch {
9999- title.clear();
100100- BufReader::new(&*task).read_line(&mut title)?;
101101- modify_time = task_modify_time;
111111+ if (task_modify_time - Duration::from_secs(1)) > stack_item.modify_time {
112112+ stack_item.title.clear();
113113+ BufReader::new(&*task).read_line(&mut stack_item.title)?;
114114+ stack_item.modify_time = task_modify_time;
102115 }
103103- Ok(Self {
104104- id,
105105- title,
106106- modify_time,
107107- })
116116+ Ok(stack_item)
108117 }
109118}
110119···114123 file: Flock<File>,
115124}
116125117117-impl Display for TaskStack {
118118- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119119- for task in self.all.iter() {
120120- write!(f, "{task}\n")?;
121121- }
122122- Ok(())
123123- }
124124-}
125125-126126impl TaskStack {
127127- pub fn from_tskdir(workspace_path: &PathBuf, count: Option<usize>) -> Result<Self> {
127127+ pub fn from_tskdir(workspace_path: &PathBuf) -> Result<Self> {
128128 let file = util::flopen(workspace_path.join(INDEXFILE), FlockArg::LockExclusive)?;
129129 let index = BufReader::new(&*file).lines();
130130 let mut all = VecDeque::new();
131131- if let Some(count) = count {
132132- for line in index.take(count) {
133133- let line = line?;
134134- let stack_item = StackItem::from_line(workspace_path, line)?;
135135- all.push_back(stack_item);
136136- }
137137- } else {
138138- for line in index {
139139- let stack_item = StackItem::from_line(workspace_path, line?)?;
140140- all.push_back(stack_item);
141141- }
142142- };
131131+ for line in index {
132132+ let stack_item = StackItem::from_line(workspace_path, line?)?;
133133+ all.push_back(stack_item);
134134+ }
143135 Ok(Self { all, file })
144136 }
145137···175167 self.all.is_empty()
176168 }
177169}
170170+171171+impl IntoIterator for TaskStack {
172172+ type Item = StackItem;
173173+174174+ type IntoIter = std::collections::vec_deque::IntoIter<Self::Item>;
175175+176176+ fn into_iter(self) -> Self::IntoIter {
177177+ self.all.into_iter()
178178+ }
179179+}
+25-1
src/util.rs
···11use crate::errors::{Error, Result};
22+use std::fs;
33+use std::os::unix::fs::MetadataExt;
24use std::{
35 fs::{File, OpenOptions},
44- path::PathBuf,
66+ path::{Path, PathBuf},
57};
6879use nix::fcntl::{Flock, FlockArg};
···1416 .open(path)?;
1517 Ok(Flock::lock(file, mode).map_err(|(_, errno)| Error::Lock(errno))?)
1618}
1919+2020+/// Recursively searches upwards for a directory
2121+pub fn find_parent_with_dir(
2222+ dir: PathBuf,
2323+ searching_for: impl AsRef<Path>,
2424+) -> Result<Option<PathBuf>> {
2525+ // Create a new pathbuf to modify, we slap a segment onto the end but then pop it off right
2626+ // away
2727+ let mut d = dir.join(&searching_for);
2828+ while d.pop() {
2929+ let check = d.join(&searching_for);
3030+ eprintln!("Searching {check:?}");
3131+ if check.exists() {
3232+ if fs::metadata(&check)?.dev() != fs::metadata(&dir)?.dev() {
3333+ // we hit a filesystem boundary
3434+ return Ok(None);
3535+ }
3636+ return Ok(Some(check));
3737+ }
3838+ }
3939+ Ok(None)
4040+}
+41-21
src/workspace.rs
···3344use crate::errors::{Error, Result};
55use crate::stack::TaskStack;
66-use crate::util;
66+use crate::{fzf, util};
77use std::fmt::Display;
88-use std::fs::File;
88+use std::fs::{self, File};
99use std::io::{BufRead as _, BufReader, Read, Seek, SeekFrom};
1010use std::path::PathBuf;
1111use std::str::FromStr;
···1414const INDEXFILE: &str = "index";
1515const TITLECACHEFILE: &str = "cache";
1616/// A unique identifier for a task. When referenced in text, it is prefixed with `tsk-`.
1717+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1718pub struct Id(u32);
18191920impl FromStr for Id {
···4041}
41424243impl Id {
4343- pub fn to_string(&self) -> String {
4444+ pub fn to_filename(&self) -> String {
4445 format!("tsk-{}.tsk", self.0)
4546 }
4647}
···6061 std::fs::create_dir(&tsk_dir)?;
6162 // Create the tasks directory
6263 std::fs::create_dir(&tsk_dir.join("tasks"))?;
6464+ // Create the archive directory
6565+ std::fs::create_dir(&tsk_dir.join("archive"))?;
6366 let mut next = OpenOptions::new()
6467 .read(true)
6568 .write(true)
···7073 }
71747275 pub fn from_path(path: PathBuf) -> Result<Self> {
7373- // TODO: recursively walk up the path until we find a .tsk dir or error if we can't find
7474- // one / cross a filesystem boundary
7575- let tsk_dir = path.join(".tsk");
7676- if !tsk_dir.exists() {
7777- return Err(Error::Uninitialized);
7878- } else {
7979- Ok(Self { path: tsk_dir })
8080- }
7676+ let tsk_dir = util::find_parent_with_dir(path, ".tsk")?.ok_or(Error::Uninitialized)?;
7777+ Ok(Self { path: tsk_dir })
8178 }
82798380 pub fn next_id(&self) -> Result<Id> {
···8784 let id = buf.trim().parse::<u32>()?;
8885 // reset the files contents
8986 file.set_len(0)?;
9090- // TODO: figure out if this is necessary
9187 file.seek(SeekFrom::Start(0))?;
9288 // store the *next* if
9389 file.write_all(format!("{}\n", id + 1).as_bytes())?;
···9591 }
96929793 pub fn new_task(&self, title: String, body: String) -> Result<Task> {
9898- // TODO: we could improperly increment the id if the task is not written to disk/errors
9494+ // WARN: we could improperly increment the id if the task is not written to disk/errors.
9595+ // But who cares
9996 let id = self.next_id()?;
100100- let mut file = util::flopen(
101101- self.path.join("tasks").join(format!("tsk-{}.tsk", id.0)),
102102- FlockArg::LockExclusive,
103103- )?;
9797+ let task_path = self.path.join("tasks").join(format!("tsk-{}.tsk", id.0));
9898+ let mut file = util::flopen(task_path.clone(), FlockArg::LockExclusive)?;
10499 file.write_all(format!("{title}\n\n{body}").as_bytes())?;
100100+ // create a hardlink to the archive dir
101101+ fs::hard_link(
102102+ task_path,
103103+ self.path.join("archive").join(format!("tsk-{}.tsk", id.0)),
104104+ )?;
105105 Ok(Task {
106106 id,
107107 title,
···129129 })
130130 }
131131132132- pub fn read_stack(&self, count: Option<usize>) -> Result<TaskStack> {
133133- TaskStack::from_tskdir(&self.path, count)
132132+ pub fn read_stack(&self) -> Result<TaskStack> {
133133+ TaskStack::from_tskdir(&self.path)
134134 }
135135136136 pub fn push_task(&self, task: Task) -> Result<()> {
137137- let mut stack = TaskStack::from_tskdir(&self.path, None)?;
137137+ let mut stack = TaskStack::from_tskdir(&self.path)?;
138138 stack.push(task.try_into()?);
139139 stack.save()?;
140140 Ok(())
141141 }
142142143143 pub fn swap_top(&self) -> Result<()> {
144144- let mut stack = TaskStack::from_tskdir(&self.path, None)?;
144144+ let mut stack = TaskStack::from_tskdir(&self.path)?;
145145 stack.swap();
146146 stack.save()?;
147147 Ok(())
148148+ }
149149+150150+ pub fn drop(&self) -> Result<Option<Id>> {
151151+ let mut stack = self.read_stack()?;
152152+ if let Some(stack_item) = stack.pop() {
153153+ let task_path = self
154154+ .path
155155+ .join("tasks")
156156+ .join(format!("{}.tsk", stack_item.id));
157157+ fs::remove_file(task_path)?;
158158+ stack.save()?;
159159+ Ok(Some(stack_item.id))
160160+ } else {
161161+ Ok(None)
162162+ }
163163+ }
164164+165165+ pub fn search(&self) -> Result<Option<Id>> {
166166+ let stack = self.read_stack()?;
167167+ Ok(fzf::select(stack)?.map(|si| si.id))
148168 }
149169}
150170