use std::path::Path;
use std::process::Command;
use crate::config::Config;
use crate::error::{Error, Result};
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)
}
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());
}
}