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::HookPaths;
use anyhow::{anyhow, Context, Result};
use std::{
    fmt::Display,
    fs,
    path::{Path, PathBuf},
    process::Command,
};

/// Types of hooks that can be triggered before or after task operations.
pub enum HookTypes {
    /// Runs before a new task is created.
    PreNew,
    /// Runs after a new task is created.
    PostNew,
    /// Runs before a task is removed.
    PreRemove,
    /// Runs after a task is removed.
    PostRemove,
    /// Runs before a task is moved between lists.
    PreMove,
    /// Runs after a task is moved between lists.
    PostMove,
    /// Runs before a task is updated.
    PreUpdate,
    /// Runs after a task is updated.
    PostUpdate,
}

impl Display for HookTypes {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match &self {
            Self::PreNew => write!(f, "pre new task"),
            Self::PostNew => write!(f, "post new task"),
            Self::PreRemove => write!(f, "pre remove task"),
            Self::PostRemove => write!(f, "post remove task"),
            Self::PreMove => write!(f, "pre move task"),
            Self::PostMove => write!(f, "post move task"),
            Self::PreUpdate => write!(f, "pre update task"),
            Self::PostUpdate => write!(f, "post update task"),
        }
    }
}

/// Manages execution of user-defined hook scripts for task lifecycle events.
#[derive(Default)]
pub struct Hooks {
    paths: HookPaths,
}

impl Hooks {
    /// Creates a new `Hooks` instance with the given script paths.
    pub fn new(paths: HookPaths) -> Self {
        log::debug!("Hooks: {paths:#?}");
        Self { paths }
    }

    /// Executes a hook script at the given path with the task string as an argument.
    /// Returns the script's stdout on success, or an error if the command fails.
    fn run_command(path: &Path, task: &str) -> Result<String> {
        let mut cmd = Command::new("bash");
        let path = fs::canonicalize(path)?;
        cmd.arg("--").arg(&path).arg(task);
        if let Some(parent) = path.parent() {
            cmd.current_dir(parent);
        }
        let output = cmd.output()?;
        if !output.status.success() {
            return Err(anyhow!(
                "Failed to run hook {path:?}, stderr: {}",
                String::from_utf8(output.stderr).context("Failed to parse hook stderr")?
            ));
        }
        String::from_utf8(output.stdout).context("Failed to parse hook stdout")
    }

    /// Runs a hook command and logs the result using the given hook name.
    /// Returns `Some(stdout)` on success, or `None` on failure.
    fn run_command_with_name(path: &Path, task: &str, name: &str) -> Option<String> {
        log::info!("{name} hook: {path:?} task len: {}", task.len());
        match Self::run_command(path, task) {
            Ok(stdout) => {
                log::debug!("Hook {name} return {stdout}");
                Some(stdout)
            }
            Err(e) => {
                log::error!("Hook {name} failed: {e}");
                None
            }
        }
    }

    /// Returns the configured script path for the given hook type, if any.
    fn get_path(&self, hook_type: &HookTypes) -> Option<&PathBuf> {
        match hook_type {
            HookTypes::PreNew => self.paths.pre_new_task.as_ref(),
            HookTypes::PostNew => self.paths.post_new_task.as_ref(),
            HookTypes::PreRemove => self.paths.pre_remove_task.as_ref(),
            HookTypes::PostRemove => self.paths.post_remove_task.as_ref(),
            HookTypes::PreMove => self.paths.pre_move_task.as_ref(),
            HookTypes::PostMove => self.paths.post_move_task.as_ref(),
            HookTypes::PreUpdate => self.paths.pre_update_task.as_ref(),
            HookTypes::PostUpdate => self.paths.post_update_task.as_ref(),
        }
    }

    /// Runs the hook for the given type with the provided task string.
    /// Returns `Some(stdout)` if the hook is configured and succeeds, `None` otherwise.
    pub fn run(&self, hook_type: HookTypes, task: impl AsRef<str>) -> Option<String> {
        Self::run_command_with_name(
            self.get_path(&hook_type)?,
            task.as_ref(),
            &hook_type.to_string(),
        )
    }

