1use 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 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 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 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 fn ensure_today(&mut self) {
70 let new_day = match self.last_day() {
71 Some(last_day) => {
72 if last_day.date == today() {
74 return;
75 }
76 let mut clone_last_day = last_day.clone();
78 clone_last_day.date = today();
79 clone_last_day
80 }
81 None => Day::new(today()),
83 };
84 self.days.insert(new_day.date, new_day);
85 }
86
87 #[deprecated]
90 pub fn next_day(&mut self) {
91 let mut new_day = match self.last_day() {
92 Some(last_day) => {
93 if last_day.date == today() {
95 return;
96 }
97 let mut clone_last_day = last_day.clone();
99 clone_last_day.date = today();
100 clone_last_day
101 }
102 None => Day::new(today()),
104 };
105
106 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 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 self.ensure_today();
162
163 let task: Task = task_txt.parse()?;
164
165 if let Some(day) = self.days.get_mut(&today()) {
166 let sections = &mut day.sections;
168
169 let section_pos = sections
170 .iter()
171 .position(|sec| sec.name == section)
172 .unwrap_or_else(|| {
174 sections.push(Section::new(section));
175 sections.len() - 1
176 });
177
178 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); 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(), })
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 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 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}