1#[cfg(target_os = "windows")]
2#[must_use]
13pub fn command_on_path(cmd: &str) -> bool {
14 which::which(cmd).is_ok()
15}
16
17#[cfg(target_os = "windows")]
18pub fn is_powershell_available() -> bool {
26 command_on_path("powershell.exe") || command_on_path("pwsh.exe")
27}
28
29#[cfg(not(target_os = "windows"))]
30#[must_use]
42pub fn command_on_path(cmd: &str) -> bool {
43 use std::path::Path;
44
45 fn is_exec(p: &std::path::Path) -> bool {
46 if !p.is_file() {
47 return false;
48 }
49 #[cfg(unix)]
50 {
51 use std::os::unix::fs::PermissionsExt;
52 if let Ok(meta) = std::fs::metadata(p) {
53 return meta.permissions().mode() & 0o111 != 0;
54 }
55 false
56 }
57 #[cfg(not(unix))]
58 {
59 true
60 }
61 }
62
63 if cmd.contains(std::path::MAIN_SEPARATOR) {
64 return is_exec(Path::new(cmd));
65 }
66
67 if let Some(paths) = std::env::var_os("PATH") {
68 for dir in std::env::split_paths(&paths) {
69 let candidate = dir.join(cmd);
70 if is_exec(&candidate) {
71 return true;
72 }
73 #[cfg(windows)]
74 {
75 if let Some(pathext) = std::env::var_os("PATHEXT") {
76 for ext in pathext.to_string_lossy().split(';') {
77 let candidate = dir.join(format!("{cmd}{ext}"));
78 if candidate.is_file() {
79 return true;
80 }
81 }
82 }
83 }
84 }
85 }
86 false
87}
88
89#[cfg(not(target_os = "windows"))]
90pub fn choose_terminal_index_prefer_path(terms: &[(&str, &[&str], bool)]) -> Option<usize> {
101 use std::os::unix::fs::PermissionsExt;
102 if let Some(paths) = std::env::var_os("PATH") {
103 for dir in std::env::split_paths(&paths) {
104 for (i, (name, _args, _hold)) in terms.iter().enumerate() {
105 let candidate = dir.join(name);
106 if candidate.is_file()
107 && let Ok(meta) = std::fs::metadata(&candidate)
108 && meta.permissions().mode() & 0o111 != 0
109 {
110 return Some(i);
111 }
112 }
113 }
114 }
115 None
116}
117
118#[must_use]
130pub fn shell_single_quote(s: &str) -> String {
131 if s.is_empty() {
132 return "''".to_string();
133 }
134 let mut out = String::with_capacity(s.len() + 2);
135 out.push('\'');
136 for ch in s.chars() {
137 if ch == '\'' {
138 out.push_str("'\"'\"'");
139 } else {
140 out.push(ch);
141 }
142 }
143 out.push('\'');
144 out
145}
146
147#[cfg(not(target_os = "windows"))]
148const EDITOR_FALLBACK_MESSAGE: &str = "No terminal editor found (nvim/vim/emacsclient/emacs/hx/helix/nano). Set VISUAL or EDITOR to use your preferred editor.";
151
152#[cfg(not(target_os = "windows"))]
153#[must_use]
168pub fn editor_open_config_command(path: &std::path::Path) -> String {
169 let path_str = path.display().to_string();
170 let path_quoted = shell_single_quote(&path_str);
171 format!(
173 "( [ -n \"${{VISUAL}}\" ] && command -v \"${{VISUAL%% *}}\" >/dev/null 2>&1 && eval \"${{VISUAL}}\" {path_quoted} ) || \
174 ( [ -n \"${{EDITOR}}\" ] && command -v \"${{EDITOR%% *}}\" >/dev/null 2>&1 && eval \"${{EDITOR}}\" {path_quoted} ) || \
175 ((command -v nvim >/dev/null 2>&1 || sudo pacman -Qi neovim >/dev/null 2>&1) && nvim {path_quoted}) || \
176 ((command -v vim >/dev/null 2>&1 || sudo pacman -Qi vim >/dev/null 2>&1) && vim {path_quoted}) || \
177 ((command -v hx >/dev/null 2>&1 || sudo pacman -Qi helix >/dev/null 2>&1) && hx {path_quoted}) || \
178 ((command -v helix >/dev/null 2>&1 || sudo pacman -Qi helix >/dev/null 2>&1) && helix {path_quoted}) || \
179 ((command -v emacsclient >/dev/null 2>&1 || sudo pacman -Qi emacs >/dev/null 2>&1) && emacsclient -t {path_quoted}) || \
180 ((command -v emacs >/dev/null 2>&1 || sudo pacman -Qi emacs >/dev/null 2>&1) && emacs -nw {path_quoted}) || \
181 ((command -v nano >/dev/null 2>&1 || sudo pacman -Qi nano >/dev/null 2>&1) && nano {path_quoted}) || \
182 (echo '{EDITOR_FALLBACK_MESSAGE}'; echo 'File: {path_quoted}'; read -rn1 -s _ || true)"
183 )
184}
185
186#[cfg(all(test, not(target_os = "windows")))]
187mod tests {
188 #[test]
189 fn utils_command_on_path_detects_executable() {
201 use std::fs;
202 use std::os::unix::fs::PermissionsExt;
203 use std::path::PathBuf;
204
205 let mut dir: PathBuf = std::env::temp_dir();
206 dir.push(format!(
207 "pacsea_test_utils_path_{}_{}",
208 std::process::id(),
209 std::time::SystemTime::now()
210 .duration_since(std::time::UNIX_EPOCH)
211 .expect("System time is before UNIX epoch")
212 .as_nanos()
213 ));
214 let _ = fs::create_dir_all(&dir);
215 let mut cmd_path = dir.clone();
216 cmd_path.push("mycmd");
217 fs::write(&cmd_path, b"#!/bin/sh\nexit 0\n").expect("Failed to write test command script");
218 let mut perms = fs::metadata(&cmd_path)
219 .expect("Failed to read test command script metadata")
220 .permissions();
221 perms.set_mode(0o755);
222 fs::set_permissions(&cmd_path, perms)
223 .expect("Failed to set test command script permissions");
224
225 let orig_path = std::env::var_os("PATH");
226 unsafe { std::env::set_var("PATH", dir.display().to_string()) };
227 assert!(super::command_on_path("mycmd"));
228 assert!(!super::command_on_path("notexist"));
229 unsafe {
230 if let Some(v) = orig_path {
231 std::env::set_var("PATH", v);
232 } else {
233 std::env::remove_var("PATH");
234 }
235 }
236 let _ = fs::remove_dir_all(&dir);
237 }
238
239 #[test]
240 fn utils_choose_terminal_index_prefers_first_present_in_terms_order() {
252 use std::fs;
253 use std::os::unix::fs::PermissionsExt;
254 use std::path::PathBuf;
255
256 let mut dir: PathBuf = std::env::temp_dir();
257 dir.push(format!(
258 "pacsea_test_utils_terms_{}_{}",
259 std::process::id(),
260 std::time::SystemTime::now()
261 .duration_since(std::time::UNIX_EPOCH)
262 .expect("System time is before UNIX epoch")
263 .as_nanos()
264 ));
265 let _ = fs::create_dir_all(&dir);
266 let mut kitty = dir.clone();
267 kitty.push("kitty");
268 fs::write(&kitty, b"#!/bin/sh\nexit 0\n").expect("Failed to write test kitty script");
269 let mut perms = fs::metadata(&kitty)
270 .expect("Failed to read test kitty script metadata")
271 .permissions();
272 perms.set_mode(0o755);
273 fs::set_permissions(&kitty, perms).expect("Failed to set test kitty script permissions");
274
275 let terms: &[(&str, &[&str], bool)] =
276 &[("gnome-terminal", &[], false), ("kitty", &[], false)];
277 let orig_path = std::env::var_os("PATH");
278 unsafe { std::env::set_var("PATH", dir.display().to_string()) };
279 let idx = super::choose_terminal_index_prefer_path(terms).expect("index");
280 assert_eq!(idx, 1);
281 unsafe {
282 if let Some(v) = orig_path {
283 std::env::set_var("PATH", v);
284 } else {
285 std::env::remove_var("PATH");
286 }
287 }
288 let _ = fs::remove_dir_all(&dir);
289 }
290
291 #[test]
292 fn utils_shell_single_quote_handles_edges() {
303 assert_eq!(super::shell_single_quote(""), "''");
304 assert_eq!(super::shell_single_quote("abc"), "'abc'");
305 assert_eq!(super::shell_single_quote("a'b"), "'a'\"'\"'b'");
306 }
307
308 #[test]
309 fn utils_editor_open_config_command_order_visual_then_editor_then_fallbacks() {
320 use std::path::Path;
321 let path = Path::new("/tmp/settings.conf");
322 let cmd = super::editor_open_config_command(path);
323 let idx_visual = cmd.find("VISUAL").expect("command must mention VISUAL");
324 let idx_editor = cmd.find("EDITOR").expect("command must mention EDITOR");
325 let idx_nvim = cmd
326 .find("nvim")
327 .expect("command must mention nvim fallback");
328 assert!(idx_visual < idx_editor, "VISUAL must appear before EDITOR");
329 assert!(
330 idx_editor < idx_nvim,
331 "EDITOR must appear before nvim fallback"
332 );
333 }
334
335 #[test]
336 fn utils_editor_open_config_command_contains_fallback_chain_and_message() {
347 use std::path::Path;
348 let path = Path::new("/tmp/theme.conf");
349 let cmd = super::editor_open_config_command(path);
350 assert!(cmd.contains("nvim"), "fallback chain must include nvim");
351 assert!(cmd.contains("vim"), "fallback chain must include vim");
352 assert!(cmd.contains("hx"), "fallback chain must include hx");
353 assert!(cmd.contains("helix"), "fallback chain must include helix");
354 assert!(
355 cmd.contains("emacsclient"),
356 "fallback chain must include emacsclient"
357 );
358 assert!(cmd.contains("emacs"), "fallback chain must include emacs");
359 assert!(cmd.contains("nano"), "fallback chain must include nano");
360 assert!(
361 cmd.contains("No terminal editor found"),
362 "command must include fallback message"
363 );
364 }
365
366 #[test]
367 fn utils_editor_open_config_command_path_is_shell_single_quoted() {
378 use std::path::Path;
379 let path_with_quote = Path::new("/tmp/foo'bar.conf");
380 let path_str = path_with_quote.display().to_string();
381 let path_quoted = super::shell_single_quote(&path_str);
382 let cmd = super::editor_open_config_command(path_with_quote);
383 assert!(
384 cmd.contains(&path_quoted),
385 "command must contain shell-single-quoted path, got quoted: {path_quoted:?}"
386 );
387 assert!(
389 !cmd.contains("/tmp/foo'bar.conf"),
390 "command must not contain raw path with unescaped single quote"
391 );
392 }
393}