obsidian_parser/vault/vault_open/
mod.rs

1//! Module for open impl [`Vault`]
2
3pub mod options;
4
5use super::Vault;
6use crate::note::{Note, note_on_disk::NoteOnDisk};
7pub use options::VaultOptions;
8use serde::de::DeserializeOwned;
9use std::{
10    fmt::Debug,
11    path::{Path, PathBuf},
12};
13use walkdir::{DirEntry, WalkDir};
14
15type FilterEntry = dyn FnMut(&DirEntry) -> bool;
16
17/// Builder for [`Vault`]
18pub struct VaultBuilder<'a> {
19    options: &'a VaultOptions,
20    include_hidden: bool,
21    follow_links: bool,
22    follow_root_links: bool,
23    max_depth: Option<usize>,
24    min_depth: Option<usize>,
25    filter_entry: Option<Box<FilterEntry>>,
26}
27
28impl Debug for VaultBuilder<'_> {
29    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        f.debug_struct("VaultBuilder")
31            .field("options", self.options)
32            .finish()
33    }
34}
35
36impl PartialEq for VaultBuilder<'_> {
37    fn eq(&self, other: &Self) -> bool {
38        (
39            self.options,
40            self.include_hidden,
41            self.follow_links,
42            self.follow_root_links,
43            self.max_depth,
44            self.min_depth,
45            self.filter_entry.is_some(),
46        ) == (
47            other.options,
48            other.include_hidden,
49            other.follow_links,
50            other.follow_root_links,
51            other.max_depth,
52            other.min_depth,
53            other.filter_entry.is_some(),
54        )
55    }
56}
57
58impl Eq for VaultBuilder<'_> {}
59
60fn is_hidden(path: impl AsRef<Path>) -> bool {
61    path.as_ref()
62        .file_name()
63        .is_some_and(|e| e.to_str().is_some_and(|name| name.starts_with('.')))
64}
65
66fn is_md_file(path: impl AsRef<Path>) -> bool {
67    path.as_ref()
68        .extension()
69        .is_some_and(|p| p.eq_ignore_ascii_case("md"))
70}
71
72macro_rules! impl_setter {
73    ($name:ident, $t:ty) => {
74        #[must_use]
75        #[allow(missing_docs)]
76        pub const fn $name(mut self, $name: $t) -> Self {
77            self.$name = $name;
78            self
79        }
80    };
81}
82
83impl<'a> VaultBuilder<'a> {
84    /// Create default [`VaultBuilder`]
85    #[must_use]
86    pub const fn new(options: &'a VaultOptions) -> Self {
87        Self {
88            options,
89            include_hidden: false,
90            follow_links: false,
91            follow_root_links: true,
92            max_depth: None,
93            min_depth: None,
94            filter_entry: None,
95        }
96    }
97
98    impl_setter!(include_hidden, bool);
99    impl_setter!(follow_links, bool);
100    impl_setter!(follow_root_links, bool);
101
102    /// Set max depth
103    #[must_use]
104    pub const fn max_depth(mut self, max_depth: usize) -> Self {
105        self.max_depth = Some(max_depth);
106        self
107    }
108
109    /// Set min depth
110    #[must_use]
111    pub const fn min_depth(mut self, min_depth: usize) -> Self {
112        self.min_depth = Some(min_depth);
113        self
114    }
115
116    /// Set custom filter entry
117    #[must_use]
118    pub fn filter_entry<F>(mut self, f: F) -> Self
119    where
120        F: FnMut(&DirEntry) -> bool + 'static,
121    {
122        self.filter_entry = Some(Box::new(f));
123        self
124    }
125
126    fn ignored_hidden_files(include_hidden: bool, entry: &DirEntry) -> bool {
127        if !include_hidden && is_hidden(entry.path()) {
128            return false;
129        }
130
131        true
132    }
133
134    fn get_files_from_walkdir(self) -> impl Iterator<Item = PathBuf> {
135        let include_hidden = self.include_hidden;
136        let mut custom_filter_entry = self.filter_entry.unwrap_or_else(|| Box::new(|_| true));
137
138        WalkDir::new(self.options.path())
139            .follow_links(self.follow_links)
140            .follow_root_links(self.follow_root_links)
141            .max_depth(self.max_depth.unwrap_or(usize::MAX))
142            .min_depth(self.min_depth.unwrap_or(1))
143            .into_iter()
144            .filter_entry(move |entry| {
145                Self::ignored_hidden_files(include_hidden, entry) && custom_filter_entry(entry)
146            })
147            .filter_map(Result::ok)
148            .filter(|entry| entry.file_type().is_file())
149            .map(DirEntry::into_path)
150            .filter(|path| is_md_file(path))
151    }
152
153    /// Into [`VaultBuilder`] to iterator
154    #[allow(clippy::should_implement_trait)]
155    #[cfg(not(target_family = "wasm"))]
156    pub fn into_iter<F>(self) -> impl Iterator<Item = Result<F, F::Error>>
157    where
158        F: crate::note::note_read::NoteFromFile,
159        F::Properties: DeserializeOwned,
160        F::Error: From<std::io::Error>,
161    {
162        let files = self.get_files_from_walkdir();
163
164        files.map(|path| F::from_file(path))
165    }
166
167    /// Into [`VaultBuilder`] to parallel iterator
168    #[cfg_attr(docsrs, doc(cfg(feature = "rayon")))]
169    #[cfg(feature = "rayon")]
170    #[cfg(not(target_family = "wasm"))]
171    #[must_use]
172    pub fn into_par_iter<F>(self) -> impl rayon::iter::ParallelIterator<Item = Result<F, F::Error>>
173    where
174        F: crate::prelude::NoteFromFile + Send,
175        F::Properties: DeserializeOwned,
176        F::Error: From<std::io::Error> + Send,
177    {
178        use rayon::prelude::*;
179
180        let files: Vec<_> = self.get_files_from_walkdir().collect();
181        files.into_par_iter().map(|path| F::from_file(path))
182    }
183}
184
185impl<N> Vault<N>
186where
187    N: Note,
188{
189    #[cfg_attr(feature = "tracing", tracing::instrument(skip(notes), fields(count_notes = notes.len())))]
190    fn impl_build_vault(notes: Vec<N>, options: VaultOptions) -> Self {
191        #[cfg(feature = "tracing")]
192        tracing::debug!("Building vault...");
193
194        Self {
195            notes,
196            path: options.into_path(),
197        }
198    }
199
200    /// Build vault from iterator
201    pub fn build_vault(iter: impl Iterator<Item = N>, options: &VaultOptions) -> Self {
202        let notes: Vec<_> = iter.collect();
203
204        Self::impl_build_vault(notes, options.clone())
205    }
206
207    /// Build vault from parallel iterator
208    #[cfg_attr(docsrs, doc(cfg(feature = "rayon")))]
209    #[cfg(feature = "rayon")]
210    pub fn par_build_vault(
211        iter: impl rayon::iter::ParallelIterator<Item = N>,
212        options: &VaultOptions,
213    ) -> Self
214    where
215        N: Send,
216    {
217        let notes: Vec<_> = iter.collect();
218
219        Self::impl_build_vault(notes, options.clone())
220    }
221}
222
223/// Trait for build [`Vault`] from iterator
224pub trait IteratorVaultBuilder<N = NoteOnDisk>: Iterator<Item = N>
225where
226    Self: Sized,
227    N: Note,
228{
229    /// Build [`Vault`] from iterator
230    fn build_vault(self, options: &VaultOptions) -> Vault<N> {
231        Vault::build_vault(self, options)
232    }
233}
234
235impl<N, I> IteratorVaultBuilder<N> for I
236where
237    N: Note,
238    I: Iterator<Item = N>,
239{
240}
241
242/// Trait for build [`Vault`] from parallel iterator
243#[cfg_attr(docsrs, doc(cfg(feature = "rayon")))]
244#[cfg(feature = "rayon")]
245pub trait ParallelIteratorVaultBuilder<N = NoteOnDisk>:
246    rayon::iter::ParallelIterator<Item = N>
247where
248    N: Note + Send,
249{
250    /// Build [`Vault`] from parallel iterator
251    #[cfg_attr(docsrs, doc(cfg(feature = "rayon")))]
252    fn build_vault(self, options: &VaultOptions) -> Vault<N> {
253        Vault::par_build_vault(self, options)
254    }
255}
256
257#[cfg(feature = "rayon")]
258impl<F, I> ParallelIteratorVaultBuilder<F> for I
259where
260    F: Note + Send,
261    I: rayon::iter::ParallelIterator<Item = F>,
262{
263}
264
265#[cfg(test)]
266#[cfg(not(target_family = "wasm"))]
267mod tests {
268    use super::*;
269    use crate::note::note_in_memory;
270    use crate::prelude::NoteFromFile;
271    use crate::prelude::NoteInMemory;
272    use crate::vault::VaultInMemory;
273    use crate::vault::vault_test::create_files_for_vault;
274    use std::fs::File;
275    use std::io::Write;
276
277    fn impl_open<F>(path: impl AsRef<Path>) -> Vault<F>
278    where
279        F: NoteFromFile,
280        F::Error: From<std::io::Error>,
281        F::Properties: DeserializeOwned,
282    {
283        let options = VaultOptions::new(path);
284
285        VaultBuilder::new(&options)
286            .into_iter()
287            .map(|file| file.unwrap())
288            .build_vault(&options)
289    }
290
291    #[cfg(feature = "rayon")]
292    fn impl_par_open<F>(path: impl AsRef<Path>) -> Vault<F>
293    where
294        F: NoteFromFile + Send,
295        F::Error: From<std::io::Error> + Send,
296        F::Properties: DeserializeOwned,
297    {
298        use rayon::prelude::*;
299
300        let options = VaultOptions::new(path);
301
302        VaultBuilder::new(&options)
303            .into_par_iter()
304            .map(|file| file.unwrap())
305            .build_vault(&options)
306    }
307
308    #[cfg_attr(feature = "tracing", tracing_test::traced_test)]
309    #[test]
310    fn open() {
311        let (path, vault_notes) = create_files_for_vault().unwrap();
312
313        let vault: VaultInMemory = impl_open(&path);
314
315        assert_eq!(vault.count_notes(), vault_notes.len());
316        assert_eq!(vault.path(), path.path());
317    }
318
319    #[cfg_attr(feature = "tracing", tracing_test::traced_test)]
320    #[test]
321    #[cfg(feature = "rayon")]
322    fn par_open() {
323        let (path, vault_notes) = create_files_for_vault().unwrap();
324
325        let vault: VaultInMemory = impl_par_open(&path);
326
327        assert_eq!(vault.count_notes(), vault_notes.len());
328        assert_eq!(vault.path(), path.path());
329    }
330
331    #[cfg_attr(feature = "tracing", tracing_test::traced_test)]
332    #[test]
333    fn ignore_not_md_files() {
334        let (path, vault_notes) = create_files_for_vault().unwrap();
335        File::create(path.path().join("extra_file.not_md")).unwrap();
336
337        let vault: VaultInMemory = impl_open(&path);
338
339        assert_eq!(vault.count_notes(), vault_notes.len());
340        assert_eq!(vault.path(), path.path());
341    }
342
343    #[cfg_attr(feature = "tracing", tracing_test::traced_test)]
344    #[test]
345    #[cfg(feature = "rayon")]
346    fn par_ignore_not_md_files() {
347        let (path, vault_notes) = create_files_for_vault().unwrap();
348        File::create(path.path().join("extra_file.not_md")).unwrap();
349
350        let vault: VaultInMemory = impl_par_open(&path);
351
352        assert_eq!(vault.count_notes(), vault_notes.len());
353        assert_eq!(vault.path(), path.path());
354    }
355
356    #[cfg_attr(feature = "tracing", tracing_test::traced_test)]
357    #[test]
358    fn open_with_error() {
359        let (path, _) = create_files_for_vault().unwrap();
360        let mut file = File::create(path.path().join("not_file.md")).unwrap();
361        file.write_all(b"---").unwrap();
362
363        let options = VaultOptions::new(&path);
364        let errors = VaultBuilder::new(&options)
365            .into_iter::<NoteInMemory>()
366            .filter_map(Result::err)
367            .collect::<Vec<_>>();
368
369        assert_eq!(errors.len(), 1);
370        assert!(matches!(
371            errors.last(),
372            Some(note_in_memory::Error::InvalidFormat(_))
373        ));
374    }
375
376    #[cfg_attr(feature = "tracing", tracing_test::traced_test)]
377    #[test]
378    #[cfg(feature = "rayon")]
379    fn par_open_with_error() {
380        use rayon::prelude::*;
381
382        let (path, _) = create_files_for_vault().unwrap();
383        let mut file = File::create(path.path().join("not_file.md")).unwrap();
384        file.write_all(b"---").unwrap();
385
386        let options = VaultOptions::new(&path);
387        let errors = VaultBuilder::new(&options)
388            .into_par_iter::<NoteInMemory>()
389            .filter_map(Result::err)
390            .collect::<Vec<_>>();
391
392        assert_eq!(errors.len(), 1);
393        assert!(matches!(
394            errors.last(),
395            Some(note_in_memory::Error::InvalidFormat(_))
396        ));
397    }
398
399    #[cfg_attr(feature = "tracing", tracing_test::traced_test)]
400    #[test]
401    fn open_with_error_but_ignored() {
402        let (path, vault_notes) = create_files_for_vault().unwrap();
403        let mut file = File::create(path.path().join("not_file.md")).unwrap();
404        file.write_all(b"---").unwrap();
405
406        let options = VaultOptions::new(&path);
407
408        let mut errors = Vec::new();
409        let vault = VaultBuilder::new(&options)
410            .into_iter::<NoteInMemory>()
411            .filter_map(|file| match file {
412                Ok(file) => Some(file),
413                Err(error) => {
414                    errors.push(error);
415
416                    None
417                }
418            })
419            .build_vault(&options);
420
421        assert_eq!(vault.count_notes(), vault_notes.len());
422        assert_eq!(vault.path(), path.path());
423
424        assert_eq!(errors.len(), 1);
425        assert!(matches!(
426            errors.last(),
427            Some(note_in_memory::Error::InvalidFormat(_))
428        ));
429    }
430
431    #[cfg_attr(feature = "tracing", tracing_test::traced_test)]
432    #[test]
433    #[cfg(feature = "rayon")]
434    fn par_open_with_error_but_ignored() {
435        use rayon::prelude::*;
436        use std::sync::{Arc, Mutex};
437
438        let (path, vault_notes) = create_files_for_vault().unwrap();
439        let mut file = File::create(path.path().join("not_file.md")).unwrap();
440        file.write_all(b"---").unwrap();
441
442        let options = VaultOptions::new(&path);
443
444        let errors = Arc::new(Mutex::new(Vec::new()));
445        let vault = VaultBuilder::new(&options)
446            .into_par_iter::<NoteInMemory>()
447            .filter_map(|file| match file {
448                Ok(file) => Some(file),
449                Err(error) => {
450                    errors.lock().unwrap().push(error);
451
452                    None
453                }
454            })
455            .build_vault(&options);
456
457        assert_eq!(vault.count_notes(), vault_notes.len());
458        assert_eq!(vault.path(), path.path());
459
460        assert_eq!(errors.lock().unwrap().len(), 1);
461        assert!(matches!(
462            errors.lock().unwrap().last(),
463            Some(note_in_memory::Error::InvalidFormat(_))
464        ));
465    }
466
467    #[cfg_attr(feature = "tracing", tracing_test::traced_test)]
468    #[test]
469    fn include_hidden() {
470        let (path, files) = create_files_for_vault().unwrap();
471
472        let mut file = File::create_new(path.path().join(".hidden.md")).unwrap();
473        file.write_all(b"hidden information").unwrap();
474
475        let options = VaultOptions::new(&path);
476
477        let vault_with_hidden: VaultInMemory = VaultBuilder::new(&options)
478            .include_hidden(true)
479            .into_iter()
480            .map(|file| file.unwrap())
481            .build_vault(&options);
482
483        let vault_without_hidden: VaultInMemory = VaultBuilder::new(&options)
484            .include_hidden(false)
485            .into_iter()
486            .map(|file| file.unwrap())
487            .build_vault(&options);
488
489        assert_eq!(vault_with_hidden.count_notes(), files.len() + 1);
490        assert_eq!(vault_without_hidden.count_notes(), files.len());
491    }
492
493    #[cfg_attr(feature = "tracing", tracing_test::traced_test)]
494    #[test]
495    fn max_depth() {
496        let (path, _) = create_files_for_vault().unwrap();
497
498        let options = VaultOptions::new(&path);
499        let vault: VaultInMemory = VaultBuilder::new(&options)
500            .max_depth(1) // Without `data/main.md`
501            .into_iter()
502            .map(|file| file.unwrap())
503            .build_vault(&options);
504
505        assert_eq!(vault.count_notes(), 2);
506    }
507
508    #[cfg_attr(feature = "tracing", tracing_test::traced_test)]
509    #[test]
510    fn min_depth() {
511        let (path, _) = create_files_for_vault().unwrap();
512
513        let options = VaultOptions::new(&path);
514        let vault: VaultInMemory = VaultBuilder::new(&options)
515            .min_depth(2) // Only `data/main.md`
516            .into_iter()
517            .map(|file| file.unwrap())
518            .build_vault(&options);
519
520        assert_eq!(vault.count_notes(), 1);
521    }
522
523    #[cfg_attr(feature = "tracing", tracing_test::traced_test)]
524    #[test]
525    fn filter_entry() {
526        let (path, _) = create_files_for_vault().unwrap();
527
528        let options = VaultOptions::new(&path);
529        let vault: VaultInMemory = VaultBuilder::new(&options)
530            .filter_entry(|entry| !entry.file_name().eq_ignore_ascii_case("main.md"))
531            .into_iter()
532            .map(|file| file.unwrap())
533            .build_vault(&options);
534
535        assert_eq!(vault.count_notes(), 1);
536    }
537}