#[cfg(target_os = "windows")]
#[must_use]
pub fn resolve_command_on_path(cmd: &str) -> Option<std::path::PathBuf> {
which::which(cmd).ok()
}
#[cfg(target_os = "windows")]
#[must_use]
pub fn command_on_path(cmd: &str) -> bool {
resolve_command_on_path(cmd).is_some()
}
#[cfg(target_os = "windows")]
pub fn is_powershell_available() -> bool {
command_on_path("powershell.exe") || command_on_path("pwsh.exe")
}
#[cfg(target_os = "windows")]
#[must_use]
pub fn cmd_echo_command(msg: &str) -> String {
let mut out = String::from("echo(");
for ch in msg.chars() {
match ch {
'\r' => {}
'\n' => out.push_str(" & echo("),
'^' | '&' | '|' | '<' | '>' | '(' | ')' => {
out.push('^');
out.push(ch);
}
'%' => out.push_str("%%"),
'!' => out.push_str("^!"),
_ => out.push(ch),
}
}
out
}
#[cfg(not(target_os = "windows"))]
#[must_use]
fn path_is_executable(p: &std::path::Path) -> bool {
if !p.is_file() {
return false;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(p)
.map(|meta| meta.permissions().mode() & 0o111 != 0)
.unwrap_or(false)
}
#[cfg(not(unix))]
{
true
}
}
#[cfg(not(target_os = "windows"))]
#[must_use]
pub fn resolve_command_on_path(cmd: &str) -> Option<std::path::PathBuf> {
use std::path::Path;
if cmd.contains(std::path::MAIN_SEPARATOR) {
let p = Path::new(cmd);
return path_is_executable(p).then(|| p.to_path_buf());
}
let paths = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&paths) {
let candidate = dir.join(cmd);
if path_is_executable(&candidate) {
return Some(candidate);
}
}
None
}
#[cfg(not(target_os = "windows"))]
#[must_use]
pub fn command_on_path(cmd: &str) -> bool {
resolve_command_on_path(cmd).is_some()
}
#[cfg(not(target_os = "windows"))]
pub fn choose_terminal_index_prefer_path(terms: &[(&str, &[&str], bool)]) -> Option<usize> {
if let Some(paths) = std::env::var_os("PATH") {
for dir in std::env::split_paths(&paths) {
for (i, (name, _args, _hold)) in terms.iter().enumerate() {
let candidate = dir.join(name);
if path_is_executable(&candidate) {
return Some(i);
}
}
}
}
None
}
#[must_use]
pub fn shell_single_quote(s: &str) -> String {
if s.is_empty() {
return "''".to_string();
}
let mut out = String::with_capacity(s.len() + 2);
out.push('\'');
for ch in s.chars() {
if ch == '\'' {
out.push_str("'\"'\"'");
} else {
out.push(ch);
}
}
out.push('\'');
out
}
#[must_use]
pub fn is_safe_package_name(name: &str) -> bool {
!name.is_empty()
&& name.bytes().all(|byte| {
byte.is_ascii_lowercase()
|| byte.is_ascii_digit()
|| matches!(byte, b'@' | b'.' | b'_' | b'+' | b'-')
})
}
pub fn validate_package_names(names: &[String], context: &str) -> Result<(), String> {
if let Some(invalid) = names
.iter()
.find(|name| !is_safe_package_name(name.as_str()))
{
return Err(format!(
"Invalid package name '{invalid}' for {context}. Allowed pattern: ^[a-z\\d@._+-]+$"
));
}
Ok(())
}
#[cfg(not(target_os = "windows"))]
const EDITOR_FALLBACK_MESSAGE: &str = "No terminal editor found (nvim/vim/emacsclient/emacs/hx/helix/nano). Set VISUAL or EDITOR to use your preferred editor.";
#[cfg(not(target_os = "windows"))]
#[must_use]
pub fn editor_open_config_command(path: &std::path::Path) -> String {
let path_str = path.display().to_string();
let path_quoted = shell_single_quote(&path_str);
format!(
"( [ -n \"${{VISUAL}}\" ] && command -v \"${{VISUAL%% *}}\" >/dev/null 2>&1 && eval \"${{VISUAL}}\" {path_quoted} ) || \
( [ -n \"${{EDITOR}}\" ] && command -v \"${{EDITOR%% *}}\" >/dev/null 2>&1 && eval \"${{EDITOR}}\" {path_quoted} ) || \
((command -v nvim >/dev/null 2>&1 || pacman -Qi neovim >/dev/null 2>&1) && nvim {path_quoted}) || \
((command -v vim >/dev/null 2>&1 || pacman -Qi vim >/dev/null 2>&1) && vim {path_quoted}) || \
((command -v hx >/dev/null 2>&1 || pacman -Qi helix >/dev/null 2>&1) && hx {path_quoted}) || \
((command -v helix >/dev/null 2>&1 || pacman -Qi helix >/dev/null 2>&1) && helix {path_quoted}) || \
((command -v emacsclient >/dev/null 2>&1 || pacman -Qi emacs >/dev/null 2>&1) && emacsclient -t {path_quoted}) || \
((command -v emacs >/dev/null 2>&1 || pacman -Qi emacs >/dev/null 2>&1) && emacs -nw {path_quoted}) || \
((command -v nano >/dev/null 2>&1 || pacman -Qi nano >/dev/null 2>&1) && nano {path_quoted}) || \
(echo '{EDITOR_FALLBACK_MESSAGE}'; echo 'File: {path_quoted}'; read -rn1 -s _ || true)"
)
}
#[cfg(all(test, not(target_os = "windows")))]
mod tests {
#[test]
fn utils_command_on_path_detects_executable() {
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
let mut dir: PathBuf = std::env::temp_dir();
dir.push(format!(
"pacsea_test_utils_path_{}_{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("System time is before UNIX epoch")
.as_nanos()
));
let _ = fs::create_dir_all(&dir);
let mut cmd_path = dir.clone();
cmd_path.push("mycmd");
fs::write(&cmd_path, b"#!/bin/sh\nexit 0\n").expect("Failed to write test command script");
let mut perms = fs::metadata(&cmd_path)
.expect("Failed to read test command script metadata")
.permissions();
perms.set_mode(0o755);
fs::set_permissions(&cmd_path, perms)
.expect("Failed to set test command script permissions");
let orig_path = std::env::var_os("PATH");
unsafe { std::env::set_var("PATH", dir.display().to_string()) };
assert!(super::command_on_path("mycmd"));
assert_eq!(
super::resolve_command_on_path("mycmd").as_deref(),
Some(cmd_path.as_path())
);
assert!(!super::command_on_path("notexist"));
assert!(super::resolve_command_on_path("notexist").is_none());
unsafe {
if let Some(v) = orig_path {
std::env::set_var("PATH", v);
} else {
std::env::remove_var("PATH");
}
}
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn utils_resolve_command_on_path_skips_non_executable_file() {
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
let mut dir: PathBuf = std::env::temp_dir();
dir.push(format!(
"pacsea_test_utils_notexec_{}_{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("System time is before UNIX epoch")
.as_nanos()
));
let _ = fs::create_dir_all(&dir);
let mut stub = dir.clone();
stub.push("stub");
fs::write(&stub, b"not runnable\n").expect("Failed to write stub file");
let mut perms = fs::metadata(&stub)
.expect("Failed to read stub metadata")
.permissions();
perms.set_mode(0o644);
fs::set_permissions(&stub, perms).expect("Failed to set non-executable permissions");
let orig_path = std::env::var_os("PATH");
unsafe { std::env::set_var("PATH", dir.display().to_string()) };
assert!(!super::command_on_path("stub"));
assert!(super::resolve_command_on_path("stub").is_none());
unsafe {
if let Some(v) = orig_path {
std::env::set_var("PATH", v);
} else {
std::env::remove_var("PATH");
}
}
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn utils_choose_terminal_index_prefers_first_present_in_terms_order() {
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
let mut dir: PathBuf = std::env::temp_dir();
dir.push(format!(
"pacsea_test_utils_terms_{}_{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("System time is before UNIX epoch")
.as_nanos()
));
let _ = fs::create_dir_all(&dir);
let mut kitty = dir.clone();
kitty.push("kitty");
fs::write(&kitty, b"#!/bin/sh\nexit 0\n").expect("Failed to write test kitty script");
let mut perms = fs::metadata(&kitty)
.expect("Failed to read test kitty script metadata")
.permissions();
perms.set_mode(0o755);
fs::set_permissions(&kitty, perms).expect("Failed to set test kitty script permissions");
let terms: &[(&str, &[&str], bool)] =
&[("gnome-terminal", &[], false), ("kitty", &[], false)];
let orig_path = std::env::var_os("PATH");
unsafe { std::env::set_var("PATH", dir.display().to_string()) };
let idx = super::choose_terminal_index_prefer_path(terms).expect("index");
assert_eq!(idx, 1);
unsafe {
if let Some(v) = orig_path {
std::env::set_var("PATH", v);
} else {
std::env::remove_var("PATH");
}
}
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn utils_shell_single_quote_handles_edges() {
assert_eq!(super::shell_single_quote(""), "''");
assert_eq!(super::shell_single_quote("abc"), "'abc'");
assert_eq!(super::shell_single_quote("a'b"), "'a'\"'\"'b'");
}
#[test]
fn utils_is_safe_package_name_strict_allowlist() {
assert!(super::is_safe_package_name("ripgrep"));
assert!(super::is_safe_package_name("lib32-foo+bar"));
assert!(super::is_safe_package_name("qt6-base@beta.1"));
assert!(!super::is_safe_package_name(""));
assert!(!super::is_safe_package_name("Ripgrep"));
assert!(!super::is_safe_package_name("bad;name"));
assert!(!super::is_safe_package_name("bad name"));
}
#[test]
fn utils_editor_open_config_command_order_visual_then_editor_then_fallbacks() {
use std::path::Path;
let path = Path::new("/tmp/settings.conf");
let cmd = super::editor_open_config_command(path);
let idx_visual = cmd.find("VISUAL").expect("command must mention VISUAL");
let idx_editor = cmd.find("EDITOR").expect("command must mention EDITOR");
let idx_nvim = cmd
.find("nvim")
.expect("command must mention nvim fallback");
assert!(idx_visual < idx_editor, "VISUAL must appear before EDITOR");
assert!(
idx_editor < idx_nvim,
"EDITOR must appear before nvim fallback"
);
}
#[test]
fn utils_editor_open_config_command_contains_fallback_chain_and_message() {
use std::path::Path;
let path = Path::new("/tmp/theme.conf");
let cmd = super::editor_open_config_command(path);
assert!(cmd.contains("nvim"), "fallback chain must include nvim");
assert!(cmd.contains("vim"), "fallback chain must include vim");
assert!(cmd.contains("hx"), "fallback chain must include hx");
assert!(cmd.contains("helix"), "fallback chain must include helix");
assert!(
cmd.contains("emacsclient"),
"fallback chain must include emacsclient"
);
assert!(cmd.contains("emacs"), "fallback chain must include emacs");
assert!(cmd.contains("nano"), "fallback chain must include nano");
assert!(
cmd.contains("No terminal editor found"),
"command must include fallback message"
);
}
#[test]
fn utils_editor_open_config_command_path_is_shell_single_quoted() {
use std::path::Path;
let path_with_quote = Path::new("/tmp/foo'bar.conf");
let path_str = path_with_quote.display().to_string();
let path_quoted = super::shell_single_quote(&path_str);
let cmd = super::editor_open_config_command(path_with_quote);
assert!(
cmd.contains(&path_quoted),
"command must contain shell-single-quoted path, got quoted: {path_quoted:?}"
);
assert!(
!cmd.contains("/tmp/foo'bar.conf"),
"command must not contain raw path with unescaped single quote"
);
}
}