1use std::ffi::OsStr;
4use std::os::windows::ffi::OsStrExt;
5use std::path::{Path, PathBuf};
6use std::process::Command;
7use std::thread;
8
9use windows::core::PCWSTR;
10use windows::Win32::Foundation::HWND;
11use windows::Win32::System::Com::{
12 CoCreateInstance, CoInitializeEx, CoUninitialize, CLSCTX_INPROC_SERVER,
13 COINIT_APARTMENTTHREADED,
14};
15use windows::Win32::UI::Shell::{
16 FileOperation, IFileOperation, IFileOperationProgressSink, IShellItem,
17 SHCreateItemFromParsingName, SHEmptyRecycleBinW, ShellExecuteW, FOFX_RECYCLEONDELETE,
18 FOF_ALLOWUNDO, FOF_NOCONFIRMATION, FOF_NOERRORUI, FOF_SILENT, SHERB_NOCONFIRMATION,
19 SHERB_NOPROGRESSUI, SHERB_NOSOUND,
20};
21use windows::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL;
22
23use crate::error::{Error, Result};
24
25fn to_wide_os(value: &OsStr) -> Vec<u16> {
26 value.encode_wide().chain(std::iter::once(0)).collect()
27}
28
29fn to_wide_str(value: &str) -> Vec<u16> {
30 OsStr::new(value)
31 .encode_wide()
32 .chain(std::iter::once(0))
33 .collect()
34}
35
36fn normalize_url(url: &str) -> Result<&str> {
37 let trimmed = url.trim();
38
39 if trimmed.is_empty() {
40 return Err(Error::InvalidInput("url cannot be empty"));
41 }
42
43 if trimmed.contains('\0') {
44 return Err(Error::InvalidInput("url cannot contain NUL bytes"));
45 }
46
47 Ok(trimmed)
48}
49
50fn normalize_shell_verb(verb: &str) -> Result<&str> {
51 let trimmed = verb.trim();
52
53 if trimmed.is_empty() {
54 return Err(Error::InvalidInput("verb cannot be empty"));
55 }
56
57 if trimmed.contains('\0') {
58 return Err(Error::InvalidInput("verb cannot contain NUL bytes"));
59 }
60
61 Ok(trimmed)
62}
63
64struct ComApartment;
65
66impl ComApartment {
67 fn initialize_sta() -> Result<Self> {
68 let result = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) };
69
70 if result.is_ok() {
71 Ok(Self)
72 } else {
73 Err(Error::WindowsApi {
74 context: "CoInitializeEx",
75 code: result.0,
76 })
77 }
78 }
79}
80
81impl Drop for ComApartment {
82 fn drop(&mut self) {
83 unsafe {
84 CoUninitialize();
85 }
86 }
87}
88
89fn shell_execute_raw(verb: &str, target: &OsStr) -> Result<()> {
90 let operation = to_wide_str(verb);
91 let target_w = to_wide_os(target);
92
93 let result = unsafe {
94 ShellExecuteW(
95 Some(HWND::default()),
96 PCWSTR(operation.as_ptr()),
97 PCWSTR(target_w.as_ptr()),
98 PCWSTR::null(),
99 PCWSTR::null(),
100 SW_SHOWNORMAL,
101 )
102 };
103
104 let code = result.0 as isize;
105 if code <= 32 {
106 Err(Error::WindowsApi {
107 context: "ShellExecuteW",
108 code: code as i32,
109 })
110 } else {
111 Ok(())
112 }
113}
114
115fn shell_item_from_path(path: &Path) -> Result<IShellItem> {
116 let path_w = to_wide_os(path.as_os_str());
117
118 unsafe { SHCreateItemFromParsingName(PCWSTR(path_w.as_ptr()), None) }.map_err(|err| {
119 Error::WindowsApi {
120 context: "SHCreateItemFromParsingName",
121 code: err.code().0,
122 }
123 })
124}
125
126fn validate_recycle_path(path: &Path) -> Result<()> {
127 if path.as_os_str().is_empty() {
128 return Err(Error::InvalidInput("path cannot be empty"));
129 }
130
131 if !path.is_absolute() {
132 return Err(Error::PathNotAbsolute);
133 }
134
135 if !path.exists() {
136 return Err(Error::PathDoesNotExist);
137 }
138
139 Ok(())
140}
141
142fn collect_recycle_paths<I, P>(paths: I) -> Result<Vec<PathBuf>>
143where
144 I: IntoIterator<Item = P>,
145 P: AsRef<Path>,
146{
147 let mut collected = Vec::new();
148
149 for path in paths {
150 let path = path.as_ref();
151 validate_recycle_path(path)?;
152 collected.push(PathBuf::from(path));
153 }
154
155 if collected.is_empty() {
156 Err(Error::InvalidInput("paths cannot be empty"))
157 } else {
158 Ok(collected)
159 }
160}
161
162fn queue_recycle_item(operation: &IFileOperation, path: &Path) -> Result<()> {
163 let item = shell_item_from_path(path)?;
164
165 unsafe { operation.DeleteItem(&item, Option::<&IFileOperationProgressSink>::None) }.map_err(
166 |err| Error::WindowsApi {
167 context: "IFileOperation::DeleteItem",
168 code: err.code().0,
169 },
170 )
171}
172
173fn recycle_paths_in_sta(paths: &[PathBuf]) -> Result<()> {
174 let _com = ComApartment::initialize_sta()?;
175 let operation: IFileOperation = unsafe {
176 CoCreateInstance(&FileOperation, None, CLSCTX_INPROC_SERVER)
177 }
178 .map_err(|err| Error::WindowsApi {
179 context: "CoCreateInstance(FileOperation)",
180 code: err.code().0,
181 })?;
182
183 let flags =
184 FOF_ALLOWUNDO | FOF_NOCONFIRMATION | FOF_NOERRORUI | FOF_SILENT | FOFX_RECYCLEONDELETE;
185
186 unsafe { operation.SetOperationFlags(flags) }.map_err(|err| Error::WindowsApi {
187 context: "IFileOperation::SetOperationFlags",
188 code: err.code().0,
189 })?;
190
191 for path in paths {
192 queue_recycle_item(&operation, path)?;
193 }
194
195 unsafe { operation.PerformOperations() }.map_err(|err| Error::WindowsApi {
196 context: "IFileOperation::PerformOperations",
197 code: err.code().0,
198 })?;
199
200 let aborted =
201 unsafe { operation.GetAnyOperationsAborted() }.map_err(|err| Error::WindowsApi {
202 context: "IFileOperation::GetAnyOperationsAborted",
203 code: err.code().0,
204 })?;
205
206 if aborted.as_bool() {
207 Err(Error::WindowsApi {
208 context: "IFileOperation::PerformOperations aborted",
209 code: 0,
210 })
211 } else {
212 Ok(())
213 }
214}
215
216fn empty_recycle_bin_raw(root_path: Option<&Path>) -> Result<()> {
217 let root_w = root_path.map(|path| to_wide_os(path.as_os_str()));
218 let root_ptr = root_w
219 .as_ref()
220 .map_or(PCWSTR::null(), |path| PCWSTR(path.as_ptr()));
221 let flags = SHERB_NOCONFIRMATION | SHERB_NOPROGRESSUI | SHERB_NOSOUND;
222
223 unsafe { SHEmptyRecycleBinW(None, root_ptr, flags) }.map_err(|err| Error::WindowsApi {
224 context: "SHEmptyRecycleBinW",
225 code: err.code().0,
226 })
227}
228
229fn run_in_shell_sta<T, F>(work: F) -> Result<T>
230where
231 T: Send + 'static,
232 F: FnOnce() -> Result<T> + Send + 'static,
233{
234 match thread::spawn(work).join() {
235 Ok(result) => result,
236 Err(_) => Err(Error::Unsupported("shell STA worker thread panicked")),
237 }
238}
239
240pub fn open_with_default(target: impl AsRef<Path>) -> Result<()> {
255 open_with_verb("open", target)
256}
257
258pub fn open_with_verb(verb: &str, target: impl AsRef<Path>) -> Result<()> {
277 let verb = normalize_shell_verb(verb)?;
278 let path = target.as_ref();
279
280 if path.as_os_str().is_empty() {
281 return Err(Error::InvalidInput("target cannot be empty"));
282 }
283
284 if !path.exists() {
285 return Err(Error::PathDoesNotExist);
286 }
287
288 shell_execute_raw(verb, path.as_os_str())
289}
290
291pub fn show_properties(target: impl AsRef<Path>) -> Result<()> {
309 open_with_verb("properties", target)
310}
311
312pub fn print_with_default(target: impl AsRef<Path>) -> Result<()> {
330 open_with_verb("print", target)
331}
332
333pub fn open_url(url: &str) -> Result<()> {
350 let url = normalize_url(url)?;
351 shell_execute_raw("open", OsStr::new(url))
352}
353
354pub fn reveal_in_explorer(path: impl AsRef<Path>) -> Result<()> {
371 let path = path.as_ref();
372
373 if path.as_os_str().is_empty() {
374 return Err(Error::InvalidInput("path cannot be empty"));
375 }
376
377 if !path.exists() {
378 return Err(Error::PathDoesNotExist);
379 }
380
381 Command::new("explorer.exe")
382 .arg("/select,")
383 .arg(path)
384 .spawn()?;
385
386 Ok(())
387}
388
389pub fn open_containing_folder(path: impl AsRef<Path>) -> Result<()> {
404 let path = path.as_ref();
405
406 if path.as_os_str().is_empty() {
407 return Err(Error::InvalidInput("path cannot be empty"));
408 }
409
410 if !path.exists() {
411 return Err(Error::PathDoesNotExist);
412 }
413
414 let parent = path.parent().ok_or(Error::InvalidInput(
415 "path does not have a containing folder",
416 ))?;
417
418 if parent.as_os_str().is_empty() {
419 return Err(Error::InvalidInput(
420 "path does not have a containing folder",
421 ));
422 }
423
424 open_with_default(parent)
425}
426
427pub fn move_to_recycle_bin(path: impl AsRef<Path>) -> Result<()> {
450 let path = path.as_ref();
451 validate_recycle_path(path)?;
452
453 let path = PathBuf::from(path);
454 run_in_shell_sta(move || recycle_paths_in_sta(std::slice::from_ref(&path)))
455}
456
457pub fn move_paths_to_recycle_bin<I, P>(paths: I) -> Result<()>
483where
484 I: IntoIterator<Item = P>,
485 P: AsRef<Path>,
486{
487 let paths = collect_recycle_paths(paths)?;
488 run_in_shell_sta(move || recycle_paths_in_sta(&paths))
489}
490
491pub fn empty_recycle_bin() -> Result<()> {
506 empty_recycle_bin_raw(None)
507}
508
509pub fn empty_recycle_bin_for_root(root_path: impl AsRef<Path>) -> Result<()> {
527 let root_path = root_path.as_ref();
528
529 if root_path.as_os_str().is_empty() {
530 return Err(Error::InvalidInput("root_path cannot be empty"));
531 }
532
533 if !root_path.is_absolute() {
534 return Err(Error::PathNotAbsolute);
535 }
536
537 if !root_path.exists() {
538 return Err(Error::PathDoesNotExist);
539 }
540
541 empty_recycle_bin_raw(Some(root_path))
542}
543
544#[cfg(test)]
545mod tests {
546 use super::{collect_recycle_paths, normalize_shell_verb, normalize_url};
547 use std::path::PathBuf;
548
549 #[test]
550 fn normalize_url_rejects_empty_string() {
551 let result = normalize_url("");
552 assert!(matches!(
553 result,
554 Err(crate::Error::InvalidInput("url cannot be empty"))
555 ));
556 }
557
558 #[test]
559 fn normalize_url_rejects_whitespace_only() {
560 let result = normalize_url(" ");
561 assert!(matches!(
562 result,
563 Err(crate::Error::InvalidInput("url cannot be empty"))
564 ));
565 }
566
567 #[test]
568 fn normalize_url_trims_surrounding_whitespace() {
569 assert_eq!(
570 normalize_url(" https://example.com/docs ").unwrap(),
571 "https://example.com/docs"
572 );
573 }
574
575 #[test]
576 fn normalize_url_rejects_nul_bytes() {
577 let result = normalize_url("https://example.com/\0hidden");
578 assert!(matches!(
579 result,
580 Err(crate::Error::InvalidInput("url cannot contain NUL bytes"))
581 ));
582 }
583
584 #[test]
585 fn normalize_shell_verb_rejects_empty_string() {
586 let result = normalize_shell_verb("");
587 assert!(matches!(
588 result,
589 Err(crate::Error::InvalidInput("verb cannot be empty"))
590 ));
591 }
592
593 #[test]
594 fn normalize_shell_verb_rejects_whitespace_only() {
595 let result = normalize_shell_verb(" ");
596 assert!(matches!(
597 result,
598 Err(crate::Error::InvalidInput("verb cannot be empty"))
599 ));
600 }
601
602 #[test]
603 fn normalize_shell_verb_trims_surrounding_whitespace() {
604 assert_eq!(
605 normalize_shell_verb(" properties ").unwrap(),
606 "properties"
607 );
608 }
609
610 #[test]
611 fn normalize_shell_verb_rejects_nul_bytes() {
612 let result = normalize_shell_verb("pro\0perties");
613 assert!(matches!(
614 result,
615 Err(crate::Error::InvalidInput("verb cannot contain NUL bytes"))
616 ));
617 }
618
619 #[test]
620 fn collect_recycle_paths_rejects_empty_collection() {
621 let paths: [PathBuf; 0] = [];
622 let result = collect_recycle_paths(paths);
623 assert!(matches!(
624 result,
625 Err(crate::Error::InvalidInput("paths cannot be empty"))
626 ));
627 }
628}