tpnote_lib/
filename.rs

1//! Helper functions dealing with filenames.
2use crate::config::FILENAME_COPY_COUNTER_MAX;
3use crate::config::FILENAME_DOTFILE_MARKER;
4use crate::config::FILENAME_EXTENSION_SEPARATOR_DOT;
5use crate::config::FILENAME_LEN_MAX;
6use crate::config::LIB_CFG;
7use crate::error::FileError;
8use crate::markup_language::MarkupLanguage;
9use crate::settings::SETTINGS;
10use std::mem::swap;
11use std::path::Path;
12use std::path::PathBuf;
13use std::time::SystemTime;
14
15/// Extents `PathBuf` with methods dealing with paths to Tp-Note files.
16pub trait NotePathBuf {
17    /// Concatenates the `sort_tag`, `stem`, `copy_counter`, `.` and
18    /// `extension`.
19    /// This functions inserts all potentially necessary separators and
20    /// extra separators.
21    fn from_disassembled(
22        sort_tag: &str,
23        stem: &str,
24        copy_counter: Option<usize>,
25        extension: &str,
26    ) -> Self;
27    /// Append/increment a copy counter.
28    /// When the path `p` exists on disk already, append some extension
29    /// with an incrementing counter to the sort-tag in `p` until
30    /// we find a free unused filename.
31    /// ```rust
32    /// use std::env::temp_dir;
33    /// use std::fs;
34    /// use tpnote_lib::filename::NotePathBuf;
35    ///
36    /// // Prepare test: create existing note file.
37    /// let raw = "some content";
38    /// let mut notefile = temp_dir().join("20221101-My day--Note.md");
39    /// fs::write(&notefile, raw.as_bytes()).unwrap();
40    /// let expected = temp_dir().join("20221101-My day--Note(1).md");
41    /// let _ = fs::remove_file(&expected);
42    ///
43    /// // Start test
44    /// notefile.set_next_unused();
45    /// assert_eq!(notefile, expected);
46    /// ```
47    ///
48    /// When the filename is not used, keep it.
49    /// ```rust
50    /// use std::env::temp_dir;
51    /// use std::fs;
52    /// use tpnote_lib::filename::NotePathBuf;
53    ///
54    /// // Prepare test: make sure that there is no note file.
55    /// let mut notefile = temp_dir().join("20221102-My day--Note.md");
56    /// let _ = fs::remove_file(&notefile);
57    /// // The name should not change.
58    /// let expected = notefile.clone();
59    ///
60    /// // Start test
61    /// notefile.set_next_unused();
62    /// assert_eq!(notefile, expected);
63    /// ```
64    fn set_next_unused(&mut self) -> Result<(), FileError>;
65
66    /// Shortens the stem of a filename so that
67    /// `filename.len() <= FILENAME_LEN_MAX`.
68    /// This method assumes, that the file stem does not contain a copy
69    /// counter. If stem ends with a pattern similar to a copy counter,
70    /// it appends `-` to stem (cf. unit test in the source code).
71    ///
72    /// ```rust
73    /// use std::ffi::OsString;
74    /// use std::path::PathBuf;
75    /// use tpnote_lib::filename::NotePathBuf;
76    /// use tpnote_lib::config::FILENAME_LEN_MAX;
77    ///
78    /// // Test short filename.
79    /// let mut input = PathBuf::from("short filename.md");
80    /// input.shorten_filename();
81    /// let output = input;
82    /// assert_eq!(OsString::from("short filename.md"),
83    ///            output.into_os_string());
84    ///
85    /// // Test too long filename.
86    /// let mut input = String::from("some/path/");
87    /// for _ in 0..(FILENAME_LEN_MAX - "long fi.ext".len()-1) {
88    ///     input.push('x');
89    /// }
90    /// let mut expected = input.clone();
91    /// input.push_str("long filename to be cut.ext");
92    /// let mut input = PathBuf::from(input);
93    /// expected.push_str("long fi.ext");
94    ///
95    /// input.shorten_filename();
96    /// let output = PathBuf::from(input);
97    /// assert_eq!(PathBuf::from(expected), output);
98    /// ```
99    fn shorten_filename(&mut self);
100}
101
102impl NotePathBuf for PathBuf {
103    #[inline]
104    fn from_disassembled(
105        sort_tag: &str,
106        stem: &str,
107        copy_counter: Option<usize>,
108        extension: &str,
109    ) -> Self {
110        // Assemble path.
111        let mut filename = String::new();
112
113        // Add potential sort-tag and separators.
114        let scheme = &LIB_CFG.read_recursive().scheme[SETTINGS.read_recursive().current_scheme];
115
116        if !sort_tag.is_empty() {
117            filename.push_str(sort_tag);
118            filename.push_str(&scheme.filename.sort_tag.separator);
119        }
120        // Does the beginning of `stem` look like a sort-tag?
121        // Make sure, that the path cannot be misinterpreted, even if a
122        // `sort_tag.separator` would follow.
123        let mut test_path = String::from(stem);
124        test_path.push_str(&scheme.filename.sort_tag.separator);
125        // Do we need an `extra_separator`?
126        if stem.is_empty() || !&test_path.split_sort_tag(false).0.is_empty() {
127            filename.push(scheme.filename.sort_tag.extra_separator);
128        }
129
130        filename.push_str(stem);
131
132        if let Some(cc) = copy_counter {
133            // Is `copy_counter.extra_separator` necessary?
134            // Does this stem ending look similar to a copy counter?
135            if stem.split_copy_counter().1.is_some() {
136                // Add an additional separator.
137                filename.push_str(&scheme.filename.copy_counter.extra_separator);
138            };
139
140            filename.push_str(&scheme.filename.copy_counter.opening_brackets);
141            filename.push_str(&cc.to_string());
142            filename.push_str(&scheme.filename.copy_counter.closing_brackets);
143        }
144
145        if !extension.is_empty() {
146            filename.push(FILENAME_EXTENSION_SEPARATOR_DOT);
147            filename.push_str(extension);
148        };
149        PathBuf::from(filename)
150    }
151
152    fn set_next_unused(&mut self) -> Result<(), FileError> {
153        if !&self.exists() {
154            return Ok(());
155        };
156
157        let (sort_tag, _, stem, _copy_counter, ext) = self.disassemble();
158
159        let mut new_path = self.clone();
160
161        // Try up to 99 sort tag extensions, then give up.
162        for copy_counter in 1..FILENAME_COPY_COUNTER_MAX {
163            let filename = Self::from_disassembled(sort_tag, stem, Some(copy_counter), ext);
164            new_path.set_file_name(filename);
165
166            if !new_path.exists() {
167                break;
168            }
169        }
170
171        // This only happens, when we have 99 copies already. Should never happen.
172        if new_path.exists() {
173            return Err(FileError::NoFreeFileName {
174                directory: self.parent().unwrap_or_else(|| Path::new("")).to_path_buf(),
175            });
176        }
177        swap(self, &mut new_path);
178        Ok(())
179    }
180
181    fn shorten_filename(&mut self) {
182        // Determine length of file-extension.
183        let stem = self
184            .file_stem()
185            .unwrap_or_default()
186            .to_str()
187            .unwrap_or_default();
188        let ext = self
189            .extension()
190            .unwrap_or_default()
191            .to_str()
192            .unwrap_or_default();
193        let ext_len = ext.len();
194
195        // Limit the size of the filename.
196        let mut stem_short = String::new();
197        // `+1` reserves one byte for `.` before the extension.
198        // `+1` reserves one byte for `-` a potential copy counter extra
199        // separator.
200        for i in (0..FILENAME_LEN_MAX - (ext_len + 2)).rev() {
201            if let Some(s) = stem.get(..=i) {
202                stem_short = s.to_string();
203                break;
204            }
205        }
206
207        // Does this ending look like a copy counter?
208        if stem_short.split_copy_counter().1.is_some() {
209            let scheme = &LIB_CFG.read_recursive().scheme[SETTINGS.read_recursive().current_scheme];
210
211            stem_short.push_str(&scheme.filename.copy_counter.extra_separator);
212        }
213
214        // Assemble.
215        let mut note_filename = stem_short;
216        if !ext.is_empty() {
217            note_filename.push(FILENAME_DOTFILE_MARKER);
218            note_filename.push_str(ext);
219        }
220        // Replace filename`
221        self.set_file_name(note_filename);
222    }
223}
224
225/// Extents `Path` with methods dealing with paths to Tp-Note files.
226pub trait NotePath {
227    /// Helper function that decomposes a fully qualified path name
228    /// into (`sort_tag`, `stem_copy_counter_ext`, `stem`, `copy_counter`, `ext`).
229    /// All sort-tag separators and copy-counter separators/brackets are removed.
230    fn disassemble(&self) -> (&str, &str, &str, Option<usize>, &str);
231
232    /// Compares with another `Path` to a Tp-Note file. They are considered equal
233    /// even when the copy counter is different.
234    fn exclude_copy_counter_eq(&self, p2: &Path) -> bool;
235
236    /// Compare to all file extensions Tp-Note can open.
237    fn has_tpnote_ext(&self) -> bool;
238
239    /// Check if a `Path` points to a file with a "well-formed" filename.
240    fn has_wellformed_filename(&self) -> bool;
241
242    /// Get the filename of the last created Tp-Note file in the directory
243    /// `self`. If more files have the same creation date, choose the
244    /// lexicographical last sort-tag in the current directory. Files without
245    /// sort tag are ignored.
246    /// <https://doc.rust-lang.org/std/cmp/trait.Ord.html#lexicographical-comparison>
247    fn find_last_created_file(&self) -> Option<String>;
248
249    /// Checks if the directory in `self` has a Tp-Note file starting with the
250    /// `sort_tag`. If found, return the filename, otherwise `None`
251    fn has_file_with_sort_tag(&self, sort_tag: &str) -> Option<String>;
252
253    /// A method that searches the directory in `self` for a Tp-Note
254    /// file with the sort-tag `sort_tag`. It returns the filename.
255    fn find_file_with_sort_tag(&self, sort_tag: &str) -> Option<PathBuf>;
256}
257
258impl NotePath for Path {
259    fn disassemble(&self) -> (&str, &str, &str, Option<usize>, &str) {
260        let sort_tag_stem_copy_counter_ext = self
261            .file_name()
262            .unwrap_or_default()
263            .to_str()
264            .unwrap_or_default();
265
266        let (sort_tag, stem_copy_counter_ext, _) =
267            sort_tag_stem_copy_counter_ext.split_sort_tag(false);
268
269        let ext = Path::new(stem_copy_counter_ext)
270            .extension()
271            .unwrap_or_default()
272            .to_str()
273            .unwrap_or_default(); // Trim `sort_tag`.
274
275        let (stem_copy_counter, ext) = if !ext.is_empty()
276            && ext.chars().all(|c| c.is_alphanumeric())
277        {
278            (
279                // This is a little faster than `stem_copy_counter_ext.file_stem()`.
280                &stem_copy_counter_ext[..stem_copy_counter_ext.len().saturating_sub(ext.len() + 1)],
281                // `ext` is Ok, we keep it.
282                ext,
283            )
284        } else {
285            (stem_copy_counter_ext, "")
286        };
287
288        let (stem, copy_counter) = stem_copy_counter.split_copy_counter();
289
290        (sort_tag, stem_copy_counter_ext, stem, copy_counter, ext)
291    }
292
293    /// Check if 2 filenames are equal. Compare all parts, except the copy counter.
294    /// Consider 2 file identical even when they have a different copy counter.
295    fn exclude_copy_counter_eq(&self, p2: &Path) -> bool {
296        let (sort_tag1, _, stem1, _, ext1) = self.disassemble();
297        let (sort_tag2, _, stem2, _, ext2) = p2.disassemble();
298        sort_tag1 == sort_tag2 && stem1 == stem2 && ext1 == ext2
299    }
300
301    /// Returns `True` if the path in `self` ends with an extension, that Tp-
302    /// Note considers as it's own file. To do so the extension is compared
303    /// to all items in the registered `filename.extensions` table in the
304    /// configuration file.
305    fn has_tpnote_ext(&self) -> bool {
306        MarkupLanguage::from(self).is_some()
307    }
308
309    /// Check if a `path` points to a file with a
310    /// "well formed" filename.
311    /// We consider it well formed,
312    /// * if the filename is not empty, and
313    ///   * if the filename is a dot file (len >1 and without whitespace), or
314    ///   * if the filename has an extension.
315    ///
316    /// A valid extension must not contain whitespace.
317    ///
318    /// ```rust
319    /// use std::path::Path;
320    /// use tpnote_lib::filename::NotePath;
321    ///
322    /// let f = Path::new("tpnote.toml");
323    /// assert!(f.has_wellformed_filename());
324    ///
325    /// let f = Path::new("dir/tpnote.toml");
326    /// assert!(f.has_wellformed_filename());
327    ///
328    /// let f = Path::new("tpnote.to ml");
329    /// assert!(!f.has_wellformed_filename());
330    /// ```
331    fn has_wellformed_filename(&self) -> bool {
332        let filename = &self.file_name().unwrap_or_default();
333        let ext = self
334            .extension()
335            .unwrap_or_default()
336            .to_str()
337            .unwrap_or_default();
338
339        let is_filename = !filename.is_empty();
340
341        let filename = filename.to_str().unwrap_or_default();
342        let is_dot_file = filename.starts_with(FILENAME_DOTFILE_MARKER)
343            // We consider only dot files without whitespace.
344            && (filename == filename.trim())
345            && filename.split_whitespace().count() == 1;
346
347        let has_extension = !ext.is_empty()
348            // Only accept extensions with alphanumeric characters.
349            && ext.chars().all(|c| c.is_ascii_alphanumeric());
350
351        is_filename && (is_dot_file || has_extension)
352    }
353
354    fn find_last_created_file(&self) -> Option<String> {
355        if let Ok(files) = self.read_dir() {
356            // If more than one file starts with `sort_tag`, retain the
357            // alphabetic first.
358            let mut filename_max = String::new();
359            let mut ctime_max = SystemTime::UNIX_EPOCH;
360            for file in files.flatten() {
361                match file.file_type() {
362                    Ok(ft) if ft.is_file() => {}
363                    _ => continue,
364                }
365                let ctime = file
366                    .metadata()
367                    .ok()
368                    .and_then(|md| md.created().ok())
369                    .unwrap_or(SystemTime::UNIX_EPOCH);
370                let filename = file.file_name();
371                let filename = filename.to_str().unwrap();
372                if filename.is_empty() || !filename.has_tpnote_ext() {
373                    continue;
374                }
375
376                if ctime > ctime_max
377                    || (ctime == ctime_max
378                        && filename.split_sort_tag(false).0 > filename_max.split_sort_tag(false).0)
379                {
380                    filename_max = filename.to_string();
381                    ctime_max = ctime;
382                }
383            } // End of loop.
384            // Found, return result
385            if !filename_max.is_empty() {
386                Some(filename_max.to_string())
387            } else {
388                None
389            }
390        } else {
391            None
392        }
393    }
394
395    fn has_file_with_sort_tag(&self, sort_tag: &str) -> Option<String> {
396        if let Ok(files) = self.read_dir() {
397            for file in files.flatten() {
398                match file.file_type() {
399                    Ok(ft) if ft.is_file() => {}
400                    _ => continue,
401                }
402                let filename = file.file_name();
403                let filename = filename.to_str().unwrap();
404
405                // Tests in the order of the cost.
406                if filename.starts_with(sort_tag)
407                    && filename.has_tpnote_ext()
408                    && filename.split_sort_tag(false).0 == sort_tag
409                {
410                    let filename = filename.to_string();
411                    return Some(filename);
412                }
413            }
414        }
415        None
416    }
417
418    fn find_file_with_sort_tag(&self, sort_tag: &str) -> Option<PathBuf> {
419        let mut found = None;
420
421        if let Ok(files) = self.read_dir() {
422            // If more than one file starts with `sort_tag`, retain the
423            // alphabetic first.
424            let mut minimum = PathBuf::new();
425            'file_loop: for file in files.flatten() {
426                match file.file_type() {
427                    Ok(ft) if ft.is_file() => {}
428                    _ => continue,
429                }
430                let file = file.path();
431                if !(*file).has_tpnote_ext() {
432                    continue 'file_loop;
433                }
434                // Does this sort-tag short link correspond to
435                // any sort-tag of a file in the same directory?
436                if file.disassemble().0 == sort_tag {
437                    // Before the first assignment `minimum` is empty.
438                    // Finds the minimum.
439                    if minimum == Path::new("") || minimum > file {
440                        minimum = file;
441                    }
442                }
443            } // End of loop.
444            if minimum != Path::new("") {
445                log::debug!(
446                    "File `{}` referenced by sort-tag match `{}`.",
447                    minimum.to_str().unwrap_or_default(),
448                    sort_tag,
449                );
450                // Found, return result
451                found = Some(minimum)
452            }
453        }
454        found
455    }
456}
457
458/// Some private helper functions related to note filenames.
459pub(crate) trait NotePathStr {
460    /// Returns `True` is the path in `self` ends with an extension, that
461    /// registered as Tp-Note extension in `filename.extensions`.
462    /// The input may contain a path as long as it ends with a filename.
463    fn has_tpnote_ext(&self) -> bool;
464
465    /// Helper function that expects a filename in `self`, matches the copy
466    /// counter at the end of string and returns the result and the copy
467    /// counter.
468    /// This function removes all brackets and a potential extra separator.
469    /// The input must not contain a path, only a filename is allowed here.
470    fn split_copy_counter(&self) -> (&str, Option<usize>);
471
472    /// Helper function that expects a filename in `self`:
473    /// Greedily match sort tag chars and return it as a subslice as first tuple
474    /// and the rest as second tuple: `(sort-tag, rest, is_sequential)`.
475    /// The input must not contain a path, only a filename is allowed here.
476    /// If `filename.sort_tag.separator` is defined, it must appear after the
477    /// sort-tag (without being part of it). Otherwise the sort-tag is discarded.
478    /// A sort-tag cannot contain more than
479    /// `FILENAME_SORT_TAG_LETTERS_IN_SUCCESSION_MAX` lowercase letters in a row.
480    /// If `ignore_sort_tag_separator=true` this split runs with the setting
481    /// `filename_sort_tag_separator=""`.
482    /// If the boolean return value is true, the sort-tag satisfies the
483    /// criteria for a sequential sort-tag.
484    fn split_sort_tag(&self, ignore_sort_tag_separator: bool) -> (&str, &str, bool);
485
486    /// Check and return the filename in `self`, if it contains only
487    /// `lib_cfg.filename.sort_tag.extra_chars` (no sort-tag separator, no file
488    /// stem, no extension). The number of lowercase letters in a row must not
489    /// exceed `filename.sort_tag.letters_in_succession_max`.
490    /// The input may contain a path as long as it ends with `/`, `\\` or a
491    /// filename. The path, if present, it is ignored.
492    fn is_valid_sort_tag(&self) -> Option<&str>;
493}
494
495impl NotePathStr for str {
496    fn has_tpnote_ext(&self) -> bool {
497        MarkupLanguage::from(Path::new(self)).is_some()
498    }
499
500    #[inline]
501    fn split_copy_counter(&self) -> (&str, Option<usize>) {
502        let scheme = &LIB_CFG.read_recursive().scheme[SETTINGS.read_recursive().current_scheme];
503        // Strip closing brackets at the end.
504        let tag1 =
505            if let Some(t) = self.strip_suffix(&scheme.filename.copy_counter.closing_brackets) {
506                t
507            } else {
508                return (self, None);
509            };
510        // Now strip numbers.
511        let tag2 = tag1.trim_end_matches(|c: char| c.is_numeric());
512        let copy_counter: Option<usize> = if tag2.len() < tag1.len() {
513            tag1[tag2.len()..].parse().ok()
514        } else {
515            return (self, None);
516        };
517        // And finally strip starting bracket.
518        let tag3 =
519            if let Some(t) = tag2.strip_suffix(&scheme.filename.copy_counter.opening_brackets) {
520                t
521            } else {
522                return (self, None);
523            };
524        // This is optional
525        if let Some(t) = tag3.strip_suffix(&scheme.filename.copy_counter.extra_separator) {
526            (t, copy_counter)
527        } else {
528            (tag3, copy_counter)
529        }
530    }
531
532    fn split_sort_tag(&self, ignore_sort_tag_separator: bool) -> (&str, &str, bool) {
533        let scheme = &LIB_CFG.read_recursive().scheme[SETTINGS.read_recursive().current_scheme];
534
535        let mut is_sequential_sort_tag = true;
536
537        let mut digits: u8 = 0;
538        let mut letters: u8 = 0;
539        let mut sort_tag = &self[..self
540            .chars()
541            .take_while(|&c| {
542                if c.is_ascii_digit() {
543                    digits += 1;
544                    if digits > scheme.filename.sort_tag.sequential.digits_in_succession_max {
545                        is_sequential_sort_tag = false;
546                    }
547                } else {
548                    digits = 0;
549                }
550
551                if c.is_ascii_lowercase() {
552                    letters += 1;
553                } else {
554                    letters = 0;
555                }
556
557                letters <= scheme.filename.sort_tag.letters_in_succession_max
558                    && (c.is_ascii_digit()
559                        || c.is_ascii_lowercase()
560                        || scheme.filename.sort_tag.extra_chars.contains([c]))
561            })
562            .count()];
563
564        let mut stem_copy_counter_ext;
565        if scheme.filename.sort_tag.separator.is_empty() || ignore_sort_tag_separator {
566            // `sort_tag` is correct.
567            stem_copy_counter_ext = &self[sort_tag.len()..];
568        } else {
569            // Take `sort_tag.separator` into account.
570            if let Some(i) = sort_tag.rfind(&scheme.filename.sort_tag.separator) {
571                sort_tag = &sort_tag[..i];
572                stem_copy_counter_ext = &self[i + scheme.filename.sort_tag.separator.len()..];
573            } else {
574                sort_tag = "";
575                stem_copy_counter_ext = self;
576            }
577        }
578
579        // Remove `sort_tag.extra_separator` if it is at the first position
580        // followed by a `sort_tag_char` at the second position.
581        let mut chars = stem_copy_counter_ext.chars();
582        if chars
583            .next()
584            .is_some_and(|c| c == scheme.filename.sort_tag.extra_separator)
585            && chars.next().is_some_and(|c| {
586                c.is_ascii_digit()
587                    || c.is_ascii_lowercase()
588                    || scheme.filename.sort_tag.extra_chars.contains(c)
589            })
590        {
591            stem_copy_counter_ext = stem_copy_counter_ext
592                .strip_prefix(scheme.filename.sort_tag.extra_separator)
593                .unwrap();
594        }
595
596        (sort_tag, stem_copy_counter_ext, is_sequential_sort_tag)
597    }
598
599    fn is_valid_sort_tag(&self) -> Option<&str> {
600        let filename = if let Some((_, filename)) = self.rsplit_once(['\\', '/']) {
601            filename
602        } else {
603            self
604        };
605        if filename.is_empty() {
606            return None;
607        }
608
609        // If the rest is empty, all characters are in `sort_tag`.
610        if filename.split_sort_tag(true).1.is_empty() {
611            Some(filename)
612        } else {
613            None
614        }
615    }
616}
617
618/// A trait that interprets the implementing type as filename extension.
619pub(crate) trait Extension {
620    /// Returns `True` if `self` is equal to one of the Tp-Note extensions
621    /// registered in the configuration file `filename.extensions` table.
622    fn is_tpnote_ext(&self) -> bool;
623}
624
625impl Extension for str {
626    fn is_tpnote_ext(&self) -> bool {
627        MarkupLanguage::from(self).is_some()
628    }
629}
630
631#[cfg(test)]
632mod tests {
633    use std::ffi::OsString;
634    use std::path::Path;
635    use std::path::PathBuf;
636
637    #[test]
638    fn test_from_disassembled() {
639        use crate::filename::NotePathBuf;
640
641        let expected = PathBuf::from("My_file.md");
642        let result = PathBuf::from_disassembled("", "My_file", None, "md");
643        assert_eq!(expected, result);
644
645        let expected = PathBuf::from("1_2_3-My_file(1).md");
646        let result = PathBuf::from_disassembled("1_2_3", "My_file", Some(1), "md");
647        assert_eq!(expected, result);
648
649        let expected = PathBuf::from("1_2_3-123 my_file(1).md");
650        let result = PathBuf::from_disassembled("1_2_3", "123 my_file", Some(1), "md");
651        assert_eq!(expected, result);
652
653        let expected = PathBuf::from("1_2_3-'123-My_file(1).md");
654        let result = PathBuf::from_disassembled("1_2_3", "123-My_file", Some(1), "md");
655        assert_eq!(expected, result);
656
657        let expected = PathBuf::from("'123-My_file(1).md");
658        let result = PathBuf::from_disassembled("", "123-My_file", Some(1), "md");
659        assert_eq!(expected, result);
660
661        let res = PathBuf::from_disassembled("1234", "title--subtitle", Some(9), "md");
662        assert_eq!(res, Path::new("1234-title--subtitle(9).md"));
663
664        let res = PathBuf::from_disassembled("1234ab", "title--subtitle", Some(9), "md");
665        assert_eq!(res, Path::new("1234ab-title--subtitle(9).md"));
666
667        let res = PathBuf::from_disassembled("1234", "5678", Some(9), "md");
668        assert_eq!(res, Path::new("1234-'5678(9).md"));
669
670        let res = PathBuf::from_disassembled("1234", "5678--subtitle", Some(9), "md");
671        assert_eq!(res, Path::new("1234-'5678--subtitle(9).md"));
672
673        let res = PathBuf::from_disassembled("1234", "", None, "md");
674        assert_eq!(res, Path::new("1234-'.md"));
675
676        // This is a special case, that cannot be disassembled properly.
677        let res = PathBuf::from_disassembled("1234", "'5678--subtitle", Some(9), "md");
678        assert_eq!(res, Path::new("1234-'5678--subtitle(9).md"));
679
680        let res = PathBuf::from_disassembled("", "-", Some(9), "md");
681        assert_eq!(res, Path::new("'-(9).md"));
682
683        let res = PathBuf::from_disassembled("", "(1)", Some(9), "md");
684        assert_eq!(res, Path::new("(1)-(9).md"));
685
686        // This is a special case, that cannot be disassembled properly.
687        let res = PathBuf::from_disassembled("", "(1)-", Some(9), "md");
688        assert_eq!(res, Path::new("(1)-(9).md"));
689    }
690
691    #[test]
692    fn test_set_next_unused() {
693        use crate::filename::NotePathBuf;
694
695        use std::env::temp_dir;
696        use std::fs;
697
698        let raw = "This simulates a non tp-note file";
699        let mut notefile = temp_dir().join("20221030-some.pdf--Note.md");
700        fs::write(&notefile, raw.as_bytes()).unwrap();
701
702        notefile.set_next_unused().unwrap();
703        let expected = temp_dir().join("20221030-some.pdf--Note(1).md");
704        assert_eq!(notefile, expected);
705        let _ = fs::remove_file(notefile);
706    }
707
708    #[test]
709    fn test_shorten_filename() {
710        use crate::config::FILENAME_LEN_MAX;
711        use crate::filename::NotePathBuf;
712
713        // Test a short filename with a problematic file stem ending looking
714        // like a copy counter pattern. Therefor the method appends `-`.
715        let mut input = PathBuf::from("fn(1).md");
716        let expected = PathBuf::from("fn(1)-.md");
717        // As this filename is too short, `shorten_filename()` should not change
718        // anything.
719        input.shorten_filename();
720        let output = input;
721        assert_eq!(OsString::from(expected), output);
722
723        //
724        // Test if assembled correctly.
725        let mut input = PathBuf::from("20221030-some.pdf--Note.md");
726        let expected = input.clone();
727        input.shorten_filename();
728        let output = input;
729        assert_eq!(OsString::from(expected), output);
730
731        //
732        // Test long filename.
733        let mut input = "X".repeat(FILENAME_LEN_MAX + 10);
734        input.push_str(".ext");
735
736        let mut expected = "X".repeat(FILENAME_LEN_MAX - ".ext".len() - 1);
737        expected.push_str(".ext");
738
739        let mut input = PathBuf::from(input);
740        input.shorten_filename();
741        let output = input;
742        assert_eq!(OsString::from(expected), output);
743    }
744
745    #[test]
746    fn test_disassemble_filename() {
747        use crate::filename::NotePath;
748
749        let expected = (
750            "1_2_3",
751            "my_title--my_subtitle(1).md",
752            "my_title--my_subtitle",
753            Some(1),
754            "md",
755        );
756        let p = Path::new("/my/dir/1_2_3-my_title--my_subtitle(1).md");
757        let result = p.disassemble();
758        assert_eq!(result, expected);
759
760        let expected = (
761            "1_2_3",
762            "my_title--my_subtitle(1)-(9).md",
763            "my_title--my_subtitle(1)",
764            Some(9),
765            "md",
766        );
767        let p = Path::new("/my/dir/1_2_3-my_title--my_subtitle(1)-(9).md");
768        let result = p.disassemble();
769        assert_eq!(result, expected);
770
771        let expected = (
772            "2021.04.12",
773            "my_title--my_subtitle(1).md",
774            "my_title--my_subtitle",
775            Some(1),
776            "md",
777        );
778        let p = Path::new("/my/dir/2021.04.12-my_title--my_subtitle(1).md");
779        let result = p.disassemble();
780        assert_eq!(result, expected);
781
782        let expected = (
783            "",
784            "2021 04 12 my_title--my_subtitle(1).md",
785            "2021 04 12 my_title--my_subtitle",
786            Some(1),
787            "md",
788        );
789        let p = Path::new("/my/dir/2021 04 12 my_title--my_subtitle(1).md");
790        let result = p.disassemble();
791        assert_eq!(result, expected);
792
793        let expected = ("2021-04-12", "", "", None, "");
794        let p = Path::new("/my/dir/2021-04-12-");
795        let result = p.disassemble();
796        assert_eq!(result, expected);
797
798        // This triggers the bug fixed with v1.14.3.
799        let expected = ("2021-04-12", ".dotfile", ".dotfile", None, "");
800        let p = Path::new("/my/dir/2021-04-12-'.dotfile");
801        let result = p.disassemble();
802        assert_eq!(result, expected);
803
804        let expected = ("2021-04-12", "(9).md", "", Some(9), "md");
805        let p = Path::new("/my/dir/2021-04-12-(9).md");
806        let result = p.disassemble();
807        assert_eq!(result, expected);
808
809        let expected = (
810            "20221030",
811            "Some.pdf--Note.md",
812            "Some.pdf--Note",
813            None,
814            "md",
815        );
816        let p = Path::new("/my/dir/20221030-Some.pdf--Note.md");
817        let result = p.disassemble();
818        assert_eq!(result, expected);
819
820        let expected = (
821            "1_2_3",
822            "my_title--my_subtitle(1).md",
823            "my_title--my_subtitle",
824            Some(1),
825            "md",
826        );
827        let p = Path::new("/my/dir/1_2_3-my_title--my_subtitle(1).md");
828        let result = p.disassemble();
829        assert_eq!(result, expected);
830
831        let expected = (
832            "1_2_3",
833            "123 my_title--my_subtitle(1).md",
834            "123 my_title--my_subtitle",
835            Some(1),
836            "md",
837        );
838        let p = Path::new("/my/dir/1_2_3-123 my_title--my_subtitle(1).md");
839        let result = p.disassemble();
840        assert_eq!(result, expected);
841
842        let expected = (
843            "1_2_3-123",
844            "My_title--my_subtitle(1).md",
845            "My_title--my_subtitle",
846            Some(1),
847            "md",
848        );
849        let p = Path::new("/my/dir/1_2_3-123-My_title--my_subtitle(1).md");
850        let result = p.disassemble();
851        assert_eq!(result, expected);
852
853        let expected = (
854            "1_2_3",
855            "123-my_title--my_subtitle(1).md",
856            "123-my_title--my_subtitle",
857            Some(1),
858            "md",
859        );
860        let p = Path::new("/my/dir/1_2_3-'123-my_title--my_subtitle(1).md");
861        let result = p.disassemble();
862        assert_eq!(result, expected);
863
864        let expected = (
865            "1_2_3",
866            "123 my_title--my_subtitle(1).md",
867            "123 my_title--my_subtitle",
868            Some(1),
869            "md",
870        );
871        let p = Path::new("/my/dir/1_2_3-123 my_title--my_subtitle(1).md");
872        let result = p.disassemble();
873        assert_eq!(result, expected);
874
875        let expected = (
876            "1_2_3",
877            "my_title--my_subtitle(1).md",
878            "my_title--my_subtitle",
879            Some(1),
880            "md",
881        );
882        let p = Path::new("/my/dir/1_2_3-my_title--my_subtitle(1).md");
883        let result = p.disassemble();
884        assert_eq!(expected, result);
885
886        let expected = (
887            "1a2b3ab",
888            "my_title--my_subtitle(1).md",
889            "my_title--my_subtitle",
890            Some(1),
891            "md",
892        );
893        let p = Path::new("/my/dir/1a2b3ab-my_title--my_subtitle(1).md");
894        let result = p.disassemble();
895        assert_eq!(expected, result);
896
897        let expected = (
898            "",
899            "1a2b3abc-my_title--my_subtitle(1).md",
900            "1a2b3abc-my_title--my_subtitle",
901            Some(1),
902            "md",
903        );
904        let p = Path::new("/my/dir/1a2b3abc-my_title--my_subtitle(1).md");
905        let result = p.disassemble();
906        assert_eq!(result, expected);
907
908        let expected = (
909            "1_2_3",
910            "my_title--my_subtitle(1).m d",
911            "my_title--my_subtitle(1).m d",
912            None,
913            "",
914        );
915        let p = Path::new("/my/dir/1_2_3-my_title--my_subtitle(1).m d");
916        let result = p.disassemble();
917        assert_eq!(result, expected);
918
919        let expected = (
920            "1_2_3",
921            "my_title--my_subtitle(1)",
922            "my_title--my_subtitle",
923            Some(1),
924            "",
925        );
926        let p = Path::new("/my/dir/1_2_3-my_title--my_subtitle(1)");
927        let result = p.disassemble();
928        assert_eq!(result, expected);
929    }
930
931    #[test]
932    fn test_exclude_copy_counter_eq() {
933        use crate::filename::NotePath;
934
935        let p1 = PathBuf::from("/mypath/123-title(1).md");
936        let p2 = PathBuf::from("/mypath/123-title(3).md");
937        let expected = true;
938        let result = Path::exclude_copy_counter_eq(&p1, &p2);
939        assert_eq!(expected, result);
940
941        let p1 = PathBuf::from("/mypath/123-title(1).md");
942        let p2 = PathBuf::from("/mypath/123-titlX(3).md");
943        let expected = false;
944        let result = Path::exclude_copy_counter_eq(&p1, &p2);
945        assert_eq!(expected, result);
946    }
947
948    #[test]
949    fn test_note_path_has_tpnote_ext() {
950        use crate::filename::NotePath;
951
952        //
953        let path = Path::new("/dir/file.md");
954        assert!(path.has_tpnote_ext());
955
956        //
957        let path = Path::new("/dir/file.abc");
958        assert!(!path.has_tpnote_ext());
959
960        // This goes wrong because a file path or at least a filename is
961        // expected here.
962        let path = Path::new("md");
963        assert!(!path.has_tpnote_ext());
964    }
965
966    #[test]
967    fn test_has_wellformed_filename() {
968        use crate::filename::NotePath;
969        use std::path::Path;
970
971        // Test long filename.
972        assert!(&Path::new("long filename.ext").has_wellformed_filename());
973
974        // Test long file path.
975        assert!(&Path::new("long directory name/long filename.ext").has_wellformed_filename());
976
977        // Test dot file
978        assert!(&Path::new(".dotfile").has_wellformed_filename());
979
980        // Test dot file with extension.
981        assert!(&Path::new(".dotfile.ext").has_wellformed_filename());
982
983        // Test dot file with whitespace, this fails.
984        assert!(!&Path::new(".dot file").has_wellformed_filename());
985
986        // Test space in ext, this fails.
987        assert!(!&Path::new("filename.e xt").has_wellformed_filename());
988
989        // Test space in ext, this fails.
990        assert!(!&Path::new("filename. ext").has_wellformed_filename());
991
992        // Test space in ext, this fails.
993        assert!(!&Path::new("filename.ext ").has_wellformed_filename());
994
995        // Test path.
996        assert!(&Path::new("/path/to/filename.ext").has_wellformed_filename());
997    }
998
999    #[test]
1000    fn test_trim_copy_counter() {
1001        use crate::filename::NotePathStr;
1002
1003        // Pattern found and removed.
1004        let expected = ("my_stem", Some(78));
1005        let result = "my_stem(78)".split_copy_counter();
1006        assert_eq!(expected, result);
1007
1008        // Pattern found and removed.
1009        let expected = ("my_stem", Some(78));
1010        let result = "my_stem-(78)".split_copy_counter();
1011        assert_eq!(expected, result);
1012
1013        // Pattern found and removed.
1014        let expected = ("my_stem_", Some(78));
1015        let result = "my_stem_(78)".split_copy_counter();
1016        assert_eq!(expected, result);
1017
1018        // Pattern not found.
1019        assert_eq!(expected, result);
1020        let expected = ("my_stem_(78))", None);
1021        let result = "my_stem_(78))".split_copy_counter();
1022        assert_eq!(expected, result);
1023
1024        // Pattern not found.
1025        let expected = ("my_stem_)78)", None);
1026        let result = "my_stem_)78)".split_copy_counter();
1027        assert_eq!(expected, result);
1028    }
1029
1030    #[test]
1031    fn test_split_sort_tag() {
1032        use crate::filename::NotePathStr;
1033
1034        let expected = ("123", "", true);
1035        let result = "123".split_sort_tag(true);
1036        assert_eq!(expected, result);
1037
1038        let expected = ("123", "Rest", true);
1039        let result = "123-Rest".split_sort_tag(false);
1040        assert_eq!(expected, result);
1041
1042        let expected = ("2023-10-30", "Rest", false);
1043        let result = "2023-10-30-Rest".split_sort_tag(false);
1044        assert_eq!(expected, result);
1045    }
1046
1047    #[test]
1048    fn test_note_path_str_has_tpnote() {
1049        use crate::filename::NotePathStr;
1050
1051        //
1052        let path_str = "/dir/file.md";
1053        assert!(path_str.has_tpnote_ext());
1054
1055        //
1056        let path_str = "/dir/file.abc";
1057        assert!(!path_str.has_tpnote_ext());
1058    }
1059
1060    #[test]
1061    fn test_is_tpnote_ext() {
1062        use crate::filename::Extension;
1063        //
1064        let ext = "md";
1065        assert!(ext.is_tpnote_ext());
1066
1067        // This goes wrong because only `md` is expected here.
1068        let ext = "/dir/file.md";
1069        assert!(!ext.is_tpnote_ext());
1070    }
1071
1072    #[test]
1073    fn test_filename_is_valid_sort_tag() {
1074        use super::NotePathStr;
1075        let f = "20230821";
1076        assert_eq!(f.is_valid_sort_tag(), Some("20230821"));
1077
1078        let f = "dir/20230821";
1079        assert_eq!(f.is_valid_sort_tag(), Some("20230821"));
1080
1081        let f = "dir\\20230821";
1082        assert_eq!(f.is_valid_sort_tag(), Some("20230821"));
1083
1084        let f = "1_3_2";
1085        assert_eq!(f.is_valid_sort_tag(), Some("1_3_2"));
1086
1087        let f = "1c2";
1088        assert_eq!(f.is_valid_sort_tag(), Some("1c2"));
1089
1090        let f = "2023ab";
1091        assert_eq!(f.is_valid_sort_tag(), Some("2023ab"));
1092
1093        let f = "2023abc";
1094        assert_eq!(f.is_valid_sort_tag(), None);
1095
1096        let f = "dir/2023abc";
1097        assert_eq!(f.is_valid_sort_tag(), None);
1098
1099        let f = "2023A";
1100        assert_eq!(f.is_valid_sort_tag(), None);
1101
1102        let f = "20230821";
1103        assert_eq!(f.is_valid_sort_tag(), Some("20230821"));
1104
1105        let f = "2023-08-21";
1106        assert_eq!(f.is_valid_sort_tag(), Some("2023-08-21"));
1107
1108        let f = "20-08-21";
1109        assert_eq!(f.is_valid_sort_tag(), Some("20-08-21"));
1110
1111        let f = "2023ab";
1112        assert_eq!(f.is_valid_sort_tag(), Some("2023ab"));
1113
1114        let f = "202ab";
1115        assert_eq!(f.is_valid_sort_tag(), Some("202ab"));
1116    }
1117}