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        let mut cmd = Command::new(program);
133        cmd.args(args);
134
135        match run(&mut cmd) {
136            // Exited, even non-zero: the user closed the editor.
137            Ok(EditorRunOutcome::Completed) => return Ok(()),
138            // Ended with no exit code (a signal kill) - try the next candidate.
139            Ok(EditorRunOutcome::Aborted) => {}
140            // Not installed - try the next candidate.
141            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
156/// Launch the user's editor on `path` and wait for it to close.
157///
158/// The only impure function here: it reads `$VISUAL`/`$EDITOR` and runs a real
159/// subprocess. Everything it decides is delegated to the pure functions above.
160pub 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    /// A path that never has to exist: nothing here opens the file, and the
178    /// injected `run` never spawns.
179    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        // An OS string nobody special-cased still gets a usable list.
196        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    /// An exported but empty `EDITOR=` is a common shell-profile accident. It
224    /// must not shadow the real fallbacks with a program name of "".
225    #[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        // A non-zero but present exit code means the user closed the editor.
261        assert_eq!(classify_exit(false, Some(1)), EditorRunOutcome::Completed);
262    }
263
264    #[test]
265    fn classify_exit_treats_a_missing_code_as_aborted() {
266        // No exit code (killed by a Unix signal) means try the next candidate.
267        assert_eq!(classify_exit(false, None), EditorRunOutcome::Aborted);
268    }
269
270    /// `Debug` is derived and used by the `assert_eq!`s above only when they
271    /// fail, so exercise it directly rather than leaving it to a failing run.
272    #[test]
273    fn the_outcome_enum_formats_both_variants() {
274        assert_eq!(format!("{:?}", EditorRunOutcome::Completed), "Completed");
275        assert_eq!(format!("{:?}", EditorRunOutcome::Aborted), "Aborted");
276        // `Clone` is derived alongside `Copy`; call it so it is not an
277        // uncovered function.
278        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        // Only the first candidate ran, and it ran with its flag plus the path.
290        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        // The whitespace-only candidate never reached the runner.
316        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    /// A spawn failure that is *not* "the program is missing" - a permission
355    /// denial, say - is the user's actual problem and must be reported rather
356    /// than silently skipped in favour of some other editor.
357    #[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    /// Both routes to the terminal error: running out of candidates, and never
371    /// having any. One shared runner rather than two closures, because a
372    /// closure written only for the empty-list call would never be invoked and
373    /// so would itself be uncovered.
374    #[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    /// Drives the real [`launch`]: real environment reads, a real
393    /// `Command::status()`, and a real [`classify_exit`] on the result.
394    ///
395    /// `$VISUAL` points at this very test binary with `--list`, so the
396    /// "editor" is a process that is guaranteed to exist on every platform,
397    /// exits immediately, and runs no tests (`--list` only prints names, and
398    /// the appended file path acts as a filter that matches none of them). It
399    /// is the *first* candidate, so no real editor is ever reached. `temp_env`
400    /// serializes environment mutation process-wide, which is required because
401    /// `std::env::set_var` is unsafe and this crate forbids unsafe.
402    #[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}