A file-based task manager
1#![allow(dead_code)]
2use nix::fcntl::{Flock, FlockArg};
3
4use crate::errors::{Error, Result};
5use crate::stack::TaskStack;
6use crate::{fzf, util};
7use std::fmt::Display;
8use std::fs::{self, File};
9use std::io::{BufRead as _, BufReader, Read, Seek, SeekFrom};
10use std::path::PathBuf;
11use std::str::FromStr;
12use std::{fs::OpenOptions, io::Write};
13
14const INDEXFILE: &str = "index";
15const TITLECACHEFILE: &str = "cache";
16/// A unique identifier for a task. When referenced in text, it is prefixed with `tsk-`.
17#[derive(Clone, Copy, Debug, Eq, PartialEq)]
18pub struct Id(pub u32);
19
20impl FromStr for Id {
21 type Err = Error;
22
23 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
24 let s = s
25 .strip_prefix("tsk-")
26 .ok_or(Self::Err::Parse(format!("expected tsk- prefix. Got {s}")))?;
27 Ok(Self(s.parse()?))
28 }
29}
30
31impl Display for Id {
32 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33 write!(f, "tsk-{}", self.0)
34 }
35}
36
37impl From<u32> for Id {
38 fn from(value: u32) -> Self {
39 Id(value)
40 }
41}
42
43impl Id {
44 pub fn to_filename(&self) -> String {
45 format!("tsk-{}.tsk", self.0)
46 }
47}
48
49pub enum TaskIdentifier {
50 Id(Id),
51 Relative(u32),
52 Find,
53}
54
55impl From<Id> for TaskIdentifier {
56 fn from(value: Id) -> Self {
57 TaskIdentifier::Id(value)
58 }
59}
60
61pub struct Workspace {
62 /// The path to the workspace root, excluding the .tsk directory. This should *contain* the
63 /// .tsk directory.
64 path: PathBuf,
65}
66
67impl Workspace {
68 pub fn init(path: PathBuf) -> Result<()> {
69 // TODO: detect if in a git repo and add .tsk/ to `.git/info/exclude`
70 let tsk_dir = path.join(".tsk");
71 if tsk_dir.exists() {
72 return Err(Error::AlreadyInitialized);
73 }
74 std::fs::create_dir(&tsk_dir)?;
75 // Create the tasks directory
76 std::fs::create_dir(&tsk_dir.join("tasks"))?;
77 // Create the archive directory
78 std::fs::create_dir(&tsk_dir.join("archive"))?;
79 let mut next = OpenOptions::new()
80 .read(true)
81 .write(true)
82 .create(true)
83 .open(tsk_dir.join("next"))?;
84 next.write_all(b"1\n")?;
85 Ok(())
86 }
87
88 pub fn from_path(path: PathBuf) -> Result<Self> {
89 let tsk_dir = util::find_parent_with_dir(path, ".tsk")?.ok_or(Error::Uninitialized)?;
90 Ok(Self { path: tsk_dir })
91 }
92
93 fn resolve(&self, identifier: TaskIdentifier) -> Result<Id> {
94 match identifier {
95 TaskIdentifier::Id(id) => Ok(id),
96 TaskIdentifier::Relative(r) => {
97 let stack = self.read_stack()?;
98 let stack_item = stack.get(r as usize).ok_or(Error::NoTasks)?;
99 Ok(stack_item.id)
100 }
101 TaskIdentifier::Find => self.search(None, false, false)?.ok_or(Error::NotSelected),
102 }
103 }
104
105 pub fn next_id(&self) -> Result<Id> {
106 let mut file = util::flopen(self.path.join("next"), FlockArg::LockExclusive)?;
107 let mut buf = String::new();
108 file.read_to_string(&mut buf)?;
109 let id = buf.trim().parse::<u32>()?;
110 // reset the files contents
111 file.set_len(0)?;
112 file.seek(SeekFrom::Start(0))?;
113 // store the *next* if
114 file.write_all(format!("{}\n", id + 1).as_bytes())?;
115 Ok(Id(id))
116 }
117
118 pub fn new_task(&self, title: String, body: String) -> Result<Task> {
119 // WARN: we could improperly increment the id if the task is not written to disk/errors.
120 // But who cares
121 let id = self.next_id()?;
122 let task_path = self.path.join("tasks").join(format!("tsk-{}.tsk", id.0));
123 let mut file = util::flopen(task_path.clone(), FlockArg::LockExclusive)?;
124 file.write_all(format!("{title}\n\n{body}").as_bytes())?;
125 // create a hardlink to the archive dir
126 fs::hard_link(
127 task_path,
128 self.path.join("archive").join(format!("tsk-{}.tsk", id.0)),
129 )?;
130 Ok(Task {
131 id,
132 title,
133 body,
134 file,
135 })
136 }
137
138 pub fn task(&self, identifier: TaskIdentifier) -> Result<Task> {
139 let id = self.resolve(identifier)?;
140
141 let file = util::flopen(
142 self.path.join("tasks").join(format!("tsk-{}.tsk", id.0)),
143 FlockArg::LockExclusive,
144 )?;
145 let mut title = String::new();
146 let mut body = String::new();
147 let mut reader = BufReader::new(&*file);
148 reader.read_line(&mut title)?;
149 reader.read_to_string(&mut body)?;
150 drop(reader);
151 Ok(Task {
152 id,
153 title,
154 body,
155 file,
156 })
157 }
158
159 pub fn read_stack(&self) -> Result<TaskStack> {
160 TaskStack::from_tskdir(&self.path)
161 }
162
163 pub fn push_task(&self, task: Task) -> Result<()> {
164 let mut stack = TaskStack::from_tskdir(&self.path)?;
165 stack.push(task.try_into()?);
166 stack.save()?;
167 Ok(())
168 }
169
170 pub fn swap_top(&self) -> Result<()> {
171 let mut stack = TaskStack::from_tskdir(&self.path)?;
172 stack.swap();
173 stack.save()?;
174 Ok(())
175 }
176
177 pub fn rot(&self) -> Result<()> {
178 let mut stack = TaskStack::from_tskdir(&self.path)?;
179 let top = stack.pop();
180 let second = stack.pop();
181 let third = stack.pop();
182
183 if top.is_none() || second.is_none() || third.is_none() {
184 return Ok(());
185 }
186
187 stack.push(second.unwrap());
188 stack.push(top.unwrap());
189 stack.push(third.unwrap());
190 stack.save()?;
191 Ok(())
192 }
193
194 /// The inverse of tor. Pushes the top item behind the second item, shifting #2 and #3 to #1
195 /// and #2 respectively.
196 pub fn tor(&self) -> Result<()> {
197 let mut stack = TaskStack::from_tskdir(&self.path)?;
198 let top = stack.pop();
199 let second = stack.pop();
200 let third = stack.pop();
201
202 if top.is_none() || second.is_none() || third.is_none() {
203 return Ok(());
204 }
205
206 stack.push(top.unwrap());
207 stack.push(third.unwrap());
208 stack.push(second.unwrap());
209 stack.save()?;
210 Ok(())
211 }
212
213 pub fn drop(&self) -> Result<Option<Id>> {
214 let mut stack = self.read_stack()?;
215 if let Some(stack_item) = stack.pop() {
216 let task_path = self
217 .path
218 .join("tasks")
219 .join(format!("{}.tsk", stack_item.id));
220 fs::remove_file(task_path)?;
221 stack.save()?;
222 Ok(Some(stack_item.id))
223 } else {
224 Ok(None)
225 }
226 }
227
228 pub fn search(
229 &self,
230 stack: Option<TaskStack>,
231 _search_body: bool,
232 _include_archived: bool,
233 ) -> Result<Option<Id>> {
234 let stack = if let Some(stack) = stack {
235 stack
236 } else {
237 self.read_stack()?
238 };
239 Ok(fzf::select(stack)?.map(|si| si.id))
240 }
241
242 pub fn reprioritize(&self, identifier: TaskIdentifier) -> Result<()> {
243 let id = self.resolve(identifier)?;
244 let mut stack = self.read_stack()?;
245 let index = &stack.iter().map(|i| i.id).position(|i| i == id);
246 if let Some(index) = index {
247 let prioritized_task = stack.remove(*index);
248 // unwrap here is safe because we just searched for the index and know it exists
249 stack.push(prioritized_task.unwrap());
250 stack.save()?;
251 }
252 Ok(())
253 }
254}
255
256pub struct Task {
257 pub id: Id,
258 pub title: String,
259 pub body: String,
260 pub file: Flock<File>,
261}
262
263impl Task {
264 /// Consumes a task and saves it to disk.
265 pub fn save(mut self) -> Result<()> {
266 self.file.set_len(0)?;
267 self.file.seek(SeekFrom::Start(0))?;
268 self.file.write_all(self.title.trim().as_bytes())?;
269 self.file.write_all(b"\n\n")?;
270 self.file.write_all(self.body.trim().as_bytes())?;
271 Ok(())
272 }
273}