use std::io::Write;
use std::process::Command;
use anyhow::{Context, Result, anyhow};
pub fn resolve_editor(get_var: &dyn Fn(&str) -> Option<String>) -> (String, Vec<String>) {
let raw = get_var("VISUAL")
.filter(|s| !s.trim().is_empty())
.or_else(|| get_var("EDITOR").filter(|s| !s.trim().is_empty()))
.unwrap_or_else(|| "vi".to_string());
let parts = shlex::split(&raw).unwrap_or_else(|| vec![raw.clone()]);
let mut iter = parts.into_iter();
let command = iter.next().unwrap_or_else(|| "vi".to_string());
let args: Vec<String> = iter.collect();
(command, args)
}
pub fn edit_text(initial: &str) -> Result<String> {
let mut temp = tempfile::Builder::new()
.prefix("trv-comment-")
.suffix(".md")
.tempfile()
.context("failed to create temp file for external editor")?;
temp.write_all(initial.as_bytes())
.context("failed to write initial comment buffer to temp file")?;
temp.flush()
.context("failed to flush temp file before spawning editor")?;
let path = temp.path().to_path_buf();
let (editor, extra_args) = resolve_editor(&|key| std::env::var(key).ok());
let status = Command::new(&editor)
.args(&extra_args)
.arg(&path)
.status()
.with_context(|| format!("failed to spawn editor '{editor}'"))?;
if !status.success() {
return Err(anyhow!(
"editor '{editor}' exited with non-zero status: {status}"
));
}
let content = std::fs::read_to_string(&path)
.with_context(|| format!("failed to re-read temp file at {}", path.display()))?;
Ok(content)
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn env_from(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect()
}
fn lookup(env: &HashMap<String, String>) -> impl Fn(&str) -> Option<String> + '_ {
move |key: &str| env.get(key).cloned()
}
#[test]
fn resolves_visual_over_editor() {
let env = env_from(&[("VISUAL", "nvim"), ("EDITOR", "vim")]);
let (cmd, args) = resolve_editor(&lookup(&env));
assert_eq!(cmd, "nvim");
assert!(args.is_empty());
}
#[test]
fn falls_back_to_editor() {
let env = env_from(&[("EDITOR", "emacs")]);
let (cmd, args) = resolve_editor(&lookup(&env));
assert_eq!(cmd, "emacs");
assert!(args.is_empty());
}
#[test]
fn falls_back_to_vi_when_neither_set() {
let env: HashMap<String, String> = HashMap::new();
let (cmd, args) = resolve_editor(&lookup(&env));
assert_eq!(cmd, "vi");
assert!(args.is_empty());
}
#[test]
fn empty_visual_falls_through_to_editor() {
let env = env_from(&[("VISUAL", " "), ("EDITOR", "nano")]);
let (cmd, args) = resolve_editor(&lookup(&env));
assert_eq!(cmd, "nano");
assert!(args.is_empty());
}
#[test]
fn splits_command_with_args() {
let env = env_from(&[("VISUAL", "nvim --clean -u NONE")]);
let (cmd, args) = resolve_editor(&lookup(&env));
assert_eq!(cmd, "nvim");
assert_eq!(
args,
vec!["--clean".to_string(), "-u".to_string(), "NONE".to_string()]
);
}
#[test]
fn unbalanced_quotes_fall_back_to_raw_value() {
let env = env_from(&[("VISUAL", "nvim \"unclosed")]);
let (cmd, args) = resolve_editor(&lookup(&env));
assert_eq!(cmd, "nvim \"unclosed");
assert!(args.is_empty());
}
#[test]
fn edit_text_round_trips_content_through_true_command() {
let old_visual = std::env::var("VISUAL").ok();
let old_editor = std::env::var("EDITOR").ok();
unsafe {
std::env::set_var("VISUAL", "true");
std::env::remove_var("EDITOR");
}
let result = edit_text("hello from trv\n");
unsafe {
match old_visual {
Some(v) => std::env::set_var("VISUAL", v),
None => std::env::remove_var("VISUAL"),
}
match old_editor {
Some(v) => std::env::set_var("EDITOR", v),
None => std::env::remove_var("EDITOR"),
}
}
let content = result.expect("edit_text should succeed when editor exits 0");
assert_eq!(content, "hello from trv\n");
}
}