fm/modes/menu/
trash.rs

1use std::borrow::Cow;
2use std::cmp::Ordering;
3use std::fs::{create_dir, read_dir, remove_dir_all};
4use std::io::prelude::*;
5use std::path::{Path, PathBuf};
6
7use anyhow::{anyhow, Context, Result};
8use chrono::{Local, NaiveDateTime};
9
10use crate::common::{
11    read_lines, tilde, TRASH_CONFIRM_LINE, TRASH_FOLDER_FILES, TRASH_FOLDER_INFO,
12    TRASH_INFO_EXTENSION,
13};
14use crate::config::Bindings;
15use crate::io::{CowStr, DrawMenu};
16use crate::{impl_content, impl_selectable, log_info, log_line};
17
18const TRASHINFO_DATETIME_FORMAT: &str = "%Y-%m-%dT%H:%M:%S";
19
20/// Holds the information about a trashed file.
21/// Follow the specifications of .trashinfo files as described in
22/// [Trash freedesktop specs](https://specifications.freedesktop.org/trash-spec/trashspec-latest.html)
23/// It knows
24/// - where the file came from,
25/// - what name it was given when trashed,
26/// - when it was trashed
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct Info {
29    origin: PathBuf,
30    dest_name: String,
31    deletion_date: String,
32}
33
34impl Info {
35    /// Returns a new `Info` instance.
36    /// The `deletion_date` is calculated on creation, before the file is actually trashed.
37    pub fn new(origin: &Path, dest_name: &str) -> Self {
38        let date = Local::now();
39        let deletion_date = format!("{}", date.format(TRASHINFO_DATETIME_FORMAT));
40        let dest_name = dest_name.to_owned();
41        Self {
42            origin: PathBuf::from(origin),
43            dest_name,
44            deletion_date,
45        }
46    }
47
48    fn format(&self) -> String {
49        format!(
50            "[Trash Info]
51Path={origin}
52DeletionDate={date}
53",
54            origin = url_escape::encode_fragment(&self.origin.to_string_lossy()),
55            date = self.deletion_date
56        )
57    }
58
59    /// Write itself into a .trashinfo file.
60    /// The format looks like :
61    ///
62    /// ```no_rust
63    /// [TrashInfo]
64    /// Path=/home/quentin/Documents
65    /// DeletionDate=2022-12-31T22:45:55
66    /// ```
67    /// # Errors
68    ///
69    /// This function uses [`std::fs::File::create`] internally and may fail
70    /// for the same reasons.
71    pub fn write_trash_info(&self, dest: &Path) -> Result<()> {
72        log_info!("writing trash_info {} for {:?}", self, dest);
73
74        let mut file = std::fs::File::create(dest)?;
75        if let Err(e) = write!(file, "{}", self.format()) {
76            log_info!("Couldn't write to trash file: {}", e);
77        }
78        Ok(())
79    }
80
81    /// Reads a .trashinfo file and parse it into a new instance.
82    ///
83    /// Let say `Documents.trashinfo` contains :
84    ///
85    /// ```not_rust
86    /// [TrashInfo]
87    /// Path=/home/quentin/Documents
88    /// DeletionDate=2022-12-31T22:45:55
89    /// ```
90    ///
91    /// It will be parsed into
92    /// ```rust
93    /// TrashInfo { PathBuf::from("/home/quentin/Documents"), "Documents", "2022-12-31T22:45:55" }
94    /// ```
95    pub fn from_trash_info_file(trash_info_file: &Path) -> Result<Self> {
96        let (option_path, option_deleted_time) = Self::parse_trash_info_file(trash_info_file)?;
97
98        match (option_path, option_deleted_time) {
99            (Some(origin), Some(deletion_date)) => {
100                let dest_name = Self::get_dest_name(trash_info_file)?;
101                Ok(Self {
102                    origin,
103                    dest_name,
104                    deletion_date,
105                })
106            }
107            _ => Err(anyhow!("Couldn't parse the trash info file")),
108        }
109    }
110
111    fn get_dest_name(trash_info_file: &Path) -> Result<String> {
112        if let Some(dest_name) = trash_info_file.file_name() {
113            let dest_name =
114                Self::remove_extension(dest_name.to_string_lossy().as_ref().to_owned())?;
115            Ok(dest_name)
116        } else {
117            Err(anyhow!("Couldn't parse the trash info filename"))
118        }
119    }
120
121    fn parse_trash_info_file(trash_info_file: &Path) -> Result<(Option<PathBuf>, Option<String>)> {
122        let mut option_path: Option<PathBuf> = None;
123        let mut option_deleted_time: Option<String> = None;
124
125        if let Ok(mut lines) = read_lines(trash_info_file) {
126            let Some(Ok(first_line)) = lines.next() else {
127                return Err(anyhow!("Unreadable TrashInfo file"));
128            };
129            if !first_line.starts_with("[Trash Info]") {
130                return Err(anyhow!("First line should start with [TrashInfo]"));
131            }
132
133            for line in lines {
134                let Ok(line) = line else {
135                    continue;
136                };
137                if option_path.is_none() && line.starts_with("Path=") {
138                    option_path = Some(Self::parse_option_path(&line));
139                    continue;
140                }
141                if option_deleted_time.is_none() && line.starts_with("DeletionDate=") {
142                    option_deleted_time = Some(Self::parse_deletion_date(&line)?);
143                }
144            }
145        }
146
147        Ok((option_path, option_deleted_time))
148    }
149
150    fn parse_option_path(line: &str) -> PathBuf {
151        let path_part = &line[5..];
152        let cow_path_str = url_escape::decode(path_part);
153        let path_str = cow_path_str.as_ref();
154        PathBuf::from(path_str)
155    }
156
157    fn parse_deletion_date(line: &str) -> Result<String> {
158        let deletion_date_str = &line[13..];
159        match parsed_date_from_path_info(deletion_date_str) {
160            Ok(()) => Ok(deletion_date_str.to_owned()),
161            Err(e) => Err(e),
162        }
163    }
164
165    fn remove_extension(mut destname: String) -> Result<String> {
166        if destname.ends_with(TRASH_INFO_EXTENSION) {
167            destname.truncate(destname.len() - 10);
168            Ok(destname)
169        } else {
170            Err(anyhow!(
171                "trahsinfo: filename doesn't contain {TRASH_INFO_EXTENSION}"
172            ))
173        }
174    }
175}
176
177impl Ord for Info {
178    /// Reversed to ensure most recent trashed files are displayed at top
179    fn cmp(&self, other: &Self) -> Ordering {
180        self.deletion_date.cmp(&other.deletion_date).reverse()
181    }
182}
183
184impl PartialOrd for Info {
185    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
186        Some(self.cmp(other))
187    }
188}
189
190impl std::fmt::Display for Info {
191    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
192        write!(
193            f,
194            "{} - trashed on {}",
195            &self.origin.display(),
196            self.deletion_date
197        )
198    }
199}
200
201/// Represent a view of the trash.
202/// Its content is navigable so we use a Vector to hold the content.
203/// Only files that share the same mount point as the trash folder (generally ~/.local/share/Trash)
204/// can be moved to trash.
205/// Other files are unaffected.
206#[derive(Clone)]
207pub struct Trash {
208    /// Trashed files info.
209    content: Vec<Info>,
210    index: usize,
211    /// The path to the trashed files
212    pub trash_folder_files: String,
213    trash_folder_info: String,
214    pub help: String,
215}
216
217impl Trash {
218    /// Creates an empty view of the trash.
219    /// No file is read here, we wait for the user to open the trash first.
220    ///
221    /// # Errors
222    ///
223    /// This function uses [`std::fs::create_dir_all`] internally and may fail
224    /// for the same reasons.
225    pub fn new(binds: &Bindings) -> Result<Self> {
226        let trash_folder_files = tilde(TRASH_FOLDER_FILES).to_string();
227        let trash_folder_info = tilde(TRASH_FOLDER_INFO).to_string();
228        create_if_not_exists(&trash_folder_files)?;
229        create_if_not_exists(&trash_folder_info)?;
230        let empty_trash_binds = match binds.keybind_reversed().get("TrashEmpty") {
231            Some(s) => s.to_owned(),
232            None => "alt-x".to_owned(),
233        };
234
235        let help = format!("{TRASH_CONFIRM_LINE}{empty_trash_binds}: Empty the trash");
236
237        let index = 0;
238        let content = vec![];
239
240        Ok(Self {
241            content,
242            index,
243            trash_folder_files,
244            trash_folder_info,
245            help,
246        })
247    }
248
249    fn pick_dest_name(&self, origin: &Path) -> Result<String> {
250        if let Some(file_name) = origin.file_name() {
251            let mut dest = file_name
252                .to_str()
253                .context("pick_dest_name: Couldn't parse the origin filename into a string")?
254                .to_owned();
255            let mut dest_path = PathBuf::from(&self.trash_folder_files);
256            dest_path.push(&dest);
257            while dest_path.exists() {
258                dest.push_str(&rand_string());
259                dest_path = PathBuf::from(&self.trash_folder_files);
260                dest_path.push(&dest);
261            }
262            return Ok(dest);
263        }
264        Err(anyhow!("pick_dest_name: Couldn't extract the filename",))
265    }
266
267    fn parse_updated_content(trash_folder_info: &str) -> Result<Vec<Info>> {
268        match read_dir(trash_folder_info) {
269            Ok(read_dir) => {
270                let mut content: Vec<Info> = read_dir
271                    .filter_map(std::result::Result::ok)
272                    .filter(|direntry| direntry.path().extension().is_some())
273                    .filter(|direntry| {
274                        direntry.path().extension().unwrap().to_str().unwrap() == "trashinfo"
275                    })
276                    .map(|direntry| Info::from_trash_info_file(&direntry.path()))
277                    .filter_map(std::result::Result::ok)
278                    .collect();
279
280                content.sort_unstable();
281                Ok(content)
282            }
283            Err(error) => {
284                log_info!("Couldn't read path {:?} - {}", trash_folder_info, error);
285                Err(anyhow!(error))
286            }
287        }
288    }
289
290    /// Parse the info files into a new instance.
291    /// Only the file we can parse are read.
292    ///
293    /// # Errors
294    ///
295    /// This function may fail if the `trash_folder_info` can't be parsed correctly.
296    /// If any file is listed in `trash_folder_info` but doesn't exist.
297    pub fn update(&mut self) -> Result<()> {
298        self.index = 0;
299        self.content = Self::parse_updated_content(&self.trash_folder_info)?;
300        Ok(())
301    }
302
303    /// Move a file to the trash folder and create a new trash info file.
304    /// Add a new `TrashInfo` to the content.
305    ///
306    /// # Errors
307    ///
308    /// This function may fail if the origin path is a relative path.
309    /// We have no way to know where the file is exactly located.
310    ///
311    /// It may also fail  if the trash folder can't be located, we wouldn't be
312    /// able to create a new path for the file.
313    pub fn trash(&mut self, origin: &Path) -> Result<()> {
314        if origin.is_relative() {
315            return Err(anyhow!("trash: origin path should be absolute"));
316        }
317
318        let dest_file_name = self.pick_dest_name(origin)?;
319
320        self.trash_a_file(Info::new(origin, &dest_file_name), &dest_file_name)
321    }
322
323    fn concat_path(root: &str, filename: &str) -> PathBuf {
324        let mut concatened_path = PathBuf::from(root);
325        concatened_path.push(filename);
326        concatened_path
327    }
328
329    fn trashfile_path(&self, dest_file_name: &str) -> PathBuf {
330        Self::concat_path(&self.trash_folder_files, dest_file_name)
331    }
332
333    fn trashinfo_path(&self, dest_trashinfo_name: &str) -> PathBuf {
334        let mut dest_trashinfo_name = dest_trashinfo_name.to_owned();
335        dest_trashinfo_name.push_str(TRASH_INFO_EXTENSION);
336        Self::concat_path(&self.trash_folder_info, &dest_trashinfo_name)
337    }
338
339    fn trash_a_file(&mut self, trash_info: Info, dest_file_name: &str) -> Result<()> {
340        let trashfile_filename = &self.trashfile_path(dest_file_name);
341        if let Err(error) = std::fs::rename(&trash_info.origin, trashfile_filename) {
342            log_info!("Couldn't trash {trash_info}. Error: {error:?}");
343        } else {
344            Self::log_trash_add(&trash_info.origin, dest_file_name);
345            trash_info.write_trash_info(&self.trashinfo_path(dest_file_name))?;
346            self.content.push(trash_info);
347        }
348        Ok(())
349    }
350
351    fn log_trash_add(origin: &Path, dest_file_name: &str) {
352        log_info!("moved to trash {:?} -> {:?}", origin, dest_file_name);
353        log_line!("moved to trash {:?} -> {:?}", origin, dest_file_name);
354    }
355
356    /// Empty the trash, removing all the files and the trashinfo.
357    /// This action requires a confirmation.
358    /// Watchout, it may delete files that weren't parsed.
359    ///
360    /// # Errors
361    ///
362    /// This method may fail if the trashfolder was moved or deleted or simply doesn't exist.
363    /// This method uses `std::fs::remove_dir` internally, which may fail.
364    ///
365    /// See [`std::fs::remove_file`] and [`std::fs::remove_dir`].
366    ///
367    /// `remove_dir_all` will fail if `remove_dir` or `remove_file` fail on any constituent paths, including the root path.
368    pub fn empty_trash(&mut self) -> Result<()> {
369        self.empty_trash_dirs()?;
370        let number_of_elements = self.content.len();
371        self.content = vec![];
372        Self::log_trash_empty(number_of_elements);
373        Ok(())
374    }
375
376    fn empty_trash_dirs(&self) -> Result<(), std::io::Error> {
377        Self::empty_dir(&self.trash_folder_files)?;
378        Self::empty_dir(&self.trash_folder_info)
379    }
380
381    fn empty_dir(dir: &str) -> Result<(), std::io::Error> {
382        remove_dir_all(dir)?;
383        create_dir(dir)
384    }
385
386    fn log_trash_empty(number_of_elements: usize) {
387        log_line!("Emptied the trash: {number_of_elements} files permanently deleted");
388        log_info!("Emptied the trash: {number_of_elements} files permanently deleted");
389    }
390
391    fn remove_selected_file(&mut self) -> Result<(PathBuf, PathBuf, PathBuf)> {
392        if self.is_empty() {
393            return Err(anyhow!(
394                "remove selected file: Can't restore from an empty trash"
395            ));
396        }
397        let trashinfo = &self.content[self.index];
398        let origin = trashinfo.origin.clone();
399
400        let parent = find_parent(&trashinfo.origin)?;
401
402        let trashed_file_content = self.trashfile_path(&trashinfo.dest_name);
403        let trashed_file_info = self.trashinfo_path(&trashinfo.dest_name);
404
405        if !trashed_file_content.exists() {
406            return Err(anyhow!("trash restore: Couldn't find the trashed file"));
407        }
408
409        if !trashed_file_info.exists() {
410            return Err(anyhow!("trash restore: Couldn't find the trashed info"));
411        }
412
413        self.remove_from_content_and_delete_trashinfo(&trashed_file_info)?;
414
415        Ok((origin, trashed_file_content, parent))
416    }
417
418    fn remove_from_content_and_delete_trashinfo(&mut self, trashed_file_info: &Path) -> Result<()> {
419        self.content.remove(self.index);
420        std::fs::remove_file(trashed_file_info)?;
421        Ok(())
422    }
423
424    /// Restores a file from the trash to its previous directory.
425    /// If the parent (or ancestor) folder were deleted, it is recreated.
426    ///
427    /// # Errors
428    ///
429    /// Will return an error if the file isn't trashed properly :
430    /// - missing .trashinfo
431    /// - missing file itself
432    ///
433    /// It may also fail if the file can't be restored :
434    /// For example, if the original path of the file is now
435    /// in a directory where the user has no permission to write.
436    pub fn restore(&mut self) -> Result<()> {
437        if self.is_empty() {
438            return Ok(());
439        }
440        let (origin, trashed_file_content, parent) = self.remove_selected_file()?;
441        Self::execute_restore(&origin, &trashed_file_content, &parent)?;
442        Self::log_trash_restore(&origin);
443        Ok(())
444    }
445
446    fn execute_restore(origin: &Path, trashed_file_content: &Path, parent: &Path) -> Result<()> {
447        if !parent.exists() {
448            std::fs::create_dir_all(parent)?;
449        }
450        std::fs::rename(trashed_file_content, origin)?;
451        Ok(())
452    }
453
454    fn log_trash_restore(origin: &Path) {
455        log_line!("Trash restored: {origin}", origin = origin.display());
456    }
457
458    /// Deletes a file permanently from the trash.
459    ///
460    /// # Errors
461    ///
462    /// Will return an error if the selected file isn't trashed properly:
463    /// - missing .trashinfo
464    /// - missing file itself.
465    pub fn delete_permanently(&mut self) -> Result<()> {
466        if self.is_empty() {
467            return Ok(());
468        }
469
470        let (_, trashed_file_content, _) = self.remove_selected_file()?;
471
472        std::fs::remove_file(&trashed_file_content)?;
473        Self::log_trash_remove(&trashed_file_content);
474
475        if self.index > 0 {
476            self.index -= 1;
477        }
478        Ok(())
479    }
480
481    fn log_trash_remove(trashed_file_content: &Path) {
482        log_line!(
483            "Trash removed: {trashed_file_content}",
484            trashed_file_content = trashed_file_content.display()
485        );
486    }
487}
488
489impl_content!(Trash, Info);
490
491fn parsed_date_from_path_info(ds: &str) -> Result<()> {
492    NaiveDateTime::parse_from_str(ds, TRASHINFO_DATETIME_FORMAT)?;
493    Ok(())
494}
495
496fn rand_string() -> String {
497    crate::common::random_alpha_chars().take(2).collect()
498}
499
500fn find_parent(path: &Path) -> Result<PathBuf> {
501    Ok(path
502        .parent()
503        .ok_or_else(|| anyhow!("find_parent_as_string : Couldn't find parent of {path:?}"))?
504        .to_owned())
505}
506
507fn create_if_not_exists<P>(path: P) -> std::io::Result<()>
508where
509    std::path::PathBuf: From<P>,
510    P: std::convert::AsRef<std::path::Path> + std::marker::Copy,
511{
512    if !std::path::PathBuf::from(path).exists() {
513        std::fs::create_dir_all(path)?;
514    }
515    Ok(())
516}
517
518impl CowStr for Info {
519    fn cow_str(&self) -> Cow<'_, str> {
520        self.to_string().into()
521    }
522}
523
524impl DrawMenu<Info> for Trash {}