use std::ffi::OsStr;
use std::os::windows::ffi::OsStrExt;
use std::path::Path;
#[cfg(feature = "recycle-bin")]
use std::path::PathBuf;
#[cfg(feature = "shell")]
use std::process::Command;
#[cfg(feature = "recycle-bin")]
use std::thread;
use windows::core::PCWSTR;
#[cfg(feature = "shell")]
use windows::Win32::Foundation::HWND;
#[cfg(feature = "recycle-bin")]
use windows::Win32::System::Com::{
CoCreateInstance, CoInitializeEx, CoUninitialize, CLSCTX_INPROC_SERVER,
COINIT_APARTMENTTHREADED,
};
#[cfg(feature = "shell")]
use windows::Win32::UI::Shell::ShellExecuteW;
#[cfg(feature = "recycle-bin")]
use windows::Win32::UI::Shell::{
FileOperation, IFileOperation, IFileOperationProgressSink, IShellItem,
SHCreateItemFromParsingName, SHEmptyRecycleBinW, FOFX_RECYCLEONDELETE, FOF_ALLOWUNDO,
FOF_NOCONFIRMATION, FOF_NOERRORUI, FOF_SILENT, SHERB_NOCONFIRMATION, SHERB_NOPROGRESSUI,
SHERB_NOSOUND,
};
#[cfg(feature = "shell")]
use windows::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL;
use crate::error::{Error, Result};
fn to_wide_os(value: &OsStr) -> Vec<u16> {
value.encode_wide().chain(std::iter::once(0)).collect()
}
#[cfg(feature = "shell")]
fn to_wide_str(value: &str) -> Vec<u16> {
OsStr::new(value)
.encode_wide()
.chain(std::iter::once(0))
.collect()
}
#[cfg(feature = "shell")]
fn normalize_url(url: &str) -> Result<&str> {
let trimmed = url.trim();
if trimmed.is_empty() {
return Err(Error::InvalidInput("url cannot be empty"));
}
if trimmed.contains('\0') {
return Err(Error::InvalidInput("url cannot contain NUL bytes"));
}
Ok(trimmed)
}
#[cfg(feature = "shell")]
fn normalize_shell_verb(verb: &str) -> Result<&str> {
let trimmed = verb.trim();
if trimmed.is_empty() {
return Err(Error::InvalidInput("verb cannot be empty"));
}
if trimmed.contains('\0') {
return Err(Error::InvalidInput("verb cannot contain NUL bytes"));
}
Ok(trimmed)
}
#[cfg(feature = "recycle-bin")]
struct ComApartment;
#[cfg(feature = "recycle-bin")]
impl ComApartment {
fn initialize_sta() -> Result<Self> {
let result = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) };
if result.is_ok() {
Ok(Self)
} else {
Err(Error::WindowsApi {
context: "CoInitializeEx",
code: result.0,
})
}
}
}
#[cfg(feature = "recycle-bin")]
impl Drop for ComApartment {
fn drop(&mut self) {
unsafe {
CoUninitialize();
}
}
}
#[cfg(feature = "shell")]
fn shell_execute_raw(verb: &str, target: &OsStr) -> Result<()> {
let operation = to_wide_str(verb);
let target_w = to_wide_os(target);
let result = unsafe {
ShellExecuteW(
Some(HWND::default()),
PCWSTR(operation.as_ptr()),
PCWSTR(target_w.as_ptr()),
PCWSTR::null(),
PCWSTR::null(),
SW_SHOWNORMAL,
)
};
let code = result.0 as isize;
if code <= 32 {
Err(Error::WindowsApi {
context: "ShellExecuteW",
code: code as i32,
})
} else {
Ok(())
}
}
#[cfg(feature = "recycle-bin")]
fn shell_item_from_path(path: &Path) -> Result<IShellItem> {
let path_w = to_wide_os(path.as_os_str());
unsafe { SHCreateItemFromParsingName(PCWSTR(path_w.as_ptr()), None) }.map_err(|err| {
Error::WindowsApi {
context: "SHCreateItemFromParsingName",
code: err.code().0,
}
})
}
#[cfg(feature = "recycle-bin")]
fn validate_recycle_path(path: &Path) -> Result<()> {
if path.as_os_str().is_empty() {
return Err(Error::InvalidInput("path cannot be empty"));
}
if !path.is_absolute() {
return Err(Error::PathNotAbsolute);
}
if !path.exists() {
return Err(Error::PathDoesNotExist);
}
Ok(())
}
#[cfg(feature = "recycle-bin")]
fn collect_recycle_paths<I, P>(paths: I) -> Result<Vec<PathBuf>>
where
I: IntoIterator<Item = P>,
P: AsRef<Path>,
{
let mut collected = Vec::new();
for path in paths {
let path = path.as_ref();
validate_recycle_path(path)?;
collected.push(PathBuf::from(path));
}
if collected.is_empty() {
Err(Error::InvalidInput("paths cannot be empty"))
} else {
Ok(collected)
}
}
#[cfg(feature = "recycle-bin")]
fn queue_recycle_item(operation: &IFileOperation, path: &Path) -> Result<()> {
let item = shell_item_from_path(path)?;
unsafe { operation.DeleteItem(&item, Option::<&IFileOperationProgressSink>::None) }.map_err(
|err| Error::WindowsApi {
context: "IFileOperation::DeleteItem",
code: err.code().0,
},
)
}
#[cfg(feature = "recycle-bin")]
fn recycle_paths_in_sta(paths: &[PathBuf]) -> Result<()> {
let _com = ComApartment::initialize_sta()?;
let operation: IFileOperation = unsafe {
CoCreateInstance(&FileOperation, None, CLSCTX_INPROC_SERVER)
}
.map_err(|err| Error::WindowsApi {
context: "CoCreateInstance(FileOperation)",
code: err.code().0,
})?;
let flags =
FOF_ALLOWUNDO | FOF_NOCONFIRMATION | FOF_NOERRORUI | FOF_SILENT | FOFX_RECYCLEONDELETE;
unsafe { operation.SetOperationFlags(flags) }.map_err(|err| Error::WindowsApi {
context: "IFileOperation::SetOperationFlags",
code: err.code().0,
})?;
for path in paths {
queue_recycle_item(&operation, path)?;
}
unsafe { operation.PerformOperations() }.map_err(|err| Error::WindowsApi {
context: "IFileOperation::PerformOperations",
code: err.code().0,
})?;
let aborted =
unsafe { operation.GetAnyOperationsAborted() }.map_err(|err| Error::WindowsApi {
context: "IFileOperation::GetAnyOperationsAborted",
code: err.code().0,
})?;
if aborted.as_bool() {
Err(Error::WindowsApi {
context: "IFileOperation::PerformOperations aborted",
code: 0,
})
} else {
Ok(())
}
}
#[cfg(feature = "recycle-bin")]
fn empty_recycle_bin_raw(root_path: Option<&Path>) -> Result<()> {
let root_w = root_path.map(|path| to_wide_os(path.as_os_str()));
let root_ptr = root_w
.as_ref()
.map_or(PCWSTR::null(), |path| PCWSTR(path.as_ptr()));
let flags = SHERB_NOCONFIRMATION | SHERB_NOPROGRESSUI | SHERB_NOSOUND;
unsafe { SHEmptyRecycleBinW(None, root_ptr, flags) }.map_err(|err| Error::WindowsApi {
context: "SHEmptyRecycleBinW",
code: err.code().0,
})
}
#[cfg(feature = "recycle-bin")]
fn run_in_shell_sta<T, F>(work: F) -> Result<T>
where
T: Send + 'static,
F: FnOnce() -> Result<T> + Send + 'static,
{
match thread::spawn(work).join() {
Ok(result) => result,
Err(_) => Err(Error::Unsupported("shell STA worker thread panicked")),
}
}
#[cfg(feature = "shell")]
pub fn open_with_default(target: impl AsRef<Path>) -> Result<()> {
open_with_verb("open", target)
}
#[cfg(feature = "shell")]
pub fn open_with_verb(verb: &str, target: impl AsRef<Path>) -> Result<()> {
let verb = normalize_shell_verb(verb)?;
let path = target.as_ref();
if path.as_os_str().is_empty() {
return Err(Error::InvalidInput("target cannot be empty"));
}
if !path.exists() {
return Err(Error::PathDoesNotExist);
}
shell_execute_raw(verb, path.as_os_str())
}
#[cfg(feature = "shell")]
pub fn show_properties(target: impl AsRef<Path>) -> Result<()> {
open_with_verb("properties", target)
}
#[cfg(feature = "shell")]
pub fn print_with_default(target: impl AsRef<Path>) -> Result<()> {
open_with_verb("print", target)
}
#[cfg(feature = "shell")]
pub fn open_url(url: &str) -> Result<()> {
let url = normalize_url(url)?;
shell_execute_raw("open", OsStr::new(url))
}
#[cfg(feature = "shell")]
pub fn reveal_in_explorer(path: impl AsRef<Path>) -> Result<()> {
let path = path.as_ref();
if path.as_os_str().is_empty() {
return Err(Error::InvalidInput("path cannot be empty"));
}
if !path.exists() {
return Err(Error::PathDoesNotExist);
}
Command::new("explorer.exe")
.arg("/select,")
.arg(path)
.spawn()?;
Ok(())
}
#[cfg(feature = "shell")]
pub fn open_containing_folder(path: impl AsRef<Path>) -> Result<()> {
let path = path.as_ref();
if path.as_os_str().is_empty() {
return Err(Error::InvalidInput("path cannot be empty"));
}
if !path.exists() {
return Err(Error::PathDoesNotExist);
}
let parent = path.parent().ok_or(Error::InvalidInput(
"path does not have a containing folder",
))?;
if parent.as_os_str().is_empty() {
return Err(Error::InvalidInput(
"path does not have a containing folder",
));
}
open_with_default(parent)
}
#[cfg(feature = "recycle-bin")]
pub fn move_to_recycle_bin(path: impl AsRef<Path>) -> Result<()> {
let path = path.as_ref();
validate_recycle_path(path)?;
let path = PathBuf::from(path);
run_in_shell_sta(move || recycle_paths_in_sta(std::slice::from_ref(&path)))
}
#[cfg(feature = "recycle-bin")]
pub fn move_paths_to_recycle_bin<I, P>(paths: I) -> Result<()>
where
I: IntoIterator<Item = P>,
P: AsRef<Path>,
{
let paths = collect_recycle_paths(paths)?;
run_in_shell_sta(move || recycle_paths_in_sta(&paths))
}
#[cfg(feature = "recycle-bin")]
pub fn empty_recycle_bin() -> Result<()> {
empty_recycle_bin_raw(None)
}
#[cfg(feature = "recycle-bin")]
pub fn empty_recycle_bin_for_root(root_path: impl AsRef<Path>) -> Result<()> {
let root_path = root_path.as_ref();
if root_path.as_os_str().is_empty() {
return Err(Error::InvalidInput("root_path cannot be empty"));
}
if !root_path.is_absolute() {
return Err(Error::PathNotAbsolute);
}
if !root_path.exists() {
return Err(Error::PathDoesNotExist);
}
empty_recycle_bin_raw(Some(root_path))
}
#[cfg(test)]
mod tests {
#[cfg(feature = "recycle-bin")]
use super::collect_recycle_paths;
#[cfg(feature = "shell")]
use super::{normalize_shell_verb, normalize_url};
#[cfg(feature = "recycle-bin")]
use std::path::PathBuf;
#[cfg(feature = "shell")]
#[test]
fn normalize_url_rejects_empty_string() {
let result = normalize_url("");
assert!(matches!(
result,
Err(crate::Error::InvalidInput("url cannot be empty"))
));
}
#[cfg(feature = "shell")]
#[test]
fn normalize_url_rejects_whitespace_only() {
let result = normalize_url(" ");
assert!(matches!(
result,
Err(crate::Error::InvalidInput("url cannot be empty"))
));
}
#[cfg(feature = "shell")]
#[test]
fn normalize_url_trims_surrounding_whitespace() {
assert_eq!(
normalize_url(" https://example.com/docs ").unwrap(),
"https://example.com/docs"
);
}
#[cfg(feature = "shell")]
#[test]
fn normalize_url_rejects_nul_bytes() {
let result = normalize_url("https://example.com/\0hidden");
assert!(matches!(
result,
Err(crate::Error::InvalidInput("url cannot contain NUL bytes"))
));
}
#[cfg(feature = "shell")]
#[test]
fn normalize_shell_verb_rejects_empty_string() {
let result = normalize_shell_verb("");
assert!(matches!(
result,
Err(crate::Error::InvalidInput("verb cannot be empty"))
));
}
#[cfg(feature = "shell")]
#[test]
fn normalize_shell_verb_rejects_whitespace_only() {
let result = normalize_shell_verb(" ");
assert!(matches!(
result,
Err(crate::Error::InvalidInput("verb cannot be empty"))
));
}
#[cfg(feature = "shell")]
#[test]
fn normalize_shell_verb_trims_surrounding_whitespace() {
assert_eq!(
normalize_shell_verb(" properties ").unwrap(),
"properties"
);
}
#[cfg(feature = "shell")]
#[test]
fn normalize_shell_verb_rejects_nul_bytes() {
let result = normalize_shell_verb("pro\0perties");
assert!(matches!(
result,
Err(crate::Error::InvalidInput("verb cannot contain NUL bytes"))
));
}
#[cfg(feature = "recycle-bin")]
#[test]
fn collect_recycle_paths_rejects_empty_collection() {
let paths: [PathBuf; 0] = [];
let result = collect_recycle_paths(paths);
assert!(matches!(
result,
Err(crate::Error::InvalidInput("paths cannot be empty"))
));
}
}