diff --git a/Cargo.toml b/Cargo.toml index d2f7186..34c124c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ objc2-foundation = { version = "0.3.2", default-features = false, features = [ "std", "NSError", "NSFileManager", + "NSPathUtilities", "NSString", "NSURL", ] } @@ -55,12 +56,14 @@ once_cell = "1.7.2" [target.'cfg(windows)'.dependencies] windows = { version = "0.56.0", features = [ + "implement", "Win32_Foundation", "Win32_Storage_EnhancedStorage", "Win32_System_Com_StructuredStorage", "Win32_System_SystemServices", "Win32_UI_Shell_PropertiesSystem", ] } +windows-core = "0.56.0" scopeguard = "1.2.0" # workaround for https://github.com/cross-rs/cross/issues/1345 diff --git a/src/freedesktop.rs b/src/freedesktop.rs index 99065d6..2920005 100644 --- a/src/freedesktop.rs +++ b/src/freedesktop.rs @@ -17,6 +17,7 @@ use std::{ fs::PermissionsExt, }, path::{Component, Path, PathBuf}, + time::SystemTime, }; use log::{debug, warn}; @@ -33,10 +34,16 @@ impl PlatformTrashContext { } } impl TrashContext { - pub(crate) fn delete_all_canonicalized(&self, full_paths: Vec) -> Result<(), Error> { + pub(crate) fn delete_all_canonicalized( + &self, + full_paths: Vec, + with_info: bool, + ) -> Result>, Error> { let home_trash = home_trash()?; let sorted_mount_points = get_sorted_mount_points()?; let home_topdir = home_topdir(&sorted_mount_points)?; + let mut items: Vec = Vec::new(); + debug!("The home topdir is {:?}", home_topdir); let uid = unsafe { libc::getuid() }; for path in full_paths { @@ -47,18 +54,37 @@ impl TrashContext { debug!("The topdir was identical to the home topdir, so moving to the home trash."); // Note that the following function creates the trash folder // and its required subfolders in case they don't exist. - move_to_trash(path, &home_trash, topdir).map_err(|(p, e)| fs_error(p, e))?; + let item = move_to_trash(path, &home_trash, topdir).map_err(|(p, e)| fs_error(p, e))?; + if with_info { + items.push(item); + } } else if topdir.to_str() == Some("/var/home") && home_topdir.to_str() == Some("/") { debug!("The topdir is '/var/home' but the home_topdir is '/', moving to the home trash anyway."); - move_to_trash(path, &home_trash, topdir).map_err(|(p, e)| fs_error(p, e))?; + let item = move_to_trash(path, &home_trash, topdir).map_err(|(p, e)| fs_error(p, e))?; + if with_info { + items.push(item); + } } else { + let mut item = None; execute_on_mounted_trash_folders(uid, topdir, true, true, |trash_path| { - move_to_trash(&path, trash_path, topdir) + let trash_item = move_to_trash(&path, trash_path, topdir)?; + item = Some(trash_item); + Ok(()) }) .map_err(|(p, e)| fs_error(p, e))?; + if with_info { + if let Some(trash_item) = item { + items.push(trash_item); + } + } } } - Ok(()) + + if with_info { + Ok(Some(items)) + } else { + Ok(None) + } } } @@ -450,7 +476,7 @@ fn move_to_trash( src: impl AsRef, trash_folder: impl AsRef, _topdir: impl AsRef, -) -> Result<(), FsError> { +) -> Result { let src = src.as_ref(); let trash_folder = trash_folder.as_ref(); let files_folder = trash_folder.join("files"); @@ -537,12 +563,15 @@ fn move_to_trash( } Ok(_) => { // We did it! - break; + let original_parent = src.parent().map(Path::to_owned).unwrap_or_default(); + let name = src.file_name().map(OsStr::to_owned).unwrap_or_default(); + let time_deleted = + SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(-1); + + return Ok(TrashItem { id: info_file_path.into_os_string(), name, original_parent, time_deleted }); } } } - - Ok(()) } /// An error may mean that a collision was found. diff --git a/src/lib.rs b/src/lib.rs index adccaea..9f950c1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -109,7 +109,32 @@ impl TrashContext { trace!("Starting canonicalize_paths"); let full_paths = canonicalize_paths(paths)?; trace!("Finished canonicalize_paths"); - self.delete_all_canonicalized(full_paths) + + self.delete_all_canonicalized(full_paths, false).map(|_| ()) + } + + /// Same as `delete`, but returns a `TrashItem` describing where the file + /// ended up in trash. + pub fn delete_with_info>(&self, path: T) -> Result { + self.delete_all_with_info(&[path])? + .pop() + .ok_or(Error::Unknown { description: "delete_with_info did not return trash item information".into() }) + } + + /// Same as `delete_all` but returns `TrashItem`s describing where files + /// ended up in the trash. + pub fn delete_all_with_info(&self, paths: I) -> Result, Error> + where + I: IntoIterator, + T: AsRef, + { + let full_paths = canonicalize_paths(paths)?; + + self.delete_all_canonicalized(full_paths, true).and_then(|items| { + items.ok_or(Error::Unknown { + description: "delete_all_with_info did not return trash item information".into(), + }) + }) } } @@ -120,6 +145,13 @@ pub fn delete>(path: T) -> Result<(), Error> { DEFAULT_TRASH_CTX.delete(path) } +/// Convenience method for `DEFAULT_TRASH_CTX.delete_with_info()`. +/// +/// See: [`TrashContext::delete_with_info`](TrashContext::delete_with_info) +pub fn delete_with_info>(path: T) -> Result { + DEFAULT_TRASH_CTX.delete_with_info(path) +} + /// Convenience method for `DEFAULT_TRASH_CTX.delete_all()`. /// /// See: [`TrashContext::delete_all`](TrashContext::delete_all) @@ -131,6 +163,17 @@ where DEFAULT_TRASH_CTX.delete_all(paths) } +/// Convenience method for `DEFAULT_TRASH_CTX.delete_all_with_info()`. +/// +/// See: [`TrashContext::delete_all_with_info`](TrashContext::delete_all_with_info) +pub fn delete_all_with_info(paths: I) -> Result, Error> +where + I: IntoIterator, + T: AsRef, +{ + DEFAULT_TRASH_CTX.delete_all_with_info(paths) +} + /// Provides information about an error. #[derive(Debug)] pub enum Error { diff --git a/src/macos/mod.rs b/src/macos/mod.rs index 824371d..7167d9a 100644 --- a/src/macos/mod.rs +++ b/src/macos/mod.rs @@ -2,12 +2,13 @@ use std::{ ffi::OsString, path::{Path, PathBuf}, process::Command, + time::SystemTime, }; use log::trace; use objc2_foundation::{NSFileManager, NSString, NSURL}; -use crate::{into_unknown, Error, TrashContext}; +use crate::{into_unknown, Error, TrashContext, TrashItem}; #[derive(Copy, Clone, Debug)] /// There are 2 ways to trash files: via the ≝Finder app or via the OS NsFileManager call @@ -26,7 +27,6 @@ pub enum DeleteMethod { /// - Produces the sound that Finder usually makes when deleting a file /// - Shows the "Put Back" option in the context menu, when using the Finder application /// - /// This is the default. Finder, /// Use `trashItemAtURL` from the `NSFileManager` object to delete the files. @@ -39,12 +39,14 @@ pub enum DeleteMethod { /// at: /// - /// - + /// + /// This is the default. NsFileManager, } impl DeleteMethod { - /// Returns `DeleteMethod::Finder` + /// Returns `DeleteMethod::NsFileManager` pub const fn new() -> Self { - DeleteMethod::Finder + DeleteMethod::NsFileManager } } impl Default for DeleteMethod { @@ -74,17 +76,23 @@ impl TrashContextExtMacos for TrashContext { } } impl TrashContext { - pub(crate) fn delete_all_canonicalized(&self, full_paths: Vec) -> Result<(), Error> { + pub(crate) fn delete_all_canonicalized( + &self, + full_paths: Vec, + with_info: bool, + ) -> Result>, Error> { match self.platform_specific.delete_method { - DeleteMethod::Finder => delete_using_finder(&full_paths), - DeleteMethod::NsFileManager => delete_using_file_mgr(&full_paths), + DeleteMethod::Finder => delete_using_finder(&full_paths, with_info), + DeleteMethod::NsFileManager => delete_using_file_mgr(&full_paths, with_info), } } } -fn delete_using_file_mgr>(full_paths: &[P]) -> Result<(), Error> { +fn delete_using_file_mgr>(full_paths: &[P], with_info: bool) -> Result>, Error> { trace!("Starting delete_using_file_mgr"); let file_mgr = NSFileManager::defaultManager(); + let mut trash_items = Vec::::new(); + for path in full_paths { let path = path.as_ref().as_os_str().as_encoded_bytes(); let path = match std::str::from_utf8(path) { @@ -96,8 +104,10 @@ fn delete_using_file_mgr>(full_paths: &[P]) -> Result<(), Error> let url = NSURL::fileURLWithPath(&path); trace!("Finished fileURLWithPath"); + let mut trash_url = None; + trace!("Calling trashItemAtURL"); - let res = file_mgr.trashItemAtURL_resultingItemURL_error(&url, None); + let res = file_mgr.trashItemAtURL_resultingItemURL_error(&url, Some(&mut trash_url)); trace!("Finished trashItemAtURL"); if let Err(err) = res { @@ -105,27 +115,72 @@ fn delete_using_file_mgr>(full_paths: &[P]) -> Result<(), Error> description: format!("While deleting '{:?}', `trashItemAtURL` failed: {err}", &path), }); } + + if with_info { + trash_items.push(TrashItem { + name: OsString::from(path.lastPathComponent().to_string()), + original_parent: path.stringByDeletingLastPathComponent().to_string().into(), + id: trash_url + .and_then(|url| url.path()) + .map(|path| OsString::from(path.to_string())) + .unwrap_or_default(), + time_deleted: SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map(|duration| duration.as_secs() as i64) + .unwrap_or(-1), + }); + } + } + + if with_info { + Ok(Some(trash_items)) + } else { + Ok(None) } - Ok(()) } -fn delete_using_finder>(full_paths: &[P]) -> Result<(), Error> { +fn delete_using_finder>(full_paths: &[P], with_info: bool) -> Result>, Error> { // AppleScript command to move files (or directories) to Trash looks like - // osascript -e 'tell application "Finder" to delete { POSIX file "file1", POSIX "file2" }' - // The `-e` flag is used to execute only one line of AppleScript. + // the snippet below, with `-e` being used to execute only one line of + // AppleScript. + // + // ``` + // osascript -e 'tell application "Finder" to delete { POSIX file "file1", POSIX "file2" }' + // ``` let mut command = Command::new("osascript"); let posix_files = full_paths .iter() - .map(|p| { - let path_b = p.as_ref().as_os_str().as_encoded_bytes(); - match std::str::from_utf8(path_b) { + .map(|path| { + let path_bytes = path.as_ref().as_os_str().as_encoded_bytes(); + + match std::str::from_utf8(path_bytes) { Ok(path_utf8) => format!(r#"POSIX file "{}""#, esc_quote(path_utf8)), // utf-8 path, escape \" - Err(_) => format!(r#"POSIX file "{}""#, esc_quote(&percent_encode(path_b))), // binary path, %-encode it and escape \" + Err(_) => format!(r#"POSIX file "{}""#, esc_quote(&percent_encode(path_bytes))), // binary path, %-encode it and escape \" } }) .collect::>() .join(", "); - let script = format!("tell application \"Finder\" to delete {{ {posix_files} }}"); + + // When `with_info` is requested, we convert the Finder object references + // returned by `delete` into POSIX paths using `as alias`. The results are + // newline-delimited so we can split them reliably (commas would be + // ambiguous for filenames containing commas). + let script = if with_info { + format!( + r#"tell application "Finder" + set trashedItems to delete {{ {posix_files} }} + if class of trashedItems is not list then set trashedItems to {{trashedItems}} + set posixPaths to "" + repeat with t in trashedItems + if posixPaths is not "" then set posixPaths to posixPaths & linefeed + set posixPaths to posixPaths & POSIX path of (t as alias) + end repeat + return posixPaths +end tell"# + ) + } else { + format!("tell application \"Finder\" to delete {{ {posix_files} }}") + }; let argv: Vec = vec!["-e".into(), script.into()]; command.args(argv); @@ -149,7 +204,37 @@ fn delete_using_finder>(full_paths: &[P]) -> Result<(), Error> { } }; } - Ok(()) + + if with_info { + let stdout = String::from_utf8_lossy(&result.stdout); + let time_deleted = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map(|duration| duration.as_secs() as i64) + .unwrap_or(-1); + + // In practice, the Finder `delete` command returns results in the same + // order as the input. We rely on this to pair trash paths with their + // original paths via `.zip()`. + let trash_items: Vec = stdout + .lines() + .zip(full_paths.iter()) + .map(|(trash_path, original_path)| { + let trash_path = Path::new(trash_path); + let original = original_path.as_ref(); + + TrashItem { + id: trash_path.as_os_str().to_os_string(), + name: original.file_name().map(|name| name.to_os_string()).unwrap_or_default(), + original_parent: original.parent().map(Path::to_owned).unwrap_or_default(), + time_deleted, + } + }) + .collect(); + + Ok(Some(trash_items)) + } else { + Ok(None) + } } /// std's from_utf8_lossy, but non-utf8 byte sequences are %-encoded instead of being replaced by a special symbol. diff --git a/src/macos/tests.rs b/src/macos/tests.rs index 7d4fcf0..21ec52c 100644 --- a/src/macos/tests.rs +++ b/src/macos/tests.rs @@ -10,6 +10,30 @@ use std::os::unix::ffi::OsStrExt; use std::path::PathBuf; use std::process::Command; +/// Holds a list of paths to files to clean up after a test. +/// +/// Simply push the paths for whatever file was created during tests and the +/// `Drop` implementation will clean up the files after the test. +struct CleanupPaths(Vec); + +impl CleanupPaths { + pub fn new() -> Self { + Self(Vec::new()) + } + + pub fn push(&mut self, path: PathBuf) { + self.0.push(path); + } +} + +impl Drop for CleanupPaths { + fn drop(&mut self) { + for path in &self.0 { + let _ = std::fs::remove_file(path); + } + } +} + #[test] #[serial] fn test_delete_with_finder_quoted_paths() { @@ -102,3 +126,51 @@ fn create_hfs_volume() -> std::io::Result<(impl Drop, tempfile::TempDir)> { }; Ok((cleanup, tmp)) } + +#[test] +#[serial] +fn test_delete_with_info_ns_file_manager() { + let mut cleanup_paths = CleanupPaths::new(); + let path = std::env::current_dir().expect("Should be able to get current directory").join(get_unique_name()); + cleanup_paths.push(path.clone()); + File::create_new(&path).unwrap(); + + let mut trash = TrashContext::new(); + trash.set_delete_method(DeleteMethod::NsFileManager); + + match trash.delete_with_info(&path) { + Ok(trash_item) => { + let id_path = PathBuf::from(&trash_item.id); + cleanup_paths.push(id_path.clone()); + + assert_eq!(trash_item.name, path.components().last().expect("Should have last component").as_os_str()); + assert_eq!(trash_item.original_parent, path.parent().expect("Should have parent").as_os_str()); + assert!(id_path.to_string_lossy().contains(".Trash")) + } + _ => panic!("Calling delete_with_info failed to return TrashItem."), + } +} + +#[test] +#[serial] +fn test_delete_with_info_finder() { + let mut cleanup_paths = CleanupPaths::new(); + let path = std::env::current_dir().expect("Should be able to get current directory").join(get_unique_name()); + cleanup_paths.push(path.clone()); + File::create_new(&path).unwrap(); + + let mut trash = TrashContext::new(); + trash.set_delete_method(DeleteMethod::Finder); + + match trash.delete_with_info(&path) { + Ok(trash_item) => { + let id_path = PathBuf::from(&trash_item.id); + cleanup_paths.push(id_path.clone()); + + assert_eq!(trash_item.name, path.components().last().expect("Should have last component").as_os_str()); + assert_eq!(trash_item.original_parent, path.parent().expect("Should have parent").as_os_str()); + assert!(id_path.to_string_lossy().contains(".Trash")) + } + _ => panic!("Calling delete_with_info with Finder method failed to return TrashItem."), + } +} diff --git a/src/windows.rs b/src/windows.rs index 8727706..1db4e32 100644 --- a/src/windows.rs +++ b/src/windows.rs @@ -1,16 +1,18 @@ use crate::{Error, TrashContext, TrashItem, TrashItemMetadata, TrashItemSize}; +use log::warn; use std::{ borrow::Borrow, ffi::{c_void, OsStr, OsString}, os::windows::{ffi::OsStrExt, prelude::*}, path::PathBuf, + sync::{Arc, Mutex}, }; use windows::Win32::{ Foundation::*, Storage::EnhancedStorage::*, System::Com::*, System::SystemServices::*, UI::Shell::PropertiesSystem::*, UI::Shell::*, }; use windows::{ - core::{Interface, PCWSTR, PWSTR}, + core::{implement, Interface, PCWSTR, PWSTR}, Win32::System::Com::StructuredStorage::PropVariantToBSTR, }; @@ -35,14 +37,34 @@ impl PlatformTrashContext { } } impl TrashContext { + /// Removes all files and folder paths recursively. /// See https://docs.microsoft.com/en-us/windows/win32/api/shellapi/ns-shellapi-_shfileopstructa - pub(crate) fn delete_specified_canonicalized(&self, full_paths: Vec) -> Result<(), Error> { + pub(crate) fn delete_all_canonicalized( + &self, + full_paths: Vec, + with_info: bool, + ) -> Result>, Error> { ensure_com_initialized(); unsafe { - let pfo: IFileOperation = CoCreateInstance(&FileOperation as *const _, None, CLSCTX_ALL).unwrap(); + let pfo: IFileOperation = CoCreateInstance(&FileOperation as *const _, None, CLSCTX_ALL)?; pfo.SetOperationFlags(FOF_NO_UI | FOF_ALLOWUNDO | FOF_WANTNUKEWARNING)?; + // The `PostDeleteItem` callback provides the item's ID immediately, + // but the full shell metadata (original location, delete time) may + // not yet be written to the Recycle Bin at that point. + // We collect IDs during the operation and do a full metadata lookup + // only after `PerformOperations` completes. + let (ids_arc, _sink_interface) = if with_info { + let sink = TrashProgressSink::new(); + let ids_arc = sink.ids.clone(); + let sink_interface: IFileOperationProgressSink = sink.into(); + pfo.Advise(&sink_interface)?; + (Some(ids_arc), Some(sink_interface)) + } else { + (None, None) + }; + for full_path in full_paths.iter() { let path_prefix = ['\\' as u16, '\\' as u16, '?' as u16, '\\' as u16]; let wide_path_container = to_wide_path(full_path); @@ -66,14 +88,25 @@ impl TrashContext { // the list of HRESULT codes is not documented. return Err(Error::Unknown { description: "Some operations were aborted".into() }); } - Ok(()) - } - } - /// Removes all files and folder paths recursively. - pub(crate) fn delete_all_canonicalized(&self, full_paths: Vec) -> Result<(), Error> { - self.delete_specified_canonicalized(full_paths)?; - Ok(()) + // Look up full TrashItem metadata for collected IDs + if let Some(ids_arc) = ids_arc { + let ids = ids_arc + .lock() + .map_err(|e| Error::Unknown { description: format!("Failed to lock trash item ids: {}", e) })?; + + let mut items = Vec::with_capacity(ids.len()); + for id in ids.iter() { + match get_trash_item_by_id(id) { + Ok(item) => items.push(item), + Err(err) => warn!("Failed to look up trash item metadata for {:?}: {}", id, err), + } + } + Ok(Some(items)) + } else { + Ok(None) + } + } } } @@ -269,6 +302,183 @@ unsafe fn get_date_deleted_unix(item: &IShellItem2) -> Result { Ok(seconds_since_unix_epoch as i64) } +/// Look up a TrashItem by its ID (parsing name) from the recycle bin +unsafe fn get_trash_item_by_id(id: &OsStr) -> Result { + let id_as_wide = to_wide_path(id); + let parsing_name = PCWSTR(id_as_wide.as_ptr()); + let item: IShellItem = SHCreateItemFromParsingName(parsing_name, None)?; + let item2: IShellItem2 = item.cast()?; + + let name = get_display_name(&item, SIGDN_PARENTRELATIVE)?; + let original_location_variant = item2.GetProperty(&SCID_ORIGINAL_LOCATION)?; + let original_location_bstr = PropVariantToBSTR(&original_location_variant)?; + let original_location = OsString::from_wide(original_location_bstr.as_wide()); + let time_deleted = get_date_deleted_unix(&item2)?; + + Ok(TrashItem { id: id.to_os_string(), name, original_parent: PathBuf::from(original_location), time_deleted }) +} + +/// A COM object implementing IFileOperationProgressSink to collect trash item IDs +/// during delete operations. The PostDeleteItem callback receives the newly created +/// item in the Recycle Bin, and we collect its ID (parsing name) for later lookup. +/// +/// We only collect IDs here because the full metadata (original location, delete time) +/// may not be available immediately in the callback. After operations complete, we +/// look up the full metadata using these IDs. +#[implement(IFileOperationProgressSink)] +pub(crate) struct TrashProgressSink { + /// Collected IDs (parsing names) of items moved to the recycle bin + pub ids: Arc>>, +} + +impl TrashProgressSink { + pub fn new() -> Self { + Self { ids: Arc::new(Mutex::new(Vec::new())) } + } +} + +impl IFileOperationProgressSink_Impl for TrashProgressSink { + fn StartOperations(&self) -> windows::core::Result<()> { + Ok(()) + } + + fn FinishOperations(&self, _hrresult: windows::core::HRESULT) -> windows::core::Result<()> { + Ok(()) + } + + fn PreRenameItem( + &self, + _dwflags: u32, + _psiitem: Option<&IShellItem>, + _psznewname: &PCWSTR, + ) -> windows::core::Result<()> { + Ok(()) + } + + fn PostRenameItem( + &self, + _dwflags: u32, + _psiitem: Option<&IShellItem>, + _psznewname: &PCWSTR, + _hrrename: windows::core::HRESULT, + _psinewlycreated: Option<&IShellItem>, + ) -> windows::core::Result<()> { + Ok(()) + } + + fn PreMoveItem( + &self, + _dwflags: u32, + _psiitem: Option<&IShellItem>, + _psidestinationfolder: Option<&IShellItem>, + _psznewname: &PCWSTR, + ) -> windows::core::Result<()> { + Ok(()) + } + + fn PostMoveItem( + &self, + _dwflags: u32, + _psiitem: Option<&IShellItem>, + _psidestinationfolder: Option<&IShellItem>, + _psznewname: &PCWSTR, + _hrmove: windows::core::HRESULT, + _psinewlycreated: Option<&IShellItem>, + ) -> windows::core::Result<()> { + Ok(()) + } + + fn PreCopyItem( + &self, + _dwflags: u32, + _psiitem: Option<&IShellItem>, + _psidestinationfolder: Option<&IShellItem>, + _psznewname: &PCWSTR, + ) -> windows::core::Result<()> { + Ok(()) + } + + fn PostCopyItem( + &self, + _dwflags: u32, + _psiitem: Option<&IShellItem>, + _psidestinationfolder: Option<&IShellItem>, + _psznewname: &PCWSTR, + _hrcopy: windows::core::HRESULT, + _psinewlycreated: Option<&IShellItem>, + ) -> windows::core::Result<()> { + Ok(()) + } + + fn PreDeleteItem(&self, _dwflags: u32, _psiitem: Option<&IShellItem>) -> windows::core::Result<()> { + Ok(()) + } + + fn PostDeleteItem( + &self, + _dwflags: u32, + _psiitem: Option<&IShellItem>, + hrdelete: windows::core::HRESULT, + psinewlycreated: Option<&IShellItem>, + ) -> windows::core::Result<()> { + // Only process if the delete succeeded and we have a new item in the trash + // HRESULT success codes have the high bit clear (SUCCEEDED macro check) + // hrdelete.is_ok() only checks for S_OK (0), but other success codes exist + let succeeded = hrdelete.0 >= 0; + if succeeded { + if let Some(trash_item) = psinewlycreated { + // Just collect the ID (parsing name) - we'll look up full metadata later + unsafe { + if let Ok(id) = get_display_name(trash_item, SIGDN_DESKTOPABSOLUTEPARSING) { + if let Ok(mut ids) = self.ids.lock() { + ids.push(id); + } + } + } + } + } + Ok(()) + } + + fn PreNewItem( + &self, + _dwflags: u32, + _psidestinationfolder: Option<&IShellItem>, + _psznewname: &PCWSTR, + ) -> windows::core::Result<()> { + Ok(()) + } + + fn PostNewItem( + &self, + _dwflags: u32, + _psidestinationfolder: Option<&IShellItem>, + _psznewname: &PCWSTR, + _psztemplatename: &PCWSTR, + _dwfileattributes: u32, + _hrnew: windows::core::HRESULT, + _psinewitem: Option<&IShellItem>, + ) -> windows::core::Result<()> { + Ok(()) + } + + fn UpdateProgress(&self, _iworktotal: u32, _iworksofar: u32) -> windows::core::Result<()> { + Ok(()) + } + + fn ResetTimer(&self) -> windows::core::Result<()> { + Ok(()) + } + + fn PauseTimer(&self) -> windows::core::Result<()> { + Ok(()) + } + + fn ResumeTimer(&self) -> windows::core::Result<()> { + Ok(()) + } +} + struct CoInitializer {} impl CoInitializer { fn new() -> CoInitializer { diff --git a/tests/trash.rs b/tests/trash.rs index 7ea478f..378d218 100644 --- a/tests/trash.rs +++ b/tests/trash.rs @@ -6,6 +6,9 @@ use log::trace; use serial_test::serial; use trash::{delete, delete_all}; +use std::env; +use trash::TrashContext; + mod util { use std::sync::atomic::{AtomicI32, Ordering}; @@ -177,3 +180,67 @@ fn recursive_file_with_content_deletion() { trash::delete(parent_dir).unwrap(); assert!(!parent_dir.exists()); } + +#[test] +#[serial] +#[cfg(target_os = "linux")] +fn test_delete_with_info() { + // Create the test file to be deleted, ensuring that we include the current + // directory so we can later assert that the `original_parent` is preserved. + let path = env::current_dir().expect("Should be able to get current directory").join(get_unique_name()); + File::create_new(&path).unwrap(); + + // Create a new trash context for deleting the file with info. + let trash = TrashContext::new(); + + match trash.delete_with_info(&path) { + Ok(trash_item) => { + // Before asserting any of the fields, we'll go ahead and remove the + // trashed file from the Freedesktop trash, otherwise it'll be kept + // around after the test is finished. + // On Freedesktop, `id` points to the `.trashinfo` file. The actual + // trashed file lives in the sibling `files/` directory, so we need + // to clean up both. + let info_path = PathBuf::from(&trash_item.id); + let _ = std::fs::remove_file(&info_path); + if let Some(info_dir) = info_path.parent().and_then(|p| p.parent()) { + let file_in_trash = info_dir.join("files").join(info_path.file_stem().unwrap_or_default()); + let _ = std::fs::remove_file(&file_in_trash).or_else(|_| std::fs::remove_dir_all(&file_in_trash)); + } + + assert_eq!(trash_item.name, path.components().last().expect("Should have last component").as_os_str()); + assert_eq!(trash_item.original_parent, path.parent().expect("Should have parent").as_os_str()); + } + _ => panic!("Calling delete_with_info failed to return TrashItem."), + } +} + +#[test] +#[serial] +#[cfg(target_os = "windows")] +fn test_delete_with_info() { + use trash::os_limited::purge_all; + + // Create the test file to be deleted, ensuring that we include the current + // directory so we can later assert that the `original_parent` is preserved. + let path = env::current_dir().expect("Should be able to get current directory").join(get_unique_name()); + File::create_new(&path).unwrap(); + + // Create a new trash context for deleting the file with info. + let trash = TrashContext::new(); + + match trash.delete_with_info(&path) { + Ok(trash_item) => { + // Before asserting any of the fields, we'll go ahead and remove the + // trashed file from the Recycle Bin, otherwise it'll be kept around + // after the test is finished. + // The returned `Result` is ignored, as we don't want the test to + // fail in case we're not able to remove the file from trash. + let _ = purge_all([&trash_item]); + + assert_eq!(trash_item.name, path.components().last().expect("Should have last component").as_os_str()); + assert_eq!(trash_item.original_parent, path.parent().expect("Should have parent")); + } + _ => panic!("Calling delete_with_info failed to return TrashItem."), + } +}