Skip to main content

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}