1use std::path::Path;
15use std::process::Command;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum EditorRunOutcome {
26 Completed,
29 Aborted,
32}
33
34pub fn default_editors_for(os: &str) -> Vec<&'static str> {
51 match os {
52 "windows" => vec!["edit", "notepad", "vim"],
53 _ => vec!["vim", "nano", "vi"],
54 }
55}
56
57pub fn editor_candidates(visual: Option<&str>, editor: Option<&str>, os: &str) -> Vec<String> {
66 let mut candidates: Vec<String> = Vec::new();
67 for preferred in [visual, editor] {
68 if let Some(value) = preferred
69 && !value.is_empty()
70 {
71 candidates.push(value.to_string());
72 }
73 }
74 candidates.extend(default_editors_for(os).into_iter().map(str::to_string));
75 candidates
76}
77
78pub fn editor_argv(candidate: &str, path: &str) -> Option<(String, Vec<String>)> {
88 let mut parts = candidate.split_whitespace();
89 let program = parts.next()?;
90 let mut args: Vec<String> = parts.map(str::to_string).collect();
91 args.push(path.to_string());
92 Some((program.to_string(), args))
93}
94
95pub fn classify_exit(success: bool, code: Option<i32>) -> EditorRunOutcome {
100 if success || code.is_some() {
101 EditorRunOutcome::Completed
102 } else {
103 EditorRunOutcome::Aborted
104 }
105}
106
107pub fn launch_via(
122 path: &Path,
123 candidates: &[String],
124 run: &mut dyn FnMut(&mut Command) -> std::io::Result<EditorRunOutcome>,
125) -> std::io::Result<()> {
126 let path_str = path.to_string_lossy();
127
128 for candidate in candidates {
129 let Some((program, args)) = editor_argv(candidate, path_str.as_ref()) else {
130 continue;
131 };
132 let mut cmd = Command::new(program);
133 cmd.args(args);
134
135 match run(&mut cmd) {
136 Ok(EditorRunOutcome::Completed) => return Ok(()),
138 Ok(EditorRunOutcome::Aborted) => {}
140 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
142 Err(e) => {
143 return Err(std::io::Error::other(format!(
144 "Failed to launch editor '{candidate}': {e}"
145 )));
146 }
147 }
148 }
149
150 Err(std::io::Error::new(
151 std::io::ErrorKind::NotFound,
152 "No editor found. Set $VISUAL or $EDITOR, or install vim, nano, or edit.",
153 ))
154}
155
156pub fn launch(path: &Path) -> std::io::Result<()> {
161 let visual = std::env::var("VISUAL").ok();
162 let editor = std::env::var("EDITOR").ok();
163 let candidates = editor_candidates(visual.as_deref(), editor.as_deref(), std::env::consts::OS);
164 launch_via(path, &candidates, &mut |cmd| {
165 cmd.status().map(|s| classify_exit(s.success(), s.code()))
166 })
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172
173 fn owned(parts: &[&str]) -> Vec<String> {
174 parts.iter().map(|s| s.to_string()).collect()
175 }
176
177 fn some_path() -> std::path::PathBuf {
180 std::path::PathBuf::from("/lev/task.txt")
181 }
182
183 #[test]
184 fn windows_prefers_the_console_editor_then_notepad() {
185 assert_eq!(
186 default_editors_for("windows"),
187 vec!["edit", "notepad", "vim"]
188 );
189 }
190
191 #[test]
192 fn unix_and_unknown_oses_get_the_same_list() {
193 assert_eq!(default_editors_for("linux"), vec!["vim", "nano", "vi"]);
194 assert_eq!(default_editors_for("macos"), vec!["vim", "nano", "vi"]);
195 assert_eq!(default_editors_for("dragonfly"), vec!["vim", "nano", "vi"]);
197 }
198
199 #[test]
200 fn visual_comes_before_editor_and_both_before_the_defaults() {
201 assert_eq!(
202 editor_candidates(Some("code --wait"), Some("nvim"), "linux"),
203 owned(&["code --wait", "nvim", "vim", "nano", "vi"])
204 );
205 }
206
207 #[test]
208 fn an_unset_visual_or_editor_contributes_nothing() {
209 assert_eq!(
210 editor_candidates(None, Some("nvim"), "linux"),
211 owned(&["nvim", "vim", "nano", "vi"])
212 );
213 assert_eq!(
214 editor_candidates(Some("nvim"), None, "linux"),
215 owned(&["nvim", "vim", "nano", "vi"])
216 );
217 assert_eq!(
218 editor_candidates(None, None, "windows"),
219 owned(&["edit", "notepad", "vim"])
220 );
221 }
222
223 #[test]
226 fn an_empty_visual_or_editor_is_skipped() {
227 assert_eq!(
228 editor_candidates(Some(""), Some(""), "linux"),
229 owned(&["vim", "nano", "vi"])
230 );
231 }
232
233 #[test]
234 fn editor_argv_splits_flags_and_appends_the_path() {
235 let (program, args) = editor_argv("code --wait --new-window", "/tmp/t.txt").unwrap();
236 assert_eq!(program, "code");
237 assert_eq!(args, owned(&["--wait", "--new-window", "/tmp/t.txt"]));
238 }
239
240 #[test]
241 fn editor_argv_appends_the_path_to_a_bare_program() {
242 let (program, args) = editor_argv("vim", "/tmp/t.txt").unwrap();
243 assert_eq!(program, "vim");
244 assert_eq!(args, owned(&["/tmp/t.txt"]));
245 }
246
247 #[test]
248 fn editor_argv_rejects_a_candidate_with_no_program_token() {
249 assert!(editor_argv(" ", "/tmp/t.txt").is_none());
250 assert!(editor_argv("", "/tmp/t.txt").is_none());
251 }
252
253 #[test]
254 fn classify_exit_treats_success_as_completed() {
255 assert_eq!(classify_exit(true, Some(0)), EditorRunOutcome::Completed);
256 }
257
258 #[test]
259 fn classify_exit_treats_a_nonzero_code_as_completed() {
260 assert_eq!(classify_exit(false, Some(1)), EditorRunOutcome::Completed);
262 }
263
264 #[test]
265 fn classify_exit_treats_a_missing_code_as_aborted() {
266 assert_eq!(classify_exit(false, None), EditorRunOutcome::Aborted);
268 }
269
270 #[test]
273 fn the_outcome_enum_formats_both_variants() {
274 assert_eq!(format!("{:?}", EditorRunOutcome::Completed), "Completed");
275 assert_eq!(format!("{:?}", EditorRunOutcome::Aborted), "Aborted");
276 assert_eq!(EditorRunOutcome::Aborted.clone(), EditorRunOutcome::Aborted);
279 }
280
281 #[test]
282 fn launch_via_returns_on_the_first_candidate_that_completes() {
283 let mut seen: Vec<String> = Vec::new();
284 let result = launch_via(&some_path(), &owned(&["code --wait", "vim"]), &mut |cmd| {
285 seen.push(cmd.get_program().to_string_lossy().to_string());
286 Ok(EditorRunOutcome::Completed)
287 });
288 assert!(result.is_ok());
289 assert_eq!(seen, owned(&["code"]));
291 }
292
293 #[test]
294 fn launch_via_passes_the_flags_and_the_path_through_to_the_command() {
295 let mut args: Vec<String> = Vec::new();
296 let result = launch_via(&some_path(), &owned(&["code --wait"]), &mut |cmd| {
297 args = cmd
298 .get_args()
299 .map(|a| a.to_string_lossy().to_string())
300 .collect();
301 Ok(EditorRunOutcome::Completed)
302 });
303 assert!(result.is_ok());
304 assert_eq!(args, owned(&["--wait", "/lev/task.txt"]));
305 }
306
307 #[test]
308 fn launch_via_skips_a_candidate_with_no_program_token() {
309 let mut seen: Vec<String> = Vec::new();
310 let result = launch_via(&some_path(), &owned(&[" ", "vim"]), &mut |cmd| {
311 seen.push(cmd.get_program().to_string_lossy().to_string());
312 Ok(EditorRunOutcome::Completed)
313 });
314 assert!(result.is_ok());
315 assert_eq!(seen, owned(&["vim"]));
317 }
318
319 #[test]
320 fn launch_via_tries_the_next_candidate_after_an_abort() {
321 let mut seen: Vec<String> = Vec::new();
322 let result = launch_via(&some_path(), &owned(&["a", "b"]), &mut |cmd| {
323 let program = cmd.get_program().to_string_lossy().to_string();
324 seen.push(program.clone());
325 if program == "a" {
326 Ok(EditorRunOutcome::Aborted)
327 } else {
328 Ok(EditorRunOutcome::Completed)
329 }
330 });
331 assert!(result.is_ok());
332 assert_eq!(seen, owned(&["a", "b"]));
333 }
334
335 #[test]
336 fn launch_via_tries_the_next_candidate_when_one_is_not_installed() {
337 let mut seen: Vec<String> = Vec::new();
338 let result = launch_via(&some_path(), &owned(&["a", "b"]), &mut |cmd| {
339 let program = cmd.get_program().to_string_lossy().to_string();
340 seen.push(program.clone());
341 if program == "a" {
342 Err(std::io::Error::new(
343 std::io::ErrorKind::NotFound,
344 "no such file",
345 ))
346 } else {
347 Ok(EditorRunOutcome::Completed)
348 }
349 });
350 assert!(result.is_ok());
351 assert_eq!(seen, owned(&["a", "b"]));
352 }
353
354 #[test]
358 fn launch_via_reports_a_spawn_failure_that_is_not_a_missing_program() {
359 let result = launch_via(&some_path(), &owned(&["locked-editor"]), &mut |_cmd| {
360 Err(std::io::Error::from(std::io::ErrorKind::PermissionDenied))
361 });
362 let err = result.unwrap_err();
363 assert!(
364 err.to_string()
365 .starts_with("Failed to launch editor 'locked-editor'"),
366 "{err}"
367 );
368 }
369
370 #[test]
375 fn launch_via_reports_no_editor_when_the_candidates_run_out() {
376 let mut runner = |_cmd: &mut Command| {
377 Err(std::io::Error::new(
378 std::io::ErrorKind::NotFound,
379 "no such file",
380 ))
381 };
382
383 let err = launch_via(&some_path(), &owned(&["a", "b"]), &mut runner).unwrap_err();
384 assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
385 assert!(err.to_string().starts_with("No editor found."), "{err}");
386
387 let err = launch_via(&some_path(), &[], &mut runner).unwrap_err();
388 assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
389 assert!(err.to_string().starts_with("No editor found."), "{err}");
390 }
391
392 #[test]
403 fn launch_runs_the_first_candidate_and_reports_it_completed() {
404 let exe = std::env::current_exe().expect("test binary path");
405 let visual = format!("{} --list", exe.display());
406 temp_env::with_vars(
407 [("VISUAL", Some(visual.as_str())), ("EDITOR", None)],
408 || {
409 let dir = tempfile::tempdir().unwrap();
410 let file = dir.path().join("task.txt");
411 std::fs::write(&file, "content").unwrap();
412 launch(&file).expect("the stand-in editor should run to completion");
413 },
414 );
415 }
416}