use std::path::PathBuf;
use chrono::Local;
use regex::Regex;
use anyhow::{anyhow, Result};
use crate::{
egress,
ingress::{SrcType, TaskSrc},
DONE_FILE,
};
#[derive(Debug, Clone)]
pub struct Project {
pub name: String,
pub todo: u8,
pub done: u8,
tasks: TaskList,
}
impl Project {
pub fn search_project(
name: String,
todo_tasks: TaskList,
done_tasks: CompletedTaskList,
) -> Option<Project> {
if todo_tasks.clone().contains_project(name.clone())
|| done_tasks.clone().contains_project(name.clone())
{
let project_task_list = todo_tasks.clone().get_project_tasks(name.clone());
let todos = project_task_list.clone().tasks;
let dones = done_tasks.get_project_tasks(name.clone()).tasks;
Some(Project {
name,
todo: todos.len() as u8,
done: dones.len() as u8,
tasks: project_task_list,
})
} else {
None
}
}
pub fn get_current_task(self) -> Option<Task> {
self.tasks.get_current_task()
}
pub fn complete_current_task(self) -> Result<()> {
if let Some(current_task) = self.get_current_task() {
let task_line = current_task.raw.clone();
let source_type = current_task.source_type.clone();
let source_location = current_task.source_location.clone();
egress::remove_task(task_line, source_type, source_location)?;
let completed_task: CompletedTask = current_task.complete()?;
egress::add_completed_task(
completed_task.raw,
completed_task.source_type,
completed_task.source_location,
)?;
Ok(())
} else {
Err(anyhow!(
"Could not obtain and complete project's current task."
))
}
}
}
#[derive(Debug, Clone, Eq)]
pub struct CompletedTask {
#[allow(dead_code)]
description: String,
project: String,
#[allow(dead_code)]
context: String,
raw: String,
#[allow(dead_code)]
source_type: SrcType,
#[allow(dead_code)]
source_location: String,
#[allow(dead_code)]
completion_date: String,
}
impl PartialEq for CompletedTask {
fn eq(&self, other: &Self) -> bool {
self.completion_date == other.completion_date
}
}
impl PartialOrd for CompletedTask {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for CompletedTask {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.completion_date.cmp(&other.raw)
}
}
impl CompletedTask {
fn new(task_line: &str, source_type: SrcType, source_location: String) -> Result<Self> {
let raw = task_line.to_string();
match Self::validate_completed_task_line(task_line) {
Ok(re) => {
let caps = match re.captures(task_line) {
Some(caps) => caps,
None => return Err(anyhow!("The regex is already matched during validation, this should not be happening!\ntask_line: {:?}\nre: {:?}", task_line, re)),
};
let description = match caps.name("description") {
Some(desc) => desc.as_str().trim().to_string(),
None => "".to_string(),
};
let project = match caps.name("project") {
Some(proj) => proj.as_str().trim().to_string(),
None => "".to_string(),
};
let context = match caps.name("context") {
Some(context) => context.as_str().trim().to_string(),
None => "".to_string(),
};
let completion_date = match caps.name("date") {
Some(date) => date.as_str().trim().to_string(),
None => "".to_string(),
};
Ok(Self {
description,
project,
context,
raw,
source_type,
source_location,
completion_date,
})
}
Err(err) => {
eprintln!(
"could not validate completed task_line: {}\nError: {}",
task_line, err
);
Ok(Self {
description: task_line.to_string(),
project: "".to_string(),
context: "".to_string(),
raw,
source_type,
source_location,
completion_date: "".to_string(),
})
}
}
}
fn validate_completed_task_line(task_line: &str) -> Result<Regex> {
let re1 = match Regex::new(
r"^x (?<date>[\d]{4}-[\d]{2}-[\d]{2}){1}(?<description>[[:alnum:]ë\s_\-\.']+)+(\+(?<project>[[:alnum:]]+))?\s?(@(?<context>[[:alnum:]]+))?$",
) {
Ok(re) => re,
Err(err) => return Err(anyhow!("Issue with pattern: {}", err)),
};
let re2 = match Regex::new(
r"^x (?<date>[\d]{4}-[\d]{2}-[\d]{2}){1}(?<description>[[:alnum:]ë\s_\-\.']+)+(@(?<context>[[:alnum:]]+))?\s?(\+(?<project>[[:alnum:]]+))?$",
) {
Ok(re) => re,
Err(err) => return Err(anyhow!("Issue with pattern: {}", err)),
};
if re1.is_match(task_line) {
Ok(re1)
} else if re2.is_match(task_line) {
Ok(re2)
} else {
Err(anyhow!("Invalid task line."))
}
}
}
#[derive(Debug, Clone, Eq)]
pub struct Task {
#[allow(dead_code)]
priority: char,
pub description: String,
pub project: String,
#[allow(dead_code)]
context: String,
raw: String,
#[allow(dead_code)]
source_type: SrcType,
#[allow(dead_code)]
source_location: String,
}
impl PartialEq for Task {
fn eq(&self, other: &Self) -> bool {
self.raw == other.raw
}
}
impl PartialOrd for Task {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Task {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.raw.cmp(&other.raw)
}
}
impl Task {
fn new(task_line: &str, source_type: SrcType, source_location: String) -> Result<Self> {
let raw = task_line.to_string();
match Self::validate_todo_task_line(task_line) {
Ok(re) => {
let caps = match re.captures(task_line) {
Some(caps) => caps,
None => return Err(anyhow!("The regex is already matched during validation, this should not be happening!\ntask_line: {:?}\nre: {:?}", task_line, re)),
};
let priority = match caps.name("priority") {
Some(prio) => {
if let Some(prio) = prio.as_str().chars().nth(0) {
prio
} else {
return Err(anyhow!("Could not extract first char from single character priority string {:?}", prio));
}
}
None => 'Z',
};
let description = match caps.name("description") {
Some(desc) => desc.as_str().trim().to_string(),
None => "".to_string(),
};
let project = match caps.name("project") {
Some(proj) => proj.as_str().trim().to_string(),
None => "".to_string(),
};
let context = match caps.name("context") {
Some(cont) => cont.as_str().trim().to_string(),
None => "".to_string(),
};
Ok(Self {
priority,
description,
project,
context,
raw,
source_type,
source_location,
})
}
Err(err) => {
eprintln!(
"could not validate todo task_line: {}\nError: {}",
task_line, err
);
Ok(Self {
priority: 'Z',
description: task_line.to_string(),
project: "".to_string(),
context: "".to_string(),
raw,
source_type,
source_location,
})
}
}
}
fn validate_todo_task_line(task_line: &str) -> Result<Regex> {
let re1 = match Regex::new(
r"^([\(](?<priority>[A-Z])[\)])?(?<description>[[:alnum:]ë\s_\-\.']+)+(\+(?<project>[[:alnum:]]+))?\s?(@(?<context>[[:alnum:]]+))?$",
) {
Ok(re) => re,
Err(err) => return Err(anyhow!("Issue with pattern: {}", err)),
};
let re2 = match Regex::new(
r"^([\(](?<priority>[A-Z])[\)])?(?<description>[[:alnum:]ë\s_\-\.']+)+(@(?<context>[[:alnum:]]+))?\s?(\+(?<project>[[:alnum:]]+))?$",
) {
Ok(re) => re,
Err(err) => return Err(anyhow!("Issue with pattern: {}", err)),
};
if re1.is_match(task_line) {
Ok(re1)
} else if re2.is_match(task_line) {
Ok(re2)
} else {
Err(anyhow!("Invalid task line."))
}
}
fn complete(&self) -> Result<CompletedTask> {
let source_type = self.source_type.clone();
let description = self.description.clone();
let context = self.context.clone();
let project = self.project.clone();
let completion_date = Local::now().format("%Y-%m-%d").to_string();
let source_location: String;
let path_buf = PathBuf::from(self.source_location.clone());
if let Some(parent) = path_buf.parent() {
let mut path_buf = parent.to_path_buf();
path_buf.push(DONE_FILE);
if let Some(path) = path_buf.to_str() {
source_location = String::from(path);
} else {
return Err(anyhow!("Failed to convert source_location path to string."));
}
} else {
return Err(anyhow!("Failed to extract source_location directory."));
}
let mut raw = String::new();
raw.push('x');
raw.push(' ');
raw.push_str(&completion_date);
raw.push(' ');
raw.push_str(&description);
if !project.is_empty() {
raw.push(' ');
raw.push('+');
raw.push_str(&project);
}
if !context.is_empty() {
raw.push(' ');
raw.push('@');
raw.push_str(&context);
}
Ok(CompletedTask {
description,
project,
context,
raw,
source_type,
source_location,
completion_date,
})
}
}
#[derive(Clone)]
pub struct CompletedTaskList {
tasks: Vec<CompletedTask>,
}
impl CompletedTaskList {
pub fn new(sources: Vec<TaskSrc>) -> Result<Self> {
let mut tasks: Vec<CompletedTask> = vec![];
for source in sources {
tasks.push(CompletedTask::new(
&source.data,
source.kind,
source.location,
)?);
}
Ok(Self { tasks })
}
fn contains_project(self, name: String) -> bool {
self.tasks.into_iter().any(|t| t.project == name)
}
fn get_project_tasks(self, name: String) -> CompletedTaskList {
CompletedTaskList {
tasks: self
.tasks
.into_iter()
.filter(|t| t.project == name)
.collect(),
}
}
}
#[derive(Debug, Clone)]
pub struct TaskList {
tasks: Vec<Task>,
}
impl TaskList {
pub fn new(sources: Vec<TaskSrc>) -> Result<Self> {
let mut tasks: Vec<Task> = vec![];
for source in sources {
tasks.push(Task::new(&source.data, source.kind, source.location)?);
}
tasks.sort();
Ok(Self { tasks })
}
fn contains_project(self, name: String) -> bool {
self.tasks.into_iter().any(|t| t.project == name)
}
fn get_project_tasks(self, name: String) -> TaskList {
TaskList {
tasks: self
.tasks
.into_iter()
.filter(|t| t.project == name)
.collect(),
}
}
pub fn get_current_task(self) -> Option<Task> {
if self.tasks.is_empty() {
None
} else {
self.tasks.first().map(|task| task.to_owned())
}
}
pub fn complete_current_task(self) -> Result<()> {
if let Some(current_task) = self.get_current_task() {
let task_line = current_task.raw.clone();
let source_type = current_task.source_type.clone();
let source_location = current_task.source_location.clone();
egress::remove_task(task_line, source_type, source_location)?;
let completed_task: CompletedTask = current_task.complete()?;
egress::add_completed_task(
completed_task.raw,
completed_task.source_type,
completed_task.source_location,
)?;
Ok(())
} else {
Err(anyhow!("Could not obtain and complete current task."))
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
const TEST_DATA: &str = r"(C) alpha task 1 +alpha @context
(D) bravo task 1 @context +bravo
(B) bravo task 2 +bravo @context
an unprioritised task 1 +monday @week
(D) alpha task 2 +alpha";
const DONE_DATA: &str = r"x 2025-01-11 done alpha task 1 +alpha
x 2025-01-10 done bravo task 2 +bravo";
const VALID_TASKS: &str = r"(A) A description's _fine_ example-sentence. +alph2a @context
(B) description 2 @context +alph2a
(C) description 3 +alph2a
(D) description 4 @context
(E) description 5
description 6 +alph2a @context
(F) description 7 @context +alph2a
description 8 +alph2a
description 9 @context
description 10";
const INVALID_TASKS: &str = r"(b) description 1 @context +alph2a
(C) description 2 +alph2a desc
() description & ? ! , ; 3 @context
(3) description 4
description 5 +alph2a description @context
description 6 +alph2a description
description @ 7
description + 8";
fn get_test_sources(location: String, test_data: &str) -> Vec<TaskSrc> {
let mut sources: Vec<TaskSrc> = vec![];
for data in test_data.to_string().lines() {
let test_task_src = TaskSrc {
data: data.to_string(),
kind: SrcType::File,
location: location.clone(),
};
sources.push(test_task_src);
}
sources
}
fn get_test_tasklist(location: String) -> TaskList {
let sources = get_test_sources(location.clone(), TEST_DATA);
TaskList::new(sources).unwrap()
}
fn get_test_completedtasklist(location: String) -> CompletedTaskList {
let sources = get_test_sources(location.clone(), DONE_DATA);
CompletedTaskList::new(sources).unwrap()
}
#[test]
fn test_search_project() {
let name = "alpha".to_string();
let location = "/path/to/source".to_string();
let todo_tasks = get_test_tasklist(location.clone());
let done_tasks = get_test_completedtasklist(location);
let result = Project::search_project(name, todo_tasks.clone(), done_tasks).unwrap();
assert_eq!(result.name, "alpha");
assert_eq!(result.todo, 2);
assert_eq!(result.done, 1);
}
#[test]
fn test_project_get_current_task() {
let name = "alpha".to_string();
let location = "/path/to/source".to_string();
let todo_tasks = get_test_tasklist(location.clone());
let done_tasks = get_test_completedtasklist(location);
let result = Project::search_project(name, todo_tasks.clone(), done_tasks)
.unwrap()
.clone()
.get_current_task()
.unwrap();
assert_eq!(result.raw, "(C) alpha task 1 +alpha @context");
assert_eq!(result.priority, 'C');
assert_eq!(result.description, "alpha task 1");
assert_eq!(result.project, "alpha");
assert_eq!(result.context, "context");
assert_eq!(result.source_type, SrcType::File);
assert_eq!(result.source_location, "/path/to/source");
}
#[test]
fn test_unprioritised_task() {
let name = "monday".to_string();
let location = "/path/to/source".to_string();
let todo_tasks = get_test_tasklist(location.clone());
let done_tasks = get_test_completedtasklist(location);
let result = Project::search_project(name, todo_tasks.clone(), done_tasks)
.unwrap()
.clone()
.get_current_task()
.unwrap();
assert_eq!(result.raw, "an unprioritised task 1 +monday @week");
assert_eq!(result.priority, 'Z');
assert_eq!(result.description, "an unprioritised task 1");
assert_eq!(result.project, "monday");
assert_eq!(result.context, "week");
assert_eq!(result.source_type, SrcType::File);
assert_eq!(result.source_location, "/path/to/source");
}
#[test]
fn test_tasklist_get_current_task() {
let location = "/path/to/source".to_string();
let result = get_test_tasklist(location.clone())
.get_current_task()
.unwrap();
assert_eq!(result.raw, "(B) bravo task 2 +bravo @context");
assert_eq!(result.priority, 'B');
assert_eq!(result.description, "bravo task 2");
assert_eq!(result.project, "bravo");
assert_eq!(result.context, "context");
assert_eq!(result.source_type, SrcType::File);
assert_eq!(result.source_location, "/path/to/source");
}
#[test]
fn test_task_complete_output() {
let location = "/path/to/source".to_string();
let current_task = get_test_tasklist(location.clone())
.get_current_task()
.unwrap();
let result = current_task.complete().unwrap();
let today = Local::now().format("%Y-%m-%d").to_string();
let mut raw = String::new();
raw.push('x');
raw.push(' ');
raw.push_str(&today);
raw.push(' ');
raw.push_str("bravo task 2");
raw.push(' ');
raw.push_str("+bravo");
raw.push(' ');
raw.push_str("@context");
assert_eq!(result.completion_date, today);
assert_eq!(result.description, "bravo task 2");
assert_eq!(result.raw, raw);
assert_eq!(result.project, "bravo");
assert_eq!(result.context, "context");
}
#[test]
fn test_valid_tasks() {
for line in VALID_TASKS.lines() {
assert!(Task::validate_todo_task_line(line).is_ok());
}
}
#[test]
fn test_invalid_tasks() {
for line in INVALID_TASKS.lines() {
assert!(Task::validate_todo_task_line(line).is_err());
}
}
}