#![forbid(unsafe_code)]
use std::cmp::Reverse;
use std::collections::BTreeMap;
use std::env;
use std::fmt::{self, Display, Formatter};
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitStatus};
use std::str::FromStr;
use std::sync::LazyLock;
use std::time::SystemTime;
use chrono::NaiveDate;
use regex::Regex;
use crate::priority::make_priority_map;
use crate::{
TASK_IDENTIFIERS, TfError,
config::{Config, ConfigError},
priority::Priority,
tasks::{Task, TaskSet},
};
pub static DUE_DATE_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?:(?:due:|📅 ))([0-9]{4}-[0-9]{2}-[0-9]{2})").unwrap());
pub static COMPLETED_DATE_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?:(?:completed:|✅ ))([0-9]{4}-[0-9]{2}-[0-9]{2})").unwrap());
pub static RECURRING_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(\([1-9]*[dwmy]\))").unwrap());
pub static TAG_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?<tag>#[\w&&[^\s]&&[^#]+]+)").unwrap());
pub static ORDER_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?:::[0-9]*)").unwrap());
#[derive(PartialEq, Clone, Debug)]
pub enum FileStatus {
Active,
Archived,
Stale,
}
impl Display for FileStatus {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
match self {
FileStatus::Active => write!(f, "active"),
FileStatus::Archived => write!(f, "archived"),
FileStatus::Stale => write!(f, "stale"),
}
}
}
#[derive(Debug, Clone)]
pub struct FileWithTasks {
pub file: PathBuf,
pub last_modified: SystemTime,
pub priority: Option<Priority>,
pub status: FileStatus,
pub has_due_date: bool,
pub head: Vec<String>,
pub tags: Vec<String>,
pub task_sets: Vec<TaskSet>,
}
impl FileWithTasks {
pub fn collect(config: &Config) -> Result<Vec<FileWithTasks>, TfError> {
let paths = collect_file_paths(&config.path, &config.file_extensions, vec![])?;
let priority_map = make_priority_map(config.priority_markers.clone());
let mut files_with_tasks: Vec<FileWithTasks> = vec![];
for path in &paths {
if let Ok(Some(v)) = Self::extract(path, config.days_to_stale, priority_map.clone()) {
files_with_tasks.push(v);
}
}
for file in files_with_tasks.iter_mut() {
file.task_sets
.sort_unstable_by_key(|task_set| Reverse(task_set.priority))
}
Ok(files_with_tasks)
}
pub fn extract(
path: &Path,
days_to_stale: u64,
priority_map: BTreeMap<Priority, String>,
) -> Result<Option<FileWithTasks>, TfError> {
let contents = match fs::read_to_string(path) {
Ok(v) => v,
Err(_) => return Ok(None),
};
if !contents.contains("TODO") {
return Ok(None);
}
let last_modified = fs::metadata(path)?.modified()?;
let stale_threshold = std::time::Duration::from_secs(60 * 60 * 24 * days_to_stale);
let status = if contents.contains("@archived") {
FileStatus::Archived
} else if last_modified.elapsed()? > stale_threshold {
FileStatus::Stale
} else {
FileStatus::Active
};
let head: Vec<String> = contents.lines().take(2).map(String::from).collect();
let file_tags: Vec<String> = TAG_RE
.find_iter(&head.join(""))
.map(|m| m.as_str().strip_prefix("#").unwrap().to_string())
.collect();
let file_priority = Priority::extract(&head.join("\n"), priority_map.clone());
let mut file_with_tasks = FileWithTasks {
file: path.to_path_buf(),
last_modified,
priority: file_priority,
status,
has_due_date: false,
head,
tags: file_tags.clone(),
task_sets: vec![],
};
let mut task_set: Option<TaskSet> = None;
for (i, line) in contents.lines().enumerate() {
if task_set.is_none() && !line.trim().contains("TODO") {
continue;
}
if line.trim().contains("TODO") {
if let Some(v) = task_set {
file_with_tasks.task_sets.push(v);
}
let mut task_set_priority = Priority::extract(line, priority_map.clone());
let mut task_set_due_date = None;
let mut task_set_completed_date = None;
if task_set_priority.is_none()
&& !matches!(file_priority, Some(Priority::NoPriority))
{
task_set_priority = file_priority;
}
if let Some(dd) = DUE_DATE_RE.find(line.trim()) {
task_set_due_date = Some(make_date(dd.as_str()));
}
if let Some(cd) = COMPLETED_DATE_RE.find(line.trim()) {
task_set_completed_date = Some(make_date(cd.as_str()));
}
let mut ts_tags: Vec<String> = TAG_RE
.find_iter(line.trim())
.map(|m| m.as_str().strip_prefix("#").unwrap().to_string())
.collect();
ts_tags.append(&mut file_tags.clone());
let mut ts_text = line.to_string();
if !ts_tags.is_empty() {
for tag in &ts_tags {
ts_text = ts_text.replace(&format!("#{tag}"), "");
}
}
if let Some(dd) = task_set_due_date {
ts_text = ts_text.replace(&format!("due:{dd}"), "");
}
if let Some(cd) = task_set_completed_date {
ts_text = ts_text.replace(&format!("completed:{cd}"), "");
}
if let Some(v) = task_set_priority {
ts_text = ts_text.replace(&v.as_string(priority_map.clone()), "");
}
ts_text = ts_text.trim_end().to_string();
task_set = Some(TaskSet::new(
ts_text,
task_set_priority,
task_set_due_date,
task_set_completed_date,
ts_tags,
));
continue;
}
if let Some(ts) = &mut task_set {
for ider in TASK_IDENTIFIERS {
if line.trim_start().starts_with(ider) {
let mut task_priority = Priority::extract(line, priority_map.clone());
if task_priority.is_none() {
if ts.priority.is_some() {
task_priority = ts.priority;
} else if file_priority.is_some() {
task_priority = file_priority;
}
}
let due_date = if let Some(dd) = DUE_DATE_RE.find(line) {
Some(make_date(dd.as_str()))
} else {
ts.due_date
};
let completed_date = if let Some(cd) = COMPLETED_DATE_RE.find(line) {
Some(make_date(cd.as_str()))
} else {
ts.completed_date
};
let completed = line.trim().to_lowercase().starts_with("[x]")
|| line.trim().to_lowercase().starts_with("- [x]");
let recurring = RECURRING_RE.is_match(line);
let mut task_tags: Vec<String> = TAG_RE
.find_iter(line.trim())
.map(|m| m.as_str().strip_prefix("#").unwrap().to_string())
.collect();
task_tags.append(&mut ts.tags.clone());
let order = if let Some(o) = ORDER_RE.find(line) {
o.as_str().strip_prefix("::").unwrap().parse().ok()
} else {
None
};
let mut task_text = line.to_string();
if let Some(dd) = due_date {
task_text = task_text.replace(&format!("due:{dd}"), "");
task_text = task_text.replace(&format!("📅 {dd}"), "");
}
if let Some(cd) = completed_date {
task_text = task_text.replace(&format!("completed:{cd}"), "");
task_text = task_text.replace(&format!("✅ {cd}"), "");
}
if !task_tags.is_empty() {
for tag in &task_tags {
task_text = task_text.replace(&format!("#{tag}"), "");
}
}
if let Some(o) = order {
task_text = task_text.replace(&format!("::{o}"), "");
}
if let Some(v) = task_priority {
task_text = task_text.replace(&v.as_string(priority_map.clone()), "");
}
task_text = task_text.trim_end().to_string();
let task = Task {
text: task_text,
completed,
recurring,
completed_date,
due_date,
priority: task_priority,
tags: task_tags,
line: i + 1,
order,
};
if task.due_date.is_some() {
file_with_tasks.has_due_date = true;
}
ts.tasks.push(task);
break;
}
}
}
}
if let Some(v) = task_set {
file_with_tasks.task_sets.push(v);
}
for task_set in &file_with_tasks.task_sets {
if !task_set.tasks.is_empty() {
return Ok(Some(file_with_tasks));
}
}
Ok(None)
}
}
fn make_date(s: &str) -> NaiveDate {
let date_reversed = s.chars().rev().take(10).collect::<String>();
let date = date_reversed.chars().rev().collect::<String>();
match NaiveDate::from_str(&date) {
Ok(v) => v,
Err(_) => NaiveDate::from_str("1900-01-01").unwrap(),
}
}
fn collect_file_paths(
dir: &Path,
extensions: &Vec<String>,
mut results: Vec<PathBuf>,
) -> Result<Vec<PathBuf>, TfError> {
let mut config_dir = match dirs::config_dir() {
Some(v) => v,
None => return Err(ConfigError::LocateConfigDir)?,
};
config_dir.push("taskfinder/ignore");
let ignored_dirs = match fs::read_to_string(config_dir) {
Ok(v) => v
.lines()
.filter(|l| !l.is_empty())
.map(Path::new)
.map(|p| p.to_path_buf())
.collect::<Vec<_>>(),
Err(_) => vec![],
};
if dir.is_dir() {
'outer: for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
if !ignored_dirs.is_empty() {
for ignored_dir in &ignored_dirs {
if path.starts_with(ignored_dir) {
continue 'outer;
}
}
}
results = collect_file_paths(&path, extensions, results)?;
} else if let Some(v) = path.extension()
&& extensions.contains(&v.to_str().unwrap().to_owned())
{
results.push(path)
}
}
}
Ok(results)
}
pub fn edit(file: PathBuf, line: Option<usize>) -> io::Result<ExitStatus> {
let editor = match env::var("EDITOR") {
Ok(v) => v,
Err(_) => String::from("vim"),
};
if let Some(v) = line {
if ["helix", "hx", "vi", "vim", "nvim"].contains(&editor.as_str()) {
Command::new(editor)
.args([file, format!("+{v}").into()])
.status()
} else {
Command::new(editor).args([file]).status()
}
} else {
Command::new(editor).args([file]).status()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn make_date_due_date() {
assert_eq!(
make_date("due:2025-11-01"),
NaiveDate::from_str("2025-11-01").unwrap()
)
}
#[test]
fn make_date_due_emoji_date() {
assert_eq!(
make_date("✅ 2025-11-01"),
NaiveDate::from_str("2025-11-01").unwrap()
)
}
#[test]
fn make_date_completed_date() {
assert_eq!(
make_date("completed:2025-11-01"),
NaiveDate::from_str("2025-11-01").unwrap()
)
}
#[test]
fn make_date_completed_emoji_date() {
assert_eq!(
make_date("📅 2025-11-01"),
NaiveDate::from_str("2025-11-01").unwrap()
)
}
#[test]
fn make_date_handles_invalid_date() {
assert_eq!(
make_date("due:2025-04-31"),
NaiveDate::from_str("1900-01-01").unwrap()
)
}
}