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