todotxt-tui 0.3.0

Todo.txt TUI is a highly customizable terminal-based application for managing your todo tasks. It follows the todo.txt format and offers a wide range of configuration options to suit your needs.
Documentation
use crate::{config::FileWorkerConfig, file_worker::file_format::FileFormatTrait, todo::ToDo};
use anyhow::{Context, Result};
use std::{
    fs::File,
    io::{BufRead, BufReader, BufWriter, Read, Write},
    path::{Path, PathBuf},
    str::FromStr,
};
use todo_txt::task::Simple as Task;

fn load_from_reader<R: Read>(reader: R, todo: &mut ToDo) -> Result<()> {
    for line in BufReader::new(reader).lines() {
        let line = line?;
        let line = line.trim();
        if line.is_empty() {
            continue;
        }
        match Task::from_str(line) {
            Ok(task) => todo.add_task(task),
            Err(e) => log::warn!("Task cannot be load due {e}: {line}"),
        }
    }
    Ok(())
}

fn save_to_writer<W: Write>(writer: &mut W, tasks: &[Task]) -> Result<()> {
    let mut writer = BufWriter::new(writer);
    for task in tasks.iter() {
        writer.write_all((task.to_string() + "\n").as_bytes())?;
    }
    Ok(())
}

pub struct TodoTxt {
    path: PathBuf,
    archive: Option<PathBuf>,
}

impl TodoTxt {
    pub fn new(config: &FileWorkerConfig) -> Self {
        Self {
            path: config.todo_path.clone(),
            archive: config.archive_path.clone(),
        }
    }
}

impl FileFormatTrait for TodoTxt {
    fn load_tasks(&self, todo: &mut ToDo) -> Result<()> {
        load_tasks(&self.path, todo)?;
        if let Some(archive) = &self.archive {
            load_tasks(archive, todo)?;
        }
        Ok(())
    }

    fn save_tasks(&self, todo: &ToDo) -> Result<()> {
        match &self.archive {
            Some(archive) => {
                save_tasks(&self.path, &todo.pending)?;
                save_tasks(archive, &todo.done)
            }
            None => {
                let mut all = todo.pending.clone();
                all.extend_from_slice(&todo.done);
                save_tasks(&self.path, &all)
            }
        }
    }
}

fn load_tasks(path: &Path, todo: &mut ToDo) -> Result<()> {
    let file = File::open(path).with_context(|| format!("{path:?}"))?;
    load_from_reader(file, todo)
}

fn save_tasks(path: &Path, tasks: &[Task]) -> Result<()> {
    let file = File::create(path).with_context(|| format!("{path:?}"))?;
    save_to_writer(&mut { file }, tasks)
}

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

    const TESTING_STRING: &str = r#"
        x (A) 2023-05-21 2023-04-30 measure space for 1 +project1 @context1 #hashtag1 due:2023-06-30
                         2023-04-30 measure space for 2 +project2 @context2           due:2023-06-30
                     (C) 2023-04-30 measure space for 3 +project3 @context3           due:2023-06-30
                                    measure space for 4 +project2 @context3 #hashtag1 due:2023-06-30
                                  x measure space for 5 +project3 @context3 #hashtag2 due:2023-06-30
                                    measure space for 6 +project3 @context2 #hashtag2 due:2023-06-30
        "#;

    #[test]
    fn test_load_tasks() -> Result<()> {
        let mut todo = ToDo::default();
        load_from_reader(TESTING_STRING.as_bytes(), &mut todo)?;
        assert_eq!(todo.pending.len(), 4);
        assert_eq!(todo.done.len(), 2);
        assert_eq!(
            todo.pending[0].subject,
            "measure space for 2 +project2 @context2"
        );
        assert_eq!(
            todo.pending[1].subject,
            "measure space for 3 +project3 @context3"
        );
        assert_eq!(todo.pending[1].priority, 2);
        assert_eq!(
            todo.pending[2].subject,
            "measure space for 4 +project2 @context3 #hashtag1"
        );
        assert_eq!(
            todo.pending[3].subject,
            "measure space for 6 +project3 @context2 #hashtag2"
        );
        assert_eq!(
            todo.done[0].subject,
            "measure space for 1 +project1 @context1 #hashtag1"
        );
        assert_eq!(
            todo.done[1].subject,
            "measure space for 5 +project3 @context3 #hashtag2"
        );
        Ok(())
    }

    #[test]
    fn test_save_tasks() -> Result<()> {
        let mut todo = ToDo::default();
        load_from_reader(TESTING_STRING.as_bytes(), &mut todo)?;
        let get_expected = |line: fn(&String) -> bool| {
            TESTING_STRING
                .trim()
                .lines()
                .map(|line| line.split_whitespace().collect::<Vec<_>>().join(" "))
                .filter(line)
                .collect::<Vec<String>>()
                .join("\n")
                + "\n"
        };
        let pretty_assert = |tasks, expected: &str, msg: &str| -> Result<()> {
            let mut buf: Vec<u8> = Vec::new();
            save_to_writer(&mut buf, tasks)?;
            assert_eq!(
                expected.as_bytes(),
                buf,
                "\n-----{}-----\nGET:\n{}\n----------------\nEXPECTED:\n{}\n",
                msg,
                String::from_utf8(buf.clone()).unwrap(),
                expected
            );
            Ok(())
        };

        pretty_assert(
            &todo.pending,
            &get_expected(|line| !line.starts_with("x ")),
            "Pending check is wrong",
        )?;
        pretty_assert(
            &todo.done,
            &get_expected(|line| line.starts_with("x ")),
            "Done check is wrong",
        )?;

        Ok(())
    }
}