Skip to main content

vault_tasks_core/
task.rs

1use color_eyre::{Result, eyre::bail};
2use core::fmt;
3use std::fmt::Write;
4use std::{
5    cmp::Ordering,
6    fmt::Display,
7    fs::{File, read_to_string},
8    path::PathBuf,
9};
10use tracing::{debug, info};
11
12use crate::TasksConfig;
13use crate::config::{CoreConfig, PrettySymbolsConfig};
14
15use super::date::Date;
16
17/// A task's state
18///
19/// Ordering is `Todo < Done`
20#[derive(Debug, Hash, Eq, PartialEq, Clone)]
21pub enum State {
22    ToDo,
23    Done,
24    Incomplete,
25    Canceled,
26}
27
28impl Ord for State {
29    fn cmp(&self, other: &Self) -> Ordering {
30        match (self, other) {
31            (State::ToDo, State::ToDo)
32            | (State::Done, State::Done)
33            | (State::Canceled, State::Canceled)
34            | (State::Incomplete, State::Incomplete) => Ordering::Equal,
35            (State::Canceled | State::Done, State::ToDo)
36            | (State::ToDo | State::Done | State::Canceled, State::Incomplete)
37            | (State::Done, State::Canceled) => Ordering::Greater,
38            (State::ToDo, State::Done | State::Canceled)
39            | (State::Incomplete, State::ToDo | State::Done | State::Canceled)
40            | (State::Canceled, State::Done) => Ordering::Less,
41        }
42    }
43}
44
45impl PartialOrd for State {
46    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
47        Some(self.cmp(other))
48    }
49}
50
51impl State {
52    pub fn display(&self, state_symbols: &PrettySymbolsConfig) -> String {
53        match self {
54            Self::Done => state_symbols.task_done.clone(),
55            Self::ToDo => state_symbols.task_todo.clone(),
56            Self::Incomplete => state_symbols.task_incomplete.clone(),
57            Self::Canceled => state_symbols.task_canceled.clone(),
58        }
59    }
60}
61impl Display for State {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        let default_symbols = TasksConfig::default().pretty_symbols.clone();
64        write!(f, "{}", self.display(&default_symbols))?;
65        Ok(())
66    }
67}
68
69#[derive(Debug, Hash, Eq, PartialEq, Clone)]
70pub struct Task {
71    pub subtasks: Vec<Task>,
72    pub description: Option<String>,
73    /// None means the task has no associated due date
74    pub due_date: Option<Date>,
75    pub path: PathBuf,
76    /// Line number in the file, if None then the task was not
77    /// parsed from the file but added from CLI, it should be
78    /// appended at the end.
79    pub line_number: Option<usize>,
80    pub name: String,
81    pub priority: usize,
82    pub completion: Option<usize>,
83    pub state: State,
84    pub tags: Option<Vec<String>>,
85    pub is_today: bool,
86}
87
88impl Default for Task {
89    fn default() -> Self {
90        Self {
91            due_date: None,
92            name: String::new(),
93            priority: 0,
94            state: State::ToDo,
95            tags: None,
96            description: None,
97            line_number: None,
98            subtasks: vec![],
99            path: PathBuf::new(),
100            is_today: false,
101            completion: None,
102        }
103    }
104}
105
106impl fmt::Display for Task {
107    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108        let default_symbols = PrettySymbolsConfig::default(); // TODO: that's bad, shouldn't use this default since it's empty!
109        let state = self.state.to_string();
110        let title = format!("{state} {}", self.name);
111        writeln!(f, "{title}")?;
112
113        let mut data_line = String::new();
114        let is_today = if self.is_today {
115            format!("{} ", default_symbols.today_tag)
116        } else {
117            String::new()
118        };
119        data_line.push_str(&is_today);
120        if let Some(date) = &self.due_date {
121            write!(
122                data_line,
123                "{} {} ({}) ",
124                default_symbols.due_date,
125                date,
126                date.get_relative_str()
127            )?;
128        }
129
130        if self.priority > 0 {
131            write!(data_line, "{}{} ", default_symbols.priority, self.priority)?;
132        }
133        if let Some(bar) = self.completion_bar_to_string(5, &default_symbols) {
134            data_line.push_str(&bar);
135        }
136        if !data_line.is_empty() {
137            writeln!(f, "{data_line}")?;
138        }
139        let mut tag_line = String::new();
140        if self.tags.is_some() {
141            tag_line.push_str(
142                &self
143                    .tags
144                    .clone()
145                    .unwrap()
146                    .iter()
147                    .map(|t| format!("#{t}"))
148                    .collect::<Vec<String>>()
149                    .join(" "),
150            );
151        }
152        if !tag_line.is_empty() {
153            writeln!(f, "{tag_line}")?;
154        }
155        if let Some(description) = self.description.clone() {
156            for l in description.lines() {
157                writeln!(f, "{l}")?;
158            }
159        }
160        Ok(())
161    }
162}
163impl Task {
164    #[must_use]
165    pub fn priority_to_string(&self, symbols: &PrettySymbolsConfig) -> String {
166        if self.priority > 0 {
167            format!("{}{}", symbols.priority, self.priority)
168        } else {
169            String::new()
170        }
171    }
172    #[must_use]
173    pub fn due_date_to_string(
174        &self,
175        symbols: &PrettySymbolsConfig,
176        core_config: &CoreConfig,
177    ) -> String {
178        match &self.due_date {
179            None => String::new(),
180            Some(date) => {
181                let this = &date;
182                let due_date_symbol: &str = &symbols.due_date;
183                format!(
184                    "{due_date_symbol} {}",
185                    this.to_string_format(core_config.use_american_format)
186                )
187            }
188        }
189    }
190    #[must_use]
191    pub fn is_today_to_string(&self, symbols: &PrettySymbolsConfig) -> String {
192        if self.is_today {
193            symbols.today_tag.to_string()
194        } else {
195            String::new()
196        }
197    }
198    #[must_use]
199    pub fn state_to_string(&self, symbols: &PrettySymbolsConfig) -> String {
200        self.state.display(symbols)
201    }
202    #[must_use]
203    pub fn completion_bar_to_string(
204        &self,
205        length: usize,
206        symbols: &PrettySymbolsConfig,
207    ) -> Option<String> {
208        let percentage = self.completion?;
209        let progress_bar = (1..=length)
210            .map(|c| {
211                if c * (100 / length) <= percentage {
212                    symbols.progress_bar_true.clone()
213                } else {
214                    symbols.progress_bar_false.clone()
215                }
216            })
217            .collect::<String>();
218
219        Some(format!("[{progress_bar} {percentage}%]"))
220    }
221    pub fn get_fixed_attributes(&self, config: &TasksConfig, indent_length: usize) -> String {
222        let indent = " ".repeat(indent_length);
223
224        let state_str = match self.state {
225            State::Done => config.task_state_markers.done,
226            State::ToDo => config.task_state_markers.todo,
227            State::Incomplete => config.task_state_markers.incomplete,
228            State::Canceled => config.task_state_markers.canceled,
229        };
230
231        let priority = if self.priority > 0 {
232            format!("p{}", self.priority)
233        } else {
234            String::new()
235        };
236
237        let completion = match self.completion {
238            Some(c) => format!("c{c}"),
239            None => String::new(),
240        };
241
242        let due_date = if let Some(due_date) = &self.due_date {
243            due_date.to_string_format(config.core.use_american_format)
244        } else {
245            String::new()
246        };
247
248        let tags_str = self.tags.as_ref().map_or_else(String::new, |tags| {
249            tags.clone()
250                .iter()
251                .map(|t| format!("#{t}"))
252                .collect::<Vec<String>>()
253                .join(" ")
254        });
255
256        let today_tag = if self.is_today {
257            String::from("@today")
258        } else {
259            String::new()
260        };
261
262        let res = format!(
263            "{}- [{}] {}",
264            indent,
265            state_str,
266            [
267                self.name.clone(),
268                due_date,
269                completion,
270                priority,
271                tags_str,
272                today_tag
273            ]
274            .into_iter()
275            .filter(|s| !String::is_empty(s))
276            .collect::<Vec<String>>()
277            .join(" ")
278        );
279        res.trim_end().to_string()
280    }
281
282    /// Rewrite a task in its file with current attributes.
283    /// # Errors
284    /// If the file cannot be read or written to.
285    pub fn fix_task_attributes(&self, config: &TasksConfig) -> Result<()> {
286        if !self.path.exists() {
287            // Create it
288            File::create(self.path.clone())?;
289        }
290        if !self.path.is_file() {
291            bail!(
292                "Tried to fix tasks attributes but {:?} is not a file",
293                self.path
294            );
295        }
296        let content = read_to_string(self.path.clone())?;
297        let mut lines = content
298            .replace('\r', "")
299            .split('\n')
300            .map(str::to_owned)
301            .collect::<Vec<String>>();
302        if let Some(line_number) = self.line_number {
303            if lines.len() < line_number - 1 {
304                bail!(
305                    "Task's line number {} was greater than length of file {:?}",
306                    line_number,
307                    self.path
308                );
309            }
310
311            let indent_length = lines[line_number - 1]
312                .chars()
313                .take_while(|c| c.is_whitespace())
314                .count();
315
316            let fixed_line = self.get_fixed_attributes(config, indent_length);
317
318            if lines[line_number - 1] != fixed_line {
319                debug!(
320                    "\nReplacing\n{}\nWith\n{}\n",
321                    lines[line_number - 1],
322                    self.get_fixed_attributes(config, indent_length,)
323                );
324                lines[line_number - 1] = fixed_line;
325
326                info!("Wrote to {1:?} at line {}", line_number, self.path);
327            }
328        } else {
329            debug!("Creating a new task at end of file {}", self.path.display());
330            let fixed_line = self.get_fixed_attributes(config, 0);
331            lines.push(fixed_line);
332            lines.push(String::new()); // Empty line
333        }
334        let mut file = File::create(self.path.clone())?;
335        std::io::Write::write_all(&mut file, lines.join("\n").as_bytes())?;
336
337        Ok(())
338    }
339}
340
341#[cfg(test)]
342mod tests_tasks {
343    use chrono::NaiveDate;
344    use pretty_assertions::assert_eq;
345
346    use crate::{
347        TasksConfig,
348        task::{Date, State, Task},
349    };
350
351    #[test]
352    fn test_fix_attributes() {
353        let config = TasksConfig {
354            ..Default::default()
355        };
356        let task = Task {
357            due_date: Some(Date::Day(NaiveDate::from_ymd_opt(2021, 12, 3).unwrap())),
358            name: String::from("Test Task"),
359            priority: 1,
360            state: State::ToDo,
361            tags: Some(vec![String::from("tag1"), String::from("tag2")]),
362            description: Some(String::from("This is a test task.")),
363            line_number: Some(2),
364            ..Default::default()
365        };
366        let res = task.get_fixed_attributes(&config, 0);
367        assert_eq!(res, "- [ ] Test Task 2021-12-03 p1 #tag1 #tag2");
368    }
369
370    #[test]
371    fn test_fix_attributes_with_no_date() {
372        let config = TasksConfig {
373            ..Default::default()
374        };
375        let task = Task {
376            due_date: None,
377            name: String::from("Test Task with No Date"),
378            priority: 2,
379            state: State::Done,
380            tags: Some(vec![String::from("tag3")]),
381            description: None,
382            line_number: Some(3),
383            ..Default::default()
384        };
385
386        let res = task.get_fixed_attributes(&config, 0);
387        assert_eq!(res, "- [x] Test Task with No Date p2 #tag3");
388    }
389    #[test]
390    fn test_fix_attributes_with_today_tag() {
391        let config = TasksConfig {
392            ..Default::default()
393        };
394        let task = Task {
395            due_date: None,
396            name: String::from("Test Task with Today tag"),
397            priority: 2,
398            state: State::Done,
399            tags: Some(vec![String::from("tag3")]),
400            description: None,
401            line_number: Some(3),
402            is_today: true,
403            ..Default::default()
404        };
405
406        let res = task.get_fixed_attributes(&config, 0);
407        assert_eq!(res, "- [x] Test Task with Today tag p2 #tag3 @today");
408    }
409}
410#[cfg(test)]
411mod tests_due_date {
412
413    use crate::task::Date;
414    use chrono::{Duration, Local};
415
416    #[test]
417    fn test_day_today() {
418        let today = Local::now().naive_local().date();
419        let due = Date::Day(today);
420        assert_eq!(due.get_relative_str(), ("today".to_string()));
421    }
422
423    #[test]
424    fn test_day_tomorrow() {
425        let date = Local::now().naive_local().date() + Duration::days(1);
426        let due = Date::Day(date);
427        assert_eq!(due.get_relative_str(), ("tomorrow".to_string()));
428    }
429
430    #[test]
431    fn test_day_yesterday() {
432        let date = Local::now().naive_local().date() - Duration::days(1);
433        let due = Date::Day(date);
434        assert_eq!(due.get_relative_str(), ("yesterday".to_string()));
435    }
436
437    #[test]
438    fn test_day_in_three_days() {
439        let date = Local::now().naive_local().date() + Duration::days(3);
440        let due = Date::Day(date);
441        assert_eq!(due.get_relative_str(), ("in 3 days".to_string()));
442    }
443
444    #[test]
445    fn test_day_two_weeks_ago() {
446        let date = Local::now().naive_local().date() - Duration::weeks(2);
447        let due = Date::Day(date);
448        assert_eq!(due.get_relative_str(), ("2 weeks ago".to_string()));
449    }
450
451    #[test]
452    fn test_day_in_two_months() {
453        let date = Local::now().naive_local().date() + Duration::weeks(9); // ~2 months
454        let due = Date::Day(date);
455        assert_eq!(due.get_relative_str(), ("in 2 months".to_string()));
456    }
457
458    #[test]
459    fn test_day_three_years_ago() {
460        let date = Local::now().naive_local().date() - Duration::weeks(4 * 12 * 3);
461        let due = Date::Day(date);
462        assert_eq!(due.get_relative_str(), ("3 years ago".to_string()));
463    }
464
465    #[test]
466    fn test_daytime_now() {
467        let now = Local::now().naive_local();
468        let due = Date::DayTime(now);
469        assert_eq!(due.get_relative_str(), ("today".to_string()));
470    }
471
472    #[test]
473    fn test_daytime_in_30_minutes() {
474        let dt = Local::now().naive_local() + Duration::minutes(30);
475        let due = Date::DayTime(dt);
476        assert_eq!(due.get_relative_str(), ("in 30 minutes".to_string()));
477    }
478
479    #[test]
480    fn test_daytime_three_hours_ago() {
481        let dt = Local::now().naive_local() - Duration::hours(3);
482        let due = Date::DayTime(dt);
483        assert_eq!(due.get_relative_str(), ("3 hours ago".to_string()));
484    }
485
486    #[test]
487    fn test_daytime_tomorrow_same_time() {
488        let dt = Local::now().naive_local() + Duration::hours(24);
489        let due = Date::DayTime(dt);
490        assert_eq!(due.get_relative_str(), ("tomorrow".to_string()));
491    }
492    #[test]
493    fn test_daytime_tomorrow_hours() {
494        let dt = Local::now().naive_local() + Duration::hours(25);
495        let due = Date::DayTime(dt);
496        assert_eq!(due.get_relative_str(), ("in 25 hours".to_string()));
497    }
498
499    #[test]
500    fn test_daytime_yesterday_same_time() {
501        let dt = Local::now().naive_local() - Duration::hours(24);
502        let due = Date::DayTime(dt);
503        assert_eq!(due.get_relative_str(), ("yesterday".to_string()));
504    }
505
506    #[test]
507    fn test_daytime_in_1_minute() {
508        let dt = Local::now().naive_local() + Duration::minutes(1);
509        let due = Date::DayTime(dt);
510        assert_eq!(due.get_relative_str(), ("in 1 minutes".to_string()));
511    }
512
513    #[test]
514    fn test_daytime_45_minutes_ago() {
515        let dt = Local::now().naive_local() - Duration::minutes(45);
516        let due = Date::DayTime(dt);
517        assert_eq!(due.get_relative_str(), ("45 minutes ago".to_string()));
518    }
519
520    #[test]
521    fn test_daytime_in_3_hours() {
522        let dt = Local::now().naive_local() + Duration::hours(3);
523        let due = Date::DayTime(dt);
524        assert_eq!(due.get_relative_str(), ("in 3 hours".to_string()));
525    }
526
527    #[test]
528    fn test_daytime_12_hours_ago() {
529        let dt = Local::now().naive_local() - Duration::hours(12);
530        let due = Date::DayTime(dt);
531        assert_eq!(due.get_relative_str(), ("12 hours ago".to_string()));
532    }
533
534    #[test]
535    fn test_daytime_in_1_day_exact() {
536        let dt = Local::now().naive_local() + Duration::days(1);
537        let due = Date::DayTime(dt);
538        assert_eq!(due.get_relative_str(), ("tomorrow".to_string()));
539    }
540
541    #[test]
542    fn test_daytime_exactly_yesterday() {
543        let dt = Local::now().naive_local() - Duration::days(1);
544        let due = Date::DayTime(dt);
545        assert_eq!(due.get_relative_str(), ("yesterday".to_string()));
546    }
547
548    #[test]
549    fn test_daytime_in_2_days() {
550        let dt = Local::now().naive_local() + Duration::days(2);
551        let due = Date::DayTime(dt);
552        assert_eq!(due.get_relative_str(), ("in 2 days".to_string()));
553    }
554
555    #[test]
556    fn test_daytime_10_days_ago() {
557        let dt = Local::now().naive_local() - Duration::days(10);
558        let due = Date::DayTime(dt);
559        assert_eq!(due.get_relative_str(), ("10 days ago".to_string()));
560    }
561
562    #[test]
563    fn test_daytime_in_3_weeks() {
564        let dt = Local::now().naive_local() + Duration::weeks(3);
565        let due = Date::DayTime(dt);
566        assert_eq!(due.get_relative_str(), ("in 3 weeks".to_string()));
567    }
568
569    #[test]
570    fn test_daytime_2_months_ago() {
571        let dt = Local::now().naive_local() - Duration::weeks(9); // ≈ 2 months
572        let due = Date::DayTime(dt);
573        assert_eq!(due.get_relative_str(), ("2 months ago".to_string()));
574    }
575}