Skip to main content

rusty_vipe/
editor.rs

1//! Editor resolution + argv parsing.
2//!
3//! Precedence ladder (FR-009):
4//! 1. `--editor=<cmd>` flag (Default mode only); empty value falls through.
5//! 2. `$VISUAL`
6//! 3. `$EDITOR`
7//! 4. `/usr/bin/editor` (Unix only; existence + executable check)
8//! 5. `vi` (Unix) / `notepad.exe` (Windows)
9
10use std::ffi::OsString;
11use std::path::Path;
12
13use crate::{CompatibilityMode, EditorSource, Error};
14
15/// Result of resolving the editor command: the argv to spawn (NOT yet
16/// including the tempfile path) plus the resolved source for diagnostics.
17#[derive(Debug, Clone)]
18pub struct Resolved {
19    pub argv: Vec<OsString>,
20    pub source: EditorSource,
21}
22
23/// Walk the resolution ladder. Returns the editor argv (without tempfile)
24/// and the matched source rung.
25pub fn resolve(
26    explicit_override: Option<&str>,
27    env_visual: Option<&str>,
28    env_editor: Option<&str>,
29    mode: CompatibilityMode,
30) -> Result<Resolved, Error> {
31    // (1) Explicit override (Default mode only; empty falls through).
32    if mode == CompatibilityMode::Default {
33        if let Some(cmd) = explicit_override {
34            if !cmd.is_empty() {
35                let argv = parse_editor_value(cmd)?;
36                if !argv.is_empty() {
37                    return Ok(Resolved {
38                        argv,
39                        source: EditorSource::Override(cmd.to_string()),
40                    });
41                }
42            }
43        }
44    }
45
46    // (2) $VISUAL
47    if let Some(v) = env_visual {
48        if !v.is_empty() {
49            let argv = parse_editor_value(v)?;
50            if !argv.is_empty() {
51                return Ok(Resolved {
52                    argv,
53                    source: EditorSource::EnvLookup,
54                });
55            }
56        }
57    }
58
59    // (3) $EDITOR
60    if let Some(v) = env_editor {
61        if !v.is_empty() {
62            let argv = parse_editor_value(v)?;
63            if !argv.is_empty() {
64                return Ok(Resolved {
65                    argv,
66                    source: EditorSource::EnvLookup,
67                });
68            }
69        }
70    }
71
72    // (4) Unix-only: /usr/bin/editor if executable + resolvable.
73    #[cfg(unix)]
74    {
75        const USR_BIN_EDITOR: &str = "/usr/bin/editor";
76        if is_unix_executable(Path::new(USR_BIN_EDITOR)) {
77            return Ok(Resolved {
78                argv: vec![OsString::from(USR_BIN_EDITOR)],
79                source: EditorSource::EnvLookup,
80            });
81        }
82    }
83
84    // (5) Platform default.
85    #[cfg(unix)]
86    let fallback = "vi";
87    #[cfg(windows)]
88    let fallback = "notepad.exe";
89
90    Ok(Resolved {
91        argv: vec![OsString::from(fallback)],
92        source: EditorSource::EnvLookup,
93    })
94}
95
96/// Parse an editor command string into argv using `shell-words` rules.
97/// Returns `Error::InvalidEditorCommand(value.to_string())` on parse failure
98/// (per FR-010 + STF-003).
99pub fn parse_editor_value(value: &str) -> Result<Vec<OsString>, Error> {
100    match shell_words::split(value) {
101        Ok(parts) => Ok(parts.into_iter().map(OsString::from).collect()),
102        Err(_) => Err(Error::InvalidEditorCommand(value.to_string())),
103    }
104}
105
106/// Returns true if the path exists, is a regular (or symlink-resolving-to-regular)
107/// file, and has at least one executable mode bit set on Unix.
108#[cfg(unix)]
109fn is_unix_executable(path: &Path) -> bool {
110    use std::os::unix::fs::PermissionsExt;
111    // metadata() follows symlinks — if the link target is missing, this returns Err.
112    let Ok(meta) = std::fs::metadata(path) else {
113        return false;
114    };
115    meta.is_file() && (meta.permissions().mode() & 0o111) != 0
116}
117
118#[cfg(not(unix))]
119#[allow(dead_code)]
120fn is_unix_executable(_path: &Path) -> bool {
121    false
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn parse_simple_command() {
130        let argv = parse_editor_value("vi").unwrap();
131        assert_eq!(argv, vec![OsString::from("vi")]);
132    }
133
134    #[test]
135    fn parse_command_with_arg() {
136        let argv = parse_editor_value("code --wait").unwrap();
137        assert_eq!(argv, vec![OsString::from("code"), OsString::from("--wait")]);
138    }
139
140    #[test]
141    fn parse_quoted_path_with_spaces() {
142        let argv = parse_editor_value(r#""path with spaces/editor" --flag"#).unwrap();
143        assert_eq!(
144            argv,
145            vec![
146                OsString::from("path with spaces/editor"),
147                OsString::from("--flag")
148            ]
149        );
150    }
151
152    #[test]
153    fn parse_unbalanced_quote_returns_typed_error() {
154        let raw = r#""unbalanced"#;
155        match parse_editor_value(raw) {
156            Err(Error::InvalidEditorCommand(s)) => assert_eq!(s, raw),
157            other => panic!("expected InvalidEditorCommand, got {other:?}"),
158        }
159    }
160
161    #[test]
162    fn resolve_override_wins_in_default_mode() {
163        let r = resolve(
164            Some("my-editor"),
165            Some("/should/be/ignored"),
166            Some("/also/ignored"),
167            CompatibilityMode::Default,
168        )
169        .unwrap();
170        assert_eq!(r.argv, vec![OsString::from("my-editor")]);
171        assert!(matches!(r.source, EditorSource::Override(_)));
172    }
173
174    #[test]
175    fn resolve_override_ignored_in_strict_mode() {
176        // Strict mode should NOT consume the --editor override; falls through
177        // to env. (The builder-level rejection happens earlier in real usage;
178        // this test verifies the resolver itself is correct even if a Strict
179        // mode somehow had an Override.)
180        let r = resolve(
181            Some("override"),
182            Some("from-visual"),
183            None,
184            CompatibilityMode::Strict,
185        )
186        .unwrap();
187        assert_eq!(r.argv, vec![OsString::from("from-visual")]);
188    }
189
190    #[test]
191    fn resolve_empty_override_falls_through() {
192        let r = resolve(
193            Some(""),
194            Some("from-visual"),
195            None,
196            CompatibilityMode::Default,
197        )
198        .unwrap();
199        assert_eq!(r.argv, vec![OsString::from("from-visual")]);
200    }
201
202    #[test]
203    fn resolve_visual_wins_over_editor() {
204        let r = resolve(
205            None,
206            Some("from-visual"),
207            Some("from-editor"),
208            CompatibilityMode::Default,
209        )
210        .unwrap();
211        assert_eq!(r.argv, vec![OsString::from("from-visual")]);
212    }
213
214    #[test]
215    fn resolve_editor_used_when_visual_unset() {
216        let r = resolve(None, None, Some("from-editor"), CompatibilityMode::Default).unwrap();
217        assert_eq!(r.argv, vec![OsString::from("from-editor")]);
218    }
219
220    #[cfg(unix)]
221    #[test]
222    fn resolve_unix_falls_back_to_vi_when_nothing_else() {
223        // /usr/bin/editor may exist on the test host; we can't assert which
224        // rung wins without overriding PATH. Instead assert that the result
225        // is either /usr/bin/editor or "vi".
226        let r = resolve(None, None, None, CompatibilityMode::Default).unwrap();
227        let first = r.argv.first().expect("at least argv[0]");
228        assert!(
229            first == &OsString::from("/usr/bin/editor") || first == &OsString::from("vi"),
230            "Unix fallback should be /usr/bin/editor or vi, got {first:?}"
231        );
232    }
233
234    #[cfg(windows)]
235    #[test]
236    fn resolve_windows_falls_back_to_notepad() {
237        let r = resolve(None, None, None, CompatibilityMode::Default).unwrap();
238        assert_eq!(r.argv, vec![OsString::from("notepad.exe")]);
239    }
240}