vault_tasks_core/
task.rs

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/// A task's state
16/// Ordering is `Todo < Done`
17#[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)]
42/// This type accounts for the case where the task has a due date but no exact due time
43pub 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}