netxt/
lib.rs

1//! Todo is NOT timezone-aware
2
3use chrono::NaiveDate;
4use section::Section;
5use std::{
6    collections::HashMap, error, fmt, fs::read_to_string, fs::OpenOptions, io::Write,
7    path::PathBuf, str,
8};
9
10mod day;
11mod section;
12mod task;
13mod util;
14
15pub use util::today;
16use util::*;
17
18pub use day::{Day, DayIterator};
19use task::Task;
20
21#[derive(PartialEq, Debug, Clone)]
22pub struct Todo {
23    pub days: HashMap<NaiveDate, Day>,
24    pub file_path: PathBuf,
25}
26
27impl Todo {
28    pub fn new(path: Option<&str>) -> Result<Todo> {
29        let path: PathBuf = match path {
30            // if path present but file doesnt exist, create it
31            Some(path) => {
32                if let Err(_error) = OpenOptions::new().read(true).open(path) {
33                    OpenOptions::new().write(true).create(true).open(path)?;
34                };
35                path.into()
36            }
37            // if path not present, create default file if possible
38            None => {
39                if let Err(_error) = OpenOptions::new()
40                    .write(true)
41                    .create_new(true)
42                    .open(DEFAULT_TODO_FILE)
43                {
44                    return err!("File with default name {DEFAULT_TODO_FILE} already exists");
45                }
46                DEFAULT_TODO_FILE.into()
47            }
48        };
49
50        // load from file or create new blank one
51        let todo = Todo::load(&path).unwrap_or(Todo {
52            days: HashMap::<NaiveDate, Day>::new(),
53            file_path: path,
54        });
55
56        Ok(todo)
57    }
58
59    fn last_day(&self) -> Option<&Day> {
60        if self.days.len() == 0 {
61            return None;
62        }
63
64        self.days.values().max_by_key(|x| x.date)
65    }
66
67    /// Creates new empty day if there isn't a day with today() as date.
68    /// ensure_today is idempotent, meaning it will do nothing if today already exists in days
69    fn ensure_today(&mut self) {
70        let new_day = match self.last_day() {
71            Some(last_day) => {
72                // days and today is created: do nothing
73                if last_day.date == today() {
74                    return;
75                }
76                // days but no today: copy last day
77                let mut clone_last_day = last_day.clone();
78                clone_last_day.date = today();
79                clone_last_day
80            }
81            // no days: create new empty day
82            None => Day::new(today()),
83        };
84        self.days.insert(new_day.date, new_day);
85    }
86
87    /// Creates new day with all tasks/sections from most recent day and cleared Done section
88    /// next_day is idempotent, meaning it will do nothing if today already exists in days
89    #[deprecated]
90    pub fn next_day(&mut self) {
91        let mut new_day = match self.last_day() {
92            Some(last_day) => {
93                // days and today is created: do nothing
94                if last_day.date == today() {
95                    return;
96                }
97                // days but no today: copy last day
98                let mut clone_last_day = last_day.clone();
99                clone_last_day.date = today();
100                clone_last_day
101            }
102            // no days: create new empty day
103            None => Day::new(today()),
104        };
105
106        // clear "Done" section, create one if didnt find
107        match new_day
108            .sections
109            .iter()
110            .position(|section| section.name == "Done")
111        {
112            Some(pos) => {
113                new_day.sections[pos].tasks = Vec::<Task>::new();
114            }
115            None => {
116                let sec = Section::new("Done");
117                new_day.sections.push(sec);
118            }
119        }
120
121        self.days.insert(new_day.date, new_day);
122    }
123
124    pub fn save(&mut self) -> Result<()> {
125        if let Some(last_day_in_file) = get_last_day(&self.file_path) {
126            if last_day_in_file > today() {
127                return err!("Invalid date: date on file is ahead of today");
128            }
129        }
130
131        // don't save if file is up to date
132        let file_todo = Todo::load(&self.file_path)?;
133        if file_todo == *self {
134            return err!("File already up to date");
135        }
136
137        let mut f = OpenOptions::new()
138            .write(true)
139            .truncate(true)
140            .open(&self.file_path)?;
141        f.write_all(format!("{self}\n").as_bytes())?;
142        Ok(())
143    }
144
145    pub fn load(todo_file: &PathBuf) -> Result<Todo> {
146        if let Some(last_day) = get_last_day(todo_file) {
147            if last_day > today() {
148                return err!("Invalid date: date on file is ahead of today");
149            }
150        }
151        let mut todo: Todo = read_to_string(&todo_file)
152            .expect("Unable to read file")
153            .parse()
154            .expect("Unable to parse file contents");
155        todo.file_path = todo_file.to_path_buf();
156        Ok(todo)
157    }
158
159    pub fn add(&mut self, task_txt: &str, section: &str) -> Result<()> {
160        // make sure current day exists
161        self.ensure_today();
162
163        let task: Task = task_txt.parse()?;
164
165        if let Some(day) = self.days.get_mut(&today()) {
166            // find section position in vec
167            let sections = &mut day.sections;
168
169            let section_pos = sections
170                .iter()
171                .position(|sec| sec.name == section)
172                // create section if it doesnt exist
173                .unwrap_or_else(|| {
174                    sections.push(Section::new(section));
175                    sections.len() - 1
176                });
177
178            // put task in section
179            day.sections[section_pos].tasks.push(task);
180        }
181        Ok(())
182    }
183}
184
185impl str::FromStr for Todo {
186    type Err = Box<dyn error::Error + Send + Sync>;
187    fn from_str(s: &str) -> Result<Self> {
188        let text = s.trim().to_string();
189        let mut days: HashMap<NaiveDate, Day> = HashMap::new();
190        let day_iter = DayIterator::new(&text);
191
192        let old_date = NaiveDate::from_ymd_opt(1970, 1, 1).unwrap();
193        let mut last_day = Day::new(old_date); // set date to old date
194
195        for day in day_iter {
196            if day.date > last_day.date {
197                last_day = day.clone();
198            }
199            days.insert(day.date, day);
200        }
201
202        Ok(Todo {
203            days,
204            file_path: PathBuf::new(), // no path to give, is this an issue?
205        })
206    }
207}
208
209impl fmt::Display for Todo {
210    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
211        let mut dates: Vec<&NaiveDate> = self.days.keys().collect();
212        dates.sort_by(|a, b| b.cmp(a));
213
214        let sorted_str_days: Vec<String> = dates
215            .iter()
216            .filter_map(|date| self.days.get(date).map(|day| day.to_string()))
217            .collect();
218
219        write!(f, "{}", sorted_str_days.join("\n"))
220    }
221}
222
223#[cfg(test)]
224#[allow(deprecated)]
225mod tests {
226    use super::*;
227    use crate::section::Section;
228    use crate::task::Task;
229
230    use chrono::Duration as ChronoDuration;
231    use chrono::NaiveDate;
232    use indoc::indoc;
233    use std::io::Write;
234    use tempfile::NamedTempFile;
235
236    /// Creates a tmp file with string contents and return the file path
237    fn create_file_with_contents(contents: String) -> NamedTempFile {
238        let mut file = NamedTempFile::new().expect("Unable to create tmp file");
239        file.write_all(contents.as_bytes())
240            .expect("Unable to write to tmp file");
241        file
242    }
243
244    #[test]
245    fn parse_todo() {
246        let file = create_file_with_contents(
247            indoc! {"
248            [2024-03-06]
249            Section 1
250            - task 1
251            - task 3
252            - task 2
253            Done
254
255            [2024-03-07]
256            Section 2
257            - task 11
258            - task 31
259            - task 21
260            Done
261            - task 4
262        "}
263            .to_string(),
264        );
265        let path = file.path().to_path_buf();
266        let expected = Todo {
267            days: HashMap::from([
268                (
269                    NaiveDate::from_ymd_opt(2024, 3, 6).unwrap(),
270                    Day {
271                        date: NaiveDate::from_ymd_opt(2024, 3, 6).unwrap(),
272                        sections: vec![
273                            Section {
274                                name: "Section 1".to_string(),
275                                tasks: vec![
276                                    Task {
277                                        text: "task 1".to_string(),
278                                    },
279                                    Task {
280                                        text: "task 3".to_string(),
281                                    },
282                                    Task {
283                                        text: "task 2".to_string(),
284                                    },
285                                ],
286                            },
287                            Section {
288                                name: "Done".to_string(),
289                                tasks: vec![],
290                            },
291                        ],
292                    },
293                ),
294                (
295                    NaiveDate::from_ymd_opt(2024, 3, 7).unwrap(),
296                    Day {
297                        date: NaiveDate::from_ymd_opt(2024, 3, 7).unwrap(),
298                        sections: vec![
299                            Section {
300                                name: "Section 2".to_string(),
301                                tasks: vec![
302                                    Task {
303                                        text: "task 11".to_string(),
304                                    },
305                                    Task {
306                                        text: "task 31".to_string(),
307                                    },
308                                    Task {
309                                        text: "task 21".to_string(),
310                                    },
311                                ],
312                            },
313                            Section {
314                                name: "Done".to_string(),
315                                tasks: vec![Task {
316                                    text: "task 4".to_string(),
317                                }],
318                            },
319                        ],
320                    },
321                ),
322            ]),
323            file_path: path.clone(),
324        };
325
326        let actual = Todo::load(&path).expect("Unable to load file");
327        assert_eq!(actual, expected);
328    }
329
330    #[test]
331    fn save_todo() {
332        let expected = indoc! {"
333            [2024-03-07]
334            Section 2
335            - task 11
336            - task 31
337            - task 21
338
339            Done
340
341            [2024-03-06]
342            Section 1
343            - task 1
344            - task 3
345            - task 2
346
347            Done
348
349        "};
350
351        let file = NamedTempFile::new().expect("Unable to create tmp file");
352        let path = file.path();
353        let mut todo = Todo {
354            file_path: path.to_path_buf(),
355            days: HashMap::from([
356                (
357                    NaiveDate::from_ymd_opt(2024, 3, 6).unwrap(),
358                    Day {
359                        date: NaiveDate::from_ymd_opt(2024, 3, 6).unwrap(),
360                        sections: vec![
361                            Section {
362                                name: "Section 1".to_string(),
363                                tasks: vec![
364                                    Task {
365                                        text: "task 1".to_string(),
366                                    },
367                                    Task {
368                                        text: "task 3".to_string(),
369                                    },
370                                    Task {
371                                        text: "task 2".to_string(),
372                                    },
373                                ],
374                            },
375                            Section {
376                                name: "Done".to_string(),
377                                tasks: vec![],
378                            },
379                        ],
380                    },
381                ),
382                (
383                    NaiveDate::from_ymd_opt(2024, 3, 7).unwrap(),
384                    Day {
385                        date: NaiveDate::from_ymd_opt(2024, 3, 7).unwrap(),
386                        sections: vec![
387                            Section {
388                                name: "Section 2".to_string(),
389                                tasks: vec![
390                                    Task {
391                                        text: "task 11".to_string(),
392                                    },
393                                    Task {
394                                        text: "task 31".to_string(),
395                                    },
396                                    Task {
397                                        text: "task 21".to_string(),
398                                    },
399                                ],
400                            },
401                            Section {
402                                name: "Done".to_string(),
403                                tasks: vec![],
404                            },
405                        ],
406                    },
407                ),
408            ]),
409        };
410
411        let _ = todo.save().expect("Unable to load file");
412
413        let actual = read_to_string(&path).expect("Unable to read file");
414        assert_eq!(actual, expected);
415    }
416
417    #[test]
418    fn add_task() {
419        let base = Todo {
420            days: HashMap::from([(
421                today(),
422                Day {
423                    date: today(),
424                    sections: vec![
425                        Section {
426                            name: "Section 1".to_string(),
427                            tasks: vec![
428                                Task {
429                                    text: "task 1".to_string(),
430                                },
431                                Task {
432                                    text: "task 2".to_string(),
433                                },
434                                Task {
435                                    text: "task 3".to_string(),
436                                },
437                            ],
438                        },
439                        Section {
440                            name: "Done".to_string(),
441                            tasks: vec![],
442                        },
443                    ],
444                },
445            )]),
446            file_path: PathBuf::new(),
447        };
448
449        let expected = Todo {
450            days: HashMap::from([(
451                today(),
452                Day {
453                    date: today(),
454                    sections: vec![
455                        Section {
456                            name: "Section 1".to_string(),
457                            tasks: vec![
458                                Task {
459                                    text: "task 1".to_string(),
460                                },
461                                Task {
462                                    text: "task 2".to_string(),
463                                },
464                                Task {
465                                    text: "task 3".to_string(),
466                                },
467                                Task {
468                                    text: "added task".to_string(),
469                                },
470                            ],
471                        },
472                        Section {
473                            name: "Done".to_string(),
474                            tasks: vec![],
475                        },
476                    ],
477                },
478            )]),
479            file_path: PathBuf::new(),
480        };
481
482        let mut actual = base.clone();
483        actual.add("- added task", "Section 1").unwrap();
484        assert_eq!(actual, expected);
485    }
486
487    #[test]
488    fn add_task_new_section() {
489        let base = Todo {
490            days: HashMap::from([(
491                today(),
492                Day {
493                    date: today(),
494                    sections: vec![
495                        Section {
496                            name: "Section 1".to_string(),
497                            tasks: vec![
498                                Task {
499                                    text: "task 1".to_string(),
500                                },
501                                Task {
502                                    text: "task 2".to_string(),
503                                },
504                                Task {
505                                    text: "task 3".to_string(),
506                                },
507                            ],
508                        },
509                        Section {
510                            name: "Done".to_string(),
511                            tasks: vec![],
512                        },
513                    ],
514                },
515            )]),
516            file_path: PathBuf::new(),
517        };
518
519        let expected = Todo {
520            days: HashMap::from([(
521                today(),
522                Day {
523                    date: today(),
524                    sections: vec![
525                        Section {
526                            name: "Section 1".to_string(),
527                            tasks: vec![
528                                Task {
529                                    text: "task 1".to_string(),
530                                },
531                                Task {
532                                    text: "task 2".to_string(),
533                                },
534                                Task {
535                                    text: "task 3".to_string(),
536                                },
537                            ],
538                        },
539                        Section {
540                            name: "Done".to_string(),
541                            tasks: vec![],
542                        },
543                        // new section is added to end of vec, not before Done
544                        Section {
545                            name: "New Section".to_string(),
546                            tasks: vec![Task {
547                                text: "added task".to_string(),
548                            }],
549                        },
550                    ],
551                },
552            )]),
553            file_path: PathBuf::new(),
554        };
555
556        let mut actual = base.clone();
557        actual.add("- added task", "New Section").unwrap();
558        assert_eq!(actual, expected);
559    }
560
561    #[test]
562    fn next_day() {
563        let base = Todo {
564            days: HashMap::from([(
565                today() - ChronoDuration::days(1),
566                Day {
567                    date: today() - ChronoDuration::days(1),
568                    sections: vec![
569                        Section {
570                            name: "Section 1".to_string(),
571                            tasks: vec![
572                                Task {
573                                    text: "task 1".to_string(),
574                                },
575                                Task {
576                                    text: "task 2".to_string(),
577                                },
578                            ],
579                        },
580                        Section {
581                            name: "Done".to_string(),
582                            tasks: vec![Task {
583                                text: "task 3".to_string(),
584                            }],
585                        },
586                    ],
587                },
588            )]),
589            file_path: PathBuf::new(),
590        };
591
592        let expected = Todo {
593            days: HashMap::from([
594                (
595                    today(),
596                    Day {
597                        date: today(),
598                        sections: vec![
599                            Section {
600                                name: "Section 1".to_string(),
601                                tasks: vec![
602                                    Task {
603                                        text: "task 1".to_string(),
604                                    },
605                                    Task {
606                                        text: "task 2".to_string(),
607                                    },
608                                ],
609                            },
610                            Section {
611                                name: "Done".to_string(),
612                                tasks: vec![],
613                            },
614                        ],
615                    },
616                ),
617                (
618                    today() - ChronoDuration::days(1),
619                    Day {
620                        date: today() - ChronoDuration::days(1),
621                        sections: vec![
622                            Section {
623                                name: "Section 1".to_string(),
624                                tasks: vec![
625                                    Task {
626                                        text: "task 1".to_string(),
627                                    },
628                                    Task {
629                                        text: "task 2".to_string(),
630                                    },
631                                ],
632                            },
633                            Section {
634                                name: "Done".to_string(),
635                                tasks: vec![Task {
636                                    text: "task 3".to_string(),
637                                }],
638                            },
639                        ],
640                    },
641                ),
642            ]),
643            file_path: PathBuf::new(),
644        };
645
646        let mut actual = base.clone();
647
648        actual.next_day();
649        assert_eq!(actual, expected);
650    }
651}