1use std::ffi::OsStr;
4use std::os::windows::ffi::OsStrExt;
5use std::path::Path;
6#[cfg(feature = "recycle-bin")]
7use std::path::PathBuf;
8#[cfg(feature = "shell")]
9use std::process::Command;
10#[cfg(feature = "recycle-bin")]
11use std::thread;
12
13use windows::core::PCWSTR;
14#[cfg(feature = "shell")]
15use windows::Win32::Foundation::HWND;
16#[cfg(feature = "recycle-bin")]
17use windows::Win32::System::Com::{
18 CoCreateInstance, CoInitializeEx, CoUninitialize, CLSCTX_INPROC_SERVER,
19 COINIT_APARTMENTTHREADED,
20};
21#[cfg(feature = "shell")]
22use windows::Win32::UI::Shell::ShellExecuteW;
23#[cfg(feature = "recycle-bin")]
24use windows::Win32::UI::Shell::{
25 FileOperation, IFileOperation, IFileOperationProgressSink, IShellItem,
26 SHCreateItemFromParsingName, SHEmptyRecycleBinW, FOFX_RECYCLEONDELETE, FOF_ALLOWUNDO,
27 FOF_NOCONFIRMATION, FOF_NOERRORUI, FOF_SILENT, SHERB_NOCONFIRMATION, SHERB_NOPROGRESSUI,
28 SHERB_NOSOUND,
29};
30#[cfg(feature = "shell")]
31use windows::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL;
32
33use crate::error::{Error, Result};
34
35fn to_wide_os(value: &OsStr) -> Vec<u16> {
36 value.encode_wide().chain(std::iter::once(0)).collect()
37}
38
39#[cfg(feature = "shell")]
40fn to_wide_str(value: &str) -> Vec<u16> {
41 OsStr::new(value)
42 .encode_wide()
43 .chain(std::iter::once(0))
44 .collect()
45}
46
47#[cfg(feature = "shell")]
48fn normalize_url(url: &str) -> Result<&str> {
49 let trimmed = url.trim();
50
51 if trimmed.is_empty() {
52 return Err(Error::InvalidInput("url cannot be empty"));
53 }
54
55 if trimmed.contains('\0') {
56 return Err(Error::InvalidInput("url cannot contain NUL bytes"));
57 }
58
59 Ok(trimmed)
60}
61
62#[cfg(feature = "shell")]
63fn normalize_shell_verb(verb: &str) -> Result<&str> {
64 let trimmed = verb.trim();
65
66 if trimmed.is_empty() {
67 return Err(Error::InvalidInput("verb cannot be empty"));
68 }
69
70 if trimmed.contains('\0') {
71 return Err(Error::InvalidInput("verb cannot contain NUL bytes"));
72 }
73
74 Ok(trimmed)
75}
76
77#[cfg(feature = "recycle-bin")]
78struct ComApartment;
79
80#[cfg(feature = "recycle-bin")]
81impl ComApartment {
82 fn initialize_sta() -> Result<Self> {
83 let result = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) };
84
85 if result.is_ok() {
86 Ok(Self)
87 } else {
88 Err(Error::WindowsApi {
89 context: "CoInitializeEx",
90 code: result.0,
91 })
92 }
93 }
94}
95
96#[cfg(feature = "recycle-bin")]
97impl Drop for ComApartment {
98 fn drop(&mut self) {
99 unsafe {
100 CoUninitialize();
101 }
102 }
103}
104
105#[cfg(feature = "shell")]
106fn shell_execute_raw(verb: &str, target: &OsStr) -> Result<()> {
107 let operation = to_wide_str(verb);
108 let target_w = to_wide_os(target);
109
110 let result = unsafe {
111 ShellExecuteW(
112 Some(HWND::default()),
113 PCWSTR(operation.as_ptr()),
114 PCWSTR(target_w.as_ptr()),
115 PCWSTR::null(),
116 PCWSTR::null(),
117 SW_SHOWNORMAL,
118 )
119 };
120
121 let code = result.0 as isize;
122 if code <= 32 {
123 Err(Error::WindowsApi {
124 context: "ShellExecuteW",
125 code: code as i32,
126 })
127 } else {
128 Ok(())
129 }
130}
131
132#[cfg(feature = "recycle-bin")]
133fn shell_item_from_path(path: &Path) -> Result<IShellItem> {
134 let path_w = to_wide_os(path.as_os_str());
135
136 unsafe { SHCreateItemFromParsingName(PCWSTR(path_w.as_ptr()), None) }.map_err(|err| {
137 Error::WindowsApi {
138 context: "SHCreateItemFromParsingName",
139 code: err.code().0,
140 }
141 })
142}
143
144#[cfg(feature = "recycle-bin")]
145fn validate_recycle_path(path: &Path) -> Result<()> {
146 if path.as_os_str().is_empty() {
147 return Err(Error::InvalidInput("path cannot be empty"));
148 }
149
150 if !path.is_absolute() {
151 return Err(Error::PathNotAbsolute);
152 }
153
154 if !path.exists() {
155 return Err(Error::PathDoesNotExist);
156 }
157
158 Ok(())
159}
160
161#[cfg(feature = "recycle-bin")]
162fn collect_recycle_paths<I, P>(paths: I) -> Result<Vec<PathBuf>>
163where
164 I: IntoIterator<Item = P>,
165 P: AsRef<Path>,
166{
167 let mut collected = Vec::new();
168
169 for path in paths {
170 let path = path.as_ref();
171 validate_recycle_path(path)?;
172 collected.push(PathBuf::from(path));
173 }
174
175 if collected.is_empty() {
176 Err(Error::InvalidInput("paths cannot be empty"))
177 } else {
178 Ok(collected)
179 }
180}
181
182#[cfg(feature = "recycle-bin")]
183fn queue_recycle_item(operation: &IFileOperation, path: &Path) -> Result<()> {
184 let item = shell_item_from_path(path)?;
185
186 unsafe { operation.DeleteItem(&item, Option::<&IFileOperationProgressSink>::None) }.map_err(
187 |err| Error::WindowsApi {
188 context: "IFileOperation::DeleteItem",
189 code: err.code().0,
190 },
191 )
192}
193
194#[cfg(feature = "recycle-bin")]
195fn recycle_paths_in_sta(paths: &[PathBuf]) -> Result<()> {
196 let _com = ComApartment::initialize_sta()?;
197 let operation: IFileOperation = unsafe {
198 CoCreateInstance(&FileOperation, None, CLSCTX_INPROC_SERVER)
199 }
200 .map_err(|err| Error::WindowsApi {
201 context: "CoCreateInstance(FileOperation)",
202 code: err.code().0,
203 })?;
204
205 let flags =
206 FOF_ALLOWUNDO | FOF_NOCONFIRMATION | FOF_NOERRORUI | FOF_SILENT | FOFX_RECYCLEONDELETE;
207
208 unsafe { operation.SetOperationFlags(flags) }.map_err(|err| Error::WindowsApi {
209 context: "IFileOperation::SetOperationFlags",
210 code: err.code().0,
211 })?;
212
213 for path in paths {
214 queue_recycle_item(&operation, path)?;
215 }
216
217 unsafe { operation.PerformOperations() }.map_err(|err| Error::WindowsApi {
218 context: "IFileOperation::PerformOperations",
219 code: err.code().0,
220 })?;
221
222 let aborted =
223 unsafe { operation.GetAnyOperationsAborted() }.map_err(|err| Error::WindowsApi {
224 context: "IFileOperation::GetAnyOperationsAborted",
225 code: err.code().0,
226 })?;
227
228 if aborted.as_bool() {
229 Err(Error::WindowsApi {
230 context: "IFileOperation::PerformOperations aborted",
231 code: 0,
232 })
233 } else {
234 Ok(())
235 }
236}
237
238#[cfg(feature = "recycle-bin")]
239fn empty_recycle_bin_raw(root_path: Option<&Path>) -> Result<()> {
240 let root_w = root_path.map(|path| to_wide_os(path.as_os_str()));
241 let root_ptr = root_w
242 .as_ref()
243 .map_or(PCWSTR::null(), |path| PCWSTR(path.as_ptr()));
244 let flags = SHERB_NOCONFIRMATION | SHERB_NOPROGRESSUI | SHERB_NOSOUND;
245
246 unsafe { SHEmptyRecycleBinW(None, root_ptr, flags) }.map_err(|err| Error::WindowsApi {
247 context: "SHEmptyRecycleBinW",
248 code: err.code().0,
249 })
250}
251
252#[cfg(feature = "recycle-bin")]
253fn run_in_shell_sta<T, F>(work: F) -> Result<T>
254where
255 T: Send + 'static,
256 F: FnOnce() -> Result<T> + Send + 'static,
257{
258 match thread::spawn(work).join() {
259 Ok(result) => result,
260 Err(_) => Err(Error::Unsupported("shell STA worker thread panicked")),
261 }
262}
263
264#[cfg(feature = "shell")]
279pub fn open_with_default(target: impl AsRef<Path>) -> Result<()> {
280 open_with_verb("open", target)
281}
282
283#[cfg(feature = "shell")]
302pub fn open_with_verb(verb: &str, target: impl AsRef<Path>) -> Result<()> {
303 let verb = normalize_shell_verb(verb)?;
304 let path = target.as_ref();
305
306 if path.as_os_str().is_empty() {
307 return Err(Error::InvalidInput("target cannot be empty"));
308 }
309
310 if !path.exists() {
311 return Err(Error::PathDoesNotExist);
312 }
313
314 shell_execute_raw(verb, path.as_os_str())
315}
316
317#[cfg(feature = "shell")]
335pub fn show_properties(target: impl AsRef<Path>) -> Result<()> {
336 open_with_verb("properties", target)
337}
338
339#[cfg(feature = "shell")]
357pub fn print_with_default(target: impl AsRef<Path>) -> Result<()> {
358 open_with_verb("print", target)
359}
360
361#[cfg(feature = "shell")]
378pub fn open_url(url: &str) -> Result<()> {
379 let url = normalize_url(url)?;
380 shell_execute_raw("open", OsStr::new(url))
381}
382
383#[cfg(feature = "shell")]
400pub fn reveal_in_explorer(path: impl AsRef<Path>) -> Result<()> {
401 let path = path.as_ref();
402
403 if path.as_os_str().is_empty() {
404 return Err(Error::InvalidInput("path cannot be empty"));
405 }
406
407 if !path.exists() {
408 return Err(Error::PathDoesNotExist);
409 }
410
411 Command::new("explorer.exe")
412 .arg("/select,")
413 .arg(path)
414 .spawn()?;
415
416 Ok(())
417}
418
419#[cfg(feature = "shell")]
434pub fn open_containing_folder(path: impl AsRef<Path>) -> Result<()> {
435 let path = path.as_ref();
436
437 if path.as_os_str().is_empty() {
438 return Err(Error::InvalidInput("path cannot be empty"));
439 }
440
441 if !path.exists() {
442 return Err(Error::PathDoesNotExist);
443 }
444
445 let parent = path.parent().ok_or(Error::InvalidInput(
446 "path does not have a containing folder",
447 ))?;
448
449 if parent.as_os_str().is_empty() {
450 return Err(Error::InvalidInput(
451 "path does not have a containing folder",
452 ));
453 }
454
455 open_with_default(parent)
456}
457
458#[cfg(feature = "recycle-bin")]
481pub fn move_to_recycle_bin(path: impl AsRef<Path>) -> Result<()> {
482 let path = path.as_ref();
483 validate_recycle_path(path)?;
484
485 let path = PathBuf::from(path);
486 run_in_shell_sta(move || recycle_paths_in_sta(std::slice::from_ref(&path)))
487}
488
489#[cfg(feature = "recycle-bin")]
515pub fn move_paths_to_recycle_bin<I, P>(paths: I) -> Result<()>
516where
517 I: IntoIterator<Item = P>,
518 P: AsRef<Path>,
519{
520 let paths = collect_recycle_paths(paths)?;
521 run_in_shell_sta(move || recycle_paths_in_sta(&paths))
522}
523
524#[cfg(feature = "recycle-bin")]
539pub fn empty_recycle_bin() -> Result<()> {
540 empty_recycle_bin_raw(None)
541}
542
543#[cfg(feature = "recycle-bin")]
561pub fn empty_recycle_bin_for_root(root_path: impl AsRef<Path>) -> Result<()> {
562 let root_path = root_path.as_ref();
563
564 if root_path.as_os_str().is_empty() {
565 return Err(Error::InvalidInput("root_path cannot be empty"));
566 }
567
568 if !root_path.is_absolute() {
569 return Err(Error::PathNotAbsolute);
570 }
571
572 if !root_path.exists() {
573 return Err(Error::PathDoesNotExist);
574 }
575
576 empty_recycle_bin_raw(Some(root_path))
577}
578
579#[cfg(test)]
580mod tests {
581 #[cfg(feature = "recycle-bin")]
582 use super::collect_recycle_paths;
583 #[cfg(feature = "shell")]
584 use super::{normalize_shell_verb, normalize_url};
585 #[cfg(feature = "recycle-bin")]
586 use std::path::PathBuf;
587
588 #[cfg(feature = "shell")]
589 #[test]
590 fn normalize_url_rejects_empty_string() {
591 let result = normalize_url("");
592 assert!(matches!(
593 result,
594 Err(crate::Error::InvalidInput("url cannot be empty"))
595 ));
596 }
597
598 #[cfg(feature = "shell")]
599 #[test]
600 fn normalize_url_rejects_whitespace_only() {
601 let result = normalize_url(" ");
602 assert!(matches!(
603 result,
604 Err(crate::Error::InvalidInput("url cannot be empty"))
605 ));
606 }
607
608 #[cfg(feature = "shell")]
609 #[test]
610 fn normalize_url_trims_surrounding_whitespace() {
611 assert_eq!(
612 normalize_url(" https://example.com/docs ").unwrap(),
613 "https://example.com/docs"
614 );
615 }
616
617 #[cfg(feature = "shell")]
618 #[test]
619 fn normalize_url_rejects_nul_bytes() {
620 let result = normalize_url("https://example.com/\0hidden");
621 assert!(matches!(
622 result,
623 Err(crate::Error::InvalidInput("url cannot contain NUL bytes"))
624 ));
625 }
626
627 #[cfg(feature = "shell")]
628 #[test]
629 fn normalize_shell_verb_rejects_empty_string() {
630 let result = normalize_shell_verb("");
631 assert!(matches!(
632 result,
633 Err(crate::Error::InvalidInput("verb cannot be empty"))
634 ));
635 }
636
637 #[cfg(feature = "shell")]
638 #[test]
639 fn normalize_shell_verb_rejects_whitespace_only() {
640 let result = normalize_shell_verb(" ");
641 assert!(matches!(
642 result,
643 Err(crate::Error::InvalidInput("verb cannot be empty"))
644 ));
645 }
646
647 #[cfg(feature = "shell")]
648 #[test]
649 fn normalize_shell_verb_trims_surrounding_whitespace() {
650 assert_eq!(
651 normalize_shell_verb(" properties ").unwrap(),
652 "properties"
653 );
654 }
655
656 #[cfg(feature = "shell")]
657 #[test]
658 fn normalize_shell_verb_rejects_nul_bytes() {
659 let result = normalize_shell_verb("pro\0perties");
660 assert!(matches!(
661 result,
662 Err(crate::Error::InvalidInput("verb cannot contain NUL bytes"))
663 ));
664 }
665
666 #[cfg(feature = "recycle-bin")]
667 #[test]
668 fn collect_recycle_paths_rejects_empty_collection() {
669 let paths: [PathBuf; 0] = [];
670 let result = collect_recycle_paths(paths);
671 assert!(matches!(
672 result,
673 Err(crate::Error::InvalidInput("paths cannot be empty"))
674 ));
675 }
676}