1#![forbid(unsafe_code)]
2
3use std::cmp::Reverse;
4use std::fmt::{self, Display, Formatter};
5use std::io;
6use std::path::{Path, PathBuf};
7use std::process::{Command, ExitStatus};
8use std::str::FromStr;
9use std::sync::LazyLock;
10use std::time::SystemTime;
11use std::{env, fs};
12
13use chrono::{Local, NaiveDate};
14use ratatui::layout::{Constraint, Layout, Rect};
15use ratatui::widgets::TableState;
16use regex::Regex;
17use thiserror::Error;
18
19pub mod config;
20pub mod count;
21pub mod dialog;
22pub mod modes;
23
24use config::{Config, ConfigError};
25use count::TaskCount;
26use dialog::Dialog;
27use modes::{
28 Mode,
29 config_mode::{ConfigMode, ConfigSetting},
30 evergreen_mode::EvergreenMode,
31 files_mode::{self, DueFilter, FilesMode},
32 help_mode::{HelpMode, USAGE},
33 log_mode::{LogMode, LogSubMode},
34 tasks_mode::{self, RecurringStatus, TasksMode},
35};
36
37pub const SIDEBAR_SIZE: u16 = 38;
38pub const TASK_IDENTIFIERS: &[&str] = &[
39 "[ ]", "- [ ]", "[]", "- []", "[x]", "- [x]", "[X]", "- [X]", "[/]", "- [/]", "[\\]", "- [\\]",
40];
41
42pub static DUE_DATE_RE: LazyLock<Regex> =
44 LazyLock::new(|| Regex::new(r"(?:due:[0-9]{4}-[0-9]{2}-[0-9]{2})").unwrap());
45pub static COMPLETED_DATE_RE: LazyLock<Regex> =
46 LazyLock::new(|| Regex::new(r"(?:completed:[0-9]{4}-[0-9]{2}-[0-9]{2})").unwrap());
47
48pub static TAG_RE: LazyLock<Regex> =
50 LazyLock::new(|| Regex::new(r"(?<tag>#[\w&&[^\s]&&[^#]+]+)").unwrap());
51
52pub static ORDER_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?:::[0-9]*)").unwrap());
54
55#[derive(Error, Debug)]
57pub enum TfError {
58 #[error("IoError: {0}")]
59 Io(#[from] io::Error),
60 #[error("System time error")]
61 SystemTime(#[from] std::time::SystemTimeError),
62 #[error("Parsing error")]
63 ParseInt(#[from] std::num::ParseIntError),
64 #[error("No matching priority.")]
65 NoMatchingPriority,
66 #[error("Path does not exist.")]
67 NoSuchPath,
68 #[error("{0}")]
69 ConfigError(#[from] config::ConfigError),
70}
71
72pub struct App {
74 pub mode: Mode,
75 pub config: Config,
76 pub filemode: FilesMode,
77 pub logmode: LogMode,
78 pub configmode: ConfigMode,
79 pub helpmode: HelpMode,
80 pub evergreenmode: EvergreenMode,
81 pub taskmode: TasksMode,
82 pub current_date: NaiveDate,
83 pub exit: bool,
84}
85
86impl App {
87 pub fn new(config: &Config, task_counts: Vec<TaskCount>) -> Self {
88 Self {
89 mode: config.start_mode.clone(),
90 config: config.clone(),
91 filemode: FilesMode {
92 current_file: 0,
93 files: vec![],
94 completed: config.include_completed,
95 due: DueFilter::Any,
96 file_status: FileStatus::Active,
97 priority: None,
98 tag_dialog: Dialog::default(),
99 search_dialog: Dialog::default(),
100 line_offset: 0,
101 },
102 logmode: LogMode {
103 submode: LogSubMode::Table,
104 data: task_counts,
105 table: TableState::default().with_selected(0),
106 },
107 configmode: ConfigMode {
108 table: TableState::default().with_selected(0),
109 setting: None,
110 dialog: Dialog::default(),
111 message_to_user: None,
112 },
113 helpmode: HelpMode {
114 line_offset: 0,
115 help_text: USAGE.to_string(),
116 },
117 evergreenmode: EvergreenMode { line_offset: 0 },
118 taskmode: TasksMode {
119 data: vec![],
120 table: TableState::default().with_selected(0),
121 file_status: FileStatus::Active,
122 completion_status: CompletionStatus::Incomplete,
123 recurring_status: RecurringStatus::All,
124 tag_dialog: Dialog::default(),
125 search_dialog: Dialog::default(),
126 dates: tasks_mode::Dates::default(),
127 },
128 current_date: Local::now().date_naive(),
129 exit: false,
130 }
131 }
132
133 pub fn mode_init(&mut self, mode: Mode) -> Result<(), TfError> {
135 self.mode = mode;
136
137 match self.mode {
138 Mode::Files => {
139 files_mode::refine_files(self)?;
140 self.filemode.line_offset = 0;
141 }
142 Mode::Tasks => {
143 self.filemode.files = FileWithTasks::collect(&self.config)?;
145
146 self.taskmode.data = RichTask::collect(self)?;
148
149 self.taskmode.dates = tasks_mode::Dates::default();
151
152 if self.taskmode.data.is_empty() {
155 self.taskmode.table.select(None);
156 } else if self.taskmode.table.selected() == Some(self.taskmode.data.len()) {
157 self.taskmode
158 .table
159 .select(Some(self.taskmode.data.len() - 1));
160 }
161 }
162 Mode::Log => {
163 self.logmode.submode = LogSubMode::Table;
165
166 if let Some(v) = self.logmode.data.first_mut() {
169 let active_count =
170 TaskCount::extract(&FileWithTasks::collect(&self.config)?, &self.mode);
171 *v = active_count;
172 }
173 }
174 Mode::Help => {
175 self.helpmode.line_offset = 0;
176 }
177 Mode::Evergreen => {
178 if self.config.evergreen_file != Path::new("").to_path_buf() {
179 self.evergreenmode.line_offset = 0;
180 }
181 }
182 Mode::Config => {
183 self.configmode.table.select(Some(0));
184 self.configmode.setting = ConfigSetting::get(0);
185 self.filemode.line_offset = 0;
186 }
187 };
188 Ok(())
189 }
190}
191
192#[derive(Clone, Copy, PartialEq, PartialOrd, Ord, Hash, Eq, Debug)]
194pub enum Priority {
195 NoPriority, Five,
197 Four,
198 Three,
199 Two,
200 One,
201}
202
203impl Priority {
204 pub fn extract(haystack: &str) -> Option<Priority> {
206 let priorities = vec![
207 Priority::NoPriority,
208 Priority::One,
209 Priority::Two,
210 Priority::Three,
211 Priority::Four,
212 Priority::Five,
213 ];
214 let mut priority = None;
215
216 for each in priorities {
217 if haystack.contains(&format!("{each}")) {
218 priority = Some(each);
219 break; }
221 }
222 priority
223 }
224}
225
226impl Display for Priority {
227 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
228 let priority_markers = Config::priority_markers();
229 let priority_str = match self {
230 Priority::One => priority_markers[0].to_string(),
231 Priority::Two => priority_markers[1].to_string(),
232 Priority::Three => priority_markers[2].to_string(),
233 Priority::Four => priority_markers[3].to_string(),
234 Priority::Five => priority_markers[4].to_string(),
235 Priority::NoPriority => "pri@0".to_string(),
236 };
237 write!(f, "{priority_str}")
238 }
239}
240
241impl FromStr for Priority {
242 type Err = TfError;
243 fn from_str(s: &str) -> Result<Priority, TfError> {
244 let priority_markers = Config::priority_markers();
245 match s {
246 "pri@0" => Ok(Priority::NoPriority),
247 s if s == priority_markers[0] => Ok(Priority::One),
248 s if s == priority_markers[1] => Ok(Priority::Two),
249 s if s == priority_markers[2] => Ok(Priority::Three),
250 s if s == priority_markers[3] => Ok(Priority::Four),
251 s if s == priority_markers[4] => Ok(Priority::Five),
252 _ => Err(TfError::NoMatchingPriority),
253 }
254 }
255}
256
257#[derive(PartialEq, Clone, Debug)]
259pub enum FileStatus {
260 Active,
261 Archived,
262 Stale,
263}
264
265impl Display for FileStatus {
266 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
267 match self {
268 FileStatus::Active => write!(f, "active"),
269 FileStatus::Archived => write!(f, "archived"),
270 FileStatus::Stale => write!(f, "stale"),
271 }
272 }
273}
274
275pub enum CompletionStatus {
277 Incomplete,
278 Completed,
279}
280
281impl Display for CompletionStatus {
282 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
283 match self {
284 CompletionStatus::Incomplete => write!(f, "incomplete"),
285 CompletionStatus::Completed => write!(f, "completed"),
286 }
287 }
288}
289
290#[derive(Debug, Clone)]
292pub struct FileWithTasks {
293 pub file: PathBuf,
294 pub last_modified: SystemTime,
295 pub priority: Option<Priority>,
296 pub status: FileStatus,
297 pub has_due_date: bool,
298 pub head: Vec<String>,
299 pub tags: Vec<String>,
300 pub task_sets: Vec<TaskSet>,
301}
302
303impl FileWithTasks {
304 pub fn collect(config: &Config) -> Result<Vec<FileWithTasks>, TfError> {
306 let paths = collect_file_paths(&config.path, &config.file_extensions, vec![])?;
308
309 let mut files_with_tasks: Vec<FileWithTasks> = vec![];
311 for path in &paths {
312 if let Ok(Some(v)) = Self::extract(path, config.days_to_stale) {
313 files_with_tasks.push(v);
314 }
315 }
316 for file in files_with_tasks.iter_mut() {
318 file.task_sets
319 .sort_unstable_by_key(|task_set| Reverse(task_set.priority))
320 }
321
322 Ok(files_with_tasks)
323 }
324
325 pub fn extract(path: &Path, days_to_stale: u64) -> Result<Option<FileWithTasks>, TfError> {
327 let contents = match fs::read_to_string(path) {
328 Ok(v) => v,
329 Err(_) => return Ok(None),
330 };
331
332 if !contents.contains("TODO") {
333 return Ok(None);
334 }
335
336 let last_modified = fs::metadata(path)?.modified()?;
337
338 let stale_threshold = std::time::Duration::from_secs(60 * 60 * 24 * days_to_stale);
340 let status = if contents.contains("@archived") {
341 FileStatus::Archived
342 } else if last_modified.elapsed()? > stale_threshold {
343 FileStatus::Stale
344 } else {
345 FileStatus::Active
346 };
347
348 let head: Vec<String> = contents.lines().take(2).map(String::from).collect();
350
351 let file_tags: Vec<String> = TAG_RE
353 .find_iter(&head.join(""))
354 .map(|m| m.as_str().strip_prefix("#").unwrap().to_string())
355 .collect();
356
357 let file_priority = Priority::extract(&head.join("\n"));
359
360 let mut file_with_tasks = FileWithTasks {
362 file: path.to_path_buf(),
363 last_modified,
364 priority: file_priority,
365 status,
366 has_due_date: false,
367 head,
368 tags: file_tags.clone(),
369 task_sets: vec![],
370 };
371
372 let mut task_set: Option<TaskSet> = None;
373 for (i, line) in contents.lines().enumerate() {
374 if task_set.is_none() && !line.trim().contains("TODO") {
376 continue;
377 }
378
379 if line.trim().contains("TODO") {
381 if let Some(v) = task_set {
384 file_with_tasks.task_sets.push(v);
385 }
386
387 let mut task_set_priority = Priority::extract(line);
389 let mut task_set_due_date = None;
390 let mut task_set_completed_date = None;
391
392 if task_set_priority.is_none()
395 && !matches!(file_priority, Some(Priority::NoPriority))
396 {
397 task_set_priority = file_priority;
398 }
399
400 if let Some(dd) = DUE_DATE_RE.find(line.trim()) {
401 task_set_due_date = Some(make_date(dd.as_str(), DateKind::Due));
402 }
403
404 if let Some(cd) = COMPLETED_DATE_RE.find(line.trim()) {
405 task_set_completed_date = Some(make_date(cd.as_str(), DateKind::Completed));
406 }
407
408 let mut ts_tags: Vec<String> = TAG_RE
410 .find_iter(line.trim())
411 .map(|m| m.as_str().strip_prefix("#").unwrap().to_string())
412 .collect();
413 ts_tags.append(&mut file_tags.clone());
414
415 let mut ts_text = line.to_string();
417
418 if !ts_tags.is_empty() {
419 for tag in &ts_tags {
420 ts_text = ts_text.replace(&format!("#{tag}"), "");
421 }
422 }
423 if let Some(dd) = task_set_due_date {
424 ts_text = ts_text.replace(&format!("due:{dd}"), "");
425 }
426 if let Some(cd) = task_set_completed_date {
427 ts_text = ts_text.replace(&format!("completed:{cd}"), "");
428 }
429 if let Some(v) = task_set_priority {
430 ts_text = ts_text.replace(&format!("{v}"), "");
431 }
432 ts_text = ts_text.trim_end().to_string();
433
434 task_set = Some(TaskSet::new(
435 ts_text,
436 task_set_priority,
437 task_set_due_date,
438 task_set_completed_date,
439 ts_tags,
440 ));
441 continue;
442 }
443
444 if let Some(ts) = &mut task_set {
446 for ider in TASK_IDENTIFIERS {
447 if line.trim_start().starts_with(ider) {
448 let mut task_priority = Priority::extract(line);
450 if task_priority.is_none() {
451 if ts.priority.is_some() {
452 task_priority = ts.priority;
453 } else if file_priority.is_some() {
454 task_priority = file_priority;
455 }
456 }
457
458 let due_date = if let Some(dd) = DUE_DATE_RE.find(line) {
460 Some(make_date(dd.as_str(), DateKind::Due))
461 } else {
462 ts.due_date
463 };
464
465 let completed_date = if let Some(cd) = COMPLETED_DATE_RE.find(line) {
467 Some(make_date(cd.as_str(), DateKind::Completed))
468 } else {
469 ts.completed_date
470 };
471
472 let completed = line.trim().to_lowercase().starts_with("[x]")
473 || line.trim().to_lowercase().starts_with("- [x]");
474
475 let mut task_tags: Vec<String> = TAG_RE
478 .find_iter(line.trim())
479 .map(|m| m.as_str().strip_prefix("#").unwrap().to_string())
480 .collect();
481 task_tags.append(&mut ts.tags.clone());
482
483 let order = if let Some(o) = ORDER_RE.find(line) {
485 o.as_str().strip_prefix("::").unwrap().parse().ok()
486 } else {
487 None
488 };
489
490 let mut task_text = line.to_string();
492 if let Some(dd) = due_date {
493 task_text = task_text.replace(&format!("due:{dd}"), "");
494 }
495 if let Some(cd) = completed_date {
496 task_text = task_text.replace(&format!("completed:{cd}"), "");
497 }
498 if !task_tags.is_empty() {
499 for tag in &task_tags {
500 task_text = task_text.replace(&format!("#{tag}"), "");
501 }
502 }
503 if let Some(o) = order {
504 task_text = task_text.replace(&format!("::{o}"), "");
505 }
506 if let Some(v) = task_priority {
507 task_text = task_text.replace(&format!("{v}"), "");
508 }
509 task_text = task_text.trim_end().to_string();
510
511 let task = Task {
512 text: task_text,
513 completed,
514 completed_date,
515 due_date,
516 priority: task_priority,
517 tags: task_tags,
518 line: i + 1,
519 order,
520 };
521
522 if task.due_date.is_some() {
524 file_with_tasks.has_due_date = true;
525 }
526 ts.tasks.push(task);
527 break;
528 }
529 }
530 }
531 }
532 if let Some(v) = task_set {
534 file_with_tasks.task_sets.push(v);
535 }
536
537 for task_set in &file_with_tasks.task_sets {
540 if !task_set.tasks.is_empty() {
541 return Ok(Some(file_with_tasks));
542 }
543 }
544 Ok(None)
545 }
546}
547
548#[derive(Debug, Clone)]
550pub struct TaskSet {
551 pub name: String,
552 pub priority: Option<Priority>,
553 pub due_date: Option<NaiveDate>,
554 pub completed_date: Option<NaiveDate>,
555 pub tasks: Vec<Task>,
556 pub tags: Vec<String>,
557}
558
559impl TaskSet {
560 fn new(
561 name: String,
562 priority: Option<Priority>,
563 due_date: Option<NaiveDate>,
564 completed_date: Option<NaiveDate>,
565 tags: Vec<String>,
566 ) -> Self {
567 Self {
568 name,
569 priority,
570 due_date,
571 completed_date,
572 tasks: vec![],
573 tags,
574 }
575 }
576}
577
578#[derive(Debug, Clone)]
580pub struct Task {
581 pub text: String,
582 pub completed: bool,
583 pub due_date: Option<NaiveDate>,
584 pub completed_date: Option<NaiveDate>,
585 pub priority: Option<Priority>,
586 pub tags: Vec<String>,
587 pub line: usize,
588 pub order: Option<usize>,
589}
590
591#[derive(Debug, Clone)]
593pub struct RichTask {
594 pub task: Task,
595 pub task_set: String,
596 pub file_name: String,
597 pub file_path: PathBuf,
598 pub file_status: FileStatus,
599}
600
601impl RichTask {
602 pub fn collect(app: &App) -> Result<Vec<Self>, TfError> {
604 let mut files = app.filemode.files.clone();
607
608 match app.taskmode.file_status {
610 FileStatus::Active => {
611 files.retain(|f| f.status == FileStatus::Active);
612 }
613 FileStatus::Archived => {
614 files.retain(|f| f.status == FileStatus::Archived);
615 }
616 FileStatus::Stale => {
617 files.retain(|f| f.status == FileStatus::Stale);
618 }
619 }
620
621 let mut tasks = vec![];
623
624 if !app.taskmode.search_dialog.submitted_input.is_empty() {
628 for file in files {
629 for task_set in file.task_sets {
630 for task in task_set.tasks {
631 match app.taskmode.completion_status {
633 CompletionStatus::Incomplete => {
634 if task.completed {
635 continue;
636 }
637 }
638 CompletionStatus::Completed => {
639 if !task.completed {
640 continue;
641 }
642 }
643 }
644 if !app.taskmode.tag_dialog.submitted_input.is_empty()
646 && !task.tags.contains(&app.taskmode.tag_dialog.submitted_input)
647 {
648 continue;
649 }
650
651 match app.taskmode.recurring_status {
653 RecurringStatus::All => (),
654 RecurringStatus::Recurring => {
655 if !RecurringStatus::check(task.text.clone()) {
656 continue;
657 }
658 }
659 RecurringStatus::NonRecurring => {
660 if RecurringStatus::check(task.text.clone()) {
661 continue;
662 }
663 }
664 }
665 if !task
667 .text
668 .to_lowercase()
669 .contains(&app.taskmode.search_dialog.submitted_input)
670 {
671 continue;
672 }
673 tasks.push(RichTask {
674 task,
675 task_set: task_set.name.clone(),
676 file_name: file.head[0].clone(),
677 file_path: file.file.clone(),
678 file_status: file.status.clone(),
679 })
680 }
681 }
682 }
683 } else {
684 for file in files {
685 for task_set in file.task_sets {
686 for task in task_set.tasks {
687 match app.taskmode.completion_status {
689 CompletionStatus::Incomplete => {
690 if task.completed {
691 continue;
692 }
693 }
694 CompletionStatus::Completed => {
695 if !task.completed {
696 continue;
697 }
698 }
699 }
700 if !app.taskmode.tag_dialog.submitted_input.is_empty()
702 && !task.tags.contains(&app.taskmode.tag_dialog.submitted_input)
703 {
704 continue;
705 }
706 match app.taskmode.recurring_status {
708 RecurringStatus::All => (),
709 RecurringStatus::Recurring => {
710 if !RecurringStatus::check(task.text.clone()) {
711 continue;
712 }
713 }
714 RecurringStatus::NonRecurring => {
715 if RecurringStatus::check(task.text.clone()) {
716 continue;
717 }
718 }
719 }
720 tasks.push(RichTask {
721 task,
722 task_set: task_set.name.clone(),
723 file_name: file.head[0].clone(),
724 file_path: file.file.clone(),
725 file_status: file.status.clone(),
726 })
727 }
728 }
729 }
730 }
731
732 tasks.sort_by_key(|task| Reverse(task.task.priority));
734
735 tasks.sort_by_key(|task| task.task.order.unwrap_or(9999));
737
738 match app.taskmode.completion_status {
740 CompletionStatus::Incomplete => {
741 let mut with_due_dates = tasks.clone();
744 with_due_dates.retain(|task| task.task.due_date.is_some());
746 with_due_dates.sort_by_key(|task| task.task.due_date);
747
748 let mut without_due_dates = tasks;
749 without_due_dates.retain(|task| task.task.due_date.is_none());
750
751 with_due_dates.append(&mut without_due_dates);
752 tasks = with_due_dates;
753 }
754 CompletionStatus::Completed => {
755 tasks.sort_by_key(|task| Reverse(task.task.completed_date));
756 }
757 }
758
759 Ok(tasks)
760 }
761}
762
763fn collect_file_paths(
766 dir: &Path,
767 extensions: &Vec<String>,
768 mut results: Vec<PathBuf>,
769) -> Result<Vec<PathBuf>, TfError> {
770 let mut config_dir = match dirs::config_dir() {
772 Some(v) => v,
773 None => return Err(ConfigError::LocateConfigDir)?,
774 };
775 config_dir.push("taskfinder/ignore");
776 let ignored_dirs = match fs::read_to_string(config_dir) {
777 Ok(v) => v
778 .lines()
779 .filter(|l| !l.is_empty())
780 .map(Path::new)
781 .map(|p| p.to_path_buf())
782 .collect::<Vec<_>>(),
783 Err(_) => vec![],
784 };
785
786 if dir.is_dir() {
788 'outer: for entry in fs::read_dir(dir)? {
789 let entry = entry?;
790 let path = entry.path();
791 if path.is_dir() {
792 if !ignored_dirs.is_empty() {
793 for ignored_dir in &ignored_dirs {
794 if path.starts_with(ignored_dir) {
795 continue 'outer;
796 }
797 }
798 }
799 results = collect_file_paths(&path, extensions, results)?;
800 } else if let Some(v) = path.extension() {
801 if extensions.contains(&v.to_str().unwrap().to_owned()) {
802 results.push(path)
803 }
804 }
805 }
806 }
807 Ok(results)
808}
809
810pub fn edit(file: PathBuf, line: Option<usize>) -> io::Result<ExitStatus> {
812 let editor = match env::var("EDITOR") {
813 Ok(v) => v,
814 Err(_) => String::from("vim"),
815 };
816
817 if let Some(v) = line {
818 if ["hx", "vi", "vim", "nvim"].contains(&editor.as_str()) {
819 Command::new(editor)
820 .args([file, format!("+{v}").into()])
821 .status()
822 } else {
823 Command::new(editor).args([file]).status()
824 }
825 } else {
826 Command::new(editor).args([file]).status()
827 }
828}
829
830pub fn centered_rect(x: u16, y: u16, r: Rect, sidebar_size: u16) -> Rect {
834 let dialog_layout = Layout::vertical([
835 Constraint::Fill(1),
836 Constraint::Length(y),
837 Constraint::Fill(1),
838 ])
839 .split(r);
840
841 Layout::horizontal([
842 Constraint::Fill(1),
843 Constraint::Length(x),
844 Constraint::Fill(1),
845 Constraint::Length(sidebar_size),
846 ])
847 .split(dialog_layout[1])[1]
848}
849
850enum DateKind {
852 Due,
853 Completed,
854}
855
856fn make_date(s: &str, date_kind: DateKind) -> NaiveDate {
860 let prefix = match date_kind {
861 DateKind::Due => "due:",
862 DateKind::Completed => "completed:",
863 };
864
865 match NaiveDate::from_str(s.strip_prefix(prefix).unwrap()) {
866 Ok(v) => v,
867 Err(_) => NaiveDate::from_str("1900-01-01").unwrap(),
868 }
869}
870
871#[cfg(test)]
872mod tests {
873 use super::*;
874 use std::fs;
875
876 #[test]
877 fn readme_contains_current_version_tag() {
880 let readme = fs::read_to_string(Path::new("README.md")).unwrap();
881 assert!(readme.contains(std::env!("CARGO_PKG_VERSION")))
882 }
883 #[test]
884 fn extract_correct_number_of_task_sets() {
885 let file = Path::new("test_files/01_basics.txt");
886 let file_with_tasks = FileWithTasks::extract(file, 365).unwrap().unwrap();
887 assert_eq!(file_with_tasks.task_sets.len(), 2);
888 }
889
890 #[test]
891 fn extract_correct_number_of_tasks_in_task_set() {
892 let file = Path::new("test_files/01_basics.txt");
893 let task_sets = FileWithTasks::extract(file, 365)
894 .unwrap()
895 .unwrap()
896 .task_sets
897 .clone();
898 assert_eq!(task_sets[0].tasks.len(), 2);
899 assert_eq!(task_sets[1].tasks.len(), 4);
900 }
901
902 #[test]
903 fn tasks_ignored_with_no_todo_header() {
904 let file = Path::new("test_files/no_tasks.txt");
905 let task_sets = FileWithTasks::extract(file, 365).unwrap();
906 assert!(task_sets.is_none());
907 }
908
909 #[test]
910 fn highest_priority_in_file_is_correct() {
911 let priority = Priority::extract(
912 &fs::read_to_string(Path::new("test_files/04_priorities.txt")).unwrap(),
913 );
914 assert_eq!(priority, Some(Priority::One));
915
916 let priority =
917 Priority::extract(&fs::read_to_string(Path::new("test_files/01_basics.txt")).unwrap());
918 assert_eq!(priority, None);
919 }
920
921 #[test]
922 fn due_date_inheritance_is_correct() {
923 let file_with_dates = FileWithTasks::extract(Path::new("test_files/03_dates.txt"), 365)
924 .unwrap()
925 .unwrap();
926
927 assert_eq!(
928 file_with_dates.task_sets[0].due_date,
929 Some(NaiveDate::from_str("2025-04-30").unwrap())
930 );
931 assert!(file_with_dates.task_sets[1].due_date.is_none());
932 assert_eq!(
933 file_with_dates.task_sets[0].tasks[0].due_date,
934 Some(NaiveDate::from_str("2025-04-30").unwrap())
935 );
936 assert_eq!(
937 file_with_dates.task_sets[0].tasks[1].due_date,
938 Some(NaiveDate::from_str("2025-04-30").unwrap())
939 );
940 }
941}