1#[cfg(target_os = "windows")]
2#[must_use]
13pub fn resolve_command_on_path(cmd: &str) -> Option<std::path::PathBuf> {
14 which::which(cmd).ok()
15}
16
17#[cfg(target_os = "windows")]
18#[must_use]
29pub fn command_on_path(cmd: &str) -> bool {
30 resolve_command_on_path(cmd).is_some()
31}
32
33#[cfg(target_os = "windows")]
34pub fn is_powershell_available() -> bool {
42 command_on_path("powershell.exe") || command_on_path("pwsh.exe")
43}
44
45#[cfg(target_os = "windows")]
46#[must_use]
60pub fn cmd_echo_command(msg: &str) -> String {
61 let mut out = String::from("echo(");
62 for ch in msg.chars() {
63 match ch {
64 '\r' => {}
65 '\n' => out.push_str(" & echo("),
66 '^' | '&' | '|' | '<' | '>' | '(' | ')' => {
67 out.push('^');
68 out.push(ch);
69 }
70 '%' => out.push_str("%%"),
71 '!' => out.push_str("^!"),
72 _ => out.push(ch),
73 }
74 }
75 out
76}
77
78#[cfg(not(target_os = "windows"))]
79#[must_use]
90fn path_is_executable(p: &std::path::Path) -> bool {
91 if !p.is_file() {
92 return false;
93 }
94 #[cfg(unix)]
95 {
96 use std::os::unix::fs::PermissionsExt;
97 std::fs::metadata(p).is_ok_and(|meta| meta.permissions().mode() & 0o111 != 0)
98 }
99 #[cfg(not(unix))]
100 {
101 true
102 }
103}
104
105#[cfg(not(target_os = "windows"))]
106#[must_use]
117pub fn resolve_command_on_path(cmd: &str) -> Option<std::path::PathBuf> {
118 use std::path::Path;
119
120 if cmd.contains(std::path::MAIN_SEPARATOR) {
121 let p = Path::new(cmd);
122 return path_is_executable(p).then(|| p.to_path_buf());
123 }
124
125 let paths = std::env::var_os("PATH")?;
126 for dir in std::env::split_paths(&paths) {
127 let candidate = dir.join(cmd);
128 if path_is_executable(&candidate) {
129 return Some(candidate);
130 }
131 }
132 None
133}
134
135#[cfg(not(target_os = "windows"))]
136#[must_use]
147pub fn command_on_path(cmd: &str) -> bool {
148 resolve_command_on_path(cmd).is_some()
149}
150
151#[cfg(not(target_os = "windows"))]
152pub fn choose_terminal_index_prefer_path(terms: &[(&str, &[&str], bool)]) -> Option<usize> {
163 if let Some(paths) = std::env::var_os("PATH") {
164 for dir in std::env::split_paths(&paths) {
165 for (i, (name, _args, _hold)) in terms.iter().enumerate() {
166 let candidate = dir.join(name);
167 if path_is_executable(&candidate) {
168 return Some(i);
169 }
170 }
171 }
172 }
173 None
174}
175
176#[must_use]
188pub fn shell_single_quote(s: &str) -> String {
189 if s.is_empty() {
190 return "''".to_string();
191 }
192 let mut out = String::with_capacity(s.len() + 2);
193 out.push('\'');
194 for ch in s.chars() {
195 if ch == '\'' {
196 out.push_str("'\"'\"'");
197 } else {
198 out.push(ch);
199 }
200 }
201 out.push('\'');
202 out
203}
204
205#[must_use]
217pub fn is_safe_package_name(name: &str) -> bool {
218 !name.is_empty()
219 && name.bytes().all(|byte| {
220 byte.is_ascii_lowercase()
221 || byte.is_ascii_digit()
222 || matches!(byte, b'@' | b'.' | b'_' | b'+' | b'-')
223 })
224}
225
226pub fn validate_package_names(names: &[String], context: &str) -> Result<(), String> {
239 if let Some(invalid) = names
240 .iter()
241 .find(|name| !is_safe_package_name(name.as_str()))
242 {
243 return Err(format!(
244 "Invalid package name '{invalid}' for {context}. Allowed pattern: ^[a-z\\d@._+-]+$"
245 ));
246 }
247 Ok(())
248}
249
250#[cfg(not(target_os = "windows"))]
251const EDITOR_FALLBACK_MESSAGE: &str = "No terminal editor found (nvim/vim/emacsclient/emacs/hx/helix/nano). Set VISUAL or EDITOR to use your preferred editor.";
254
255#[cfg(not(target_os = "windows"))]
256#[must_use]
271pub fn editor_open_config_command(path: &std::path::Path) -> String {
272 let path_str = path.display().to_string();
273 let path_quoted = shell_single_quote(&path_str);
274 format!(
276 "( [ -n \"${{VISUAL}}\" ] && command -v \"${{VISUAL%% *}}\" >/dev/null 2>&1 && eval \"${{VISUAL}}\" {path_quoted} ) || \
277 ( [ -n \"${{EDITOR}}\" ] && command -v \"${{EDITOR%% *}}\" >/dev/null 2>&1 && eval \"${{EDITOR}}\" {path_quoted} ) || \
278 ((command -v nvim >/dev/null 2>&1 || pacman -Qi neovim >/dev/null 2>&1) && nvim {path_quoted}) || \
279 ((command -v vim >/dev/null 2>&1 || pacman -Qi vim >/dev/null 2>&1) && vim {path_quoted}) || \
280 ((command -v hx >/dev/null 2>&1 || pacman -Qi helix >/dev/null 2>&1) && hx {path_quoted}) || \
281 ((command -v helix >/dev/null 2>&1 || pacman -Qi helix >/dev/null 2>&1) && helix {path_quoted}) || \
282 ((command -v emacsclient >/dev/null 2>&1 || pacman -Qi emacs >/dev/null 2>&1) && emacsclient -t {path_quoted}) || \
283 ((command -v emacs >/dev/null 2>&1 || pacman -Qi emacs >/dev/null 2>&1) && emacs -nw {path_quoted}) || \
284 ((command -v nano >/dev/null 2>&1 || pacman -Qi nano >/dev/null 2>&1) && nano {path_quoted}) || \
285 (echo '{EDITOR_FALLBACK_MESSAGE}'; echo 'File: {path_quoted}'; read -rn1 -s _ || true)"
286 )
287}
288
289#[cfg(all(test, not(target_os = "windows")))]
290mod tests {
291 #[test]
292 fn utils_command_on_path_detects_executable() {
304 use std::fs;
305 use std::os::unix::fs::PermissionsExt;
306 use std::path::PathBuf;
307
308 let mut dir: PathBuf = std::env::temp_dir();
309 dir.push(format!(
310 "pacsea_test_utils_path_{}_{}",
311 std::process::id(),
312 std::time::SystemTime::now()
313 .duration_since(std::time::UNIX_EPOCH)
314 .expect("System time is before UNIX epoch")
315 .as_nanos()
316 ));
317 let _ = fs::create_dir_all(&dir);
318 let mut cmd_path = dir.clone();
319 cmd_path.push("mycmd");
320 fs::write(&cmd_path, b"#!/bin/sh\nexit 0\n").expect("Failed to write test command script");
321 let mut perms = fs::metadata(&cmd_path)
322 .expect("Failed to read test command script metadata")
323 .permissions();
324 perms.set_mode(0o755);
325 fs::set_permissions(&cmd_path, perms)
326 .expect("Failed to set test command script permissions");
327
328 let orig_path = std::env::var_os("PATH");
329 unsafe { std::env::set_var("PATH", dir.display().to_string()) };
330 assert!(super::command_on_path("mycmd"));
331 assert_eq!(
332 super::resolve_command_on_path("mycmd").as_deref(),
333 Some(cmd_path.as_path())
334 );
335 assert!(!super::command_on_path("notexist"));
336 assert!(super::resolve_command_on_path("notexist").is_none());
337 unsafe {
338 if let Some(v) = orig_path {
339 std::env::set_var("PATH", v);
340 } else {
341 std::env::remove_var("PATH");
342 }
343 }
344 let _ = fs::remove_dir_all(&dir);
345 }
346
347 #[test]
348 fn utils_resolve_command_on_path_skips_non_executable_file() {
359 use std::fs;
360 use std::os::unix::fs::PermissionsExt;
361 use std::path::PathBuf;
362
363 let mut dir: PathBuf = std::env::temp_dir();
364 dir.push(format!(
365 "pacsea_test_utils_notexec_{}_{}",
366 std::process::id(),
367 std::time::SystemTime::now()
368 .duration_since(std::time::UNIX_EPOCH)
369 .expect("System time is before UNIX epoch")
370 .as_nanos()
371 ));
372 let _ = fs::create_dir_all(&dir);
373 let mut stub = dir.clone();
374 stub.push("stub");
375 fs::write(&stub, b"not runnable\n").expect("Failed to write stub file");
376 let mut perms = fs::metadata(&stub)
377 .expect("Failed to read stub metadata")
378 .permissions();
379 perms.set_mode(0o644);
380 fs::set_permissions(&stub, perms).expect("Failed to set non-executable permissions");
381
382 let orig_path = std::env::var_os("PATH");
383 unsafe { std::env::set_var("PATH", dir.display().to_string()) };
384 assert!(!super::command_on_path("stub"));
385 assert!(super::resolve_command_on_path("stub").is_none());
386 unsafe {
387 if let Some(v) = orig_path {
388 std::env::set_var("PATH", v);
389 } else {
390 std::env::remove_var("PATH");
391 }
392 }
393 let _ = fs::remove_dir_all(&dir);
394 }
395
396 #[test]
397 fn utils_choose_terminal_index_prefers_first_present_in_terms_order() {
409 use std::fs;
410 use std::os::unix::fs::PermissionsExt;
411 use std::path::PathBuf;
412
413 let mut dir: PathBuf = std::env::temp_dir();
414 dir.push(format!(
415 "pacsea_test_utils_terms_{}_{}",
416 std::process::id(),
417 std::time::SystemTime::now()
418 .duration_since(std::time::UNIX_EPOCH)
419 .expect("System time is before UNIX epoch")
420 .as_nanos()
421 ));
422 let _ = fs::create_dir_all(&dir);
423 let mut kitty = dir.clone();
424 kitty.push("kitty");
425 fs::write(&kitty, b"#!/bin/sh\nexit 0\n").expect("Failed to write test kitty script");
426 let mut perms = fs::metadata(&kitty)
427 .expect("Failed to read test kitty script metadata")
428 .permissions();
429 perms.set_mode(0o755);
430 fs::set_permissions(&kitty, perms).expect("Failed to set test kitty script permissions");
431
432 let terms: &[(&str, &[&str], bool)] =
433 &[("gnome-terminal", &[], false), ("kitty", &[], false)];
434 let orig_path = std::env::var_os("PATH");
435 unsafe { std::env::set_var("PATH", dir.display().to_string()) };
436 let idx = super::choose_terminal_index_prefer_path(terms).expect("index");
437 assert_eq!(idx, 1);
438 unsafe {
439 if let Some(v) = orig_path {
440 std::env::set_var("PATH", v);
441 } else {
442 std::env::remove_var("PATH");
443 }
444 }
445 let _ = fs::remove_dir_all(&dir);
446 }
447
448 #[test]
449 fn utils_shell_single_quote_handles_edges() {
460 assert_eq!(super::shell_single_quote(""), "''");
461 assert_eq!(super::shell_single_quote("abc"), "'abc'");
462 assert_eq!(super::shell_single_quote("a'b"), "'a'\"'\"'b'");
463 }
464
465 #[test]
466 fn utils_is_safe_package_name_strict_allowlist() {
477 assert!(super::is_safe_package_name("ripgrep"));
478 assert!(super::is_safe_package_name("lib32-foo+bar"));
479 assert!(super::is_safe_package_name("qt6-base@beta.1"));
480 assert!(!super::is_safe_package_name(""));
481 assert!(!super::is_safe_package_name("Ripgrep"));
482 assert!(!super::is_safe_package_name("bad;name"));
483 assert!(!super::is_safe_package_name("bad name"));
484 }
485
486 #[test]
487 fn utils_editor_open_config_command_order_visual_then_editor_then_fallbacks() {
498 use std::path::Path;
499 let path = Path::new("/tmp/settings.conf");
500 let cmd = super::editor_open_config_command(path);
501 let idx_visual = cmd.find("VISUAL").expect("command must mention VISUAL");
502 let idx_editor = cmd.find("EDITOR").expect("command must mention EDITOR");
503 let idx_nvim = cmd
504 .find("nvim")
505 .expect("command must mention nvim fallback");
506 assert!(idx_visual < idx_editor, "VISUAL must appear before EDITOR");
507 assert!(
508 idx_editor < idx_nvim,
509 "EDITOR must appear before nvim fallback"
510 );
511 }
512
513 #[test]
514 fn utils_editor_open_config_command_contains_fallback_chain_and_message() {
525 use std::path::Path;
526 let path = Path::new("/tmp/theme.conf");
527 let cmd = super::editor_open_config_command(path);
528 assert!(cmd.contains("nvim"), "fallback chain must include nvim");
529 assert!(cmd.contains("vim"), "fallback chain must include vim");
530 assert!(cmd.contains("hx"), "fallback chain must include hx");
531 assert!(cmd.contains("helix"), "fallback chain must include helix");
532 assert!(
533 cmd.contains("emacsclient"),
534 "fallback chain must include emacsclient"
535 );
536 assert!(cmd.contains("emacs"), "fallback chain must include emacs");
537 assert!(cmd.contains("nano"), "fallback chain must include nano");
538 assert!(
539 cmd.contains("No terminal editor found"),
540 "command must include fallback message"
541 );
542 }
543
544 #[test]
545 fn utils_editor_open_config_command_path_is_shell_single_quoted() {
556 use std::path::Path;
557 let path_with_quote = Path::new("/tmp/foo'bar.conf");
558 let path_str = path_with_quote.display().to_string();
559 let path_quoted = super::shell_single_quote(&path_str);
560 let cmd = super::editor_open_config_command(path_with_quote);
561 assert!(
562 cmd.contains(&path_quoted),
563 "command must contain shell-single-quoted path, got quoted: {path_quoted:?}"
564 );
565 assert!(
567 !cmd.contains("/tmp/foo'bar.conf"),
568 "command must not contain raw path with unescaped single quote"
569 );
570 }
571}