    /// Runs the hook for the given type, lazily evaluating the task string only
    /// if the hook is configured. Returns `Some(stdout)` on success, `None` otherwise.
    pub fn run_lazy(&self, hook_type: HookTypes, task: impl Fn() -> String) -> Option<String> {
        Self::run_command_with_name(
            self.get_path(&hook_type)?,
            task().as_ref(),
            &hook_type.to_string(),
        )
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::env::var;
    use test_log::test;

    #[test]
    fn run_empty_hooks() {
        let hooks = Hooks::new(HookPaths::default());
        assert_eq!(hooks.run(HookTypes::PreNew, ""), None);
        assert_eq!(hooks.run(HookTypes::PostNew, ""), None);
        assert_eq!(hooks.run(HookTypes::PreRemove, ""), None);
        assert_eq!(hooks.run(HookTypes::PostRemove, ""), None);
        assert_eq!(hooks.run(HookTypes::PreMove, ""), None);
        assert_eq!(hooks.run(HookTypes::PostMove, ""), None);
        assert_eq!(hooks.run(HookTypes::PreUpdate, ""), None);
        assert_eq!(hooks.run(HookTypes::PostUpdate, ""), None);
    }

    #[test]
    fn run_hooks() {
        let path = PathBuf::from(var("TODO_TUI_TEST_DIR").unwrap()).join("hook.sh");
        let hooks = Hooks::new(HookPaths {
            pre_new_task: Some(path.clone()),
            post_new_task: Some(path.clone()),
            pre_remove_task: Some(path.clone()),
            post_remove_task: Some(path.clone()),
            pre_move_task: Some(path.clone()),
            post_move_task: Some(path.clone()),
            pre_update_task: Some(path.clone()),
            post_update_task: Some(path.clone()),
        });

        assert_eq!(
            hooks.run(HookTypes::PreNew, "pre new"),
            Some(String::from("hook: pre new"))
        );
        assert_eq!(
            hooks.run(HookTypes::PostNew, "post new"),
            Some(String::from("hook: post new"))
        );
        assert_eq!(
            hooks.run(HookTypes::PreRemove, "pre remove"),
            Some(String::from("hook: pre remove"))
        );
        assert_eq!(
            hooks.run(HookTypes::PostRemove, "post remove"),
            Some(String::from("hook: post remove"))
        );
        assert_eq!(
            hooks.run(HookTypes::PreMove, "pre move"),
            Some(String::from("hook: pre move"))
        );
        assert_eq!(
            hooks.run(HookTypes::PostMove, "post move"),
            Some(String::from("hook: post move"))
        );
        assert_eq!(
            hooks.run(HookTypes::PreUpdate, "pre update"),
            Some(String::from("hook: pre update"))
        );
        assert_eq!(
            hooks.run(HookTypes::PostUpdate, "post update"),
            Some(String::from("hook: post update"))
        );

        assert_eq!(
            hooks.run_lazy(HookTypes::PreNew, || String::from("pre new")),
            Some(String::from("hook: pre new"))
        );
        assert_eq!(
            hooks.run_lazy(HookTypes::PostNew, || String::from("post new")),
            Some(String::from("hook: post new"))
        );
        assert_eq!(
            hooks.run_lazy(HookTypes::PreRemove, || String::from("pre remove")),
            Some(String::from("hook: pre remove"))
        );
        assert_eq!(
            hooks.run_lazy(HookTypes::PostRemove, || String::from("post remove")),
            Some(String::from("hook: post remove"))
        );
        assert_eq!(
            hooks.run_lazy(HookTypes::PreMove, || String::from("pre move")),
            Some(String::from("hook: pre move"))
        );
        assert_eq!(
            hooks.run_lazy(HookTypes::PostMove, || String::from("post move")),
            Some(String::from("hook: post move"))
        );
        assert_eq!(
            hooks.run_lazy(HookTypes::PreUpdate, || String::from("pre update")),
            Some(String::from("hook: pre update"))
        );
        assert_eq!(
            hooks.run_lazy(HookTypes::PostUpdate, || String::from("post update")),
            Some(String::from("hook: post update"))
        );
    }

    #[test]
    fn run_lazy_skips_closure_when_no_path() {
        let hooks = Hooks::new(HookPaths::default());
        let called = std::cell::Cell::new(false);
        let result = hooks.run_lazy(HookTypes::PreNew, || {
            called.set(true);
            String::from("should not be evaluated")
        });
        assert_eq!(result, None);
        assert!(
            !called.get(),
            "closure should not be called when no hook path is configured"
        );
    }

    #[test]
    fn run_lazy_evaluates_closure_when_path_configured() {
        let path = PathBuf::from(var("TODO_TUI_TEST_DIR").unwrap()).join("hook.sh");
        let hooks = Hooks::new(HookPaths {
            pre_new_task: Some(path),
            ..HookPaths::default()
        });
        let called = std::cell::Cell::new(false);
        let result = hooks.run_lazy(HookTypes::PreNew, || {
            called.set(true);
            String::from("lazy task")
        });
        assert!(
            called.get(),
            "closure should be called when hook path is configured"
        );
        assert_eq!(result, Some(String::from("hook: lazy task")));
    }

    #[test]
    fn invalid_path() {
        let path = PathBuf::from("/this/path/is/not/valid");
        assert!(Hooks::run_command(&path, "").is_err());
        assert_eq!(
            Hooks::run_command_with_name(&path, "task", "cmd name"),
            None
        );

        let path = PathBuf::from(var("TODO_TUI_TEST_DIR").unwrap()).join("hook_failed.sh");

        assert!(Hooks::run_command(&path, "").is_err());
        assert_eq!(
            Hooks::run_command_with_name(&path, "task", "cmd name"),
            None
        );
    }
}