taskfinder/
lib.rs

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
42// Configure regex for finding due dates.
43pub 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
48// regex for finding tags.
49pub static TAG_RE: LazyLock<Regex> =
50    LazyLock::new(|| Regex::new(r"(?<tag>#[\w&&[^\s]&&[^#]+]+)").unwrap());
51
52// regex for order.
53pub static ORDER_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?:::[0-9]*)").unwrap());
54
55/// The errors that can occur.
56#[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
72/// The data and state of the app.
73pub 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    /// Initialize current mode of the app.
134    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                // Need to first collect files, since tasks come from them.
144                self.filemode.files = FileWithTasks::collect(&self.config)?;
145
146                // Collect the tasks.
147                self.taskmode.data = RichTask::collect(self)?;
148
149                // Set the dates.
150                self.taskmode.dates = tasks_mode::Dates::default();
151
152                // Keep position in table (roughly at least), but protect against
153                // out-of-bounds error
154                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                // Set back to table, the default submode.
164                self.logmode.submode = LogSubMode::Table;
165
166                // Recalculate latest log data - this is an expensive operation;
167                // only do it when log is opened, not during navigation of log.
168                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/// Priority of a file, task set, or task, from lowest to highest.
193#[derive(Clone, Copy, PartialEq, PartialOrd, Ord, Hash, Eq, Debug)]
194pub enum Priority {
195    NoPriority, // Explicitly set.
196    Five,
197    Four,
198    Three,
199    Two,
200    One,
201}
202
203impl Priority {
204    /// Extract the highest priority from some amount text.
205    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; // return early, to return highest priority found
220            }
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/// How a file can be categorized.
258#[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
275/// Status of a task.
276pub 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/// A file that contains tasks.
291#[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    /// Get all files from configured directory that contain tasks.
305    pub fn collect(config: &Config) -> Result<Vec<FileWithTasks>, TfError> {
306        // Gather all possible files to examine.
307        let paths = collect_file_paths(&config.path, &config.file_extensions, vec![])?;
308
309        // Extract the tasks.
310        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        // Sort tasks sets within files.
317        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    /// Extract data from a file.
326    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        // Set file status.
339        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        // The first two lines of the file - the title and tags - are the head.
349        let head: Vec<String> = contents.lines().take(2).map(String::from).collect();
350
351        // File-level tags.
352        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        // file-level priority
358        let file_priority = Priority::extract(&head.join("\n"));
359
360        // Create the FileWithTasks.
361        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            // Don't do anything with a line until there's a taskset.
375            if task_set.is_none() && !line.trim().contains("TODO") {
376                continue;
377            }
378
379            // Create TaskSet if we come to a TODO section
380            if line.trim().contains("TODO") {
381                // If there was an existing TaskSet, we've come to a new one,
382                // so save the existing one by appending it to Vec we're returning.
383                if let Some(v) = task_set {
384                    file_with_tasks.task_sets.push(v);
385                }
386
387                // Start new TaskSet.
388                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 the task set has no  priority, it inherits the priority of its file, if any.
393                // (Unless it has the explicit no priority marker.)
394                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                // Taskset-level tags, which inherit/include the file-level tags.
409                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                // Get the name of the task set only.
416                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            // Extract tasks if we're in a task set.
445            if let Some(ts) = &mut task_set {
446                for ider in TASK_IDENTIFIERS {
447                    if line.trim_start().starts_with(ider) {
448                        // Set priority at the task level, inheriting if necessary.
449                        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                        // Set due date at the task level, inheriting if necessary.
459                        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                        // Set completed date at the task level, inheriting if necessary.
466                        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                        // Task-level tags, which inherit taskset-level tags (which in turn
476                        // inherited file-level tags).
477                        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                        // Set order, if any
484                        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                        // Get task name only.
491                        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                        // Indicate if the file has a task with a due date, to ease work elsewhere.
523                        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        // Add last/only TaskSet to the FileWithTasks
533        if let Some(v) = task_set {
534            file_with_tasks.task_sets.push(v);
535        }
536
537        // If any task set contains tasks, return it. Otherwise (if all tasks sets are empty),
538        // it falls through to returning None.
539        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/// A group of tasks.
549#[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/// An individual task.
579#[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/// A task that includes the task set and file it belongs to.
592#[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    /// Collect all rich tasks.
603    pub fn collect(app: &App) -> Result<Vec<Self>, TfError> {
604        // Start from files that have already been collected - that's an expensive operation
605        // and no need to re-do it here.
606        let mut files = app.filemode.files.clone();
607
608        // Filter files by status.
609        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        // Filter tasks/collect as RichTasks.
622        let mut tasks = vec![];
623
624        // Instead of having this if/else statement, the check could be done just during the
625        // loop through tasks, but it's better for performance to avoid that check if it's not
626        // necessary.
627        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                        // Filter by completion status.
632                        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                        // Filter by task-level tag.
645                        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                        // Filter by recurring status.
652                        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                        // Filter files by search term.
666                        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                        // Filter by completion status.
688                        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                        // Filter by tag.
701                        if !app.taskmode.tag_dialog.submitted_input.is_empty()
702                            && !task.tags.contains(&app.taskmode.tag_dialog.submitted_input)
703                        {
704                            continue;
705                        }
706                        // Filter by recurring status.
707                        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        // Sort, first by priority.
733        tasks.sort_by_key(|task| Reverse(task.task.priority));
734
735        // Sort, next by order.
736        tasks.sort_by_key(|task| task.task.order.unwrap_or(9999));
737
738        // Sort, next by due or completed due.
739        match app.taskmode.completion_status {
740            CompletionStatus::Incomplete => {
741                // Split vec in two in order to do this - otherwise it will put those without
742                // due dates first.
743                let mut with_due_dates = tasks.clone();
744                // with_due_dates.sort_by_key(|task| task.task.order);
745                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
763/// Collect all paths within configured directory and with specified extensions, ignoring any
764/// full paths listed in the ignore file.
765fn collect_file_paths(
766    dir: &Path,
767    extensions: &Vec<String>,
768    mut results: Vec<PathBuf>,
769) -> Result<Vec<PathBuf>, TfError> {
770    // Get ignored directories.
771    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    // Loop through directories, excluding ignored, and
787    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
810/// Open a file for editing.
811pub 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
830/// Create a centered rect, offset by sidebar.
831///
832/// Modified version of <https://ratatui.rs/how-to/layout/center-a-rect/>.
833pub 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
850/// Whether a date is a due date or a completed date.
851enum DateKind {
852    Due,
853    Completed,
854}
855
856/// Create date from text.
857///
858/// If the date is invalid, this sets it to one long in the past to indicate that to the user.
859fn 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    /// The readme links to the usage doc corresponding to the most recent release, and the tag
878    /// should match the format of the version, so make sure it's there.
879    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}