use crate::model::{normalize_for_search, SearchItem};
use std::cmp::Ordering;
use std::collections::{HashMap, HashSet};
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
pub const ACTION_UNINSTALL_PREFIX: &str = "__nex_action_uninstall__:";
const UNINSTALL_CACHE_TTL: Duration = Duration::from_secs(300);
#[derive(Debug, Clone, PartialEq, Eq)]
struct UninstallEntry {
token: String,
display_name: String,
publisher: String,
uninstall_command: String,
fallback_uninstall_command: Option<String>,
}
#[derive(Debug, Default)]
struct UninstallCache {
loaded_at: Option<Instant>,
entries: Vec<UninstallEntry>,
}
static UNINSTALL_CACHE: OnceLock<Mutex<UninstallCache>> = OnceLock::new();
pub fn search_uninstall_actions(query: &str, limit: usize) -> Vec<SearchItem> {
if limit == 0 {
return Vec::new();
}
let Some(search_term) = extract_uninstall_search_term(query) else {
return Vec::new();
};
let entries = match load_cached_entries(false) {
Ok(entries) => entries,
Err(_) => return Vec::new(),
};
search_uninstall_actions_with_entries(search_term.as_str(), limit, &entries)
}
pub fn has_uninstall_intent(query: &str) -> bool {
extract_uninstall_search_term(query).is_some()
}
pub fn execute_uninstall_action(action_id: &str) -> Result<(), String> {
let token = action_id
.strip_prefix(ACTION_UNINSTALL_PREFIX)
.ok_or_else(|| "invalid uninstall action id".to_string())?;
let mut entries = load_cached_entries(false)?;
let mut entry = entries
.into_iter()
.find(|candidate| candidate.token == token);
if entry.is_none() {
entries = load_cached_entries(true)?;
entry = entries
.into_iter()
.find(|candidate| candidate.token == token);
}
let target = entry.ok_or_else(|| "uninstall target is no longer available".to_string())?;
execute_entry(&target)
}
pub fn is_display_name_registered(display_name: &str) -> Result<bool, String> {
let normalized_query = normalize_for_search(display_name);
if normalized_query.is_empty() {
return Ok(false);
}
let entries = load_cached_entries(true)?;
Ok(entries
.iter()
.map(|entry| normalize_for_search(entry.display_name.as_str()))
.any(|normalized_name| normalized_name == normalized_query))
}
pub fn publishers_by_display_name() -> Result<HashMap<String, String>, String> {
let entries = load_cached_entries(false)?;
let mut out = HashMap::new();
for entry in entries {
let publisher = entry.publisher.trim();
if publisher.is_empty() {
continue;
}
for key in publisher_lookup_keys(entry.display_name.as_str()) {
if key.is_empty() {
continue;
}
out.entry(key).or_insert_with(|| publisher.to_string());
}
}
Ok(out)
}
fn publisher_lookup_keys(display_name: &str) -> Vec<String> {
let mut keys = Vec::new();
let mut seen = HashSet::new();
let raw = display_name.trim();
if raw.is_empty() {
return keys;
}
let normalized_raw = normalize_for_search(raw);
if !normalized_raw.is_empty() && seen.insert(normalized_raw.clone()) {
keys.push(normalized_raw);
}
let no_paren = strip_parenthetical_segments(raw);
let normalized_no_paren = normalize_for_search(no_paren.as_str());
if !normalized_no_paren.is_empty() && seen.insert(normalized_no_paren.clone()) {
keys.push(normalized_no_paren);
}
let no_version = strip_common_version_suffix(no_paren.as_str());
let normalized_no_version = normalize_for_search(no_version.as_str());
if !normalized_no_version.is_empty() && seen.insert(normalized_no_version.clone()) {
keys.push(normalized_no_version);
}
let alpha_only = no_version
.chars()
.filter(|ch| ch.is_ascii_alphabetic())
.flat_map(|ch| ch.to_lowercase())
.collect::<String>();
if !alpha_only.is_empty() && seen.insert(alpha_only.clone()) {
keys.push(alpha_only);
}
keys
}
fn strip_parenthetical_segments(value: &str) -> String {
let mut out = String::with_capacity(value.len());
let mut depth = 0usize;
for ch in value.chars() {
match ch {
'(' | '[' | '{' => {
depth = depth.saturating_add(1);
}
')' | ']' | '}' => {
depth = depth.saturating_sub(1);
}
_ if depth == 0 => out.push(ch),
_ => {}
}
}
out
}
fn strip_common_version_suffix(value: &str) -> String {
let trimmed = value.trim();
if trimmed.is_empty() {
return String::new();
}
let lowered = trimmed.to_ascii_lowercase();
if let Some(idx) = lowered.find(" version ") {
return trimmed[..idx].trim().to_string();
}
let mut kept = Vec::new();
for token in trimmed.split_whitespace() {
let starts_numeric = token
.chars()
.next()
.map(|ch| ch.is_ascii_digit())
.unwrap_or(false);
if starts_numeric {
break;
}
kept.push(token);
}
if kept.is_empty() {
trimmed.to_string()
} else {
kept.join(" ")
}
}
fn search_uninstall_actions_with_entries(
search_term: &str,
limit: usize,
entries: &[UninstallEntry],
) -> Vec<SearchItem> {
if limit == 0 || entries.is_empty() {
return Vec::new();
}
let normalized_query = normalize_for_search(search_term);
let mut ranked: Vec<(i64, &UninstallEntry)> = entries
.iter()
.filter_map(|entry| {
uninstall_entry_score(entry, &normalized_query).map(|score| (score, entry))
})
.collect();
ranked.sort_by(|(left_score, left_entry), (right_score, right_entry)| {
right_score
.cmp(left_score)
.then_with(|| compare_display_name(left_entry, right_entry))
.then_with(|| left_entry.token.cmp(&right_entry.token))
});
ranked
.into_iter()
.take(limit)
.map(|(_, entry)| uninstall_entry_to_action(entry))
.collect()
}
fn compare_display_name(left: &UninstallEntry, right: &UninstallEntry) -> Ordering {
left.display_name
.to_ascii_lowercase()
.cmp(&right.display_name.to_ascii_lowercase())
}
fn uninstall_entry_to_action(entry: &UninstallEntry) -> SearchItem {
let id = format!("{ACTION_UNINSTALL_PREFIX}{}", entry.token);
let subtitle = if entry.publisher.trim().is_empty() {
"Installed application".to_string()
} else {
format!("{} application", entry.publisher.trim())
};
SearchItem::new(
&id,
"action",
&format!("Uninstall {}", entry.display_name.trim()),
&subtitle,
)
}
fn uninstall_entry_score(entry: &UninstallEntry, normalized_query: &str) -> Option<i64> {
if normalized_query.is_empty() {
return Some(100);
}
let normalized_name = normalize_for_search(entry.display_name.as_str());
let normalized_publisher = normalize_for_search(entry.publisher.as_str());
if normalized_name == normalized_query {
return Some(20_000);
}
if normalized_name.starts_with(normalized_query) {
return Some(16_000 - (normalized_name.len() as i64 - normalized_query.len() as i64).abs());
}
if normalized_name.contains(normalized_query) {
return Some(12_000 - (normalized_name.len() as i64 - normalized_query.len() as i64).abs());
}
if normalized_publisher.contains(normalized_query) {
return Some(
8_000 - (normalized_publisher.len() as i64 - normalized_query.len() as i64).abs(),
);
}
None
}
fn extract_uninstall_search_term(query: &str) -> Option<String> {
let trimmed = query.trim();
if trimmed.is_empty() {
return None;
}
let mut parts = trimmed.split_whitespace();
let first = parts.next()?.to_ascii_lowercase();
if !matches!(
first.as_str(),
"uninstall" | "remove" | "delete" | "del" | "rm" | "u"
) {
return None;
}
Some(parts.collect::<Vec<_>>().join(" ").trim().to_string())
}
fn load_cached_entries(force_refresh: bool) -> Result<Vec<UninstallEntry>, String> {
let cache = UNINSTALL_CACHE.get_or_init(|| Mutex::new(UninstallCache::default()));
let mut state = cache
.lock()
.map_err(|_| "uninstall cache lock poisoned".to_string())?;
let stale = state
.loaded_at
.map(|loaded| loaded.elapsed() >= UNINSTALL_CACHE_TTL)
.unwrap_or(true);
if force_refresh || stale {
state.entries = load_entries()?;
state.loaded_at = Some(Instant::now());
}
Ok(state.entries.clone())
}
fn load_entries() -> Result<Vec<UninstallEntry>, String> {
#[cfg(target_os = "windows")]
{
return load_entries_windows();
}
#[cfg(not(target_os = "windows"))]
{
Ok(Vec::new())
}
}
#[cfg(target_os = "windows")]
fn execute_entry(entry: &UninstallEntry) -> Result<(), String> {
match launch_uninstall_command(
entry.display_name.as_str(),
entry.uninstall_command.as_str(),
) {
Ok(()) => Ok(()),
Err(primary_error) => {
if let Some(fallback_command) = entry.fallback_uninstall_command.as_deref() {
match launch_uninstall_command(entry.display_name.as_str(), fallback_command) {
Ok(()) => Ok(()),
Err(fallback_error) => {
Err(format!("{primary_error}; fallback_error={fallback_error}"))
}
}
} else {
Err(primary_error)
}
}
}
}
#[cfg(not(target_os = "windows"))]
fn execute_entry(_entry: &UninstallEntry) -> Result<(), String> {
Err("uninstall actions are only supported on Windows".to_string())
}
#[cfg(target_os = "windows")]
fn load_entries_windows() -> Result<Vec<UninstallEntry>, String> {
use windows_sys::Win32::System::Registry::{
HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE, KEY_WOW64_32KEY,
};
let mut entries = Vec::new();
collect_entries_from_uninstall_key("hkcu", HKEY_CURRENT_USER, 0, &mut entries)?;
collect_entries_from_uninstall_key("hklm", HKEY_LOCAL_MACHINE, 0, &mut entries)?;
collect_entries_from_uninstall_key(
"hklm32",
HKEY_LOCAL_MACHINE,
KEY_WOW64_32KEY,
&mut entries,
)?;
let mut seen = HashSet::new();
entries.retain(|entry| {
let key = format!(
"{}|{}|{}|{}",
normalize_for_search(entry.display_name.as_str()),
normalize_for_search(entry.publisher.as_str()),
normalize_for_search(entry.uninstall_command.as_str()),
normalize_for_search(
entry
.fallback_uninstall_command
.as_deref()
.unwrap_or_default()
),
);
seen.insert(key)
});
entries.sort_by(compare_display_name);
Ok(entries)
}
#[cfg(target_os = "windows")]
fn collect_entries_from_uninstall_key(
hive_label: &str,
root: windows_sys::Win32::System::Registry::HKEY,
view_flags: u32,
out: &mut Vec<UninstallEntry>,
) -> Result<(), String> {
use windows_sys::Win32::Foundation::{
ERROR_FILE_NOT_FOUND, ERROR_NO_MORE_ITEMS, ERROR_SUCCESS,
};
use windows_sys::Win32::System::Registry::{
RegCloseKey, RegEnumKeyExW, RegOpenKeyExW, RegQueryInfoKeyW, HKEY, KEY_READ,
};
const UNINSTALL_SUBKEY: &str = r"Software\Microsoft\Windows\CurrentVersion\Uninstall";
let mut uninstall_root: HKEY = std::ptr::null_mut();
let uninstall_subkey_wide = to_wide(UNINSTALL_SUBKEY);
let open_status = unsafe {
RegOpenKeyExW(
root,
uninstall_subkey_wide.as_ptr(),
0,
KEY_READ | view_flags,
&mut uninstall_root,
)
};
if open_status == ERROR_FILE_NOT_FOUND {
return Ok(());
}
if open_status != ERROR_SUCCESS {
return Err(format!(
"failed to open uninstall key ({hive_label}) with code {open_status}"
));
}
let mut subkey_count = 0_u32;
let mut max_subkey_len = 0_u32;
let info_status = unsafe {
RegQueryInfoKeyW(
uninstall_root,
std::ptr::null_mut(),
std::ptr::null_mut(),
std::ptr::null_mut(),
&mut subkey_count,
&mut max_subkey_len,
std::ptr::null_mut(),
std::ptr::null_mut(),
std::ptr::null_mut(),
std::ptr::null_mut(),
std::ptr::null_mut(),
std::ptr::null_mut(),
)
};
if info_status != ERROR_SUCCESS {
unsafe {
RegCloseKey(uninstall_root);
}
return Err(format!(
"failed to inspect uninstall key ({hive_label}) with code {info_status}"
));
}
let mut name_buffer = vec![0_u16; max_subkey_len as usize + 2];
for index in 0..subkey_count {
let mut name_len = max_subkey_len + 1;
let enum_status = unsafe {
RegEnumKeyExW(
uninstall_root,
index,
name_buffer.as_mut_ptr(),
&mut name_len,
std::ptr::null_mut(),
std::ptr::null_mut(),
std::ptr::null_mut(),
std::ptr::null_mut(),
)
};
if enum_status == ERROR_NO_MORE_ITEMS {
break;
}
if enum_status != ERROR_SUCCESS {
continue;
}
let subkey_name = String::from_utf16_lossy(&name_buffer[..name_len as usize]);
if let Some(entry) =
read_uninstall_entry(hive_label, uninstall_root, view_flags, &subkey_name)?
{
out.push(entry);
}
}
unsafe {
RegCloseKey(uninstall_root);
}
Ok(())
}
#[cfg(target_os = "windows")]
fn read_uninstall_entry(
hive_label: &str,
uninstall_root: windows_sys::Win32::System::Registry::HKEY,
view_flags: u32,
subkey_name: &str,
) -> Result<Option<UninstallEntry>, String> {
use windows_sys::Win32::Foundation::{ERROR_FILE_NOT_FOUND, ERROR_SUCCESS};
use windows_sys::Win32::System::Registry::{RegCloseKey, RegOpenKeyExW, HKEY, KEY_READ};
let subkey_wide = to_wide(subkey_name);
let mut app_key: HKEY = std::ptr::null_mut();
let open_status = unsafe {
RegOpenKeyExW(
uninstall_root,
subkey_wide.as_ptr(),
0,
KEY_READ | view_flags,
&mut app_key,
)
};
if open_status == ERROR_FILE_NOT_FOUND {
return Ok(None);
}
if open_status != ERROR_SUCCESS {
return Err(format!(
"failed to open uninstall item key ({hive_label}:{subkey_name}) with code {open_status}"
));
}
let display_name = read_reg_string_value(app_key, "DisplayName");
let publisher = read_reg_string_value(app_key, "Publisher").unwrap_or_default();
let quiet_uninstall = read_reg_string_value(app_key, "QuietUninstallString");
let uninstall = read_reg_string_value(app_key, "UninstallString");
let release_type = read_reg_string_value(app_key, "ReleaseType").unwrap_or_default();
let parent_key = read_reg_string_value(app_key, "ParentKeyName").unwrap_or_default();
let system_component = read_reg_dword_value(app_key, "SystemComponent").unwrap_or(0);
unsafe {
RegCloseKey(app_key);
}
let Some(display_name) = display_name else {
return Ok(None);
};
let uninstall_candidates = dedupe_uninstall_candidates(quiet_uninstall, uninstall);
let mut usable_commands = uninstall_candidates
.into_iter()
.filter(|candidate| is_uninstall_command_usable(candidate.as_str()))
.collect::<Vec<_>>();
if display_name.trim().is_empty() || usable_commands.is_empty() {
return Ok(None);
}
if system_component == 1 {
return Ok(None);
}
if !parent_key.trim().is_empty() {
return Ok(None);
}
if looks_like_update_entry(display_name.as_str(), release_type.as_str()) {
return Ok(None);
}
let uninstall_command = usable_commands.remove(0);
let fallback_uninstall_command = usable_commands.into_iter().next();
Ok(Some(UninstallEntry {
token: format!("{hive_label}:{subkey_name}"),
display_name: display_name.trim().to_string(),
publisher: publisher.trim().to_string(),
uninstall_command: uninstall_command.trim().to_string(),
fallback_uninstall_command,
}))
}
#[cfg(target_os = "windows")]
fn read_reg_string_value(
key: windows_sys::Win32::System::Registry::HKEY,
value_name: &str,
) -> Option<String> {
use windows_sys::Win32::Foundation::{ERROR_FILE_NOT_FOUND, ERROR_SUCCESS};
use windows_sys::Win32::System::Registry::{RegQueryValueExW, REG_EXPAND_SZ, REG_SZ};
let value_name_wide = to_wide(value_name);
let mut value_type = 0_u32;
let mut size = 0_u32;
let query_status = unsafe {
RegQueryValueExW(
key,
value_name_wide.as_ptr(),
std::ptr::null(),
&mut value_type,
std::ptr::null_mut(),
&mut size,
)
};
if query_status == ERROR_FILE_NOT_FOUND || query_status != ERROR_SUCCESS || size == 0 {
return None;
}
if value_type != REG_SZ && value_type != REG_EXPAND_SZ {
return None;
}
let mut buffer = vec![0_u8; size as usize];
let read_status = unsafe {
RegQueryValueExW(
key,
value_name_wide.as_ptr(),
std::ptr::null(),
&mut value_type,
buffer.as_mut_ptr(),
&mut size,
)
};
if read_status != ERROR_SUCCESS {
return None;
}
let mut wide = Vec::with_capacity(buffer.len() / 2);
for chunk in buffer.chunks_exact(2) {
wide.push(u16::from_le_bytes([chunk[0], chunk[1]]));
}
while wide.last().copied() == Some(0) {
wide.pop();
}
let value = String::from_utf16_lossy(&wide).trim().to_string();
if value.is_empty() {
None
} else {
Some(value)
}
}
#[cfg(target_os = "windows")]
fn read_reg_dword_value(
key: windows_sys::Win32::System::Registry::HKEY,
value_name: &str,
) -> Option<u32> {
use windows_sys::Win32::Foundation::{ERROR_FILE_NOT_FOUND, ERROR_SUCCESS};
use windows_sys::Win32::System::Registry::{RegQueryValueExW, REG_DWORD};
let value_name_wide = to_wide(value_name);
let mut value_type = 0_u32;
let mut size = std::mem::size_of::<u32>() as u32;
let mut value = 0_u32;
let status = unsafe {
RegQueryValueExW(
key,
value_name_wide.as_ptr(),
std::ptr::null(),
&mut value_type,
&mut value as *mut u32 as *mut u8,
&mut size,
)
};
if status == ERROR_FILE_NOT_FOUND || status != ERROR_SUCCESS {
return None;
}
if value_type != REG_DWORD {
return None;
}
Some(value)
}
#[cfg(target_os = "windows")]
fn looks_like_update_entry(display_name: &str, release_type: &str) -> bool {
let display_lower = display_name.to_ascii_lowercase();
let release_lower = release_type.to_ascii_lowercase();
release_lower.contains("update")
|| release_lower.contains("hotfix")
|| release_lower.contains("security")
|| display_lower.starts_with("update for ")
|| display_lower.starts_with("security update for ")
|| display_lower.contains("hotfix")
}
#[cfg(target_os = "windows")]
fn dedupe_uninstall_candidates(
quiet_uninstall: Option<String>,
uninstall: Option<String>,
) -> Vec<String> {
let mut out = Vec::new();
for candidate in [quiet_uninstall, uninstall].into_iter().flatten() {
let trimmed = candidate.trim();
if trimmed.is_empty() {
continue;
}
if out
.iter()
.any(|existing: &String| existing.eq_ignore_ascii_case(trimmed))
{
continue;
}
out.push(trimmed.to_string());
}
out
}
#[cfg(target_os = "windows")]
fn is_uninstall_command_usable(command: &str) -> bool {
let Ok((program_raw, _)) = split_uninstall_command(command) else {
return false;
};
let program = expand_environment_strings(program_raw.as_str());
let trimmed = program.trim();
if trimmed.is_empty() {
return false;
}
let has_path_hint = trimmed.contains('\\')
|| trimmed.contains('/')
|| trimmed.contains(':')
|| trimmed.starts_with('.');
if !has_path_hint {
return true;
}
std::path::Path::new(trimmed).exists()
}
#[cfg(target_os = "windows")]
fn launch_uninstall_command(display_name: &str, command: &str) -> Result<(), String> {
use windows_sys::Win32::UI::Shell::ShellExecuteW;
use windows_sys::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL;
let (program_raw, parameters_raw) = split_uninstall_command(command)?;
let program = expand_environment_strings(program_raw.as_str());
let mut parameters = expand_environment_strings(parameters_raw.as_str());
if is_msiexec_program(program.as_str()) {
parameters = rewrite_msiexec_install_to_uninstall(parameters.as_str());
}
let program_wide = to_wide(program.as_str());
let parameters_wide = to_wide(parameters.as_str());
let parameters_ptr = if parameters.trim().is_empty() {
std::ptr::null()
} else {
parameters_wide.as_ptr()
};
let result = unsafe {
ShellExecuteW(
std::ptr::null_mut(),
std::ptr::null(),
program_wide.as_ptr(),
parameters_ptr,
std::ptr::null(),
SW_SHOWNORMAL,
)
} as isize;
if result > 32 {
return Ok(());
}
let shell_hint = match result {
0 => "out_of_memory_or_resources",
2 => "file_not_found",
3 => "path_not_found",
5 => "access_denied",
8 => "out_of_memory",
26 => "sharing_violation",
27 => "association_incomplete",
28 => "dde_timeout",
29 => "dde_failed",
30 => "dde_busy",
31 => "no_association",
_ => "unknown_shell_error",
};
let fallback_err = launch_uninstall_via_cmd(command).err().unwrap_or_default();
Err(format!(
"failed to launch uninstall command for '{}' (shell_code={result}:{shell_hint}); fallback={fallback_err}",
display_name,
))
}
#[cfg(target_os = "windows")]
fn launch_uninstall_via_cmd(raw_command: &str) -> Result<(), String> {
use std::os::windows::process::CommandExt;
use std::process::Command;
const CREATE_NO_WINDOW: u32 = 0x08000000;
let status = Command::new("cmd.exe")
.args(["/C", raw_command.trim()])
.creation_flags(CREATE_NO_WINDOW)
.spawn()
.map_err(|error| format!("cmd fallback spawn failed: {error}"))?;
if status.id() == 0 {
return Err("cmd fallback returned invalid process id".to_string());
}
Ok(())
}
#[cfg(target_os = "windows")]
fn split_uninstall_command(command: &str) -> Result<(String, String), String> {
let trimmed = command.trim();
if trimmed.is_empty() {
return Err("uninstall command is empty".to_string());
}
if let Some(rest) = trimmed.strip_prefix('"') {
if let Some(end_quote) = rest.find('"') {
let executable = rest[..end_quote].trim();
if executable.is_empty() {
return Err("uninstall command executable is empty".to_string());
}
let args = rest[end_quote + 1..].trim().to_string();
return Ok((executable.to_string(), args));
}
}
if let Some((program, args)) = split_unquoted_path_with_extension(trimmed) {
return Ok((program, args));
}
let mut parts = trimmed.splitn(2, char::is_whitespace);
let executable = parts.next().unwrap_or_default().trim();
if executable.is_empty() {
return Err("uninstall command executable is empty".to_string());
}
let args = parts.next().unwrap_or_default().trim().to_string();
Ok((executable.to_string(), args))
}
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
fn split_unquoted_path_with_extension(command: &str) -> Option<(String, String)> {
let trimmed = command.trim();
let lower = trimmed.to_ascii_lowercase();
for extension in [".exe", ".msi", ".cmd", ".bat", ".com"] {
let mut offset = 0;
while let Some(relative) = lower[offset..].find(extension) {
let end = offset + relative + extension.len();
let boundary = if end >= lower.len() {
true
} else {
lower.as_bytes()[end].is_ascii_whitespace()
};
if boundary {
let executable = trimmed[..end].trim();
if !executable.is_empty() {
let args = trimmed[end..].trim().to_string();
return Some((executable.to_string(), args));
}
}
offset = end;
if offset >= lower.len() {
break;
}
}
}
None
}
#[cfg(target_os = "windows")]
fn is_msiexec_program(executable: &str) -> bool {
let normalized = executable.replace('/', "\\");
let file_name = normalized
.rsplit('\\')
.next()
.unwrap_or(normalized.as_str());
file_name.eq_ignore_ascii_case("msiexec") || file_name.eq_ignore_ascii_case("msiexec.exe")
}
#[cfg_attr(not(any(test, target_os = "windows")), allow(dead_code))]
fn rewrite_msiexec_install_to_uninstall(parameters: &str) -> String {
let trimmed = parameters.trim();
if trimmed.is_empty() {
return String::new();
}
let lower = trimmed.to_ascii_lowercase();
if lower.contains("/x") || lower.contains("-x") {
return trimmed.to_string();
}
let bytes = trimmed.as_bytes();
if bytes.len() < 2 {
return trimmed.to_string();
}
for index in 0..(bytes.len() - 1) {
let current = bytes[index];
let next = bytes[index + 1];
if !matches!(current, b'/' | b'-') {
continue;
}
if !matches!(next, b'i' | b'I') {
continue;
}
let prev_ok = index == 0 || bytes[index - 1].is_ascii_whitespace();
let next_ok = index + 2 >= bytes.len()
|| bytes[index + 2].is_ascii_whitespace()
|| bytes[index + 2] == b'{'
|| bytes[index + 2] == b'"';
if !prev_ok || !next_ok {
continue;
}
let mut rewritten = trimmed.as_bytes().to_vec();
rewritten[index + 1] = b'X';
return String::from_utf8_lossy(&rewritten).to_string();
}
trimmed.to_string()
}
#[cfg(target_os = "windows")]
fn expand_environment_strings(input: &str) -> String {
use windows_sys::Win32::System::Environment::ExpandEnvironmentStringsW;
if !input.contains('%') {
return input.to_string();
}
let input_wide = to_wide(input);
let needed = unsafe { ExpandEnvironmentStringsW(input_wide.as_ptr(), std::ptr::null_mut(), 0) };
if needed == 0 {
return input.to_string();
}
let mut output = vec![0_u16; needed as usize];
let written =
unsafe { ExpandEnvironmentStringsW(input_wide.as_ptr(), output.as_mut_ptr(), needed) };
if written == 0 {
return input.to_string();
}
while output.last().copied() == Some(0) {
output.pop();
}
String::from_utf16_lossy(&output)
}
#[cfg(target_os = "windows")]
fn to_wide(value: &str) -> Vec<u16> {
value.encode_utf16().chain(std::iter::once(0)).collect()
}
#[cfg(test)]
mod tests {
use super::{
extract_uninstall_search_term, rewrite_msiexec_install_to_uninstall,
search_uninstall_actions_with_entries, split_unquoted_path_with_extension, UninstallEntry,
};
#[test]
fn parses_uninstall_intent_prefixes() {
assert_eq!(
extract_uninstall_search_term("uninstall Discord"),
Some("Discord".to_string())
);
assert_eq!(
extract_uninstall_search_term("remove VLC"),
Some("VLC".to_string())
);
assert_eq!(
extract_uninstall_search_term("u Notepad"),
Some("Notepad".to_string())
);
assert_eq!(extract_uninstall_search_term("delete"), Some(String::new()));
}
#[test]
fn ignores_non_uninstall_queries() {
assert_eq!(extract_uninstall_search_term("discord"), None);
assert_eq!(extract_uninstall_search_term("open uninstall menu"), None);
assert_eq!(extract_uninstall_search_term(""), None);
}
#[test]
fn ranks_uninstall_actions_by_name_match_strength() {
let entries = vec![
UninstallEntry {
token: "1".to_string(),
display_name: "Discord".to_string(),
publisher: "Discord Inc.".to_string(),
uninstall_command: "C:\\Tools\\uninstall_discord.exe".to_string(),
fallback_uninstall_command: None,
},
UninstallEntry {
token: "2".to_string(),
display_name: "Visual Studio Code".to_string(),
publisher: "Microsoft".to_string(),
uninstall_command: "C:\\Tools\\uninstall_vscode.exe".to_string(),
fallback_uninstall_command: None,
},
UninstallEntry {
token: "3".to_string(),
display_name: "Codium".to_string(),
publisher: "VSCodium".to_string(),
uninstall_command: "C:\\Tools\\uninstall_codium.exe".to_string(),
fallback_uninstall_command: None,
},
];
let results = search_uninstall_actions_with_entries("dis", 10, &entries);
assert_eq!(results[0].title, "Uninstall Discord");
}
#[test]
fn rewrites_msiexec_install_switch_when_needed() {
assert_eq!(
rewrite_msiexec_install_to_uninstall("/I {1234-5678}"),
"/X {1234-5678}"
);
assert_eq!(
rewrite_msiexec_install_to_uninstall("/X {1234-5678}"),
"/X {1234-5678}"
);
}
#[test]
fn splits_unquoted_executable_path_with_spaces() {
let parsed = split_unquoted_path_with_extension(
"C:\\Program Files\\Example App\\uninstall.exe /SILENT",
)
.expect("should parse executable and args");
assert_eq!(parsed.0, "C:\\Program Files\\Example App\\uninstall.exe");
assert_eq!(parsed.1, "/SILENT");
}
}