1use std::ffi::OsString;
11use std::path::Path;
12
13use crate::{CompatibilityMode, EditorSource, Error};
14
15#[derive(Debug, Clone)]
18pub struct Resolved {
19 pub argv: Vec<OsString>,
20 pub source: EditorSource,
21}
22
23pub 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 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 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 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 #[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 #[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
96pub 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#[cfg(unix)]
109fn is_unix_executable(path: &Path) -> bool {
110 use std::os::unix::fs::PermissionsExt;
111 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 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 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}