toddi 0.2.0

A TODO focuser built on top of todo.txt
Documentation
use anyhow::{anyhow, Result};
use regex::{escape, Regex};

pub mod config;

/// Representation of a project being worked on.
pub struct Project {
    /// Project name
    pub name: String,

    /// Current task
    pub current_task: Task,

    /// Number of tasks left to do.
    pub todo: u8,

    /// Number of tasks done.
    pub done: u8,

    /// Remaining tasks
    pub tasks: Vec<String>,
}

#[derive(Debug)]
/// Representation of a task line as defined in todo.txt
pub struct Task {
    /// Task priority
    pub priority: Option<char>,

    /// Task description
    pub description: String,

    /// Task's project
    pub project: String,

    /// Task's context
    pub context: String,
}

impl Task {
    fn new(task_line: &str) -> Result<Self> {
        match validate_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) => {
                        let prio: Vec<_> = prio.as_str().to_string().chars().collect();
                        Some(prio[0])
                    }
                    None => None,
                };
                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,
                })
            }
            Err(err) => {
                eprintln!(
                    "could not validate task_line: {}\nError: {}",
                    task_line, err
                );
                Ok(Self {
                    priority: None,
                    description: task_line.to_string(),
                    project: "".to_string(),
                    context: "".to_string(),
                })
            }
        }
    }
}

fn validate_task_line(task_line: &str) -> Result<Regex, String> {
    let re1 = match Regex::new(
        r"^([\(](?<priority>[A-Z])[\)])?(?<description>[[:alnum:]\s_\-\.']+)+(\+(?<project>[[:alnum:]]+))?\s?(@(?<context>[[:alnum:]]+))?$",
    ) {
        Ok(re) => re,
        Err(err) => return Err(format!("Issue with pattern: {}", err).to_string()),
    };
    let re2 = match Regex::new(
        r"^([\(](?<priority>[A-Z])[\)])?(?<description>[[:alnum:]\s_\-\.']+)+(@(?<context>[[:alnum:]]+))?\s?(\+(?<project>[[:alnum:]]+))?$",
    ) {
        Ok(re) => re,
        Err(err) => return Err(format!("Issue with pattern: {}", err).to_string()),
    };
    if re1.is_match(task_line) {
        Ok(re1)
    } else if re2.is_match(task_line) {
        Ok(re2)
    } else {
        Err("Invalid task line.".to_string())
    }
}

pub fn get_project(name: String, todos: &str, dones: &str) -> Result<Option<Project>> {
    let pattern = escape(&name);
    let pattern = String::from(r"\+") + &pattern + r"\b";
    let re = match Regex::new(&pattern) {
        Ok(re) => re,
        Err(err) => {
            eprintln!("could not use pattern: {}\nError: {}", &pattern, err);
            return Ok(None);
        }
    };
    if re.is_match(todos) || re.is_match(dones) {
        let mut todo_matches: Vec<_> = todos.lines().filter(|l| re.is_match(l)).collect();
        let done_count = re.find_iter(dones).count();
        todo_matches.sort();
        Ok(Some(Project {
            name,
            current_task: Task::new(todo_matches[0])?,
            todo: todo_matches.len() as u8,
            done: done_count as u8,
            tasks: todo_matches.iter().map(|m| m.to_string()).collect(),
        }))
    } else {
        Ok(None)
    }
}

pub fn get_top_task(todos: &str) -> Result<Option<Task>> {
    let mut todos: Vec<_> = todos.lines().collect();
    if todos.is_empty() {
        Ok(None)
    } else {
        todos.sort();
        let task_line = todos[0];
        Ok(Some(Task::new(task_line)?))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    const TEST_DATA: &str = r"
(C) do not match +testproj
(D) match @context +projtest
(B) match this one +projtest @textcon
(D) do not match +testproj
";

    const DONE_DATA: &str = r"
(E) do not match +testproj
(A) match done @context +projtest
(C) match done +projtest @textcon
(B) match done +projtest @textcon
(F) do not match +testproj
";

    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";

    #[test]
    fn test_get_project() {
        if let Some(project) = get_project("projtest".to_string(), TEST_DATA, DONE_DATA).unwrap() {
            assert_eq!(project.name, "projtest");
            assert_eq!(project.current_task.description, "match this one");
            assert_eq!(project.todo, 2);
            assert_eq!(project.done, 3);
        }
    }

    #[test]
    fn test_get_no_project() {
        assert!(get_project("absent".to_string(), TEST_DATA, DONE_DATA)
            .unwrap()
            .is_none());
    }

    #[test]
    fn test_valid_tasks() {
        for line in VALID_TASKS.lines() {
            assert!(validate_task_line(line).is_ok());
        }
    }

    #[test]
    fn test_valid_top_task() {
        let result = get_top_task(VALID_TASKS).unwrap().unwrap();
        assert_eq!(result.priority.unwrap(), 'A');
        assert_eq!(
            result.description,
            "A description's _fine_ example-sentence."
        );
        assert_eq!(result.project, "alph2a");
        assert_eq!(result.context, "context");
    }

    #[test]
    fn test_invalid_top_task() {
        let result = get_top_task(INVALID_TASKS).unwrap().unwrap();
        assert_eq!(result.priority, None);
        assert_eq!(result.description, "() description & ? ! , ; 3 @context");
        assert_eq!(result.project, "");
        assert_eq!(result.context, "");
    }

    #[test]
    fn test_empty_todo() {
        assert!(get_top_task("").unwrap().is_none());
    }

    #[test]
    fn test_invalid_tasks() {
        for line in INVALID_TASKS.lines() {
            assert!(validate_task_line(line).is_err());
        }
    }
}