···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
···1mod errors;
02mod stack;
3mod util;
4mod workspace;
···6use std::io;
7use std::path::PathBuf;
8use std::{env::current_dir, io::Read};
9-use workspace::Workspace;
1011//use smol;
12//use iocraft::prelude::*;
13-use clap::{Args, CommandFactory, Parser, Subcommand};
14use edit::edit as open_editor;
1516fn default_dir() -> PathBuf {
···3031#[derive(Subcommand)]
32enum Commands {
033 Init,
34 /// Creates a new task, automatically assigning it a unique identifider and persisting
35 Push {
···56 count: usize,
57 },
580059 Swap,
60061 Edit {
62- #[arg(short = 't')]
63- task_id: Option<u32>,
64 },
65066 Completion {
67 #[arg(short = 's')]
68 shell: Shell,
69 },
70- /*
71- Drop {
72- #[arg(short = 't')]
73- task_id: Option<u32>,
74- }
75- */
0000000076}
7778#[derive(Args)]
79-#[group(required = true, multiple = false)]
80struct Title {
81 /// The title of the task. This is useful for when you also wish to specify the body of the
82 /// task as an argument (ie. with -b).
···87 title_simple: Option<Vec<String>>,
88}
89000000000090fn main() {
91 let cli = Cli::parse();
92 match cli.command {
···98 Commands::Swap => command_swap(cli.dir.unwrap_or(default_dir())),
99 Commands::Edit { task_id } => command_edit(cli.dir.unwrap_or(default_dir()), task_id),
100 Commands::Completion { shell } => command_completion(shell),
00000101 }
102}
103···142fn command_list(dir: PathBuf, all: bool, count: usize) {
143 let workspace = Workspace::from_path(dir).expect("Unable to find .tsk dir");
144 let stack = if all {
145- workspace.read_stack(None).expect("Failed to read index")
146 } else {
147- workspace
148- .read_stack(Some(count))
149- .expect("Failed to read index")
150 };
151 if stack.empty() {
152 println!("*No tasks*");
153 } else {
154- println!("{}", stack);
00000000155 }
156}
157···160 workspace.swap_top().expect("swap to work");
161}
162163-fn command_edit(dir: PathBuf, id: Option<u32>) {
164 let workspace = Workspace::from_path(dir).expect("Unable to find .tsk dir");
165- let mut task = if let Some(id) = id {
0166 workspace.task(id.into()).expect("To read task from disk")
167 } else {
168- let mut stack = workspace.read_stack(Some(1)).expect("to read stack");
169 let stack_item = stack.pop().expect("No tasks on stack.");
170 workspace.task(stack_item.id).expect("couldn't read task")
171 };
···181fn command_completion(shell: Shell) {
182 generate(shell, &mut Cli::command(), "tsk", &mut io::stdout())
183}
0000000000000000000
···1mod errors;
2+mod fzf;
3mod stack;
4mod util;
5mod workspace;
···7use std::io;
8use std::path::PathBuf;
9use std::{env::current_dir, io::Read};
10+use workspace::{Id, Workspace};
1112//use smol;
13//use iocraft::prelude::*;
14+use clap::{value_parser, Args, CommandFactory, Parser, Subcommand};
15use edit::edit as open_editor;
1617fn default_dir() -> PathBuf {
···3132#[derive(Subcommand)]
33enum Commands {
34+ /// Initializes a .tsk workspace in the current effective directory, which defaults to PWD.
35 Init,
36 /// Creates a new task, automatically assigning it a unique identifider and persisting
37 Push {
···58 count: usize,
59 },
6061+ /// Swaps the top two tasks on the stack. If there are less than 2 tasks on the stack, there is
62+ /// no effect.
63 Swap,
6465+ /// Open up an editor to modify the task with the given ID.
66 Edit {
67+ #[command(flatten)]
68+ task_id: TaskId,
69 },
7071+ /// Generates completion for a given shell.
72 Completion {
73 #[arg(short = 's')]
74 shell: Shell,
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,
90}
9192#[derive(Args)]
93+#[group(required = false, multiple = false)]
94struct Title {
95 /// The title of the task. This is useful for when you also wish to specify the body of the
96 /// task as an argument (ie. with -b).
···101 title_simple: Option<Vec<String>>,
102}
103104+#[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+114fn main() {
115 let cli = Cli::parse();
116 match cli.command {
···122 Commands::Swap => command_swap(cli.dir.unwrap_or(default_dir())),
123 Commands::Edit { task_id } => command_edit(cli.dir.unwrap_or(default_dir()), task_id),
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())),
130 }
131}
132···171fn command_list(dir: PathBuf, all: bool, count: usize) {
172 let workspace = Workspace::from_path(dir).expect("Unable to find .tsk dir");
173 let stack = if all {
174+ workspace.read_stack().expect("Failed to read index")
175 } else {
176+ workspace.read_stack().expect("Failed to read index")
00177 };
178 if stack.empty() {
179 println!("*No tasks*");
180 } else {
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+ }
190 }
191}
192···195 workspace.swap_top().expect("swap to work");
196}
197198+fn command_edit(dir: PathBuf, id: TaskId) {
199 let workspace = Workspace::from_path(dir).expect("Unable to find .tsk dir");
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 {
202 workspace.task(id.into()).expect("To read task from disk")
203 } else {
204+ let mut stack = workspace.read_stack().expect("to read stack");
205 let stack_item = stack.pop().expect("No tasks on stack.");
206 workspace.task(stack_item.id).expect("couldn't read task")
207 };
···217fn command_completion(shell: Shell) {
218 generate(shell, &mut Cli::command(), "tsk", &mut io::stdout())
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
···7use std::collections::VecDeque;
8use std::fmt::Display;
9use std::io::{self, BufRead, BufReader, Seek, Write};
010use std::time::{Duration, SystemTime, UNIX_EPOCH};
11use std::{fs::File, path::PathBuf};
12···17const TASKSFOLDER: &str = "tasks";
18const INDEXFILE: &str = "index";
1920-pub(crate) struct StackItem {
21 pub id: Id,
22 pub title: String,
23 pub modify_time: SystemTime,
···58 ))
59}
6061-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");
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."
···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();
000000000091 let task = util::flopen(
92- workspace_path.join(TASKSFOLDER).join(id.to_string()),
0093 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;
102 }
103- Ok(Self {
104- id,
105- title,
106- modify_time,
107- })
108 }
109}
110···114 file: Flock<File>,
115}
116117-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-126impl TaskStack {
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···175 self.all.is_empty()
176 }
177}
0000000000
···7use std::collections::VecDeque;
8use std::fmt::Display;
9use std::io::{self, BufRead, BufReader, Seek, Write};
10+use std::str::FromStr;
11use std::time::{Duration, SystemTime, UNIX_EPOCH};
12use std::{fs::File, path::PathBuf};
13···18const TASKSFOLDER: &str = "tasks";
19const INDEXFILE: &str = "index";
2021+pub struct StackItem {
22 pub id: Id,
23 pub title: String,
24 pub modify_time: SystemTime,
···59 ))
60}
6162+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");
67 let id: Id = parts
68 .next()
69 .ok_or(Error::Parse(format!(
70 "Incomplete index line. Missing tsk ID"
71 )))?
72 .parse()?;
73+ let title: String = parts
74 .next()
75 .ok_or(Error::Parse(format!(
76 "Incomplete index line. Missing title."
···82 // get a usable system time from the UNIX epoch, defaulting to the UNIX_EPOCH if there's
83 // any failures. This means that if there's errors, we will always read the title and
84 // modify_time from the task file.
85+ let modify_time = UNIX_EPOCH
86 .checked_add(Duration::from_secs(index_epoch))
87 .unwrap_or(UNIX_EPOCH);
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+102 let task = util::flopen(
103+ workspace_path
104+ .join(TASKSFOLDER)
105+ .join(stack_item.id.to_filename()),
106 FlockArg::LockExclusive,
107 )?;
108 let task_modify_time = task.metadata()?.modified()?;
109 // if the task file has been modified since we last looked at it, re-read the title and
110 // metadata
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;
115 }
116+ Ok(stack_item)
0000117 }
118}
119···123 file: Flock<File>,
124}
125000000000126impl TaskStack {
127+ pub fn from_tskdir(workspace_path: &PathBuf) -> 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+ for line in index {
132+ let stack_item = StackItem::from_line(workspace_path, line?)?;
133+ all.push_back(stack_item);
134+ }
00000000135 Ok(Self { all, file })
136 }
137···167 self.all.is_empty()
168 }
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+}