Skip to main content

email/account/config/
mod.rs

1//! Module dedicated to account configuration.
2//!
3//! This module contains the representation of the user's current
4//! account configuration named [`AccountConfig`].
5
6#[cfg(feature = "oauth2")]
7pub mod oauth2;
8pub mod passwd;
9#[cfg(feature = "pgp")]
10pub mod pgp;
11
12use std::{
13    collections::HashMap,
14    env::temp_dir,
15    ffi::OsStr,
16    fs, io,
17    path::{Path, PathBuf},
18    vec,
19};
20
21#[cfg(feature = "derive")]
22use crate::serde::deserialize_shell_expanded_string;
23#[cfg(feature = "sync")]
24use dirs::data_dir;
25use dirs::download_dir;
26use mail_builder::headers::address::{Address, EmailAddress};
27use mail_parser::Address::*;
28use mml::MimeInterpreterBuilder;
29#[cfg(feature = "notify")]
30use notify_rust::Notification;
31use process::Command;
32use shellexpand_utils::{shellexpand_path, shellexpand_str, try_shellexpand_path};
33use tracing::debug;
34
35#[cfg(feature = "pgp")]
36use self::pgp::PgpConfig;
37#[cfg(feature = "sync")]
38use super::sync::config::SyncConfig;
39#[doc(inline)]
40pub use super::{Error, Result};
41use crate::{
42    date::from_mail_parser_to_chrono_datetime,
43    email::config::EmailTextPlainFormat,
44    envelope::{config::EnvelopeConfig, Envelope},
45    flag::config::FlagConfig,
46    folder::{config::FolderConfig, FolderKind, DRAFTS, INBOX, SENT, TRASH},
47    message::config::MessageConfig,
48    template::{
49        config::TemplateConfig,
50        forward::config::{ForwardTemplatePostingStyle, ForwardTemplateSignatureStyle},
51        new::config::NewTemplateSignatureStyle,
52        reply::config::{ReplyTemplatePostingStyle, ReplyTemplateSignatureStyle},
53    },
54    watch::config::WatchHook,
55};
56
57pub const DEFAULT_PAGE_SIZE: usize = 10;
58pub const DEFAULT_SIGNATURE_DELIM: &str = "-- \n";
59
60pub trait HasAccountConfig {
61    fn account_config(&self) -> &AccountConfig;
62}
63
64/// The user's account configuration.
65///
66/// It represents everything that the user can customize for a given
67/// account. It is the main configuration used by all other
68/// modules. Usually, it serves as a reference for building config
69/// file structure.
70#[derive(Clone, Debug, Default, Eq, PartialEq)]
71#[cfg_attr(
72    feature = "derive",
73    derive(serde::Serialize, serde::Deserialize),
74    serde(rename_all = "kebab-case", deny_unknown_fields)
75)]
76pub struct AccountConfig {
77    /// The name of the user account.
78    ///
79    /// The account name is used as an unique identifier for a given
80    /// configuration.
81    pub name: String,
82
83    /// The email address of the user account.
84    #[cfg_attr(
85        feature = "derive",
86        serde(deserialize_with = "deserialize_shell_expanded_string")
87    )]
88    pub email: String,
89
90    /// The display name of the user.
91    ///
92    /// It usually corresponds to the full name of the user.
93    pub display_name: Option<String>,
94
95    /// The email signature of the user.
96    ///
97    /// It can be either a path to a file (usually `~/.signature`) or
98    /// a raw string.
99    pub signature: Option<String>,
100
101    /// The email signature delimiter of the user signature.
102    ///
103    /// Defaults to `-- \n`.
104    pub signature_delim: Option<String>,
105
106    /// The downloads directory.
107    ///
108    /// It is mostly used for downloading messages
109    /// attachments. Defaults to the system temporary directory
110    /// (usually `/tmp`).
111    pub downloads_dir: Option<PathBuf>,
112
113    /// The folder configuration.
114    pub folder: Option<FolderConfig>,
115
116    /// The envelope configuration.
117    pub envelope: Option<EnvelopeConfig>,
118
119    /// The flag configuration.
120    pub flag: Option<FlagConfig>,
121
122    /// The message configuration.
123    pub message: Option<MessageConfig>,
124
125    /// The message configuration.
126    pub template: Option<TemplateConfig>,
127
128    /// The account synchronization configuration.
129    #[cfg(feature = "sync")]
130    pub sync: Option<SyncConfig>,
131
132    /// The PGP configuration.
133    #[cfg(feature = "pgp")]
134    pub pgp: Option<PgpConfig>,
135}
136
137impl AccountConfig {
138    /// Get the signature, including the delimiter.
139    ///
140    /// Uses the default delimiter `-- \n` in case no delimiter has
141    /// been defined. Return `None` if no signature has been defined.
142    pub fn find_full_signature(&self) -> Option<String> {
143        let delim = self
144            .signature_delim
145            .as_deref()
146            .unwrap_or(DEFAULT_SIGNATURE_DELIM);
147
148        let signature = self.signature.as_ref();
149
150        signature.map(|path_or_raw| {
151            let signature = try_shellexpand_path(path_or_raw)
152                .map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))
153                .and_then(fs::read_to_string)
154                .unwrap_or_else(|_err| {
155                    debug!("cannot read signature from path: {_err}");
156                    debug!("{_err:?}");
157                    shellexpand_str(path_or_raw)
158                });
159            format!("{}{}", delim, signature.trim())
160        })
161    }
162
163    /// Get then expand the downloads directory path.
164    ///
165    /// Falls back to [`dirs::download_dir`].
166    pub fn get_downloads_dir(&self) -> PathBuf {
167        self.downloads_dir
168            .as_ref()
169            .map(shellexpand_path)
170            .or_else(download_dir)
171            .unwrap_or_else(temp_dir)
172    }
173
174    /// Build the downloadable version of the given path.
175    ///
176    /// The aim of this helper is to build a safe download path for a
177    /// given path.
178    ///
179    /// First, only the file name of the give path is taken in order
180    /// to prevent any interaction outside of the downloads directory.
181    ///
182    /// Then, a suffix may be added to the final path if it already
183    /// exists on the filesystem in order to prevent any overriding or
184    /// data loss.
185    pub fn get_download_file_path(
186        &self,
187        downloads_dir: Option<&Path>,
188        path: impl AsRef<Path>,
189    ) -> Result<PathBuf> {
190        let path = path.as_ref();
191
192        let file_name = path
193            .file_name()
194            .ok_or_else(|| Error::GetFileNameFromPathSyncError(path.to_owned()))?;
195
196        let final_path = match downloads_dir {
197            Some(dir) => dir.join(file_name),
198            None => self.get_downloads_dir().join(file_name),
199        };
200
201        rename_file_if_duplicate(&final_path, |path, _count| path.is_file())
202    }
203
204    /// Return `true` if the synchronization is enabled.
205    #[cfg(feature = "sync")]
206    pub fn is_sync_enabled(&self) -> bool {
207        self.sync
208            .as_ref()
209            .and_then(|c| c.enable)
210            .unwrap_or_default()
211    }
212
213    /// Return `true` if the synchronization directory already exists.
214    #[cfg(feature = "sync")]
215    pub fn does_sync_dir_exist(&self) -> bool {
216        match self.sync.as_ref().and_then(|c| c.dir.as_ref()) {
217            Some(dir) => try_shellexpand_path(dir).is_ok(),
218            None => data_dir()
219                .map(|dir| {
220                    dir.join("pimalaya")
221                        .join("email")
222                        .join("sync")
223                        .join(&self.name)
224                        .is_dir()
225                })
226                .unwrap_or_default(),
227        }
228    }
229
230    /// Execute the envelope received hook.
231    #[cfg(feature = "watch")]
232    pub async fn exec_received_envelope_hook(&self, envelope: &Envelope) {
233        let hook = self
234            .envelope
235            .as_ref()
236            .and_then(|c| c.watch.as_ref())
237            .and_then(|c| c.received.as_ref());
238
239        if let Some(hook) = hook.as_ref() {
240            self.exec_envelope_hook(hook, envelope).await
241        }
242    }
243
244    /// Execute the envelope any hook.
245    #[cfg(feature = "watch")]
246    pub async fn exec_any_envelope_hook(&self, envelope: &Envelope) {
247        let hook = self
248            .envelope
249            .as_ref()
250            .and_then(|c| c.watch.as_ref())
251            .and_then(|c| c.any.as_ref());
252
253        if let Some(hook) = hook.as_ref() {
254            self.exec_envelope_hook(hook, envelope).await
255        }
256    }
257
258    /// Execute the given envelope hook.
259    pub async fn exec_envelope_hook(&self, hook: &WatchHook, envelope: &Envelope) {
260        let sender = envelope.from.name.as_deref().unwrap_or(&envelope.from.addr);
261        let sender_name = envelope.from.name.as_deref().unwrap_or("unknown");
262        let recipient = envelope.to.name.as_deref().unwrap_or(&envelope.to.addr);
263        let recipient_name = envelope.to.name.as_deref().unwrap_or("unknown");
264
265        if let Some(cmd) = hook.cmd.as_ref() {
266            let res = cmd
267                .clone()
268                .replace("{id}", &envelope.id)
269                .replace("{subject}", &envelope.subject)
270                .replace("{sender}", sender)
271                .replace("{sender.name}", sender_name)
272                .replace("{sender.address}", &envelope.from.addr)
273                .replace("{recipient}", recipient)
274                .replace("{recipient.name}", recipient_name)
275                .replace("{recipient.address}", &envelope.to.addr)
276                .run()
277                .await;
278
279            if let Err(_err) = res {
280                debug!("error while executing watch command hook");
281                debug!("{_err:?}");
282            }
283        }
284
285        #[allow(unused_variables)]
286        let replace = move |fmt: &str, envelope: &Envelope| -> String {
287            fmt.replace("{id}", &envelope.id)
288                .replace("{subject}", &envelope.subject)
289                .replace("{sender}", sender)
290                .replace("{sender.name}", sender_name)
291                .replace("{sender.address}", &envelope.from.addr)
292                .replace("{recipient}", recipient)
293                .replace("{recipient.name}", recipient_name)
294                .replace("{recipient.address}", &envelope.to.addr)
295        };
296
297        #[cfg(all(feature = "notify", target_os = "linux"))]
298        if let Some(notify) = hook.notify.as_ref() {
299            let res = Notification::new()
300                .summary(&replace(&notify.summary, envelope))
301                .body(&replace(&notify.body, envelope))
302                .show_async()
303                .await;
304            if let Err(err) = res {
305                debug!("error while sending system notification");
306                debug!("{err:?}");
307            }
308        }
309
310        #[cfg(all(feature = "notify", not(target_os = "linux")))]
311        if let Some(notify) = hook.notify.as_ref() {
312            let summary = replace(&notify.summary, &envelope);
313            let body = replace(&notify.body, &envelope);
314
315            let res = tokio::task::spawn_blocking(move || {
316                Notification::new().summary(&summary).body(&body).show()
317            })
318            .await;
319
320            if let Err(err) = res {
321                debug!("cannot send system notification");
322                debug!("{err:?}");
323            } else {
324                let res = res.unwrap();
325                if let Err(err) = res {
326                    debug!("error while sending system notification");
327                    debug!("{err:?}");
328                }
329            }
330        }
331
332        if let Some(callback) = hook.callback.as_ref() {
333            let res = callback(envelope).await;
334            if let Err(_err) = res {
335                debug!("error while executing callback");
336                debug!("{_err:?}");
337            }
338        }
339    }
340
341    /// Find the alias of the given folder name.
342    ///
343    /// The alias is also shell expanded.
344    pub fn find_folder_alias(&self, from_name: &str) -> Option<String> {
345        self.folder
346            .as_ref()
347            .and_then(|c| c.aliases.as_ref())
348            .and_then(|aliases| {
349                aliases.iter().find_map(|(name, alias)| {
350                    if name.eq_ignore_ascii_case(from_name.trim()) {
351                        Some(shellexpand_str(alias))
352                    } else {
353                        None
354                    }
355                })
356            })
357    }
358
359    /// Find the alias of the given folder, otherwise return the given
360    /// folder itself.
361    pub fn get_folder_alias(&self, folder: &str) -> String {
362        self.find_folder_alias(folder)
363            .unwrap_or_else(|| shellexpand_str(folder))
364    }
365
366    /// Get the inbox folder alias.
367    pub fn get_inbox_folder_alias(&self) -> String {
368        self.get_folder_alias(INBOX)
369    }
370
371    /// Get the sent folder alias.
372    pub fn get_sent_folder_alias(&self) -> String {
373        self.get_folder_alias(SENT)
374    }
375
376    /// Get the drafts folder alias.
377    pub fn get_drafts_folder_alias(&self) -> String {
378        self.get_folder_alias(DRAFTS)
379    }
380
381    /// Get the trash folder alias.
382    pub fn get_trash_folder_alias(&self) -> String {
383        self.get_folder_alias(TRASH)
384    }
385
386    /// Return `true` if the given folder matches the Trash folder.
387    pub fn is_trash_folder(&self, folder: &str) -> bool {
388        self.get_folder_alias(folder) == self.get_trash_folder_alias()
389    }
390
391    /// Return `true` if the delete message style matches the
392    /// flag-based message deletion style.
393    pub fn is_delete_message_style_flag(&self) -> bool {
394        self.message
395            .as_ref()
396            .and_then(|c| c.delete.as_ref())
397            .and_then(|c| c.style.as_ref())
398            .filter(|c| c.is_flag())
399            .is_some()
400    }
401
402    /// Get all folder aliases.
403    pub fn get_folder_aliases(&self) -> Option<&HashMap<String, String>> {
404        self.folder.as_ref().and_then(|c| c.aliases.as_ref())
405    }
406
407    /// Find the folder kind associated to the given folder alias.
408    ///
409    /// This function is the reverse of [`get_folder_alias`], as it
410    /// tries to find a key (folder kind) matching the given value
411    /// (folder alias).
412    pub fn find_folder_kind_from_alias(&self, alias: &str) -> Option<FolderKind> {
413        self.folder
414            .as_ref()
415            .and_then(|c| c.aliases.as_ref())
416            .and_then(|aliases| {
417                let from_alias = shellexpand_str(alias);
418                aliases.iter().find_map(|(kind_or_name, alias)| {
419                    if shellexpand_str(alias).eq_ignore_ascii_case(&from_alias) {
420                        Some(kind_or_name.into())
421                    } else {
422                        None
423                    }
424                })
425            })
426    }
427
428    /// Get the envelope listing page size if defined, otherwise
429    /// return the default one.
430    pub fn get_envelope_list_page_size(&self) -> usize {
431        self.envelope
432            .as_ref()
433            .and_then(|c| c.list.as_ref())
434            .and_then(|c| c.page_size)
435            .unwrap_or(DEFAULT_PAGE_SIZE)
436    }
437
438    /// Get the envelope threading page size if defined, otherwise
439    /// return the default one.
440    #[cfg(feature = "thread")]
441    pub fn get_envelope_thread_page_size(&self) -> usize {
442        self.envelope
443            .as_ref()
444            .and_then(|c| c.thread.as_ref())
445            .and_then(|c| c.page_size)
446            .unwrap_or(DEFAULT_PAGE_SIZE)
447    }
448
449    /// Get the message reading format if defined, otherwise return
450    /// the default one.
451    pub fn get_message_read_format(&self) -> EmailTextPlainFormat {
452        self.message
453            .as_ref()
454            .and_then(|c| c.read.as_ref())
455            .and_then(|c| c.format.as_ref())
456            .cloned()
457            .unwrap_or_default()
458    }
459
460    /// Get the message reading headers if defined, otherwise return
461    /// the default ones.
462    pub fn get_message_read_headers(&self) -> Vec<String> {
463        self.message
464            .as_ref()
465            .and_then(|c| c.read.as_ref())
466            .and_then(|c| c.headers.as_ref())
467            .cloned()
468            .unwrap_or(vec![
469                "From".into(),
470                "To".into(),
471                "Cc".into(),
472                "Subject".into(),
473            ])
474    }
475
476    /// Get the message writing headers if defined, otherwise return
477    /// the default ones.
478    pub fn get_message_write_headers(&self) -> Vec<String> {
479        self.message
480            .as_ref()
481            .and_then(|c| c.write.as_ref())
482            .and_then(|c| c.headers.as_ref())
483            .cloned()
484            .unwrap_or(vec![
485                "From".into(),
486                "To".into(),
487                "In-Reply-To".into(),
488                "Cc".into(),
489                "Subject".into(),
490            ])
491    }
492
493    /// Find the message pre-send hook.
494    pub fn find_message_pre_send_hook(&self) -> Option<&Command> {
495        self.message
496            .as_ref()
497            .and_then(|c| c.send.as_ref())
498            .and_then(|c| c.pre_hook.as_ref())
499    }
500
501    /// Return `true` if a copy of sent messages should be saved in
502    /// the sent folder.
503    pub fn should_save_copy_sent_message(&self) -> bool {
504        self.message
505            .as_ref()
506            .and_then(|c| c.send.as_ref())
507            .and_then(|c| c.save_copy)
508            .unwrap_or(true)
509    }
510
511    /// Generate a template interpreter with prefilled options from
512    /// the current user account configuration.
513    pub fn generate_tpl_interpreter(&self) -> MimeInterpreterBuilder {
514        let builder =
515            MimeInterpreterBuilder::new().with_save_attachments_dir(self.get_downloads_dir());
516
517        #[cfg(feature = "pgp")]
518        if let Some(ref pgp) = self.pgp {
519            return builder.with_pgp(pgp.clone());
520        }
521
522        builder
523    }
524
525    /// Get the envelope listing datetime format, otherwise return the
526    /// default one.
527    pub fn get_envelope_list_datetime_fmt(&self) -> String {
528        self.envelope
529            .as_ref()
530            .and_then(|c| c.list.as_ref())
531            .and_then(|c| c.datetime_fmt.clone())
532            .unwrap_or_else(|| String::from("%F %R%:z"))
533    }
534
535    /// Return `true` if the envelope listing datetime local timezone
536    /// option is enabled.
537    pub fn has_envelope_list_datetime_local_tz(&self) -> bool {
538        self.envelope
539            .as_ref()
540            .and_then(|c| c.list.as_ref())
541            .and_then(|c| c.datetime_local_tz)
542            .unwrap_or_default()
543    }
544
545    /// Get the new template signature placement.
546    pub fn get_new_template_signature_style(&self) -> NewTemplateSignatureStyle {
547        self.template
548            .as_ref()
549            .and_then(|c| c.new.as_ref())
550            .and_then(|c| c.signature_style.clone())
551            .unwrap_or_default()
552    }
553
554    pub fn get_reply_template_signature_style(&self) -> ReplyTemplateSignatureStyle {
555        self.template
556            .as_ref()
557            .and_then(|c| c.reply.as_ref())
558            .and_then(|c| c.signature_style.clone())
559            .unwrap_or_default()
560    }
561
562    pub fn get_reply_template_posting_style(&self) -> ReplyTemplatePostingStyle {
563        self.template
564            .as_ref()
565            .and_then(|c| c.reply.as_ref())
566            .and_then(|c| c.posting_style.clone())
567            .unwrap_or_default()
568    }
569
570    pub fn get_reply_template_quote_headline(&self, msg: &mail_parser::Message) -> Option<String> {
571        let date = from_mail_parser_to_chrono_datetime(msg.date()?)?;
572
573        let senders = match (msg.from(), msg.sender()) {
574            (Some(List(a)), _) if !a.is_empty() => {
575                a.iter().fold(String::new(), |mut senders, sender| {
576                    if let Some(name) = sender.name() {
577                        if !senders.is_empty() {
578                            senders.push_str(", ");
579                        }
580                        senders.push_str(name);
581                    } else if let Some(addr) = sender.address() {
582                        if !senders.is_empty() {
583                            senders.push_str(", ");
584                        }
585                        senders.push_str(addr);
586                    }
587                    senders
588                })
589            }
590            (Some(Group(g)), _) if !g.is_empty() => {
591                g.iter().fold(String::new(), |mut senders, sender| {
592                    if let Some(ref name) = sender.name {
593                        if !senders.is_empty() {
594                            senders.push_str(", ");
595                        }
596                        senders.push_str(name);
597                    }
598                    senders
599                })
600            }
601            (_, Some(List(a))) if !a.is_empty() => {
602                a.iter().fold(String::new(), |mut senders, sender| {
603                    if let Some(name) = sender.name() {
604                        if !senders.is_empty() {
605                            senders.push_str(", ");
606                        }
607                        senders.push_str(name);
608                    } else if let Some(addr) = sender.address() {
609                        if !senders.is_empty() {
610                            senders.push_str(", ");
611                        }
612                        senders.push_str(addr);
613                    }
614                    senders
615                })
616            }
617            (_, Some(Group(g))) if !g.is_empty() => {
618                g.iter().fold(String::new(), |mut senders, sender| {
619                    if let Some(ref name) = sender.name {
620                        if !senders.is_empty() {
621                            senders.push_str(", ");
622                        }
623                        senders.push_str(name);
624                    }
625                    senders
626                })
627            }
628            _ => String::new(),
629        };
630
631        let fmt = self
632            .template
633            .as_ref()
634            .and_then(|c| c.reply.as_ref())
635            .and_then(|c| c.quote_headline_fmt.clone())
636            .unwrap_or_else(|| String::from("On %d/%m/%Y %H:%M, {senders} wrote:\n"));
637
638        Some(date.format(&fmt.replace("{senders}", &senders)).to_string())
639    }
640
641    pub fn get_forward_template_signature_style(&self) -> ForwardTemplateSignatureStyle {
642        self.template
643            .as_ref()
644            .and_then(|c| c.forward.as_ref())
645            .and_then(|c| c.signature_style.clone())
646            .unwrap_or_default()
647    }
648
649    pub fn get_forward_template_posting_style(&self) -> ForwardTemplatePostingStyle {
650        self.template
651            .as_ref()
652            .and_then(|c| c.forward.as_ref())
653            .and_then(|c| c.posting_style.clone())
654            .unwrap_or_default()
655    }
656
657    pub fn get_forward_template_quote_headline(&self) -> String {
658        self.template
659            .as_ref()
660            .and_then(|c| c.forward.as_ref())
661            .and_then(|c| c.quote_headline.clone())
662            .unwrap_or_else(|| String::from("-------- Forwarded Message --------\n"))
663    }
664}
665
666impl<'a> From<&'a AccountConfig> for Address<'a> {
667    fn from(config: &'a AccountConfig) -> Self {
668        Address::Address(EmailAddress {
669            name: config.display_name.as_ref().map(Into::into),
670            email: config.email.as_str().into(),
671        })
672    }
673}
674
675/// Rename duplicated file by adding a auto-incremented counter
676/// suffix.
677///
678/// Helper that check if the given file path already exists: if so,
679/// creates a new path with an auto-incremented integer suffix and
680/// returs it, otherwise returs the original file path.
681pub(crate) fn rename_file_if_duplicate(
682    origin_file_path: &Path,
683    is_file: impl Fn(&PathBuf, u8) -> bool,
684) -> Result<PathBuf> {
685    let mut count = 0;
686
687    let mut file_path = origin_file_path.to_owned();
688    let file_stem = origin_file_path.file_stem().and_then(OsStr::to_str);
689    let file_ext = origin_file_path
690        .extension()
691        .and_then(OsStr::to_str)
692        .map(|fext| String::from(".") + fext)
693        .unwrap_or_default();
694
695    while is_file(&file_path, count) {
696        count += 1;
697        file_path.set_file_name(
698            &file_stem
699                .map(|fstem| format!("{}_{}{}", fstem, count, file_ext))
700                .ok_or_else(|| Error::ParseDownloadFileNameError(file_path.to_owned()))?,
701        );
702    }
703
704    Ok(file_path)
705}
706
707#[cfg(test)]
708mod tests {
709    use std::path::PathBuf;
710
711    #[test]
712    fn rename_file_if_duplicate() {
713        let path = PathBuf::from("downloads/file.ext");
714
715        // when file path is unique
716        assert!(matches!(
717            super::rename_file_if_duplicate(&path, |_, _| false),
718            Ok(path) if path == PathBuf::from("downloads/file.ext")
719        ));
720
721        // when 1 file path already exist
722        assert!(matches!(
723            super::rename_file_if_duplicate(&path, |_, count| count <  1),
724            Ok(path) if path == PathBuf::from("downloads/file_1.ext")
725        ));
726
727        // when 5 file paths already exist
728        assert!(matches!(
729            super::rename_file_if_duplicate(&path, |_, count| count < 5),
730            Ok(path) if path == PathBuf::from("downloads/file_5.ext")
731        ));
732
733        // when file path has no extension
734        let path = PathBuf::from("downloads/file");
735        assert!(matches!(
736            super::rename_file_if_duplicate(&path, |_, count| count < 5),
737            Ok(path) if path == PathBuf::from("downloads/file_5")
738        ));
739
740        // when file path has 2 extensions
741        let path = PathBuf::from("downloads/file.ext.ext2");
742        assert!(matches!(
743            super::rename_file_if_duplicate(&path, |_, count| count < 5),
744            Ok(path) if path == PathBuf::from("downloads/file.ext_5.ext2")
745        ));
746    }
747}