tasks-cli-rs 0.11.0

Markdown-based TODO task management CLI: each task is a Markdown file with YAML front matter
use std::path::Path;
use std::process::Command;

use crate::config::Config;
use crate::error::{Error, Result};

/// Editor detection priority:
/// 1. --editor CLI flag
/// 2. TASKS_EDITOR env var
/// 3. config.toml `editor` field
/// 4. $VISUAL
/// 5. $EDITOR
/// 6. auto-detect: terminal editors when in SSH session, otherwise desktop
pub fn detect(cli_flag: Option<&str>, config: &Config) -> Result<String> {
    detect_with(cli_flag, config, &RealEnv)
}

pub trait Env {
    fn var(&self, key: &str) -> Option<String>;
    fn command_exists(&self, name: &str) -> bool;
}

struct RealEnv;

impl Env for RealEnv {
    fn var(&self, key: &str) -> Option<String> {
        std::env::var(key).ok().filter(|v| !v.is_empty())
    }

    fn command_exists(&self, name: &str) -> bool {
        Command::new("which")
            .arg(name)
            .output()
            .is_ok_and(|o| o.status.success())
    }
}

pub fn detect_with(cli_flag: Option<&str>, config: &Config, env: &dyn Env) -> Result<String> {
    if let Some(e) = cli_flag {
        return Ok(e.to_string());
    }
    if let Some(e) = env.var("TASKS_EDITOR") {
        return Ok(e);
    }
    if let Some(e) = &config.editor {
        return Ok(e.clone());
    }
    if let Some(e) = env.var("VISUAL") {
        return Ok(e);
    }
    if let Some(e) = env.var("EDITOR") {
        return Ok(e);
    }
    let is_ssh = env.var("SSH_TTY").is_some() || env.var("SSH_CONNECTION").is_some();
    let candidates: &[&str] = if is_ssh {
        &["vim", "nano", "vi"]
    } else {
        &["code", "zed", "nvim", "vim", "nano", "vi"]
    };
    for c in candidates {
        if env.command_exists(c) {
            return Ok(c.to_string());
        }
    }
    Err(Error::NoEditor)
}

/// GUI editors detach immediately; pass --wait so we can re-validate after
/// the user closes the file.
pub fn open(editor: &str, path: &Path) -> Result<()> {
    let mut cmd = Command::new(editor);
    if editor == "code" || editor == "zed" {
        cmd.arg("--wait");
    }
    let status = cmd.arg(path).status()?;
    if !status.success() {
        return Err(Error::EditorFailed(editor.to_string()));
    }
    Ok(())
}

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

    struct FakeEnv {
        vars: HashMap<String, String>,
        commands: Vec<String>,
    }

    impl FakeEnv {
        fn new(vars: &[(&str, &str)], commands: &[&str]) -> Self {
            FakeEnv {
                vars: vars.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
                commands: commands.iter().map(|s| s.to_string()).collect(),
            }
        }
    }

    impl Env for FakeEnv {
        fn var(&self, key: &str) -> Option<String> {
            self.vars.get(key).cloned()
        }

        fn command_exists(&self, name: &str) -> bool {
            self.commands.iter().any(|c| c == name)
        }
    }

    #[test]
    fn cli_flag_wins() {
        let env = FakeEnv::new(&[("TASKS_EDITOR", "nano"), ("EDITOR", "vi")], &[]);
        let got = detect_with(Some("code"), &Config::default(), &env).unwrap();
        assert_eq!(got, "code");
    }

    #[test]
    fn priority_chain() {
        let config = Config { editor: Some("zed".into()), ..Default::default() };

        let env = FakeEnv::new(&[("TASKS_EDITOR", "nano"), ("VISUAL", "vi")], &[]);
        assert_eq!(detect_with(None, &config, &env).unwrap(), "nano");

        let env = FakeEnv::new(&[("VISUAL", "vi")], &[]);
        assert_eq!(detect_with(None, &config, &env).unwrap(), "zed");

        let env = FakeEnv::new(&[("VISUAL", "vi"), ("EDITOR", "emacs")], &[]);
        assert_eq!(detect_with(None, &Config::default(), &env).unwrap(), "vi");

        let env = FakeEnv::new(&[("EDITOR", "emacs")], &[]);
        assert_eq!(detect_with(None, &Config::default(), &env).unwrap(), "emacs");
    }

    #[test]
    fn ssh_prefers_terminal_editors() {
        let env = FakeEnv::new(&[("SSH_TTY", "/dev/pts/0")], &["code", "vim"]);
        assert_eq!(detect_with(None, &Config::default(), &env).unwrap(), "vim");
    }

    #[test]
    fn desktop_prefers_gui_editors() {
        let env = FakeEnv::new(&[], &["code", "vim"]);
        assert_eq!(detect_with(None, &Config::default(), &env).unwrap(), "code");
    }

    #[test]
    fn no_editor_errors() {
        let env = FakeEnv::new(&[], &[]);
        assert!(detect_with(None, &Config::default(), &env).is_err());
    }
}