1use chrono::{NaiveDate, NaiveDateTime};
2use color_eyre::{eyre::bail, Result};
3use core::fmt;
4use std::{
5 cmp::Ordering,
6 fmt::Display,
7 fs::{read_to_string, File},
8 io::Write,
9 path::PathBuf,
10};
11use tracing::{debug, info};
12
13use crate::{PrettySymbolsConfig, TasksConfig};
14
15#[derive(Debug, Hash, Eq, PartialEq, Clone, PartialOrd, Ord)]
18pub enum State {
19 ToDo,
20 Done,
21 Incomplete,
22 Canceled,
23}
24impl State {
25 pub fn display(&self, state_symbols: PrettySymbolsConfig) -> String {
26 match self {
27 Self::Done => state_symbols.task_done,
28 Self::ToDo => state_symbols.task_todo,
29 Self::Incomplete => state_symbols.task_incomplete,
30 Self::Canceled => state_symbols.task_canceled,
31 }
32 }
33}
34impl Display for State {
35 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36 let default_symbols = PrettySymbolsConfig::default();
37 write!(f, "{}", self.display(default_symbols))?;
38 Ok(())
39 }
40}
41#[derive(Debug, Hash, Eq, PartialEq, Clone)]
42pub enum DueDate {
44 NoDate,
45 Day(NaiveDate),
46 DayTime(NaiveDateTime),
47}
48impl Display for DueDate {
49 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50 match self {
51 Self::Day(date) => write!(f, "{date}"),
52 Self::DayTime(date) => write!(f, "{date}"),
53 Self::NoDate => Ok(()),
54 }
55 }
56}
57
58impl DueDate {
59 #[must_use]
60 pub fn to_display_format(&self, due_date_symbol: String, not_american_format: bool) -> String {
61 if matches!(self, Self::NoDate) {
62 String::new()
63 } else {
64 format!(
65 "{due_date_symbol} {}",
66 self.to_string_format(not_american_format)
67 )
68 }
69 }
70 #[must_use]
71 pub fn to_string_format(&self, not_american_format: bool) -> String {
72 let format_date = if not_american_format {
73 "%d/%m/%Y"
74 } else {
75 "%Y/%m/%d"
76 };
77 let format_datetime = if not_american_format {
78 "%d/%m/%Y %T"
79 } else {
80 "%Y/%m/%d %T"
81 };
82
83 match self {
84 Self::Day(date) => date.format(format_date).to_string(),
85 Self::DayTime(date) => date.format(format_datetime).to_string(),
86 Self::NoDate => String::new(),
87 }
88 }
89
90 #[must_use]
91 pub fn get_relative_str(&self) -> Option<String> {
92 let now = chrono::Local::now();
93 let time_delta = match self {
94 Self::NoDate => return None,
95 Self::Day(date) => now.date_naive().signed_duration_since(*date),
96 Self::DayTime(date_time) => {
97 now.date_naive().signed_duration_since(date_time.date())
98 + now.time().signed_duration_since(date_time.time())
99 }
100 };
101
102 let (prefix, suffix) = match time_delta.num_seconds().cmp(&0) {
103 Ordering::Less => (String::from("in "), String::new()),
104 Ordering::Equal => (String::new(), String::new()),
105 Ordering::Greater => (String::new(), String::from(" ago")),
106 };
107
108 let time_delta_abs = time_delta.abs();
109
110 if time_delta_abs.is_zero() {
111 return Some(String::from("today"));
112 }
113 if time_delta.num_seconds() < 0 && time_delta_abs.num_days() == 1 {
114 return Some(String::from("tomorrow"));
115 }
116 if time_delta.num_seconds() > 0 && time_delta_abs.num_days() == 1 {
117 return Some(String::from("yesterday"));
118 }
119
120 let res = if 4 * 12 * 2 <= time_delta_abs.num_weeks() {
121 format!("{} years", time_delta_abs.num_weeks() / (12 * 4))
122 } else if 5 <= time_delta_abs.num_weeks() {
123 format!("{} months", time_delta_abs.num_weeks() / 4)
124 } else if 2 <= time_delta_abs.num_weeks() {
125 format!("{} weeks", time_delta_abs.num_weeks())
126 } else if 2 <= time_delta_abs.num_days() {
127 format!("{} days", time_delta_abs.num_days())
128 } else {
129 format!("{} hours", time_delta_abs.num_hours())
130 };
131 Some(format!("{prefix}{res}{suffix}"))
132 }
133}
134
135#[derive(Debug, Hash, Eq, PartialEq, Clone)]
136pub struct Task {
137 pub subtasks: Vec<Task>,
138 pub description: Option<String>,
139 pub due_date: DueDate,
140 pub filename: String,
141 pub line_number: usize,
142 pub name: String,
143 pub priority: usize,
144 pub state: State,
145 pub tags: Option<Vec<String>>,
146 pub is_today: bool,
147}
148
149impl Default for Task {
150 fn default() -> Self {
151 Self {
152 due_date: DueDate::NoDate,
153 name: String::new(),
154 priority: 0,
155 state: State::ToDo,
156 tags: None,
157 description: None,
158 line_number: 1,
159 subtasks: vec![],
160 filename: String::new(),
161 is_today: false,
162 }
163 }
164}
165
166impl fmt::Display for Task {
167 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
168 let default_symbols = PrettySymbolsConfig::default();
169 let state = self.state.to_string();
170 let title = format!("{state} {}", self.name);
171 writeln!(f, "{title}")?;
172
173 let mut data_line = String::new();
174 let is_today = if self.is_today {
175 format!("{} ", default_symbols.today_tag)
176 } else {
177 String::new()
178 };
179 data_line.push_str(&is_today);
180 let due_date_str = self.due_date.to_string();
181
182 if !due_date_str.is_empty() {
183 data_line.push_str(&format!(
184 "{} {due_date_str} ({})",
185 default_symbols.due_date,
186 self.due_date.get_relative_str().unwrap_or_default()
187 ));
188 }
189 if self.priority > 0 {
190 data_line.push_str(&format!("{}{} ", default_symbols.priority, self.priority));
191 }
192 if !data_line.is_empty() {
193 writeln!(f, "{data_line}")?;
194 }
195 let mut tag_line = String::new();
196 if self.tags.is_some() {
197 tag_line.push_str(
198 &self
199 .tags
200 .clone()
201 .unwrap()
202 .iter()
203 .map(|t| format!("#{t}"))
204 .collect::<Vec<String>>()
205 .join(" "),
206 );
207 }
208 if !tag_line.is_empty() {
209 writeln!(f, "{tag_line}")?;
210 }
211 if let Some(description) = self.description.clone() {
212 for l in description.lines() {
213 writeln!(f, "{l}")?;
214 }
215 }
216 Ok(())
217 }
218}
219impl Task {
220 pub fn get_fixed_attributes(&self, config: &TasksConfig, indent_length: usize) -> String {
221 let indent = " ".repeat(indent_length);
222
223 let state_str = match self.state {
224 State::Done => config.task_state_markers.done,
225 State::ToDo => config.task_state_markers.todo,
226 State::Incomplete => config.task_state_markers.incomplete,
227 State::Canceled => config.task_state_markers.canceled,
228 };
229
230 let priority = if self.priority > 0 {
231 format!("p{} ", self.priority)
232 } else {
233 String::new()
234 };
235
236 let mut due_date = self.due_date.to_string_format(!config.use_american_format);
237 if !due_date.is_empty() {
238 due_date.push(' ');
239 }
240
241 let tags_str = self.tags.as_ref().map_or_else(String::new, |tags| {
242 tags.clone()
243 .iter()
244 .map(|t| format!("#{t}"))
245 .collect::<Vec<String>>()
246 .join(" ")
247 });
248
249 let today_tag = if self.is_today {
250 String::from(" @today")
251 } else {
252 String::new()
253 };
254
255 let res = format!(
256 "{}- [{}] {} {}{}{}{}",
257 indent, state_str, self.name, due_date, priority, tags_str, today_tag
258 );
259 res.trim_end().to_string()
260 }
261
262 pub fn fix_task_attributes(&self, config: &TasksConfig, path: &PathBuf) -> Result<()> {
263 let content = read_to_string(path.clone())?;
264 let mut lines = content.split('\n').collect::<Vec<&str>>();
265
266 if lines.len() < self.line_number - 1 {
267 bail!(
268 "Task's line number {} was greater than length of file {:?}",
269 self.line_number,
270 path
271 );
272 }
273
274 let indent_length = lines[self.line_number - 1]
275 .chars()
276 .take_while(|c| c.is_whitespace())
277 .count();
278
279 let fixed_line = self.get_fixed_attributes(config, indent_length);
280
281 if lines[self.line_number - 1] != fixed_line {
282 debug!(
283 "\nReplacing\n{}\nWith\n{}\n",
284 lines[self.line_number - 1],
285 self.get_fixed_attributes(config, indent_length,)
286 );
287 lines[self.line_number - 1] = &fixed_line;
288
289 let mut file = File::create(path)?;
290 file.write_all(lines.join("\n").as_bytes())?;
291
292 info!("Wrote to {path:?} at line {}", self.line_number);
293 }
294 Ok(())
295 }
296}
297
298#[cfg(test)]
299mod tests_tasks {
300 use chrono::NaiveDate;
301 use pretty_assertions::assert_eq;
302
303 use crate::{
304 task::{DueDate, State, Task},
305 TasksConfig,
306 };
307
308 #[test]
309 fn test_fix_attributes() {
310 let config = TasksConfig {
311 use_american_format: true,
312 ..Default::default()
313 };
314 let task = Task {
315 due_date: DueDate::Day(NaiveDate::from_ymd_opt(2021, 12, 3).unwrap()),
316 name: String::from("Test Task"),
317 priority: 1,
318 state: State::ToDo,
319 tags: Some(vec![String::from("tag1"), String::from("tag2")]),
320 description: Some(String::from("This is a test task.")),
321 line_number: 2,
322 ..Default::default()
323 };
324 let res = task.get_fixed_attributes(&config, 0);
325 assert_eq!(res, "- [ ] Test Task 2021/12/03 p1 #tag1 #tag2");
326 }
327
328 #[test]
329 fn test_fix_attributes_with_no_date() {
330 let config = TasksConfig {
331 ..Default::default()
332 };
333 let task = Task {
334 due_date: DueDate::NoDate,
335 name: String::from("Test Task with No Date"),
336 priority: 2,
337 state: State::Done,
338 tags: Some(vec![String::from("tag3")]),
339 description: None,
340 line_number: 3,
341 ..Default::default()
342 };
343
344 let res = task.get_fixed_attributes(&config, 0);
345 assert_eq!(res, "- [x] Test Task with No Date p2 #tag3");
346 }
347 #[test]
348 fn test_fix_attributes_with_today_tag() {
349 let config = TasksConfig {
350 ..Default::default()
351 };
352 let task = Task {
353 due_date: DueDate::NoDate,
354 name: String::from("Test Task with Today tag"),
355 priority: 2,
356 state: State::Done,
357 tags: Some(vec![String::from("tag3")]),
358 description: None,
359 line_number: 3,
360 is_today: true,
361 ..Default::default()
362 };
363
364 let res = task.get_fixed_attributes(&config, 0);
365 assert_eq!(res, "- [x] Test Task with Today tag p2 #tag3 @today");
366 }
367}
368#[cfg(test)]
369mod tests_due_date {
370 use chrono::TimeDelta;
371
372 use crate::task::DueDate;
373
374 #[test]
375 fn test_relative_date() {
376 let now = chrono::Local::now();
377
378 let tests = vec![
379 (-1, "yesterday"),
380 (0, "today"),
381 (1, "tomorrow"),
382 (7, "in 7 days"),
383 (17, "in 2 weeks"),
384 (65, "in 2 months"),
385 (800, "in 2 years"),
386 ];
387 for (days, res) in tests {
388 let due_date = DueDate::Day(
389 now.checked_add_signed(TimeDelta::days(days))
390 .unwrap()
391 .date_naive(),
392 );
393 assert_eq!(due_date.get_relative_str(), Some(String::from(res)));
394 }
395 }
396}