use std::path::Path;
use std::process::Command;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EditorRunOutcome {
Completed,
Aborted,
}
pub fn default_editors_for(os: &str) -> Vec<&'static str> {
match os {
"windows" => vec!["edit", "notepad", "vim"],
_ => vec!["vim", "nano", "vi"],
}
}
pub fn editor_candidates(visual: Option<&str>, editor: Option<&str>, os: &str) -> Vec<String> {
let mut candidates: Vec<String> = Vec::new();
for preferred in [visual, editor] {
if let Some(value) = preferred
&& !value.is_empty()
{
candidates.push(value.to_string());
}
}
candidates.extend(default_editors_for(os).into_iter().map(str::to_string));
candidates
}
pub fn editor_argv(candidate: &str, path: &str) -> Option<(String, Vec<String>)> {
let mut parts = candidate.split_whitespace();
let program = parts.next()?;
let mut args: Vec<String> = parts.map(str::to_string).collect();
args.push(path.to_string());
Some((program.to_string(), args))
}
pub fn classify_exit(success: bool, code: Option<i32>) -> EditorRunOutcome {
if success || code.is_some() {
EditorRunOutcome::Completed
} else {
EditorRunOutcome::Aborted
}
}
pub fn launch_via(
path: &Path,
candidates: &[String],
run: &mut dyn FnMut(&mut Command) -> std::io::Result<EditorRunOutcome>,
) -> std::io::Result<()> {
let path_str = path.to_string_lossy();
for candidate in candidates {
let Some((program, args)) = editor_argv(candidate, path_str.as_ref()) else {
continue;
};
let mut cmd = Command::new(program);
cmd.args(args);
match run(&mut cmd) {
Ok(EditorRunOutcome::Completed) => return Ok(()),
Ok(EditorRunOutcome::Aborted) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
Err(e) => {
return Err(std::io::Error::other(format!(
"Failed to launch editor '{candidate}': {e}"
)));
}
}
}
Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"No editor found. Set $VISUAL or $EDITOR, or install vim, nano, or edit.",
))
}
pub fn launch(path: &Path) -> std::io::Result<()> {
let visual = std::env::var("VISUAL").ok();
let editor = std::env::var("EDITOR").ok();
let candidates = editor_candidates(visual.as_deref(), editor.as_deref(), std::env::consts::OS);
launch_via(path, &candidates, &mut |cmd| {
cmd.status().map(|s| classify_exit(s.success(), s.code()))
})
}
#[cfg(test)]
mod tests {
use super::*;
fn owned(parts: &[&str]) -> Vec<String> {
parts.iter().map(|s| s.to_string()).collect()
}
fn some_path() -> std::path::PathBuf {
std::path::PathBuf::from("/lev/task.txt")
}
#[test]
fn windows_prefers_the_console_editor_then_notepad() {
assert_eq!(
default_editors_for("windows"),
vec!["edit", "notepad", "vim"]
);
}
#[test]
fn unix_and_unknown_oses_get_the_same_list() {
assert_eq!(default_editors_for("linux"), vec!["vim", "nano", "vi"]);
assert_eq!(default_editors_for("macos"), vec!["vim", "nano", "vi"]);
assert_eq!(default_editors_for("dragonfly"), vec!["vim", "nano", "vi"]);
}
#[test]
fn visual_comes_before_editor_and_both_before_the_defaults() {
assert_eq!(
editor_candidates(Some("code --wait"), Some("nvim"), "linux"),
owned(&["code --wait", "nvim", "vim", "nano", "vi"])
);
}
#[test]
fn an_unset_visual_or_editor_contributes_nothing() {
assert_eq!(
editor_candidates(None, Some("nvim"), "linux"),
owned(&["nvim", "vim", "nano", "vi"])
);
assert_eq!(
editor_candidates(Some("nvim"), None, "linux"),
owned(&["nvim", "vim", "nano", "vi"])
);
assert_eq!(
editor_candidates(None, None, "windows"),
owned(&["edit", "notepad", "vim"])
);
}
#[test]
fn an_empty_visual_or_editor_is_skipped() {
assert_eq!(
editor_candidates(Some(""), Some(""), "linux"),
owned(&["vim", "nano", "vi"])
);
}
#[test]
fn editor_argv_splits_flags_and_appends_the_path() {
let (program, args) = editor_argv("code --wait --new-window", "/tmp/t.txt").unwrap();
assert_eq!(program, "code");
assert_eq!(args, owned(&["--wait", "--new-window", "/tmp/t.txt"]));
}
#[test]
fn editor_argv_appends_the_path_to_a_bare_program() {
let (program, args) = editor_argv("vim", "/tmp/t.txt").unwrap();
assert_eq!(program, "vim");
assert_eq!(args, owned(&["/tmp/t.txt"]));
}
#[test]
fn editor_argv_rejects_a_candidate_with_no_program_token() {
assert!(editor_argv(" ", "/tmp/t.txt").is_none());
assert!(editor_argv("", "/tmp/t.txt").is_none());
}
#[test]
fn classify_exit_treats_success_as_completed() {
assert_eq!(classify_exit(true, Some(0)), EditorRunOutcome::Completed);
}
#[test]
fn classify_exit_treats_a_nonzero_code_as_completed() {
assert_eq!(classify_exit(false, Some(1)), EditorRunOutcome::Completed);
}
#[test]
fn classify_exit_treats_a_missing_code_as_aborted() {
assert_eq!(classify_exit(false, None), EditorRunOutcome::Aborted);
}
#[test]
fn the_outcome_enum_formats_both_variants() {
assert_eq!(format!("{:?}", EditorRunOutcome::Completed), "Completed");
assert_eq!(format!("{:?}", EditorRunOutcome::Aborted), "Aborted");
assert_eq!(EditorRunOutcome::Aborted.clone(), EditorRunOutcome::Aborted);
}
#[test]
fn launch_via_returns_on_the_first_candidate_that_completes() {
let mut seen: Vec<String> = Vec::new();
let result = launch_via(&some_path(), &owned(&["code --wait", "vim"]), &mut |cmd| {
seen.push(cmd.get_program().to_string_lossy().to_string());
Ok(EditorRunOutcome::Completed)
});
assert!(result.is_ok());
assert_eq!(seen, owned(&["code"]));
}
#[test]
fn launch_via_passes_the_flags_and_the_path_through_to_the_command() {
let mut args: Vec<String> = Vec::new();
let result = launch_via(&some_path(), &owned(&["code --wait"]), &mut |cmd| {
args = cmd
.get_args()
.map(|a| a.to_string_lossy().to_string())
.collect();
Ok(EditorRunOutcome::Completed)
});
assert!(result.is_ok());
assert_eq!(args, owned(&["--wait", "/lev/task.txt"]));
}
#[test]
fn launch_via_skips_a_candidate_with_no_program_token() {
let mut seen: Vec<String> = Vec::new();
let result = launch_via(&some_path(), &owned(&[" ", "vim"]), &mut |cmd| {
seen.push(cmd.get_program().to_string_lossy().to_string());
Ok(EditorRunOutcome::Completed)
});
assert!(result.is_ok());
assert_eq!(seen, owned(&["vim"]));
}
#[test]
fn launch_via_tries_the_next_candidate_after_an_abort() {
let mut seen: Vec<String> = Vec::new();
let result = launch_via(&some_path(), &owned(&["a", "b"]), &mut |cmd| {
let program = cmd.get_program().to_string_lossy().to_string();
seen.push(program.clone());
if program == "a" {
Ok(EditorRunOutcome::Aborted)
} else {
Ok(EditorRunOutcome::Completed)
}
});
assert!(result.is_ok());
assert_eq!(seen, owned(&["a", "b"]));
}
#[test]
fn launch_via_tries_the_next_candidate_when_one_is_not_installed() {
let mut seen: Vec<String> = Vec::new();
let result = launch_via(&some_path(), &owned(&["a", "b"]), &mut |cmd| {
let program = cmd.get_program().to_string_lossy().to_string();
seen.push(program.clone());
if program == "a" {
Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"no such file",
))
} else {
Ok(EditorRunOutcome::Completed)
}
});
assert!(result.is_ok());
assert_eq!(seen, owned(&["a", "b"]));
}
#[test]
fn launch_via_reports_a_spawn_failure_that_is_not_a_missing_program() {
let result = launch_via(&some_path(), &owned(&["locked-editor"]), &mut |_cmd| {
Err(std::io::Error::from(std::io::ErrorKind::PermissionDenied))
});
let err = result.unwrap_err();
assert!(
err.to_string()
.starts_with("Failed to launch editor 'locked-editor'"),
"{err}"
);
}
#[test]
fn launch_via_reports_no_editor_when_the_candidates_run_out() {
let mut runner = |_cmd: &mut Command| {
Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"no such file",
))
};
let err = launch_via(&some_path(), &owned(&["a", "b"]), &mut runner).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
assert!(err.to_string().starts_with("No editor found."), "{err}");
let err = launch_via(&some_path(), &[], &mut runner).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
assert!(err.to_string().starts_with("No editor found."), "{err}");
}
#[test]
fn launch_runs_the_first_candidate_and_reports_it_completed() {
let exe = std::env::current_exe().expect("test binary path");
let visual = format!("{} --list", exe.display());
temp_env::with_vars(
[("VISUAL", Some(visual.as_str())), ("EDITOR", None)],
|| {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("task.txt");
std::fs::write(&file, "content").unwrap();
launch(&file).expect("the stand-in editor should run to completion");
},
);
}
}