A file-based task manager
1#![allow(dead_code)]
2use nix::fcntl::{Flock, FlockArg};
3use xattr::FileExt;
4
5use crate::attrs::Attrs;
6use crate::errors::{Error, Result};
7use crate::stack::{StackItem, TaskStack};
8use crate::{fzf, util};
9use std::collections::{vec_deque, BTreeMap};
10use std::ffi::OsString;
11use std::fmt::Display;
12use std::fs::File;
13use std::io::{BufRead as _, BufReader, Read, Seek, SeekFrom};
14use std::ops::Deref;
15use std::os::unix::fs::symlink;
16use std::path::PathBuf;
17use std::str::FromStr;
18use std::{fs::OpenOptions, io::Write};
19
20const INDEXFILE: &str = "index";
21const TITLECACHEFILE: &str = "cache";
22const XATTRPREFIX: &str = "user.tsk.";
23/// A unique identifier for a task. When referenced in text, it is prefixed with `tsk-`.
24#[derive(Clone, Copy, Debug, Eq, PartialEq)]
25pub struct Id(pub u32);
26
27impl FromStr for Id {
28 type Err = Error;
29
30 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
31 let s = s
32 .strip_prefix("tsk-")
33 .ok_or(Self::Err::Parse(format!("expected tsk- prefix. Got {s}")))?;
34 Ok(Self(s.parse()?))
35 }
36}
37
38impl Display for Id {
39 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40 write!(f, "tsk-{}", self.0)
41 }
42}
43
44impl From<u32> for Id {
45 fn from(value: u32) -> Self {
46 Id(value)
47 }
48}
49
50impl Id {
51 pub fn to_filename(&self) -> String {
52 format!("tsk-{}.tsk", self.0)
53 }
54}
55
56pub enum TaskIdentifier {
57 Id(Id),
58 Relative(u32),
59 Find { search_body: bool, archived: bool },
60}
61
62impl From<Id> for TaskIdentifier {
63 fn from(value: Id) -> Self {
64 TaskIdentifier::Id(value)
65 }
66}
67
68pub struct Workspace {
69 /// The path to the workspace root, excluding the .tsk directory. This should *contain* the
70 /// .tsk directory.
71 path: PathBuf,
72}
73
74impl Workspace {
75 pub fn init(path: PathBuf) -> Result<()> {
76 // TODO: detect if in a git repo and add .tsk/ to `.git/info/exclude`
77 let tsk_dir = path.join(".tsk");
78 if tsk_dir.exists() {
79 return Err(Error::AlreadyInitialized);
80 }
81 std::fs::create_dir(&tsk_dir)?;
82 // Create the tasks directory
83 std::fs::create_dir(&tsk_dir.join("tasks"))?;
84 // Create the archive directory
85 std::fs::create_dir(&tsk_dir.join("archive"))?;
86 let mut next = OpenOptions::new()
87 .read(true)
88 .write(true)
89 .create(true)
90 .open(tsk_dir.join("next"))?;
91 next.write_all(b"1\n")?;
92 Ok(())
93 }
94
95 pub fn from_path(path: PathBuf) -> Result<Self> {
96 let tsk_dir = util::find_parent_with_dir(path, ".tsk")?.ok_or(Error::Uninitialized)?;
97 Ok(Self { path: tsk_dir })
98 }
99
100 fn resolve(&self, identifier: TaskIdentifier) -> Result<Id> {
101 match identifier {
102 TaskIdentifier::Id(id) => Ok(id),
103 TaskIdentifier::Relative(r) => {
104 let stack = self.read_stack()?;
105 let stack_item = stack.get(r as usize).ok_or(Error::NoTasks)?;
106 Ok(stack_item.id)
107 }
108 TaskIdentifier::Find {
109 search_body,
110 archived,
111 } => self
112 .search(None, search_body, archived)?
113 .ok_or(Error::NotSelected),
114 }
115 }
116
117 /// Increments the `next` counter and returns the previous value.
118 pub fn next_id(&self) -> Result<Id> {
119 let mut file = util::flopen(self.path.join("next"), FlockArg::LockExclusive)?;
120 let mut buf = String::new();
121 file.read_to_string(&mut buf)?;
122 let id = buf.trim().parse::<u32>()?;
123 // reset the files contents
124 file.set_len(0)?;
125 file.seek(SeekFrom::Start(0))?;
126 // store the *next* if
127 file.write_all(format!("{}\n", id + 1).as_bytes())?;
128 Ok(Id(id))
129 }
130
131 pub fn new_task(&self, title: String, body: String) -> Result<Task> {
132 // WARN: we could improperly increment the id if the task is not written to disk/errors.
133 // But who cares
134 let id = self.next_id()?;
135 let task_name = format!("tsk-{}.tsk", id.0);
136 // the task goes in the archive first
137 let task_path = self.path.join("archive").join(&task_name);
138 let mut file = util::flopen(task_path.clone(), FlockArg::LockExclusive)?;
139 file.write_all(format!("{title}\n\n{body}").as_bytes())?;
140 // create a hardlink to the task dir to mark it as "open"
141 symlink(
142 PathBuf::from("../archive").join(&task_name),
143 self.path.join("tasks").join(task_name),
144 )?;
145 Ok(Task {
146 id,
147 title,
148 body,
149 file,
150 attributes: Default::default(),
151 })
152 }
153
154 pub fn task(&self, identifier: TaskIdentifier) -> Result<Task> {
155 let id = self.resolve(identifier)?;
156
157 let file = util::flopen(
158 self.path.join("tasks").join(format!("tsk-{}.tsk", id.0)),
159 FlockArg::LockExclusive,
160 )?;
161 let mut title = String::new();
162 let mut body = String::new();
163 let mut reader = BufReader::new(&*file);
164 reader.read_line(&mut title)?;
165 reader.read_to_string(&mut body)?;
166 drop(reader);
167 let mut read_attributes = BTreeMap::new();
168 if let Ok(attrs) = file.list_xattr() {
169 for attr in attrs {
170 if let Some((key, value)) = Self::read_xattr(&file, attr) {
171 read_attributes.insert(key, value);
172 }
173 }
174 }
175 Ok(Task {
176 id,
177 file,
178 title: title.trim().to_string(),
179 body: body.trim().to_string(),
180 attributes: Attrs::from_written(read_attributes),
181 })
182 }
183
184 /// Reads an xattr from a file, stripping the prefix for
185 fn read_xattr<D: Deref<Target = File>>(file: &D, key: OsString) -> Option<(String, String)> {
186 // this *shouldn't* allocate, but it does O(n) scan the str for UTF-8 correctness
187 let parsedkey = key.as_os_str().to_str()?.strip_prefix(XATTRPREFIX)?;
188 let valuebytes = file.get_xattr(&key).ok().flatten()?;
189 Some((parsedkey.to_string(), String::from_utf8(valuebytes).ok()?))
190 }
191
192 pub fn read_stack(&self) -> Result<TaskStack> {
193 TaskStack::from_tskdir(&self.path)
194 }
195
196 pub fn push_task(&self, task: Task) -> Result<()> {
197 let mut stack = TaskStack::from_tskdir(&self.path)?;
198 stack.push(task.try_into()?);
199 stack.save()?;
200 Ok(())
201 }
202
203 pub fn swap_top(&self) -> Result<()> {
204 let mut stack = TaskStack::from_tskdir(&self.path)?;
205 stack.swap();
206 stack.save()?;
207 Ok(())
208 }
209
210 pub fn rot(&self) -> Result<()> {
211 let mut stack = TaskStack::from_tskdir(&self.path)?;
212 let top = stack.pop();
213 let second = stack.pop();
214 let third = stack.pop();
215
216 if top.is_none() || second.is_none() || third.is_none() {
217 return Ok(());
218 }
219
220 // unwrap is ok here because we checked above
221 stack.push(second.unwrap());
222 stack.push(top.unwrap());
223 stack.push(third.unwrap());
224 stack.save()?;
225 Ok(())
226 }
227
228 /// The inverse of tor. Pushes the top item behind the second item, shifting #2 and #3 to #1
229 /// and #2 respectively.
230 pub fn tor(&self) -> Result<()> {
231 let mut stack = TaskStack::from_tskdir(&self.path)?;
232 let top = stack.pop();
233 let second = stack.pop();
234 let third = stack.pop();
235
236 if top.is_none() || second.is_none() || third.is_none() {
237 return Ok(());
238 }
239
240 stack.push(top.unwrap());
241 stack.push(third.unwrap());
242 stack.push(second.unwrap());
243 stack.save()?;
244 Ok(())
245 }
246
247 pub fn drop(&self, identifier: TaskIdentifier) -> Result<Option<Id>> {
248 let id = self.resolve(identifier)?;
249 let mut stack = self.read_stack()?;
250 let index = &stack.iter().map(|i| i.id).position(|i| i == id);
251 let task = if let Some(index) = index {
252 let prioritized_task = stack.remove(*index);
253 stack.save()?;
254 prioritized_task.map(|t| t.id)
255 } else {
256 None
257 };
258 Ok(task)
259 }
260
261 pub fn search(
262 &self,
263 stack: Option<TaskStack>,
264 search_body: bool,
265 _include_archived: bool,
266 ) -> Result<Option<Id>> {
267 let stack = if let Some(stack) = stack {
268 stack
269 } else {
270 self.read_stack()?
271 };
272 if search_body {
273 let loader = LazyTaskLoader {
274 files: stack.into_iter(),
275 workspace: self,
276 };
277 // search the entirety of a task
278 Ok(fzf::select(loader)?.map(|bt| bt.id))
279 } else {
280 // just search the stack
281 Ok(fzf::select(stack)?.map(|si| si.id))
282 }
283 }
284
285 pub fn prioritize(&self, identifier: TaskIdentifier) -> Result<()> {
286 let id = self.resolve(identifier)?;
287 let mut stack = self.read_stack()?;
288 let index = &stack.iter().map(|i| i.id).position(|i| i == id);
289 if let Some(index) = index {
290 let prioritized_task = stack.remove(*index);
291 // unwrap here is safe because we just searched for the index and know it exists
292 stack.push(prioritized_task.unwrap());
293 stack.save()?;
294 }
295 Ok(())
296 }
297
298 pub fn deprioritize(&self, identifier: TaskIdentifier) -> Result<()> {
299 let id = self.resolve(identifier)?;
300 let mut stack = self.read_stack()?;
301 let index = &stack.iter().map(|i| i.id).position(|i| i == id);
302 if let Some(index) = index {
303 let deprioritized_task = stack.remove(*index);
304 // unwrap here is safe because we just searched for the index and know it exists
305 stack.push_back(deprioritized_task.unwrap());
306 stack.save()?;
307 }
308 Ok(())
309 }
310}
311
312pub struct Task {
313 pub id: Id,
314 pub title: String,
315 pub body: String,
316 pub file: Flock<File>,
317 pub attributes: Attrs,
318}
319
320impl Display for Task {
321 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
322 write!(f, "{}\n\n{}", self.title, &self.body)
323 }
324}
325
326impl Task {
327 /// Consumes a task and saves it to disk.
328 pub fn save(mut self) -> Result<()> {
329 self.file.set_len(0)?;
330 self.file.seek(SeekFrom::Start(0))?;
331 self.file.write_all(self.title.trim().as_bytes())?;
332 self.file.write_all(b"\n\n")?;
333 self.file.write_all(self.body.trim().as_bytes())?;
334 Ok(())
335 }
336
337 fn bare(self) -> SearchTask {
338 SearchTask {
339 id: self.id,
340 title: self.title,
341 body: self.body,
342 }
343 }
344}
345
346/// A task container without a file handle
347pub struct SearchTask {
348 pub id: Id,
349 pub title: String,
350 pub body: String,
351}
352
353impl FromStr for SearchTask {
354 type Err = Error;
355
356 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
357 let (tsk_id, task_content) = s.split_once('\t').ok_or(Error::Parse(format!(
358 "Missing TSK-ID or content or task parse."
359 )))?;
360 let (title, body) = task_content
361 .split_once('\t')
362 .ok_or(Error::Parse(format!("Missing body for task parse.")))?;
363 Ok(Self {
364 id: tsk_id.parse()?,
365 title: title.to_string(),
366 body: body.to_string(),
367 })
368 }
369}
370
371impl Display for SearchTask {
372 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
373 write!(
374 f,
375 "{}\t{}\t{}",
376 self.id,
377 self.title.trim(),
378 self.body.replace('\n', " ").replace('\r', "")
379 )
380 }
381}
382
383struct LazyTaskLoader<'a> {
384 files: vec_deque::IntoIter<StackItem>,
385 workspace: &'a Workspace,
386}
387
388impl<'a> Iterator for LazyTaskLoader<'a> {
389 type Item = SearchTask;
390
391 fn next(&mut self) -> Option<Self::Item> {
392 let stack_item = self.files.next()?;
393 let task = self
394 .workspace
395 .task(TaskIdentifier::Id(stack_item.id))
396 .ok()?;
397 Some(task.bare())
398 }
399}
400
401#[cfg(test)]
402mod test {
403 use super::*;
404
405 #[test]
406 fn test_bare_task_display() {
407 let task = SearchTask {
408 id: Id(123),
409 title: "Hello, world".to_string(),
410 body: "The body of the task.\nAnother line\r\nis here.".to_string(),
411 };
412 assert_eq!(
413 "tsk-123\tHello, world\tThe body of the task. Another line is here.",
414 task.to_string()
415 );
416 }
417
418 #[test]
419 fn test_task_display() {
420 let task = Task {
421 id: Id(123),
422 title: "Hello, world".to_string(),
423 body: "The body of the task.".to_string(),
424 file: util::flopen("/dev/null".into(), FlockArg::LockShared).unwrap(),
425 attributes: Default::default(),
426 };
427 assert_eq!("Hello, world\n\nThe body of the task.", task.to_string());
428 }
429}