obsidian_parser/vault/vault_open/
mod.rs1pub 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
17pub 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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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
223pub trait IteratorVaultBuilder<N = NoteOnDisk>: Iterator<Item = N>
225where
226 Self: Sized,
227 N: Note,
228{
229 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#[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 #[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) .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) .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}