use std::path::Path;
use std::process::ExitStatus;
use bears::error::{Error, Result};
pub fn resolve_editor() -> String {
std::env::var("EDITOR")
.or_else(|_| std::env::var("VISUAL"))
.unwrap_or_else(|_| "vi".into())
}
pub fn parse_editor_command(editor: &str) -> (String, Vec<String>) {
match shell_words::split(editor) {
Ok(parts) if !parts.is_empty() => {
let mut iter = parts.into_iter();
let exe = iter.next().unwrap();
let args: Vec<String> = iter.collect();
(exe, args)
}
_ => ("vi".into(), Vec::new()),
}
}
pub fn launch_editor(exe: &str, args: &[String], file_path: &Path) -> Result<ExitStatus> {
let status = std::process::Command::new(exe)
.args(args)
.arg(file_path)
.status()?;
Ok(status)
}
pub fn open_in_editor(file_path: &Path) -> Result<()> {
open_with(&resolve_editor(), file_path)
}
fn open_with(editor: &str, file_path: &Path) -> Result<()> {
let (exe, args) = parse_editor_command(editor);
let status = launch_editor(&exe, &args, file_path)?;
if !status.success() {
return Err(Error::EditorFailed {
reason: format!(
"{exe} exited with {}",
status
.code()
.map(|c| c.to_string())
.unwrap_or_else(|| "signal".into())
),
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_simple_binary() {
let (exe, args) = parse_editor_command("vim");
assert_eq!(exe, "vim");
assert!(args.is_empty());
}
#[test]
fn parse_binary_with_flags() {
let (exe, args) = parse_editor_command("code --wait");
assert_eq!(exe, "code");
assert_eq!(args, vec!["--wait"]);
}
#[test]
fn parse_multiple_flags() {
let (exe, args) = parse_editor_command("emacs -nw --no-splash");
assert_eq!(exe, "emacs");
assert_eq!(args, vec!["-nw", "--no-splash"]);
}
#[test]
fn parse_empty_falls_back_to_vi() {
let (exe, _) = parse_editor_command("");
assert_eq!(exe, "vi");
}
#[test]
fn parse_path_with_spaces() {
let (exe, args) = parse_editor_command("'/usr/local/My Apps/editor' --wait");
assert_eq!(exe, "/usr/local/My Apps/editor");
assert_eq!(args, vec!["--wait"]);
}
#[test]
fn parse_quoted_args() {
let (exe, args) = parse_editor_command("editor '--config path'");
assert_eq!(exe, "editor");
assert_eq!(args, vec!["--config path"]);
}
#[test]
fn parse_double_quoted_exe() {
let (exe, args) = parse_editor_command(r#""My Editor" --wait"#);
assert_eq!(exe, "My Editor");
assert_eq!(args, vec!["--wait"]);
}
#[test]
fn launch_nonexistent_binary_returns_error() {
let tmp = tempfile::tempdir().unwrap();
let file = tmp.path().join("test.md");
std::fs::write(&file, "test").unwrap();
let result = launch_editor("__nonexistent_editor_42__", &[], &file);
assert!(
result.is_err(),
"expected Err for nonexistent editor binary"
);
}
#[test]
fn launch_successful_editor_returns_ok() {
let tmp = tempfile::tempdir().unwrap();
let file = tmp.path().join("test.md");
std::fs::write(&file, "test").unwrap();
let result = launch_editor("true", &[], &file);
assert!(result.is_ok());
assert!(result.unwrap().success());
}
#[test]
fn launch_failing_editor_returns_nonzero_status() {
let tmp = tempfile::tempdir().unwrap();
let file = tmp.path().join("test.md");
std::fs::write(&file, "test").unwrap();
let result = launch_editor("false", &[], &file);
assert!(result.is_ok(), "process spawned, so no io error");
assert!(!result.unwrap().success(), "exit code should be non-zero");
}
#[test]
fn open_with_successful_editor_returns_ok() {
let tmp = tempfile::tempdir().unwrap();
let file = tmp.path().join("test.md");
std::fs::write(&file, "test").unwrap();
assert!(open_with("true", &file).is_ok());
}
#[test]
fn open_with_failing_editor_returns_editor_failed() {
let tmp = tempfile::tempdir().unwrap();
let file = tmp.path().join("test.md");
std::fs::write(&file, "test").unwrap();
let err = open_with("false", &file).unwrap_err();
assert!(
err.to_string().contains("editor failed"),
"expected EditorFailed, got: {err}"
);
}
}