A file-based task manager
1#![allow(dead_code)]
2//! The Task stack. Tasks created with `push` end up at the top here. It is invalid for a task that
3//! has been completed/archived to be on the stack.
4
5use crate::errors::{Error, Result};
6use crate::util;
7use std::collections::VecDeque;
8use std::fmt::Display;
9use std::io::{self, BufRead, BufReader, Seek, Write};
10use std::str::FromStr;
11use std::time::{Duration, SystemTime, UNIX_EPOCH};
12use std::{fs::File, path::PathBuf};
13
14use nix::fcntl::{Flock, FlockArg};
15
16use crate::workspace::{Id, Task};
17
18const TASKSFOLDER: &str = "tasks";
19const INDEXFILE: &str = "index";
20
21pub struct StackItem {
22 pub id: Id,
23 pub title: String,
24 pub modify_time: SystemTime,
25}
26
27impl Display for StackItem {
28 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29 // .trim is used here on the title because there may be a newline in here if we read the
30 // title from the task file.
31 write!(
32 f,
33 // NOTE: we do NOT print the access time.
34 "{}\t{}",
35 self.id,
36 self.title.trim(),
37 )
38 }
39}
40
41impl TryFrom<Task> for StackItem {
42 type Error = Error;
43
44 fn try_from(value: Task) -> std::result::Result<Self, Self::Error> {
45 let modify_time = value.file.metadata()?.modified()?;
46 Ok(Self {
47 id: value.id,
48 // replace tabs with spaces, they're not valid in StackItem titles.
49 title: value.title.replace("\t", " "),
50 modify_time,
51 })
52 }
53}
54
55fn eof() -> Error {
56 Error::Io(io::Error::new(
57 io::ErrorKind::UnexpectedEof,
58 "Unexpected end of file",
59 ))
60}
61
62impl 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."
77 )))?
78 .trim()
79 .to_string();
80 // parse the timestamp as an integer
81 let index_epoch: u64 = parts.next().unwrap_or("0").parse()?;
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
96impl 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)
117 }
118}
119
120pub struct TaskStack {
121 /// All items within the stack
122 all: VecDeque<StackItem>,
123 file: Flock<File>,
124}
125
126impl 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 }
135 Ok(Self { all, file })
136 }
137
138 /// Saves the task stack to disk.
139 pub fn save(mut self) -> Result<()> {
140 // Clear the file
141 self.file.seek(std::io::SeekFrom::Start(0))?;
142 self.file.set_len(0)?;
143 for item in self.all.iter() {
144 self.file.write_all(format!("{item}\n").as_bytes())?;
145 }
146 Ok(())
147 }
148
149 pub fn push(&mut self, item: StackItem) {
150 self.all.push_front(item);
151 }
152
153 pub fn pop(&mut self) -> Option<StackItem> {
154 self.all.pop_front()
155 }
156
157 pub fn swap(&mut self) {
158 let tip = self.all.pop_front();
159 let second = self.all.pop_front();
160 if tip.is_some() && second.is_some() {
161 self.all.push_front(tip.unwrap());
162 self.all.push_front(second.unwrap());
163 }
164 }
165
166 pub fn empty(&self) -> bool {
167 self.all.is_empty()
168 }
169}
170
171impl 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}