leviath_sys/editor.rs
1//! Opening the user's text editor on a file and waiting for it to close.
2//!
3//! The fallback editor list differs per OS (`vim`/`nano`/`vi` against
4//! `edit`/`notepad`). As in [`crate::browser`], the platform selection is a
5//! pure function taking the OS string, the candidate list and argv split are
6//! pure, and the actual process run is injected - so nothing here needs a
7//! `#[cfg]` and every branch is reachable under test on a single platform.
8//!
9//! What is *not* here is anything about what the file contains. Building the
10//! task template, stripping its comment lines and deciding whether an empty
11//! result cancels the run are Leviath policy, not OS behavior, and live in
12//! `leviath-cli`.
13
14use std::path::Path;
15use std::process::Command;
16
17/// Outcome of running one editor candidate, abstracting over the raw
18/// `ExitStatus`. This exists so the "ran but ended with no exit code" case (a
19/// signal kill on Unix) is injectable in tests on *every* platform: on Windows
20/// an `ExitStatus` always carries a code (even via `ExitStatusExt::from_raw`),
21/// so that case cannot be fabricated from a status directly. The injected `run`
22/// seam of [`launch_via`] therefore yields this enum rather than an
23/// `ExitStatus`.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum EditorRunOutcome {
26 /// Process finished (success, or any explicit exit code) - treat as the
27 /// user having closed the editor.
28 Completed,
29 /// Process ended with no exit code (e.g. killed by a signal) - try the next
30 /// candidate.
31 Aborted,
32}
33
34/// The fallback editor candidates for `os`, tried in order after any
35/// `$VISUAL`/`$EDITOR` value.
36///
37/// `os` is the value of `std::env::consts::OS`. An unrecognized OS gets the
38/// Unix list, which covers the BSDs and other Unixes - the same fallback shape
39/// as [`crate::browser::open_command_for`].
40///
41/// On Windows `edit` (Microsoft Edit, shipped with Windows 11 since 2025) comes
42/// first because it is a *console* editor: it stays in the terminal the user
43/// typed the command into, and it works over SSH and in containers where a
44/// notepad window does not. Listing it first costs nothing where it is absent,
45/// since an unresolvable program is an `ErrorKind::NotFound` that [`launch_via`]
46/// skips. `notepad` is the guaranteed fallback (Windows resolves it through the
47/// System32 search path whatever `$PATH` says, and unlike `start notepad` it
48/// blocks until the window closes). `vim` last picks up Git-for-Windows and
49/// scoop installs for users who never set `$EDITOR`.
50pub 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
57/// The full candidate list in priority order: `$VISUAL`, then `$EDITOR`, then
58/// the platform fallbacks for `os`.
59///
60/// The two environment values are parameters rather than reads, so this stays
61/// pure and every combination is testable without touching the process
62/// environment. An unset *or* empty value contributes nothing: an exported but
63/// empty `EDITOR=` is a common shell-profile accident, and treating it as a
64/// program name would spawn nothing and mask the real fallbacks.
65pub 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
78/// Split one candidate into a program and its arguments, with `path` appended.
79///
80/// Candidates are split on whitespace so an editor string carrying flags
81/// (`code --wait`) works. The consequence, which callers should document: a
82/// program *path* containing spaces is split in the wrong place and needs a
83/// wrapper script on `PATH` instead.
84///
85/// `None` when the candidate has no program token at all, which is what a
86/// whitespace-only value amounts to.
87pub 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
95/// Classify an editor subprocess's exit. `code == None` means it ended without
96/// an exit code (a signal kill). A pure function so both arms are unit-testable
97/// on every platform, independent of whether a real process can produce a
98/// code-less status there.
99pub 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
107/// Try each candidate in order and return once one runs to completion.
108///
109/// `run` is injected so every arm - including "no editor found" - is reachable
110/// under test on every platform without spawning a real, blocking, interactive
111/// editor. That matters most on Windows: `Command::new("notepad")` resolves
112/// through the System32 search path that `CreateProcess` consults *before*
113/// `$PATH`, so it cannot be made to fail short of tampering with a real system
114/// directory, and letting it actually open would hang CI with no timeout.
115///
116/// `run` is `&mut dyn FnMut` rather than `impl FnMut` because several test call
117/// sites pass distinct closure literals, and a generic parameter would give
118/// each one its own coverage-mapping instantiation. `cargo llvm-cov` sometimes
119/// reports a region as uncovered for one instantiation even when the union of
120/// all of them covers every source position.
121pub 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 // The one child meant to be seen: it inherits this process's stdio and
133 // draws in the user's terminal, so starting `vim` or `edit` without a
134 // console would leave it nowhere to draw and nothing to read from.
135 // `terminal_command` says that, where a bare `Command::new` would only
136 // have looked like a forgotten `child_command`.
137 let mut cmd = crate::process::terminal_command(program);
138 cmd.args(args);
139
140 match run(&mut cmd) {
141 // Exited, even non-zero: the user closed the editor.
142 Ok(EditorRunOutcome::Completed) => return Ok(()),
143 // Ended with no exit code (a signal kill) - try the next candidate.
144 Ok(EditorRunOutcome::Aborted) => {}
145 // Not installed - try the next candidate.
146 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
147 Err(e) => {
148 return Err(std::io::Error::other(format!(
149 "Failed to launch editor '{candidate}': {e}"
150 )));
151 }
152 }
153 }
154
155 Err(std::io::Error::new(
156 std::io::ErrorKind::NotFound,
157 "No editor found. Set $VISUAL or $EDITOR, or install vim, nano, or edit.",
158 ))
159}
160
161/// Launch the user's editor on `path` and wait for it to close.
162///
163/// The only impure function here: it reads `$VISUAL`/`$EDITOR` and runs a real
164/// subprocess. Everything it decides is delegated to the pure functions above.
165pub fn launch(path: &Path) -> std::io::Result<()> {
166 let visual = std::env::var("VISUAL").ok();
167 let editor = std::env::var("EDITOR").ok();
168 let candidates = editor_candidates(visual.as_deref(), editor.as_deref(), std::env::consts::OS);
169 launch_via(path, &candidates, &mut |cmd| {
170 cmd.status().map(|s| classify_exit(s.success(), s.code()))
171 })
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177
178 fn owned(parts: &[&str]) -> Vec<String> {
179 parts.iter().map(|s| s.to_string()).collect()
180 }
181
182 /// A path that never has to exist: nothing here opens the file, and the
183 /// injected `run` never spawns.
184 fn some_path() -> std::path::PathBuf {
185 std::path::PathBuf::from("/lev/task.txt")
186 }
187
188 #[test]
189 fn windows_prefers_the_console_editor_then_notepad() {
190 assert_eq!(
191 default_editors_for("windows"),
192 vec!["edit", "notepad", "vim"]
193 );
194 }
195
196 #[test]
197 fn unix_and_unknown_oses_get_the_same_list() {
198 assert_eq!(default_editors_for("linux"), vec!["vim", "nano", "vi"]);
199 assert_eq!(default_editors_for("macos"), vec!["vim", "nano", "vi"]);
200 // An OS string nobody special-cased still gets a usable list.
201 assert_eq!(default_editors_for("dragonfly"), vec!["vim", "nano", "vi"]);
202 }
203
204 #[test]
205 fn visual_comes_before_editor_and_both_before_the_defaults() {
206 assert_eq!(
207 editor_candidates(Some("code --wait"), Some("nvim"), "linux"),
208 owned(&["code --wait", "nvim", "vim", "nano", "vi"])
209 );
210 }
211
212 #[test]
213 fn an_unset_visual_or_editor_contributes_nothing() {
214 assert_eq!(
215 editor_candidates(None, Some("nvim"), "linux"),
216 owned(&["nvim", "vim", "nano", "vi"])
217 );
218 assert_eq!(
219 editor_candidates(Some("nvim"), None, "linux"),
220 owned(&["nvim", "vim", "nano", "vi"])
221 );
222 assert_eq!(
223 editor_candidates(None, None, "windows"),
224 owned(&["edit", "notepad", "vim"])
225 );
226 }
227
228 /// An exported but empty `EDITOR=` is a common shell-profile accident. It
229 /// must not shadow the real fallbacks with a program name of "".
230 #[test]
231 fn an_empty_visual_or_editor_is_skipped() {
232 assert_eq!(
233 editor_candidates(Some(""), Some(""), "linux"),
234 owned(&["vim", "nano", "vi"])
235 );
236 }
237
238 #[test]
239 fn editor_argv_splits_flags_and_appends_the_path() {
240 let (program, args) = editor_argv("code --wait --new-window", "/tmp/t.txt").unwrap();
241 assert_eq!(program, "code");
242 assert_eq!(args, owned(&["--wait", "--new-window", "/tmp/t.txt"]));
243 }
244
245 #[test]
246 fn editor_argv_appends_the_path_to_a_bare_program() {
247 let (program, args) = editor_argv("vim", "/tmp/t.txt").unwrap();
248 assert_eq!(program, "vim");
249 assert_eq!(args, owned(&["/tmp/t.txt"]));
250 }
251
252 #[test]
253 fn editor_argv_rejects_a_candidate_with_no_program_token() {
254 assert!(editor_argv(" ", "/tmp/t.txt").is_none());
255 assert!(editor_argv("", "/tmp/t.txt").is_none());
256 }
257
258 #[test]
259 fn classify_exit_treats_success_as_completed() {
260 assert_eq!(classify_exit(true, Some(0)), EditorRunOutcome::Completed);
261 }
262
263 #[test]
264 fn classify_exit_treats_a_nonzero_code_as_completed() {
265 // A non-zero but present exit code means the user closed the editor.
266 assert_eq!(classify_exit(false, Some(1)), EditorRunOutcome::Completed);
267 }
268
269 #[test]
270 fn classify_exit_treats_a_missing_code_as_aborted() {
271 // No exit code (killed by a Unix signal) means try the next candidate.
272 assert_eq!(classify_exit(false, None), EditorRunOutcome::Aborted);
273 }
274
275 /// `Debug` is derived and used by the `assert_eq!`s above only when they
276 /// fail, so exercise it directly rather than leaving it to a failing run.
277 #[test]
278 fn the_outcome_enum_formats_both_variants() {
279 assert_eq!(format!("{:?}", EditorRunOutcome::Completed), "Completed");
280 assert_eq!(format!("{:?}", EditorRunOutcome::Aborted), "Aborted");
281 // `Clone` is derived alongside `Copy`; call it so it is not an
282 // uncovered function.
283 assert_eq!(EditorRunOutcome::Aborted.clone(), EditorRunOutcome::Aborted);
284 }
285
286 #[test]
287 fn launch_via_returns_on_the_first_candidate_that_completes() {
288 let mut seen: Vec<String> = Vec::new();
289 let result = launch_via(&some_path(), &owned(&["code --wait", "vim"]), &mut |cmd| {
290 seen.push(cmd.get_program().to_string_lossy().to_string());
291 Ok(EditorRunOutcome::Completed)
292 });
293 assert!(result.is_ok());
294 // Only the first candidate ran, and it ran with its flag plus the path.
295 assert_eq!(seen, owned(&["code"]));
296 }
297
298 #[test]
299 fn launch_via_passes_the_flags_and_the_path_through_to_the_command() {
300 let mut args: Vec<String> = Vec::new();
301 let result = launch_via(&some_path(), &owned(&["code --wait"]), &mut |cmd| {
302 args = cmd
303 .get_args()
304 .map(|a| a.to_string_lossy().to_string())
305 .collect();
306 Ok(EditorRunOutcome::Completed)
307 });
308 assert!(result.is_ok());
309 assert_eq!(args, owned(&["--wait", "/lev/task.txt"]));
310 }
311
312 #[test]
313 fn launch_via_skips_a_candidate_with_no_program_token() {
314 let mut seen: Vec<String> = Vec::new();
315 let result = launch_via(&some_path(), &owned(&[" ", "vim"]), &mut |cmd| {
316 seen.push(cmd.get_program().to_string_lossy().to_string());
317 Ok(EditorRunOutcome::Completed)
318 });
319 assert!(result.is_ok());
320 // The whitespace-only candidate never reached the runner.
321 assert_eq!(seen, owned(&["vim"]));
322 }
323
324 #[test]
325 fn launch_via_tries_the_next_candidate_after_an_abort() {
326 let mut seen: Vec<String> = Vec::new();
327 let result = launch_via(&some_path(), &owned(&["a", "b"]), &mut |cmd| {
328 let program = cmd.get_program().to_string_lossy().to_string();
329 seen.push(program.clone());
330 if program == "a" {
331 Ok(EditorRunOutcome::Aborted)
332 } else {
333 Ok(EditorRunOutcome::Completed)
334 }
335 });
336 assert!(result.is_ok());
337 assert_eq!(seen, owned(&["a", "b"]));
338 }
339
340 #[test]
341 fn launch_via_tries_the_next_candidate_when_one_is_not_installed() {
342 let mut seen: Vec<String> = Vec::new();
343 let result = launch_via(&some_path(), &owned(&["a", "b"]), &mut |cmd| {
344 let program = cmd.get_program().to_string_lossy().to_string();
345 seen.push(program.clone());
346 if program == "a" {
347 Err(std::io::Error::new(
348 std::io::ErrorKind::NotFound,
349 "no such file",
350 ))
351 } else {
352 Ok(EditorRunOutcome::Completed)
353 }
354 });
355 assert!(result.is_ok());
356 assert_eq!(seen, owned(&["a", "b"]));
357 }
358
359 /// A spawn failure that is *not* "the program is missing" - a permission
360 /// denial, say - is the user's actual problem and must be reported rather
361 /// than silently skipped in favour of some other editor.
362 #[test]
363 fn launch_via_reports_a_spawn_failure_that_is_not_a_missing_program() {
364 let result = launch_via(&some_path(), &owned(&["locked-editor"]), &mut |_cmd| {
365 Err(std::io::Error::from(std::io::ErrorKind::PermissionDenied))
366 });
367 let err = result.unwrap_err();
368 assert!(
369 err.to_string()
370 .starts_with("Failed to launch editor 'locked-editor'"),
371 "{err}"
372 );
373 }
374
375 /// Both routes to the terminal error: running out of candidates, and never
376 /// having any. One shared runner rather than two closures, because a
377 /// closure written only for the empty-list call would never be invoked and
378 /// so would itself be uncovered.
379 #[test]
380 fn launch_via_reports_no_editor_when_the_candidates_run_out() {
381 let mut runner = |_cmd: &mut Command| {
382 Err(std::io::Error::new(
383 std::io::ErrorKind::NotFound,
384 "no such file",
385 ))
386 };
387
388 let err = launch_via(&some_path(), &owned(&["a", "b"]), &mut runner).unwrap_err();
389 assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
390 assert!(err.to_string().starts_with("No editor found."), "{err}");
391
392 let err = launch_via(&some_path(), &[], &mut runner).unwrap_err();
393 assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
394 assert!(err.to_string().starts_with("No editor found."), "{err}");
395 }
396
397 /// Drives the real [`launch`]: real environment reads, a real
398 /// `Command::status()`, and a real [`classify_exit`] on the result.
399 ///
400 /// `$VISUAL` points at this very test binary with `--list`, so the
401 /// "editor" is a process that is guaranteed to exist on every platform,
402 /// exits immediately, and runs no tests (`--list` only prints names, and
403 /// the appended file path acts as a filter that matches none of them). It
404 /// is the *first* candidate, so no real editor is ever reached. `temp_env`
405 /// serializes environment mutation process-wide, which is required because
406 /// `std::env::set_var` is unsafe and this crate forbids unsafe.
407 #[test]
408 fn launch_runs_the_first_candidate_and_reports_it_completed() {
409 let exe = std::env::current_exe().expect("test binary path");
410 let visual = format!("{} --list", exe.display());
411 temp_env::with_vars(
412 [("VISUAL", Some(visual.as_str())), ("EDITOR", None)],
413 || {
414 let dir = tempfile::tempdir().unwrap();
415 let file = dir.path().join("task.txt");
416 std::fs::write(&file, "content").unwrap();
417 launch(&file).expect("the stand-in editor should run to completion");
418 },
419 );
420 }
421}