1use std::{
2 ops::Deref,
3 path::{Component, Path, PathBuf},
4 sync::{
5 Arc,
6 atomic::{AtomicBool, Ordering},
7 },
8 time::{Instant, UNIX_EPOCH},
9};
10
11use papaya::{HashMap, HashSet};
12use rayon::prelude::*;
13use serde::{Serialize, Serializer, ser::SerializeSeq};
14
15use crate::Config;
16use crate::config::{RelationType, TagSource};
17use crate::errors::RepoError;
18use crate::relationships::{NoteRelInput, RawRelationship, RelationshipIndex};
19use crate::tag_index::{TagIndex, TaggedPage};
20use crate::vault::{Entry, LocalVault, MountPolicy, Vault, VaultError, VaultPath};
21use crate::wikilink_index::WikilinkIndex;
22
23mod snapshot;
24pub use snapshot::{FileStamp, RepoDiff, RepoSnapshot, SNAPSHOT_FORMAT, SnapshotError};
25
26#[derive(Clone, Serialize)]
27pub struct Repo {
28 #[serde(skip)]
31 vault: Arc<dyn Vault>,
32 #[serde(skip)]
53 canonical_root: PathBuf,
54 #[serde(skip)]
55 static_folder: String,
56 #[serde(skip)]
72 static_vault: Option<Arc<dyn Vault>>,
73 #[serde(skip)]
74 markdown_extensions: Vec<String>,
75 pub index_file: String,
78 #[serde(skip)]
79 ignore_dirs: Vec<String>,
80 #[serde(skip)]
81 #[allow(dead_code)] ignore_globs: Vec<String>,
83 #[serde(skip)]
84 compiled_ignore_globs: Vec<glob::Pattern>,
85 #[serde(skip)]
91 exempt_hidden_dirs: Vec<PathBuf>,
92 #[serde(skip)]
95 exempt_hidden_vault_paths: Vec<VaultPath>,
96 #[serde(skip)]
100 pub scanned_folders: HashSet<PathBuf>,
101 #[serde(skip)]
104 pub queued_folders: HashMap<PathBuf, QueuedFolder>,
105 #[serde(skip)]
110 not_downloaded_folders: HashSet<PathBuf>,
111 pub markdown_files: MarkdownFiles,
112 pub other_files: OtherFiles,
113 #[serde(skip)]
115 pub tag_index: Arc<TagIndex>,
116 #[serde(skip)]
118 pub relationship_index: Arc<RelationshipIndex>,
119 #[serde(skip)]
122 pub wikilink_index: Arc<WikilinkIndex>,
123 #[serde(skip)]
125 tag_sources: Vec<TagSource>,
126 #[serde(skip)]
128 text_extracted: Arc<AtomicBool>,
129 #[serde(skip)]
135 media_populated: Arc<AtomicBool>,
136 #[serde(skip)]
143 media_population_started: Arc<AtomicBool>,
144 #[serde(skip)]
146 scan_complete: Arc<AtomicBool>,
147 #[serde(skip)]
149 scan_notify: Arc<tokio::sync::Notify>,
150 #[serde(skip)]
152 media_notify: Arc<tokio::sync::Notify>,
153 #[serde(skip)]
158 stamps: Option<Arc<HashMap<PathBuf, FileStamp>>>,
159}
160
161#[derive(Clone)]
162pub struct MarkdownFiles(HashMap<PathBuf, MarkdownInfo>);
163impl Deref for MarkdownFiles {
164 type Target = HashMap<PathBuf, MarkdownInfo>;
165 fn deref(&self) -> &Self::Target {
166 &self.0
167 }
168}
169
170impl Serialize for MarkdownFiles {
179 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
180 where
181 S: Serializer,
182 {
183 let pin = self.pin();
184 let mut entries: Vec<&MarkdownInfo> = pin.iter().map(|(_, v)| v).collect();
185 entries.sort_unstable_by(|a, b| {
186 a.url_path
187 .cmp(&b.url_path)
188 .then_with(|| a.raw_path.cmp(&b.raw_path))
189 });
190
191 let mut s = serializer.serialize_seq(Some(entries.len()))?;
192 for v in entries {
193 s.serialize_element(v)?;
194 }
195 s.end()
196 }
197}
198
199#[derive(Clone)]
200pub struct OtherFiles(HashMap<PathBuf, OtherFileInfo>);
201impl Deref for OtherFiles {
202 type Target = HashMap<PathBuf, OtherFileInfo>;
203 fn deref(&self) -> &Self::Target {
204 &self.0
205 }
206}
207impl Serialize for OtherFiles {
209 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
210 where
211 S: Serializer,
212 {
213 let pin = self.pin();
214 let mut entries: Vec<&OtherFileInfo> = pin.iter().map(|(_, v)| v).collect();
215 entries.sort_unstable_by(|a, b| {
216 a.url_path
217 .cmp(&b.url_path)
218 .then_with(|| a.raw_path.cmp(&b.raw_path))
219 });
220
221 let mut s = serializer.serialize_seq(Some(entries.len()))?;
222 for v in entries {
223 s.serialize_element(v)?;
224 }
225 s.end()
226 }
227}
228
229#[derive(Clone, Serialize)]
230pub struct MarkdownInfo {
231 #[serde(serialize_with = "crate::url_path::serialize_as_url")]
244 pub raw_path: PathBuf,
245 pub url_path: String,
246 pub created: u64,
247 pub modified: u64,
248 #[serde(serialize_with = "crate::contact::serialize_public_frontmatter")]
254 pub frontmatter: Option<crate::markdown::SimpleMetadata>,
255 #[serde(skip)]
259 pub relationships: Vec<RawRelationship>,
260}
261
262#[derive(Clone, Serialize)]
263pub struct OtherFileInfo {
264 #[serde(skip)]
265 pub raw_path: PathBuf,
266 pub url_path: String,
267 metadata: StaticFileMetadata,
268 #[serde(skip)]
271 pub extracted_text: Option<String>,
272}
273
274const MAX_TEXT_EXTRACTION_SIZE: u64 = 10 * 1024 * 1024;
276
277impl OtherFileInfo {
278 pub fn filetype(&self) -> &'static str {
280 match &self.metadata.kind {
281 StaticFileKind::Pdf { .. } => "pdf",
282 StaticFileKind::Image { .. } => "image",
283 StaticFileKind::Video { .. } => "video",
284 StaticFileKind::Audio { .. } => "audio",
285 StaticFileKind::Text => "text",
286 StaticFileKind::Other => "other",
287 }
288 }
289
290 pub fn is_searchable(&self) -> bool {
292 matches!(
293 &self.metadata.kind,
294 StaticFileKind::Pdf { .. } | StaticFileKind::Text
295 )
296 }
297
298 fn extract_text(&self, vault: &dyn Vault, path: &VaultPath) -> Option<String> {
304 if let Some(size) = self.metadata.file_size_bytes
306 && size > MAX_TEXT_EXTRACTION_SIZE
307 {
308 tracing::debug!(
309 "Skipping text extraction for {:?}: file too large ({} bytes)",
310 self.raw_path,
311 size
312 );
313 return None;
314 }
315
316 match &self.metadata.kind {
317 StaticFileKind::Pdf { .. } => self.extract_pdf_text(vault, path),
318 StaticFileKind::Text => self.extract_plain_text(vault, path),
319 _ => None,
320 }
321 }
322
323 fn extract_pdf_text(&self, vault: &dyn Vault, path: &VaultPath) -> Option<String> {
328 let loaded = match vault.local_path(path) {
329 Some(local) => lopdf::Document::load(local),
330 None => match vault.read(path) {
331 Ok(bytes) => lopdf::Document::load_mem(&bytes),
332 Err(e) => {
333 tracing::debug!("Failed to read PDF {:?}: {}", self.raw_path, e);
334 return None;
335 }
336 },
337 };
338 let doc = match loaded {
339 Ok(doc) => doc,
340 Err(e) => {
341 tracing::debug!("Failed to load PDF {:?}: {}", self.raw_path, e);
342 return None;
343 }
344 };
345
346 let page_numbers: Vec<u32> = doc.get_pages().keys().copied().collect();
347 if page_numbers.is_empty() {
348 return None;
349 }
350
351 match doc.extract_text(&page_numbers) {
352 Ok(text) => {
353 let text = text.trim().to_string();
354 if text.is_empty() { None } else { Some(text) }
355 }
356 Err(e) => {
357 tracing::debug!("Failed to extract PDF text from {:?}: {}", self.raw_path, e);
358 None
359 }
360 }
361 }
362
363 fn extract_plain_text(&self, vault: &dyn Vault, path: &VaultPath) -> Option<String> {
365 match vault.read_to_string(path) {
366 Ok(text) => {
367 let text = text.trim().to_string();
368 if text.is_empty() { None } else { Some(text) }
369 }
370 Err(e) => {
371 tracing::debug!("Failed to read text file {:?}: {}", self.raw_path, e);
372 None
373 }
374 }
375 }
376}
377
378#[derive(Clone, Default, Serialize)]
379pub struct StaticFileMetadata {
380 #[serde(skip)]
391 path: PathBuf,
392 created: Option<u64>,
393 modified: Option<u64>,
394 file_size_bytes: Option<u64>,
395 kind: StaticFileKind,
396}
397
398#[derive(Clone, Default, Serialize)]
399#[serde(tag = "type", rename_all = "lowercase")]
400enum StaticFileKind {
401 Pdf {
402 description: Option<String>,
403 title: Option<String>,
404 author: Option<String>,
405 subject: Option<String>,
406 num_pages: Option<usize>,
407 },
408 Image {
409 width: Option<u32>,
410 height: Option<u32>,
411 },
412 Video {
413 width: Option<u32>,
414 height: Option<u32>,
415 duration: Option<String>,
416 title: Option<String>,
417 genre: Option<String>,
418 album: Option<String>,
419 },
420 Audio {
421 duration: Option<String>,
422 title: Option<String>,
423 },
424 Text,
425 #[default]
426 Other,
427}
428
429impl StaticFileMetadata {
455 pub fn empty<P: Into<std::path::PathBuf>>(file: P) -> Self {
456 let file: PathBuf = file.into();
457 match file
459 .extension()
460 .map(|x| x.to_ascii_lowercase().to_string_lossy().to_string())
461 .as_deref()
462 {
463 Some("pdf") => Self {
464 path: file,
465 kind: StaticFileKind::Pdf {
466 description: None,
467 title: None,
468 author: None,
469 subject: None,
470 num_pages: None,
471 },
472 ..Default::default()
473 },
474 Some("jpg") | Some("jpeg") | Some("png") | Some("webp") | Some("gif") | Some("bmp")
475 | Some("tif") | Some("tiff") => Self {
476 path: file,
477 kind: StaticFileKind::Image {
478 width: None,
479 height: None,
480 },
481 ..Default::default()
482 },
483 Some("aiff") | Some("mp3") | Some("aac") | Some("m4a") | Some("ogg") | Some("oga")
484 | Some("opus") | Some("wma") | Some("flac") | Some("wav") | Some("aif") | Some("") => {
485 Self {
486 path: file,
487 kind: StaticFileKind::Audio {
488 duration: None,
489 title: None,
490 },
491 ..Default::default()
492 }
493 }
494 Some("mp4") | Some("m4v") | Some("mov") | Some("webm") | Some("flv") | Some("mpg")
495 | Some("mpeg") | Some("avi") | Some("3gp") | Some("wmv") => Self {
496 path: file,
497 kind: StaticFileKind::Video {
498 width: None,
499 height: None,
500 duration: None,
501 title: None,
502 genre: None,
503 album: None,
504 },
505 ..Default::default()
506 },
507 Some("txt") | Some("css") | Some("vtt") | Some("srt") | Some("toml") | Some("json")
508 | Some("js") | Some("ts") => Self {
509 path: file,
510 kind: StaticFileKind::Text,
511 ..Default::default()
512 },
513 _ => Self {
514 path: file,
515 kind: StaticFileKind::Other,
516 ..Default::default()
517 },
518 }
519 }
520
521 pub fn populate_basic(self) -> Self {
526 let file_details_start = Instant::now();
527 let details = file_details_from_path(&self.path).ok();
528 tracing::debug!(
529 "populate file_details for {:?}: {:?}",
530 self.path,
531 file_details_start.elapsed()
532 );
533 self.with_details(details)
534 }
535
536 fn with_details(mut self, details: Option<(u64, u64, u64)>) -> Self {
539 let (size, created, modified) = match details {
540 Some((size, created, modified)) => (Some(size), Some(created), Some(modified)),
541 None => (None, None, None),
542 };
543 self.file_size_bytes = size;
544 self.created = created;
545 self.modified = modified;
546 self
547 }
548
549 #[cfg(feature = "media-metadata")]
551 pub fn populate_media(self) -> Self {
552 let mut me = self;
553 let media_start = Instant::now();
554 let kind_name = match &me.kind {
555 StaticFileKind::Pdf { .. } => "pdf",
556 StaticFileKind::Image { .. } => "image",
557 StaticFileKind::Video { .. } => "video",
558 StaticFileKind::Audio { .. } => "audio",
559 StaticFileKind::Text => "text",
560 StaticFileKind::Other => "other",
561 };
562 me.kind = match me.kind {
563 StaticFileKind::Pdf { .. } => match crate::pdf_metadata::probe_pdf(&me.path) {
564 Ok(meta) => StaticFileKind::Pdf {
565 title: meta.title,
566 author: meta.author,
567 subject: meta.subject,
568 description: None,
569 num_pages: Some(meta.num_pages as usize),
570 },
571 Err(e) => {
572 tracing::debug!("Failed to extract PDF metadata from {:?}: {}", me.path, e);
573 me.kind
574 }
575 },
576 StaticFileKind::Image { .. } => {
577 let metadata = metadata::media_file::MediaFileMetadata::new(&me.path).ok();
578
579 StaticFileKind::Image {
580 width: metadata.as_ref().and_then(|m| m.width),
581 height: metadata.as_ref().and_then(|m| m.height),
582 }
583 }
584 StaticFileKind::Audio { .. } => {
585 let metadata = metadata::media_file::MediaFileMetadata::new(&me.path).ok();
586 StaticFileKind::Audio {
587 duration: metadata.as_ref().and_then(|m| m.duration.clone()),
588 title: metadata.as_ref().and_then(|m| m.title.clone()),
589 }
590 }
591 StaticFileKind::Video { .. } => {
592 let metadata = metadata::media_file::MediaFileMetadata::new(&me.path).ok();
593
594 let genre = metadata.as_ref().and_then(|m| {
596 m.tags
597 .iter()
598 .find(|(k, _)| k.eq_ignore_ascii_case("genre"))
599 .map(|(_, v)| v.clone())
600 });
601
602 let album = metadata.as_ref().and_then(|m| {
604 m.tags
605 .iter()
606 .find(|(k, _)| k.eq_ignore_ascii_case("album"))
607 .map(|(_, v)| v.clone())
608 });
609
610 StaticFileKind::Video {
611 width: metadata.as_ref().and_then(|m| m.width),
612 height: metadata.as_ref().and_then(|m| m.height),
613 duration: metadata.as_ref().and_then(|m| m.duration.clone()),
614 title: metadata.as_ref().and_then(|m| m.title.clone()),
615 genre,
616 album,
617 }
618 }
619 _ => me.kind,
620 };
621 tracing::debug!(
622 "populate media metadata ({}) for {:?}: {:?}",
623 kind_name,
624 me.path,
625 media_start.elapsed()
626 );
627 me
628 }
629
630 pub fn populate(self) -> Self {
632 let me = self.populate_basic();
633 #[cfg(feature = "media-metadata")]
634 let me = me.populate_media();
635 me
636 }
637
638 pub fn from<P: Into<std::path::PathBuf>>(file: P) -> Self {
639 let empty = Self::empty(file);
640 empty.populate()
641 }
642}
643
644impl Repo {
645 pub fn init_from_config(c: &Config) -> Self {
646 Self::init(
647 c.root_dir.clone(),
648 c.static_folder.clone(),
649 &c.markdown_extensions[..],
650 &c.ignore_dirs[..],
651 &c.ignore_globs[..],
652 c.index_file.clone(),
653 &c.tag_sources[..],
654 &c.relationship_types[..],
655 )
656 .with_explicit_hidden_dirs(&c.explicit_hidden_dirs)
657 .with_symlink_mounts(!c.no_symlink_mounts)
658 }
659
660 #[must_use]
669 pub fn with_symlink_mounts(mut self, enabled: bool) -> Self {
670 if !enabled {
671 tracing::info!(
672 "Symlink mounts are disabled (--no-symlink-mounts / {}): links out of the \
673 repository are not served or built",
674 crate::config::NO_SYMLINK_MOUNTS_ENV
675 );
676 self.vault = Arc::new(LocalVault::new(self.vault.root()));
677 self.static_vault = self
678 .static_vault
679 .map(|overlay| Arc::new(LocalVault::new(overlay.root())) as Arc<dyn Vault>);
680 }
681 self
682 }
683
684 #[must_use]
696 pub fn with_explicit_hidden_dirs(mut self, explicit_hidden_dirs: &[PathBuf]) -> Self {
697 let absolute = explicit_hidden_dirs
698 .iter()
699 .map(|relative| self.canonical_root.join(relative))
700 .collect();
701 self.exempt_hidden_dirs = absolute;
702 self.exempt_hidden_vault_paths = explicit_hidden_dirs
703 .iter()
704 .filter_map(|relative| VaultPath::from_relative_native(relative).ok())
705 .collect();
706 self
707 }
708
709 pub fn exempt_hidden_dirs(&self) -> &[VaultPath] {
713 &self.exempt_hidden_vault_paths
714 }
715
716 #[allow(clippy::too_many_arguments)]
724 pub fn init<S: Into<String>, P: Into<std::path::PathBuf>>(
725 root_dir: P,
726 static_folder: S,
727 markdown_extensions: &[String],
728 ignore_dirs: &[String],
729 ignore_globs: &[String],
730 index_file: S,
731 tag_sources: &[TagSource],
732 relationship_types: &[RelationType],
733 ) -> Self {
734 let root_dir: PathBuf = root_dir.into();
735 let canonical_root = root_dir.canonicalize().unwrap_or_else(|_| root_dir.clone());
736 let static_folder = static_folder.into();
737 let overlay_dir =
743 match crate::config::resolve_static_overlay(&canonical_root, &static_folder) {
744 Ok(crate::config::StaticOverlay::External(dir)) => Some(dir),
745 Ok(crate::config::StaticOverlay::WithinRoot) => None,
746 Err(e) => {
747 tracing::warn!("Not indexing static_folder {static_folder:?}: {e}");
748 None
749 }
750 };
751 let vault: Arc<dyn Vault> = Arc::new(LocalVault::with_mounts(
752 root_dir,
753 MountPolicy::new(overlay_dir.iter().cloned().collect()),
754 ));
755 let static_vault: Option<Arc<dyn Vault>> = overlay_dir.map(|dir| {
756 Arc::new(LocalVault::with_mounts(
757 dir,
758 MountPolicy::new(vec![canonical_root.clone()])
759 .with_markdown_root(canonical_root.clone()),
760 )) as Arc<dyn Vault>
761 });
762
763 Self::init_with_vault(
764 vault,
765 static_folder,
766 markdown_extensions,
767 ignore_dirs,
768 ignore_globs,
769 index_file.into(),
770 tag_sources,
771 relationship_types,
772 )
773 .with_static_vault(static_vault)
774 }
775
776 #[allow(clippy::too_many_arguments)]
784 pub fn init_with_vault<S: Into<String>>(
785 vault: Arc<dyn Vault>,
786 static_folder: S,
787 markdown_extensions: &[String],
788 ignore_dirs: &[String],
789 ignore_globs: &[String],
790 index_file: S,
791 tag_sources: &[TagSource],
792 relationship_types: &[RelationType],
793 ) -> Self {
794 let compiled_ignore_globs: Vec<glob::Pattern> = ignore_globs
796 .iter()
797 .filter_map(|pat| {
798 glob::Pattern::new(pat)
799 .map_err(|e| tracing::warn!("Invalid ignore glob pattern '{}': {}", pat, e))
800 .ok()
801 })
802 .collect();
803
804 let canonical_root = vault.root().to_path_buf();
805
806 Self {
807 vault,
808 canonical_root,
809 static_folder: static_folder.into(),
810 static_vault: None,
811 markdown_extensions: markdown_extensions.to_vec(),
812 ignore_dirs: ignore_dirs.to_vec(),
813 ignore_globs: ignore_globs.to_vec(),
814 compiled_ignore_globs,
815 exempt_hidden_dirs: Vec::new(),
816 exempt_hidden_vault_paths: Vec::new(),
817 index_file: index_file.into(),
818 scanned_folders: HashSet::new(),
819 queued_folders: HashMap::new(),
820 not_downloaded_folders: HashSet::new(),
821 markdown_files: MarkdownFiles(HashMap::new()),
822 other_files: OtherFiles(HashMap::new()),
823 tag_index: Arc::new(TagIndex::new()),
824 relationship_index: Arc::new(RelationshipIndex::from_relation_types(
825 relationship_types,
826 )),
827 wikilink_index: Arc::new(WikilinkIndex::new()),
828 tag_sources: tag_sources.to_vec(),
829 text_extracted: Arc::new(AtomicBool::new(false)),
830 media_populated: Arc::new(AtomicBool::new(false)),
831 media_population_started: Arc::new(AtomicBool::new(false)),
832 scan_complete: Arc::new(AtomicBool::new(false)),
833 scan_notify: Arc::new(tokio::sync::Notify::new()),
834 media_notify: Arc::new(tokio::sync::Notify::new()),
835 stamps: None,
836 }
837 }
838
839 #[must_use]
843 pub fn with_static_vault(mut self, static_vault: Option<Arc<dyn Vault>>) -> Self {
844 self.static_vault = static_vault;
845 self
846 }
847
848 pub fn vault(&self) -> &Arc<dyn Vault> {
850 &self.vault
851 }
852
853 pub fn static_vault(&self) -> Option<&Arc<dyn Vault>> {
855 self.static_vault.as_ref()
856 }
857
858 pub fn canonical_static_root(&self) -> Option<&Path> {
860 self.static_vault.as_deref().map(|vault| vault.root())
861 }
862
863 pub fn locate(&self, key: &Path) -> Option<(&dyn Vault, VaultPath)> {
868 if let Ok(path) = self.vault.vault_path(key) {
869 return Some((self.vault.as_ref(), path));
870 }
871 let overlay = self.static_vault.as_deref()?;
872 overlay.vault_path(key).ok().map(|path| (overlay, path))
873 }
874
875 pub fn locate_shared(&self, key: &Path) -> Option<(Arc<dyn Vault>, VaultPath)> {
878 if let Ok(path) = self.vault.vault_path(key) {
879 return Some((Arc::clone(&self.vault), path));
880 }
881 let overlay = self.static_vault.as_ref()?;
882 overlay
883 .vault_path(key)
884 .ok()
885 .map(|path| (Arc::clone(overlay), path))
886 }
887
888 pub fn mounts(&self) -> Vec<(PathBuf, crate::vault::Mount)> {
892 std::iter::once(&self.vault)
893 .chain(self.static_vault.as_ref())
894 .flat_map(|vault| {
895 vault
896 .mounts()
897 .into_iter()
898 .map(|mount| (vault.key(&mount.location), mount))
899 })
900 .collect()
901 }
902
903 fn static_url_for_key(&self, key: &Path) -> String {
907 match self.static_vault.as_deref() {
908 Some(overlay) if !key.starts_with(&self.canonical_root) => {
909 match overlay.vault_path(key) {
910 Ok(path) => format!("/{path}"),
911 Err(_) => build_static_url_path(key, &self.canonical_root, &self.static_folder),
912 }
913 }
914 _ => build_static_url_path(key, &self.canonical_root, &self.static_folder),
915 }
916 }
917
918 fn basic_details(&self, key: &Path) -> Option<(u64, u64, u64)> {
921 let (vault, path) = self.locate(key)?;
922 let entry = vault.stat(&path).ok()??;
923 let (created, modified) = entry.epoch_secs()?;
924 Some((entry.size, created, modified))
925 }
926
927 fn vault_at(&self, location: ScanLocation) -> Option<&Arc<dyn Vault>> {
929 match location {
930 ScanLocation::WithinRoot => Some(&self.vault),
931 ScanLocation::StaticOverlay => self.static_vault.as_ref(),
932 }
933 }
934
935 fn relative_to_root(&self, abs_path: &Path) -> PathBuf {
944 pathdiff::diff_paths(abs_path, &self.canonical_root)
945 .unwrap_or_else(|| abs_path.to_path_buf())
946 }
947
948 fn static_folder_path(&self) -> Option<VaultPath> {
951 crate::vault::configured_folder(self.vault.as_ref(), &self.static_folder)
952 }
953
954 pub fn static_folder_vault(&self) -> Option<(&Arc<dyn Vault>, VaultPath)> {
958 match &self.static_vault {
959 Some(overlay) => Some((overlay, VaultPath::root())),
960 None => Some((&self.vault, self.static_folder_path()?)),
961 }
962 }
963
964 fn static_folder_key(&self) -> Option<PathBuf> {
966 match &self.static_vault {
967 Some(overlay) => overlay
968 .canonicalize(&VaultPath::root())
969 .ok()
970 .map(|root| overlay.key(&root)),
971 None => {
972 let path = self.static_folder_path()?;
973 let canonical = self.vault.canonicalize(&path).ok()?;
974 Some(self.vault.key(&canonical))
975 }
976 }
977 }
978
979 pub fn scan_folder<P: AsRef<Path>>(&self, relative_folder_path: &P) -> Result<(), RepoError> {
985 let relative = relative_folder_path.as_ref();
986 match VaultPath::from_relative_native(relative) {
987 Ok(folder) => self.scan_dir(ScanLocation::WithinRoot, &folder),
988 Err(e) => {
989 tracing::warn!("Skipping {relative:?}: {e}");
990 Ok(())
991 }
992 }
993 }
994
995 pub fn scan_vault_folder(&self, folder: &VaultPath) -> Result<(), RepoError> {
997 self.scan_dir(ScanLocation::WithinRoot, folder)
998 }
999
1000 fn scan_dir(&self, location: ScanLocation, folder: &VaultPath) -> Result<(), RepoError> {
1002 let Some(vault) = self.vault_at(location) else {
1003 return Ok(());
1004 };
1005
1006 let start = match vault.canonicalize(folder) {
1023 Ok(start) => start,
1024 Err(VaultError::OutsideRoot { path }) => {
1025 tracing::debug!(
1026 "Skipping {:?}: it resolves to {}, outside the repository root {}",
1027 folder.as_str(),
1028 path,
1029 vault.root().display()
1030 );
1031 return Ok(());
1032 }
1033 Err(e) => {
1034 return Err(RepoError::CanonicalizeFailed {
1035 path: vault.key(folder),
1036 source: e.into(),
1037 });
1038 }
1039 };
1040 let start_key = vault.key(&start);
1041
1042 if self.scanned_folders.pin().contains(&start_key) {
1044 return Ok(());
1045 }
1046 tracing::debug!("Scanning folder: {:?}", folder.as_str());
1047 self.scanned_folders.pin().insert(start_key.clone());
1048
1049 let list_start = Instant::now();
1053 let entries = match vault.list_dir(&start) {
1054 Ok(entries) => {
1055 self.not_downloaded_folders.pin().remove(&start_key);
1056 entries
1057 }
1058 Err(VaultError::NotDownloaded { .. }) => {
1064 tracing::info!(
1065 "Folder {} is not downloaded yet; requested it",
1066 start_key.display()
1067 );
1068 vault.request_download(std::slice::from_ref(&start));
1069 self.not_downloaded_folders.pin().insert(start_key.clone());
1070 self.scanned_folders.pin().remove(&start_key);
1071 Vec::new()
1072 }
1073 Err(e) => {
1076 tracing::debug!("Could not list {}: {e}", start_key.display());
1077 Vec::new()
1078 }
1079 };
1080
1081 if start.is_root() {
1082 let folders = entries.iter().filter(|e| e.is_dir()).count();
1085 tracing::info!(
1086 "Root {} lists {} entries ({} folders, {} files)",
1087 start_key.display(),
1088 entries.len(),
1089 folders,
1090 entries.len() - folders
1091 );
1092 }
1093
1094 let mut markdown: Vec<(PathBuf, VaultPath, MarkdownInfo)> = Vec::new();
1095 let mut other: Vec<(PathBuf, OtherFileInfo)> = Vec::new();
1096
1097 for entry in entries {
1098 let key = vault.key(&entry.path);
1102 if should_ignore_compiled(
1103 &key,
1104 entry.is_dir(),
1105 &self.ignore_dirs,
1106 &self.compiled_ignore_globs,
1107 &self.exempt_hidden_dirs,
1108 ) {
1109 continue;
1110 }
1111 let extension = entry.path.extension().unwrap_or("");
1112
1113 if entry.is_dir() {
1114 self.queued_folders.pin().insert(
1117 key,
1118 QueuedFolder {
1119 location,
1120 path: entry.path,
1121 },
1122 );
1123 } else if location == ScanLocation::StaticOverlay
1124 && is_markdown_extension(extension, &self.markdown_extensions)
1125 {
1126 tracing::warn!(
1135 "Skipping markdown file {} in the external static folder {:?}: \
1136 markdown outside the repository root has no valid URL",
1137 key.display(),
1138 self.static_folder
1139 );
1140 } else if is_markdown_extension(extension, &self.markdown_extensions) {
1141 self.record_stamp(&key, &entry);
1143 if let Some((created, modified)) = entry.epoch_secs() {
1144 let url = build_markdown_url_path(&key, &self.canonical_root, &self.index_file);
1145 let mdfile = MarkdownInfo {
1146 raw_path: self.relative_to_root(&key),
1147 url_path: url,
1148 created,
1149 modified,
1150 frontmatter: None,
1151 relationships: Vec::new(),
1152 };
1153 markdown.push((key, entry.path, mdfile));
1154 } else {
1155 tracing::warn!("Couldn't process markdown file at {:?}", key);
1156 }
1157 } else {
1158 let url = match location {
1168 ScanLocation::StaticOverlay => format!(
1169 "/{}",
1170 crate::url_path::path_to_url(
1171 key.strip_prefix(vault.root()).unwrap_or(&key)
1172 )
1173 ),
1174 ScanLocation::WithinRoot => {
1175 build_static_url_path(&key, &self.canonical_root, &self.static_folder)
1176 }
1177 };
1178 self.record_stamp(&key, &entry);
1183 let other_file = OtherFileInfo {
1184 raw_path: key.clone(),
1185 url_path: url,
1186 metadata: StaticFileMetadata::empty(&key),
1187 extracted_text: None,
1188 };
1189 other.push((key, other_file));
1190 }
1191 }
1192 tracing::debug!(
1193 "scan_folder listing for {:?}: {} markdown, {} other files in {:?}",
1194 folder.as_str(),
1195 markdown.len(),
1196 other.len(),
1197 list_start.elapsed()
1198 );
1199
1200 let frontmatter_start = Instant::now();
1202 markdown.into_par_iter().for_each(
1203 |(key, path, mddetails): (PathBuf, VaultPath, MarkdownInfo)| {
1204 let file_meta =
1205 crate::markdown::extract_metadata_from_vault(vault.as_ref(), &path).ok();
1206 let details = if let Some(file_meta) = file_meta {
1207 let frontmatter = file_meta.metadata;
1208 let relationships = file_meta.relationships;
1209 let title = get_page_title(&frontmatter, &mddetails.raw_path);
1211 let description = frontmatter
1212 .get("description")
1213 .and_then(|v| v.as_str())
1214 .map(|s| s.to_string());
1215
1216 for tag_source in &self.tag_sources {
1217 if let Some(tag_value_json) = frontmatter.get(&tag_source.field) {
1219 for tag_value in extract_tag_values(tag_value_json) {
1221 let page = if let Some(ref desc) = description {
1222 TaggedPage::with_description(
1223 &mddetails.url_path,
1224 &title,
1225 desc,
1226 &tag_value,
1227 )
1228 } else {
1229 TaggedPage::new(&mddetails.url_path, &title, &tag_value)
1230 };
1231 self.tag_index.add_page(&tag_source.field, &tag_value, page);
1232 }
1233 }
1234 }
1235
1236 MarkdownInfo {
1237 frontmatter: Some(frontmatter),
1238 relationships,
1239 ..mddetails
1240 }
1241 } else {
1242 mddetails
1243 };
1244 self.markdown_files.pin().insert(key, details);
1245 },
1246 );
1247 tracing::debug!(
1248 "scan_folder frontmatter extraction for {:?}: {:?}",
1249 folder.as_str(),
1250 frontmatter_start.elapsed()
1251 );
1252
1253 let static_insert_start = Instant::now();
1254 for (file, other_file) in other {
1255 self.other_files.pin().insert(file, other_file);
1256 }
1257 tracing::debug!(
1258 "scan_folder static file registration for {:?}: {:?}",
1259 folder.as_str(),
1260 static_insert_start.elapsed()
1261 );
1262
1263 Ok(())
1264 }
1265
1266 fn drain_queued_folders(&self) {
1269 while !self.queued_folders.is_empty() {
1270 let batch: Vec<QueuedFolder> = self
1272 .queued_folders
1273 .pin()
1274 .iter()
1275 .map(|(_, queued)| queued.clone())
1276 .collect();
1277 self.queued_folders.pin().clear();
1278 tracing::debug!("Parallel batch: {:?}", &batch);
1279 batch.into_par_iter().for_each(|queued| {
1280 self.scan_dir(queued.location, &queued.path)
1281 .unwrap_or_else(|e| {
1282 tracing::error!("Failed to scan folder {:?}: {e}", queued.path.as_str())
1283 }) });
1285 }
1286 }
1287
1288 pub fn scan_all(&self) -> Result<(), RepoError> {
1289 let scan_all_start = Instant::now();
1290
1291 let static_deferred = self.static_folder_key().inspect(|key| {
1295 self.scanned_folders.pin().insert(key.clone());
1296 });
1297
1298 self.scan_dir(ScanLocation::WithinRoot, &VaultPath::root())?;
1299 self.drain_queued_folders();
1300
1301 if let Some(ref sp) = static_deferred {
1303 self.scanned_folders.pin().remove(sp);
1304 }
1305
1306 let markdown_count = self.markdown_files.len();
1308 let other_count = self.other_files.len();
1309 let other_pin = self.other_files.pin();
1310 let mut pdf_count: usize = 0;
1311 let mut image_count: usize = 0;
1312 let mut video_count: usize = 0;
1313 let mut audio_count: usize = 0;
1314 let mut text_count: usize = 0;
1315 let mut misc_count: usize = 0;
1316 for (_, info) in other_pin.iter() {
1317 match info.filetype() {
1318 "pdf" => pdf_count += 1,
1319 "image" => image_count += 1,
1320 "video" => video_count += 1,
1321 "audio" => audio_count += 1,
1322 "text" => text_count += 1,
1323 _ => misc_count += 1,
1324 }
1325 }
1326
1327 tracing::info!(
1328 "scan_all completed in {:?}: {} markdown files, {} other files in {} folders, {} not downloaded (pdf={}, image={}, video={}, audio={}, text={}, other={})",
1329 scan_all_start.elapsed(),
1330 markdown_count,
1331 other_count,
1332 self.scanned_folders.len(),
1333 self.not_downloaded_folders.len(),
1334 pdf_count,
1335 image_count,
1336 video_count,
1337 audio_count,
1338 text_count,
1339 misc_count,
1340 );
1341
1342 Ok(())
1343 }
1344
1345 pub fn scan_static_folder(&self) -> Result<(), RepoError> {
1348 let start = Instant::now();
1349 let (location, folder) = if self.static_vault.is_some() {
1352 (ScanLocation::StaticOverlay, VaultPath::root())
1353 } else {
1354 match self.static_folder_path() {
1355 Some(folder) => (ScanLocation::WithinRoot, folder),
1356 None => return Ok(()),
1357 }
1358 };
1359 if !self
1360 .vault_at(location)
1361 .is_some_and(|vault| vault.is_dir(&folder))
1362 {
1363 return Ok(());
1364 }
1365
1366 self.scan_dir(location, &folder)?;
1367 self.drain_queued_folders();
1368
1369 let other_count = self.other_files.len();
1370 tracing::info!(
1371 "scan_static_folder completed in {:?}: {} other files total",
1372 start.elapsed(),
1373 other_count,
1374 );
1375 Ok(())
1376 }
1377
1378 pub fn mark_scan_complete(&self) {
1380 self.scan_complete.store(true, Ordering::SeqCst);
1381 self.scan_notify.notify_waiters();
1382 }
1383
1384 pub fn full_rescan(&self) {
1396 self.clear();
1397 if let Err(e) = self.scan_all() {
1398 tracing::error!("Rescan failed, continuing with partial repository state: {e}");
1399 }
1400 self.build_relationship_index();
1401 self.build_wikilink_index();
1402 if let Err(e) = self.scan_static_folder() {
1403 tracing::error!("Static folder rescan failed: {e}");
1404 }
1405 self.populate_basic_metadata();
1406 self.populate_media_metadata();
1407 self.notify_media_populated();
1408 self.ensure_text_extracted();
1409 }
1410
1411 pub fn populate_basic_metadata(&self) {
1415 let start = Instant::now();
1416 let pin = self.other_files.pin();
1417 let keys: Vec<PathBuf> = pin
1418 .iter()
1419 .filter(|(_, info)| info.metadata.file_size_bytes.is_none())
1420 .map(|(k, _)| k.clone())
1421 .collect();
1422 let count = keys.len();
1423 drop(pin);
1424
1425 keys.into_par_iter().for_each(|key| {
1426 let pin = self.other_files.pin();
1427 if let Some(info) = pin.get(&key) {
1428 let updated = OtherFileInfo {
1429 metadata: info.metadata.clone().with_details(self.basic_details(&key)),
1430 ..info.clone()
1431 };
1432 drop(pin);
1433 self.other_files.pin().insert(key, updated);
1434 }
1435 });
1436
1437 tracing::info!(
1438 "populate_basic_metadata completed for {} files in {:?}",
1439 count,
1440 start.elapsed()
1441 );
1442 }
1443
1444 pub fn is_scan_complete(&self) -> bool {
1446 self.scan_complete.load(Ordering::SeqCst)
1447 }
1448
1449 pub async fn wait_for_scan(&self) {
1458 loop {
1459 let notified = self.scan_notify.notified();
1460 tokio::pin!(notified);
1461 notified.as_mut().enable();
1462
1463 if self.is_scan_complete() {
1464 return;
1465 }
1466
1467 notified.await;
1469 }
1470 }
1471
1472 pub fn is_media_populated(&self) -> bool {
1474 self.media_populated.load(Ordering::Acquire)
1475 }
1476
1477 pub fn notify_media_populated(&self) {
1479 self.media_notify.notify_waiters();
1480 }
1481
1482 pub async fn wait_for_media(&self) {
1487 loop {
1488 let notified = self.media_notify.notified();
1489 tokio::pin!(notified);
1490 notified.as_mut().enable();
1491
1492 if self.is_media_populated() {
1493 return;
1494 }
1495
1496 notified.await;
1497 }
1498 }
1499
1500 pub fn to_json(&self) -> serde_json::Result<String> {
1501 serde_json::to_string(self)
1502 }
1503
1504 pub fn populate_media_metadata(&self) {
1507 if self.media_population_started.swap(true, Ordering::SeqCst) {
1508 return; }
1510 let start = Instant::now();
1511 let pin = self.other_files.pin();
1512 let entries: Vec<_> = pin.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
1513 drop(pin);
1514
1515 #[cfg(feature = "media-metadata")]
1518 {
1519 entries.into_par_iter().for_each(|(key, info)| {
1520 let Some(local) = self
1521 .locate(&key)
1522 .and_then(|(vault, path)| vault.local_path(&path))
1523 else {
1524 return;
1525 };
1526 let updated = OtherFileInfo {
1527 metadata: StaticFileMetadata {
1528 path: local,
1529 ..info.metadata
1530 }
1531 .populate_media(),
1532 ..info
1533 };
1534 self.other_files.pin().insert(key, updated);
1535 });
1536 }
1537 #[cfg(not(feature = "media-metadata"))]
1538 let _ = entries;
1539
1540 self.media_populated.store(true, Ordering::Release);
1545
1546 tracing::info!("populate_media_metadata completed in {:?}", start.elapsed());
1547 }
1548
1549 pub fn ensure_text_extracted(&self) {
1552 if self.text_extracted.swap(true, Ordering::SeqCst) {
1553 return; }
1555 let start = Instant::now();
1556 let pin = self.other_files.pin();
1557 let entries: Vec<_> = pin
1558 .iter()
1559 .filter(|(_, info)| info.is_searchable() && info.extracted_text.is_none())
1560 .map(|(k, v)| (k.clone(), v.clone()))
1561 .collect();
1562 drop(pin);
1563
1564 entries.into_par_iter().for_each(|(key, mut info)| {
1565 info.extracted_text = self
1566 .locate(&key)
1567 .and_then(|(vault, path)| info.extract_text(vault, &path));
1568 self.other_files.pin().insert(key, info);
1569 });
1570
1571 tracing::info!("ensure_text_extracted completed in {:?}", start.elapsed());
1572 }
1573
1574 pub fn clear(&self) {
1579 self.scanned_folders.pin().clear();
1580 self.not_downloaded_folders.pin().clear();
1581 self.markdown_files.pin().clear();
1582 self.other_files.pin().clear();
1583 self.queued_folders.pin().clear();
1584 self.tag_index.clear();
1585 self.relationship_index.clear();
1586 self.wikilink_index.clear();
1587 if let Some(stamps) = &self.stamps {
1588 stamps.pin().clear();
1589 }
1590 self.text_extracted.store(false, Ordering::SeqCst);
1591 self.media_populated.store(false, Ordering::SeqCst);
1592 self.media_population_started.store(false, Ordering::SeqCst);
1596 }
1599
1600 fn markdown_source(&self, key: &Path) -> Option<(&dyn Vault, VaultPath, u64, u64)> {
1604 let (vault, path) = self.locate(key)?;
1605 let entry = vault.stat(&path).ok()??;
1606 let (created, modified) = entry.epoch_secs()?;
1607 Some((vault, path, created, modified))
1608 }
1609
1610 pub fn invalidate_file(&self, abs_path: &Path, event: &crate::change_event::ChangeEventType) {
1614 self.invalidate_file_entry(abs_path, event);
1615 self.restamp(abs_path, event);
1616 }
1617
1618 fn restamp(&self, key: &Path, event: &crate::change_event::ChangeEventType) {
1623 let Some(stamps) = &self.stamps else {
1624 return;
1625 };
1626 let entry = match event {
1627 crate::change_event::ChangeEventType::Deleted => None,
1628 _ => self
1629 .locate(key)
1630 .and_then(|(vault, path)| vault.stat(&path).ok().flatten())
1631 .filter(Entry::is_file),
1632 };
1633 match entry {
1634 Some(entry)
1635 if self.markdown_files.pin().contains_key(key)
1636 || self.other_files.pin().contains_key(key) =>
1637 {
1638 stamps
1639 .pin()
1640 .insert(key.to_path_buf(), FileStamp::of(&entry));
1641 }
1642 _ => {
1643 stamps.pin().remove(key);
1644 }
1645 }
1646 }
1647
1648 fn invalidate_file_entry(&self, abs_path: &Path, event: &crate::change_event::ChangeEventType) {
1649 let extension = abs_path.extension().and_then(|x| x.to_str()).unwrap_or("");
1650 let is_markdown = is_markdown_extension(extension, &self.markdown_extensions);
1651 if (is_markdown && !abs_path.starts_with(&self.canonical_root))
1655 || self.locate(abs_path).is_some_and(|(_, path)| {
1656 crate::vault::has_hidden_segment(&path, self.exempt_hidden_dirs())
1657 })
1658 {
1659 return;
1660 }
1661
1662 match event {
1663 crate::change_event::ChangeEventType::Deleted => {
1664 if is_markdown {
1665 self.markdown_files.pin().remove(abs_path);
1666 } else {
1667 self.other_files.pin().remove(abs_path);
1668 }
1669 }
1670 crate::change_event::ChangeEventType::Created => {
1671 if is_markdown {
1672 if let Some((vault, path, created, modified)) = self.markdown_source(abs_path) {
1673 let url = build_markdown_url_path(
1674 abs_path,
1675 &self.canonical_root,
1676 &self.index_file,
1677 );
1678 let file_meta =
1679 crate::markdown::extract_metadata_from_vault(vault, &path).ok();
1680 let (frontmatter, relationships) = match file_meta {
1681 Some(fm) => (Some(fm.metadata), fm.relationships),
1682 None => (None, Vec::new()),
1683 };
1684
1685 if let Some(ref fm) = frontmatter {
1687 let title = get_page_title(fm, abs_path);
1688 let description = fm
1689 .get("description")
1690 .and_then(|v| v.as_str())
1691 .map(|s| s.to_string());
1692 for tag_source in &self.tag_sources {
1693 if let Some(tag_value_json) = fm.get(&tag_source.field) {
1694 for tag_value in extract_tag_values(tag_value_json) {
1695 let page = if let Some(ref desc) = description {
1696 TaggedPage::with_description(
1697 &url, &title, desc, &tag_value,
1698 )
1699 } else {
1700 TaggedPage::new(&url, &title, &tag_value)
1701 };
1702 self.tag_index.add_page(
1703 &tag_source.field,
1704 &tag_value,
1705 page,
1706 );
1707 }
1708 }
1709 }
1710 }
1711
1712 let info = MarkdownInfo {
1713 raw_path: self.relative_to_root(abs_path),
1714 url_path: url,
1715 created,
1716 modified,
1717 frontmatter,
1718 relationships,
1719 };
1720 self.markdown_files
1721 .pin()
1722 .insert(abs_path.to_path_buf(), info);
1723 }
1724 } else {
1725 let url = self.static_url_for_key(abs_path);
1726 let info = OtherFileInfo {
1727 raw_path: abs_path.to_path_buf(),
1728 url_path: url,
1729 metadata: StaticFileMetadata::empty(abs_path)
1730 .with_details(self.basic_details(abs_path)),
1731 extracted_text: None,
1732 };
1733 self.other_files.pin().insert(abs_path.to_path_buf(), info);
1734 }
1735 }
1736 crate::change_event::ChangeEventType::Modified => {
1737 if is_markdown {
1738 if let Some((vault, path, created, modified)) = self.markdown_source(abs_path) {
1740 let url = build_markdown_url_path(
1741 abs_path,
1742 &self.canonical_root,
1743 &self.index_file,
1744 );
1745 let file_meta =
1746 crate::markdown::extract_metadata_from_vault(vault, &path).ok();
1747 let (frontmatter, relationships) = match file_meta {
1748 Some(fm) => (Some(fm.metadata), fm.relationships),
1749 None => (None, Vec::new()),
1750 };
1751 let info = MarkdownInfo {
1752 raw_path: self.relative_to_root(abs_path),
1753 url_path: url,
1754 created,
1755 modified,
1756 frontmatter,
1757 relationships,
1758 };
1759 self.markdown_files
1760 .pin()
1761 .insert(abs_path.to_path_buf(), info);
1762 }
1763 } else {
1764 let url = self.static_url_for_key(abs_path);
1766 let info = OtherFileInfo {
1767 raw_path: abs_path.to_path_buf(),
1768 url_path: url,
1769 metadata: StaticFileMetadata::empty(abs_path)
1770 .with_details(self.basic_details(abs_path)),
1771 extracted_text: None,
1772 };
1773 self.other_files.pin().insert(abs_path.to_path_buf(), info);
1774 }
1775 }
1776 }
1777 }
1778
1779 pub fn tag_sources(&self) -> &[TagSource] {
1785 &self.tag_sources
1786 }
1787
1788 pub fn build_relationship_index(&self) {
1794 if !self
1799 .markdown_files
1800 .pin()
1801 .iter()
1802 .any(|(_, info)| !info.relationships.is_empty())
1803 {
1804 self.relationship_index.clear();
1805 return;
1806 }
1807 let notes = self.collect_note_inputs();
1808 self.relationship_index
1809 .rebuild(¬es, &self.markdown_extensions);
1810 }
1811
1812 pub fn build_wikilink_index(&self) {
1820 let notes = self.collect_note_inputs();
1821 self.wikilink_index.rebuild(¬es);
1822 }
1823
1824 fn collect_note_inputs(&self) -> Vec<NoteRelInput> {
1831 let pin = self.markdown_files.pin();
1832 pin.iter()
1833 .map(|(_, info)| {
1834 let title = info
1835 .frontmatter
1836 .as_ref()
1837 .map(|fm| get_page_title(fm, &info.raw_path))
1838 .unwrap_or_else(|| {
1839 info.raw_path
1840 .file_stem()
1841 .and_then(|s| s.to_str())
1842 .unwrap_or("Untitled")
1843 .to_string()
1844 });
1845 let stem = info
1846 .raw_path
1847 .file_stem()
1848 .and_then(|s| s.to_str())
1849 .unwrap_or_default()
1850 .to_string();
1851 let aliases = info
1854 .frontmatter
1855 .as_ref()
1856 .map(crate::contact::alias_names_in)
1857 .unwrap_or_default();
1858 let is_index = info
1859 .raw_path
1860 .file_name()
1861 .and_then(|f| f.to_str())
1862 .is_some_and(|f| f == self.index_file);
1863 NoteRelInput {
1864 url: info.url_path.clone(),
1865 title,
1866 stem,
1867 aliases,
1868 is_index,
1869 relationships: info.relationships.clone(),
1870 }
1871 })
1872 .collect()
1873 }
1874
1875 pub fn rebuild_tag_index(&self) {
1876 self.tag_index.clear();
1877 let pin = self.markdown_files.pin();
1878 for (_, info) in pin.iter() {
1879 if let Some(ref fm) = info.frontmatter {
1880 let title = get_page_title(fm, &info.raw_path);
1881 let description = fm
1882 .get("description")
1883 .and_then(|v| v.as_str())
1884 .map(|s| s.to_string());
1885 for tag_source in &self.tag_sources {
1886 if let Some(tag_value_json) = fm.get(&tag_source.field) {
1887 for tag_value in extract_tag_values(tag_value_json) {
1888 let page = if let Some(ref desc) = description {
1889 TaggedPage::with_description(
1890 &info.url_path,
1891 &title,
1892 desc,
1893 &tag_value,
1894 )
1895 } else {
1896 TaggedPage::new(&info.url_path, &title, &tag_value)
1897 };
1898 self.tag_index.add_page(&tag_source.field, &tag_value, page);
1899 }
1900 }
1901 }
1902 }
1903 }
1904 }
1905}
1906
1907pub fn file_details_from_path<P: AsRef<Path>>(path: P) -> Result<(u64, u64, u64), RepoError> {
1909 let path = path.as_ref();
1910 let metadata = std::fs::metadata(path).map_err(|source| RepoError::MetadataFailed {
1911 path: path.to_path_buf(),
1912 source,
1913 })?;
1914
1915 let file_size = metadata.len();
1916
1917 let modified = metadata
1919 .modified()
1920 .map_err(|source| RepoError::MetadataFailed {
1921 path: path.to_path_buf(),
1922 source,
1923 })?;
1924 let modified_secs = modified.duration_since(UNIX_EPOCH)?.as_secs();
1925
1926 let created_secs = metadata
1930 .created()
1931 .ok()
1932 .and_then(|created| created.duration_since(UNIX_EPOCH).ok())
1933 .map(|d| d.as_secs())
1934 .unwrap_or(modified_secs);
1935
1936 Ok((file_size, created_secs, modified_secs))
1937}
1938
1939#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1962pub enum ScanLocation {
1963 WithinRoot,
1965 StaticOverlay,
1967}
1968
1969#[derive(Debug, Clone, PartialEq, Eq)]
1972pub struct QueuedFolder {
1973 pub location: ScanLocation,
1974 pub path: VaultPath,
1975}
1976
1977pub fn repo_relative_within_root(root: &Path, abs_path: &Path) -> Option<PathBuf> {
1978 let relative = pathdiff::diff_paths(abs_path, root)?;
1979
1980 let escapes = relative.components().any(|component| {
1981 matches!(
1982 component,
1983 Component::ParentDir | Component::RootDir | Component::Prefix(_)
1984 )
1985 });
1986
1987 (!escapes).then_some(relative)
1988}
1989
1990#[inline]
2009fn is_ignored_hidden(path: &Path, file_name: &str, exempt_hidden_dirs: &[PathBuf]) -> bool {
2010 file_name.starts_with('.') && !exempt_hidden_dirs.iter().any(|dir| dir == path)
2011}
2012
2013pub fn should_ignore(
2026 path: &Path,
2027 ignore_dirs: &[String],
2028 ignore_globs: &[String],
2029 exempt_hidden_dirs: &[PathBuf],
2030) -> bool {
2031 let file_name = path.file_name().and_then(|x| x.to_str()).unwrap_or("");
2032
2033 if is_ignored_hidden(path, file_name, exempt_hidden_dirs) {
2035 return true;
2036 }
2037
2038 if path.is_dir() && ignore_dirs.iter().any(|x| x.as_str() == file_name) {
2040 return true;
2041 }
2042
2043 ignore_globs.iter().any(|pat| {
2045 glob::Pattern::new(pat)
2046 .map(|pat| pat.matches_path(path))
2047 .unwrap_or(false)
2048 })
2049}
2050
2051fn should_ignore_compiled(
2057 path: &Path,
2058 is_dir: bool,
2059 ignore_dirs: &[String],
2060 compiled_patterns: &[glob::Pattern],
2061 exempt_hidden_dirs: &[PathBuf],
2062) -> bool {
2063 let file_name = path.file_name().and_then(|x| x.to_str()).unwrap_or("");
2064
2065 if is_ignored_hidden(path, file_name, exempt_hidden_dirs) {
2067 return true;
2068 }
2069
2070 if is_dir && ignore_dirs.iter().any(|x| x.as_str() == file_name) {
2072 return true;
2073 }
2074
2075 compiled_patterns.iter().any(|pat| pat.matches_path(path))
2077}
2078
2079pub fn build_markdown_url_path(path: &Path, root_dir: &Path, index_file: &str) -> String {
2086 let relative = pathdiff::diff_paths(path, root_dir).unwrap_or_default();
2090 let mut url = format!("/{}", crate::url_path::path_to_url(&relative));
2091
2092 if url.rsplit('/').next() == Some(index_file) {
2096 url.truncate(url.len() - index_file.len());
2097 }
2098
2099 if let Some(dot_pos) = url.rfind('.')
2101 && !url[dot_pos..].contains('/')
2102 {
2103 url.truncate(dot_pos);
2104 url.push('/');
2105 }
2106
2107 url
2108}
2109
2110pub fn build_static_url_path(path: &Path, root_dir: &Path, static_folder: &str) -> String {
2116 let relative = pathdiff::diff_paths(path, root_dir).unwrap_or_default();
2117
2118 let stripped = relative.strip_prefix(static_folder).unwrap_or(&relative);
2122
2123 format!("/{}", crate::url_path::path_to_url(stripped))
2124}
2125
2126pub fn is_markdown_extension(extension: &str, markdown_extensions: &[String]) -> bool {
2128 markdown_extensions.iter().any(|x| x.as_str() == extension)
2129}
2130
2131pub fn parse_tag_values(values: &str) -> impl Iterator<Item = String> + '_ {
2144 values
2145 .split(',')
2146 .map(|s| s.trim().to_string())
2147 .filter(|s| !s.is_empty())
2148}
2149
2150pub fn extract_tag_values(value: &serde_json::Value) -> Vec<String> {
2166 match value {
2167 serde_json::Value::String(s) => parse_tag_values(s).collect(),
2168 serde_json::Value::Array(arr) => arr
2169 .iter()
2170 .filter_map(|v| v.as_str())
2171 .map(|s| s.to_string())
2172 .collect(),
2173 _ => vec![],
2174 }
2175}
2176
2177fn get_page_title(frontmatter: &crate::markdown::SimpleMetadata, path: &Path) -> String {
2183 frontmatter
2184 .get("title")
2185 .and_then(|v| v.as_str())
2186 .map(|s| s.to_string())
2187 .unwrap_or_else(|| {
2188 path.file_stem()
2189 .and_then(|s| s.to_str())
2190 .unwrap_or("Untitled")
2191 .to_string()
2192 })
2193}
2194
2195#[cfg(test)]
2196mod tests {
2197 use super::*;
2198
2199 #[test]
2204 fn test_repo_canonicalizes_its_root() {
2205 let tmp = tempfile::tempdir().unwrap();
2206 let canonical = tmp.path().canonicalize().unwrap();
2207
2208 let repo = Repo::init(
2209 tmp.path().to_path_buf(),
2210 "static".to_string(),
2211 &["md".to_string()],
2212 &[],
2213 &[],
2214 "index.md".to_string(),
2215 &[],
2216 &[],
2217 );
2218
2219 assert_eq!(
2220 repo.canonical_root, canonical,
2221 "Repo must store the canonical root, not the path as supplied"
2222 );
2223 }
2224
2225 #[test]
2228 fn test_canonicalize_root_falls_back_when_missing() {
2229 let missing = PathBuf::from("/definitely/does/not/exist/anywhere");
2230 assert_eq!(LocalVault::new(missing.clone()).root(), missing);
2231 assert_eq!(test_repo(&missing).canonical_root, missing);
2232 }
2233
2234 #[test]
2235 fn test_should_ignore_hidden_file() {
2236 let path = Path::new(".hidden");
2237 assert!(should_ignore(path, &[], &[], &[]));
2238 }
2239
2240 #[test]
2241 fn test_should_ignore_hidden_dir() {
2242 let path = Path::new(".git");
2243 assert!(should_ignore(path, &[], &[], &[]));
2244 }
2245
2246 #[test]
2247 fn test_should_ignore_normal_file() {
2248 let path = Path::new("readme.md");
2249 assert!(!should_ignore(path, &[], &[], &[]));
2250 }
2251
2252 #[test]
2253 fn test_should_ignore_glob_pattern() {
2254 let path = Path::new("test.log");
2255 let globs = vec!["*.log".to_string()];
2256 assert!(should_ignore(path, &[], &globs, &[]));
2257 }
2258
2259 #[test]
2260 fn test_should_ignore_glob_no_match() {
2261 let path = Path::new("test.md");
2262 let globs = vec!["*.log".to_string()];
2263 assert!(!should_ignore(path, &[], &globs, &[]));
2264 }
2265
2266 #[test]
2270 fn test_should_ignore_exempts_an_explicitly_named_hidden_dir() {
2271 let exempt = vec![PathBuf::from("/repo/.scratch")];
2272 assert!(!should_ignore(
2273 Path::new("/repo/.scratch"),
2274 &[],
2275 &[],
2276 &exempt
2277 ));
2278 }
2279
2280 #[test]
2283 fn test_should_ignore_still_skips_a_dot_dir_nested_in_an_exempt_one() {
2284 let exempt = vec![PathBuf::from("/repo/.scratch")];
2285 assert!(should_ignore(
2286 Path::new("/repo/.scratch/.git"),
2287 &[],
2288 &[],
2289 &exempt
2290 ));
2291 }
2292
2293 #[test]
2296 fn test_should_ignore_exemption_does_not_match_a_same_named_dir_elsewhere() {
2297 let exempt = vec![PathBuf::from("/repo/.scratch")];
2298 assert!(should_ignore(
2299 Path::new("/repo/docs/.scratch"),
2300 &[],
2301 &[],
2302 &exempt
2303 ));
2304 }
2305
2306 #[test]
2309 fn test_should_ignore_exemption_does_not_override_ignore_globs() {
2310 let exempt = vec![PathBuf::from("/repo/.scratch")];
2311 let globs = vec!["**/.scratch".to_string()];
2312 assert!(should_ignore(
2313 Path::new("/repo/.scratch"),
2314 &[],
2315 &globs,
2316 &exempt
2317 ));
2318 }
2319
2320 #[test]
2321 fn test_build_markdown_url_path_simple() {
2322 let root = Path::new("/root");
2323 let path = Path::new("/root/readme.md");
2324 assert_eq!(build_markdown_url_path(path, root, "index.md"), "/readme/");
2325 }
2326
2327 #[test]
2328 fn test_build_markdown_url_path_nested() {
2329 let root = Path::new("/root");
2330 let path = Path::new("/root/docs/guide.md");
2331 assert_eq!(
2332 build_markdown_url_path(path, root, "index.md"),
2333 "/docs/guide/"
2334 );
2335 }
2336
2337 #[test]
2338 fn test_build_markdown_url_path_index() {
2339 let root = Path::new("/root");
2340 let path = Path::new("/root/docs/index.md");
2341 assert_eq!(build_markdown_url_path(path, root, "index.md"), "/docs/");
2342 }
2343
2344 #[test]
2345 fn test_build_markdown_url_path_root_index() {
2346 let root = Path::new("/root");
2347 let path = Path::new("/root/index.md");
2348 assert_eq!(build_markdown_url_path(path, root, "index.md"), "/");
2349 }
2350
2351 #[test]
2352 fn test_build_static_url_path_in_static() {
2353 let root = Path::new("/root");
2354 let path = Path::new("/root/static/image.png");
2355 assert_eq!(build_static_url_path(path, root, "static"), "/image.png");
2356 }
2357
2358 #[test]
2359 fn test_build_static_url_path_not_in_static() {
2360 let root = Path::new("/root");
2361 let path = Path::new("/root/assets/image.png");
2362 assert_eq!(
2363 build_static_url_path(path, root, "static"),
2364 "/assets/image.png"
2365 );
2366 }
2367
2368 #[test]
2372 fn test_build_static_url_path_preserves_static_substring() {
2373 let root = Path::new("/root");
2374
2375 let path = Path::new("/root/notes/static-analysis/img.png");
2377 assert_eq!(
2378 build_static_url_path(path, root, "static"),
2379 "/notes/static-analysis/img.png"
2380 );
2381
2382 let nested = Path::new("/root/my-static/image.png");
2384 assert_eq!(
2385 build_static_url_path(nested, root, "static"),
2386 "/my-static/image.png"
2387 );
2388
2389 let prefixed = Path::new("/root/static/static-report.png");
2391 assert_eq!(
2392 build_static_url_path(prefixed, root, "static"),
2393 "/static-report.png"
2394 );
2395 }
2396
2397 #[test]
2401 fn test_build_markdown_url_path_myindex_not_treated_as_index() {
2402 let root = Path::new("/root");
2403
2404 let path = Path::new("/root/docs/myindex.md");
2405 assert_eq!(
2406 build_markdown_url_path(path, root, "index.md"),
2407 "/docs/myindex/"
2408 );
2409
2410 let real_index = Path::new("/root/docs/index.md");
2412 assert_eq!(
2413 build_markdown_url_path(real_index, root, "index.md"),
2414 "/docs/"
2415 );
2416 }
2417
2418 #[test]
2422 fn test_created_falls_back_to_modified() {
2423 let dir = tempfile::tempdir().expect("create temp dir");
2424 let file_path = dir.path().join("note.md");
2425 std::fs::write(&file_path, b"# hello").expect("write temp file");
2426
2427 let (size, created, modified) =
2428 file_details_from_path(&file_path).expect("file details must not fail");
2429
2430 assert_eq!(size, 7);
2431 assert!(modified > 0, "modified time should be populated");
2434 assert!(
2435 created > 0,
2436 "created time should be populated (btime or fallback)"
2437 );
2438 }
2439
2440 #[test]
2441 fn test_is_markdown_extension_true() {
2442 let extensions = vec!["md".to_string(), "markdown".to_string()];
2443 assert!(is_markdown_extension("md", &extensions));
2444 assert!(is_markdown_extension("markdown", &extensions));
2445 }
2446
2447 #[test]
2448 fn test_is_markdown_extension_false() {
2449 let extensions = vec!["md".to_string()];
2450 assert!(!is_markdown_extension("txt", &extensions));
2451 assert!(!is_markdown_extension("html", &extensions));
2452 }
2453
2454 #[test]
2455 fn test_parse_tag_values_basic() {
2456 let tags: Vec<String> = parse_tag_values("rust, programming, web dev").collect();
2457 assert_eq!(tags, vec!["rust", "programming", "web dev"]);
2458 }
2459
2460 #[test]
2461 fn test_parse_tag_values_single() {
2462 let tags: Vec<String> = parse_tag_values("rust").collect();
2463 assert_eq!(tags, vec!["rust"]);
2464 }
2465
2466 #[test]
2467 fn test_parse_tag_values_whitespace() {
2468 let tags: Vec<String> = parse_tag_values(" rust , python ").collect();
2469 assert_eq!(tags, vec!["rust", "python"]);
2470 }
2471
2472 #[test]
2473 fn test_parse_tag_values_empty() {
2474 let tags: Vec<String> = parse_tag_values("").collect();
2475 assert!(tags.is_empty());
2476 }
2477
2478 #[test]
2479 fn test_parse_tag_values_empty_between() {
2480 let tags: Vec<String> = parse_tag_values("rust,,python").collect();
2481 assert_eq!(tags, vec!["rust", "python"]);
2482 }
2483
2484 #[test]
2485 fn test_get_page_title_from_frontmatter() {
2486 let mut frontmatter = crate::markdown::SimpleMetadata::new();
2487 frontmatter.insert(
2488 "title".to_string(),
2489 serde_json::Value::String("My Page Title".to_string()),
2490 );
2491 let path = Path::new("/docs/readme.md");
2492 assert_eq!(get_page_title(&frontmatter, path), "My Page Title");
2493 }
2494
2495 #[test]
2496 fn test_get_page_title_from_filename() {
2497 let frontmatter = crate::markdown::SimpleMetadata::new();
2498 let path = Path::new("/docs/rust-guide.md");
2499 assert_eq!(get_page_title(&frontmatter, path), "rust-guide");
2500 }
2501
2502 #[test]
2503 fn test_get_page_title_fallback() {
2504 let frontmatter = crate::markdown::SimpleMetadata::new();
2505 let path = Path::new("/");
2506 assert_eq!(get_page_title(&frontmatter, path), "Untitled");
2507 }
2508
2509 #[test]
2510 fn test_extract_tag_values_from_array() {
2511 let val = serde_json::json!(["rust", "python"]);
2512 let tags = extract_tag_values(&val);
2513 assert_eq!(tags, vec!["rust", "python"]);
2514 }
2515
2516 #[test]
2517 fn test_extract_tag_values_from_comma_string() {
2518 let val = serde_json::json!("rust, python");
2519 let tags = extract_tag_values(&val);
2520 assert_eq!(tags, vec!["rust", "python"]);
2521 }
2522
2523 #[test]
2524 fn test_extract_tag_values_from_single_string() {
2525 let val = serde_json::json!("rust");
2526 let tags = extract_tag_values(&val);
2527 assert_eq!(tags, vec!["rust"]);
2528 }
2529
2530 #[test]
2531 fn test_extract_tag_values_from_number() {
2532 let val = serde_json::json!(42);
2533 let tags = extract_tag_values(&val);
2534 assert!(tags.is_empty());
2535 }
2536
2537 fn test_repo(root: &Path) -> Repo {
2541 Repo::init(
2542 root.to_path_buf(),
2543 "static".to_string(),
2544 &["md".to_string()],
2545 &[],
2546 &[],
2547 "index.md".to_string(),
2548 &[],
2549 &[],
2550 )
2551 }
2552
2553 #[test]
2559 fn test_scan_indexes_an_explicitly_named_hidden_dir() {
2560 let root = tempfile::tempdir().expect("temp dir");
2561 let scratch = root.path().join(".scratch");
2562 std::fs::create_dir(&scratch).expect("create .scratch");
2563 std::fs::write(scratch.join("alpha.md"), "# Alpha").expect("write alpha");
2564 std::fs::write(scratch.join("beta.md"), "# Beta").expect("write beta");
2565 let nested = scratch.join(".obsidian");
2567 std::fs::create_dir(&nested).expect("create nested dot dir");
2568 std::fs::write(nested.join("workspace.md"), "# Tooling").expect("write nested");
2569 let other = root.path().join(".private");
2571 std::fs::create_dir(&other).expect("create .private");
2572 std::fs::write(other.join("secret.md"), "# Secret").expect("write secret");
2573
2574 let repo = test_repo(root.path()).with_explicit_hidden_dirs(&[PathBuf::from(".scratch")]);
2575 repo.scan_all().expect("scan must succeed");
2576
2577 let markdown = repo.markdown_files.pin();
2578 let urls: Vec<&str> = markdown.iter().map(|(_, i)| i.url_path.as_str()).collect();
2579
2580 assert!(
2581 urls.contains(&"/.scratch/alpha/") && urls.contains(&"/.scratch/beta/"),
2582 "the named hidden directory must be indexed, got {urls:?}"
2583 );
2584 assert!(
2585 !urls.iter().any(|url| url.contains("workspace")),
2586 "a dot directory nested inside the named one must stay ignored, got {urls:?}"
2587 );
2588 assert!(
2589 !urls.iter().any(|url| url.contains("secret")),
2590 "an unnamed sibling hidden directory must stay ignored, got {urls:?}"
2591 );
2592 }
2593
2594 #[test]
2597 fn test_scan_skips_a_hidden_dir_that_was_not_named() {
2598 let root = tempfile::tempdir().expect("temp dir");
2599 let scratch = root.path().join(".scratch");
2600 std::fs::create_dir(&scratch).expect("create .scratch");
2601 std::fs::write(scratch.join("alpha.md"), "# Alpha").expect("write alpha");
2602
2603 let repo = test_repo(root.path());
2604 repo.scan_all().expect("scan must succeed");
2605
2606 assert!(
2607 repo.markdown_files.pin().is_empty(),
2608 "a hidden directory nobody named must not be indexed"
2609 );
2610 }
2611
2612 #[test]
2613 fn test_repo_relative_within_root_accepts_contained_paths() {
2614 let root = Path::new("/repo");
2615
2616 assert_eq!(
2617 repo_relative_within_root(root, &root.join("docs").join("guide.md")),
2618 Some(PathBuf::from("docs").join("guide.md"))
2619 );
2620 assert_eq!(repo_relative_within_root(root, root), Some(PathBuf::new()));
2622 }
2623
2624 #[test]
2625 fn test_repo_relative_within_root_rejects_escapes() {
2626 let root = Path::new("/repo");
2627
2628 assert_eq!(
2630 repo_relative_within_root(root, Path::new("/elsewhere/secret.md")),
2631 None
2632 );
2633 assert_eq!(
2635 repo_relative_within_root(root, &root.join("..").join("secret.md")),
2636 None
2637 );
2638 assert_eq!(
2641 repo_relative_within_root(Path::new("repo"), Path::new("/repo/a.md")),
2642 None
2643 );
2644 }
2645
2646 #[cfg(unix)]
2649 fn visible_tempdir() -> tempfile::TempDir {
2650 tempfile::Builder::new()
2651 .prefix("mbr-repo-test-")
2652 .tempdir()
2653 .expect("temp dir")
2654 }
2655
2656 #[cfg(unix)]
2666 #[test]
2667 fn test_scan_indexes_a_directory_symlinked_outside_root_as_a_mount() {
2668 let outside = visible_tempdir();
2669 let outside_docs = outside.path().join("docs");
2670 std::fs::create_dir_all(outside_docs.join(".private")).expect("create outside dir");
2671 std::fs::write(outside_docs.join("secret.md"), "# Linked").expect("write note");
2672 std::fs::write(outside_docs.join("secret.png"), b"not really a png").expect("write asset");
2673 std::fs::write(outside_docs.join(".private/plan.md"), "# Hidden").expect("write hidden");
2674 std::fs::write(outside_docs.join(".env"), "SECRET=1").expect("write dotfile");
2675
2676 let root = tempfile::tempdir().expect("temp dir");
2677 std::fs::write(root.path().join("index.md"), "# Home").expect("write index");
2678 std::os::unix::fs::symlink(&outside_docs, root.path().join("work")).expect("symlink");
2679
2680 let repo = test_repo(root.path());
2681 repo.scan_all().expect("scan must succeed");
2682
2683 let markdown = repo.markdown_files.pin();
2684 for (_, info) in markdown.iter() {
2685 assert!(
2686 !info.url_path.contains(".."),
2687 "markdown url_path escaped the root: {}",
2688 info.url_path
2689 );
2690 assert!(
2691 !info
2692 .raw_path
2693 .components()
2694 .any(|c| matches!(c, Component::ParentDir)),
2695 "markdown raw_path escaped the root: {}",
2696 info.raw_path.display()
2697 );
2698 }
2699 let linked = markdown
2700 .iter()
2701 .find(|(_, i)| i.url_path == "/work/secret/")
2702 .map(|(_, i)| i.raw_path.clone());
2703 assert_eq!(linked, Some(PathBuf::from("work/secret.md")));
2704 assert!(
2705 markdown.iter().all(|(_, i)| !i.url_path.contains("plan")),
2706 "hidden directories inside a mount are not indexed"
2707 );
2708 assert!(markdown.iter().any(|(_, i)| i.url_path == "/"));
2709
2710 let other = repo.other_files.pin();
2711 let urls: Vec<&str> = other.iter().map(|(_, i)| i.url_path.as_str()).collect();
2712 assert_eq!(urls, vec!["/work/secret.png"], "{urls:?}");
2713 assert_eq!(repo.mounts().len(), 1);
2714 assert!(
2715 repo.vault()
2716 .is_read_only(&VaultPath::new("work/secret.md").unwrap())
2717 );
2718 }
2719
2720 #[cfg(unix)]
2723 #[test]
2724 fn test_scan_skips_a_directory_symlinked_to_a_refused_target() {
2725 let outside = visible_tempdir();
2726 let hidden = outside.path().join(".config/app");
2727 std::fs::create_dir_all(&hidden).expect("create");
2728 std::fs::write(hidden.join("secret.md"), "# Secret").expect("write");
2729 let root = tempfile::tempdir().expect("temp dir");
2730 std::fs::write(root.path().join("index.md"), "# Home").expect("write index");
2731 std::os::unix::fs::symlink(&hidden, root.path().join("work")).expect("symlink");
2732 std::os::unix::fs::symlink(root.path(), root.path().join("self")).expect("symlink");
2733
2734 let repo = test_repo(root.path());
2735 repo.scan_all().expect("scan must succeed");
2736 let urls: Vec<String> = repo
2737 .markdown_files
2738 .pin()
2739 .iter()
2740 .map(|(_, i)| i.url_path.clone())
2741 .collect();
2742 assert_eq!(urls, vec!["/".to_string()]);
2743 assert!(repo.mounts().is_empty());
2744 }
2745
2746 fn peer_overlay_repo() -> (tempfile::TempDir, PathBuf, Repo) {
2752 let tmp = tempfile::tempdir().expect("temp dir");
2753 let project = tmp.path().to_path_buf();
2754 let content = project.join("content");
2755 std::fs::create_dir_all(content.join(".mbr")).expect("create content");
2756 std::fs::write(content.join("README.md"), "# Home").expect("write readme");
2757 std::fs::create_dir_all(project.join("static/videos")).expect("create static");
2758 std::fs::write(project.join("static/pic.png"), b"PNG bytes").expect("write pic");
2759 std::fs::write(project.join("static/videos/demo.mp4"), b"MP4 bytes").expect("write video");
2760
2761 let repo = Repo::init(
2762 content,
2763 "../static".to_string(),
2764 &["md".to_string()],
2765 &[],
2766 &[],
2767 "index.md".to_string(),
2768 &[],
2769 &[],
2770 );
2771 (tmp, project, repo)
2772 }
2773
2774 #[cfg(unix)]
2779 #[test]
2780 fn test_scan_indexes_a_mount_inside_the_external_overlay() {
2781 let (_tmp, project, repo) = peer_overlay_repo();
2782 let movies = visible_tempdir();
2783 std::fs::create_dir(movies.path().join(".secret")).unwrap();
2784 std::fs::write(movies.path().join("clip.mp4"), b"clip").unwrap();
2785 std::fs::write(movies.path().join(".dotfile"), b"x").unwrap();
2786 std::fs::write(movies.path().join(".secret/key"), b"k").unwrap();
2787 std::fs::write(movies.path().join("notes.md"), b"# skipped").unwrap();
2788 std::os::unix::fs::symlink(movies.path(), project.join("static/films")).unwrap();
2789
2790 repo.scan_all().expect("scan");
2791 repo.scan_static_folder().expect("static scan");
2792
2793 let urls = |repo: &Repo| {
2794 let mut urls: Vec<String> = repo
2795 .other_files
2796 .pin()
2797 .iter()
2798 .map(|(_, i)| i.url_path.clone())
2799 .collect();
2800 urls.sort();
2801 urls
2802 };
2803 assert_eq!(
2804 urls(&repo),
2805 vec!["/films/clip.mp4", "/pic.png", "/videos/demo.mp4"]
2806 );
2807 assert!(
2808 repo.markdown_files
2809 .pin()
2810 .iter()
2811 .all(|(_, i)| !i.url_path.contains("notes")),
2812 "markdown in an external overlay (or a mount in one) has no page URL"
2813 );
2814 let mounts = repo.mounts();
2815 assert_eq!(mounts.len(), 1);
2816 let (location_key, mount) = &mounts[0];
2817 assert_eq!(
2818 *location_key,
2819 repo.canonical_static_root().unwrap().join("films")
2820 );
2821 assert_eq!(mount.target, movies.path().canonicalize().unwrap());
2822
2823 std::fs::write(movies.path().join("new.mp4"), b"new").unwrap();
2826 repo.invalidate_file(
2827 &location_key.join("new.mp4"),
2828 &crate::change_event::ChangeEventType::Created,
2829 );
2830 repo.invalidate_file(
2831 &location_key.join(".secret").join("later"),
2832 &crate::change_event::ChangeEventType::Created,
2833 );
2834 assert_eq!(
2835 urls(&repo),
2836 vec![
2837 "/films/clip.mp4",
2838 "/films/new.mp4",
2839 "/pic.png",
2840 "/videos/demo.mp4"
2841 ]
2842 );
2843 }
2844
2845 #[test]
2849 fn test_scan_indexes_assets_in_external_static_overlay() {
2850 let (_tmp, _project, repo) = peer_overlay_repo();
2851
2852 repo.scan_all().expect("scan must succeed");
2853 repo.scan_static_folder().expect("static scan must succeed");
2854
2855 let other = repo.other_files.pin();
2856 let mut urls: Vec<_> = other.iter().map(|(_, i)| i.url_path.clone()).collect();
2857 urls.sort();
2858 assert_eq!(
2859 urls,
2860 vec!["/pic.png".to_string(), "/videos/demo.mp4".to_string()],
2861 "a peer static folder's assets must be indexed, with the overlay prefix stripped"
2862 );
2863 }
2864
2865 #[test]
2870 fn test_scan_skips_markdown_in_external_static_overlay() {
2871 let (_tmp, project, repo) = peer_overlay_repo();
2872 std::fs::write(project.join("static/stray.md"), "# Stray").expect("write stray");
2873
2874 repo.scan_all().expect("scan must succeed");
2875 repo.scan_static_folder().expect("static scan must succeed");
2876
2877 let markdown = repo.markdown_files.pin();
2878 assert!(
2879 markdown.iter().all(|(_, i)| !i.url_path.contains("stray")),
2880 "markdown in an external static overlay must not be indexed"
2881 );
2882 for (_, info) in markdown.iter() {
2883 assert!(
2884 !info.url_path.contains(".."),
2885 "markdown url_path escaped the root: {}",
2886 info.url_path
2887 );
2888 assert!(
2889 !info
2890 .raw_path
2891 .components()
2892 .any(|c| matches!(c, Component::ParentDir)),
2893 "markdown raw_path escaped the root: {}",
2894 info.raw_path.display()
2895 );
2896 }
2897 assert!(
2898 markdown.iter().any(|(_, i)| i.url_path == "/README/"),
2899 "the in-root markdown must still be indexed"
2900 );
2901
2902 let other = repo.other_files.pin();
2904 for (_, info) in other.iter() {
2905 assert!(
2906 !info.url_path.contains(".."),
2907 "static url_path escaped: {}",
2908 info.url_path
2909 );
2910 }
2911 }
2912
2913 #[cfg(unix)]
2918 #[test]
2919 fn test_external_overlay_does_not_admit_other_escaping_directories() {
2920 let (_tmp, project, repo) = peer_overlay_repo();
2921
2922 let elsewhere = tempfile::tempdir().expect("temp dir");
2923 std::fs::write(elsewhere.path().join("secret.md"), "# Secret").expect("write secret");
2924 std::fs::write(elsewhere.path().join("secret.png"), b"secret").expect("write asset");
2925 std::os::unix::fs::symlink(elsewhere.path(), project.join("content/work"))
2927 .expect("symlink into root");
2928 std::os::unix::fs::symlink(elsewhere.path(), project.join("static/leak"))
2929 .expect("symlink into overlay");
2930
2931 repo.scan_all().expect("scan must succeed");
2932 repo.scan_static_folder().expect("static scan must succeed");
2933
2934 let markdown = repo.markdown_files.pin();
2935 assert!(
2936 markdown.iter().all(|(_, i)| !i.url_path.contains("secret")),
2937 "a directory symlinked out of the repo must contribute no markdown"
2938 );
2939 let other = repo.other_files.pin();
2940 assert!(
2941 other.iter().all(|(_, i)| !i.url_path.contains("secret")),
2942 "a directory symlinked out of the repo (or out of the overlay) must contribute no assets"
2943 );
2944 for (_, info) in other.iter() {
2945 assert!(
2946 !info.url_path.contains(".."),
2947 "static url_path escaped: {}",
2948 info.url_path
2949 );
2950 }
2951 }
2952
2953 #[test]
2956 fn test_refused_static_folder_is_not_scannable() {
2957 let tmp = tempfile::tempdir().expect("temp dir");
2958 let content = tmp.path().join("project/content");
2959 std::fs::create_dir_all(&content).expect("create content");
2960
2961 for value in ["../..", "../../../elsewhere"] {
2965 let repo = Repo::init(
2966 content.clone(),
2967 value.to_string(),
2968 &["md".to_string()],
2969 &[],
2970 &[],
2971 "index.md".to_string(),
2972 &[],
2973 &[],
2974 );
2975
2976 assert_eq!(
2977 repo.canonical_static_root(),
2978 None,
2979 "a static_folder the validator refuses must not be a scan root: {value:?}"
2980 );
2981 }
2982 }
2983
2984 fn two_deep_overlay_repo(static_folder: &str) -> (tempfile::TempDir, PathBuf, Repo) {
2987 let tmp = tempfile::tempdir().expect("temp dir");
2988 let project = tmp.path().join("project");
2989 let routes = project.join("src/routes");
2990 std::fs::create_dir_all(routes.join(".mbr")).expect("create routes");
2991 std::fs::write(routes.join("README.md"), "# Home").expect("write readme");
2992 std::fs::create_dir_all(project.join("static/videos")).expect("create static");
2993 std::fs::write(project.join("static/pic.png"), b"PNG bytes").expect("write pic");
2994 std::fs::write(project.join("static/videos/demo.mp4"), b"MP4 bytes").expect("write video");
2995
2996 let repo = Repo::init(
2997 routes,
2998 static_folder.to_string(),
2999 &["md".to_string()],
3000 &[],
3001 &[],
3002 "index.md".to_string(),
3003 &[],
3004 &[],
3005 );
3006 (tmp, project, repo)
3007 }
3008
3009 #[test]
3014 fn test_two_deep_overlay_is_scannable_with_clean_urls() {
3015 let (_tmp, project, repo) = two_deep_overlay_repo("../../static");
3016
3017 assert_eq!(
3018 repo.canonical_static_root().map(Path::to_path_buf),
3019 Some(project.join("static").canonicalize().expect("canonicalize")),
3020 "a two-level overlay the validator accepts must be a scan root"
3021 );
3022
3023 repo.scan_all().expect("scan must succeed");
3024 repo.scan_static_folder().expect("static scan must succeed");
3025
3026 let other = repo.other_files.pin();
3027 let mut urls: Vec<_> = other.iter().map(|(_, i)| i.url_path.clone()).collect();
3028 urls.sort();
3029 assert_eq!(
3030 urls,
3031 vec!["/pic.png".to_string(), "/videos/demo.mp4".to_string()],
3032 "a two-level static folder's assets must be indexed with the overlay prefix stripped"
3033 );
3034 }
3035
3036 #[test]
3042 fn test_overlay_urls_survive_a_noncanonical_static_folder_spelling() {
3043 let (_tmp, _project, repo) = two_deep_overlay_repo("./../../static");
3044
3045 repo.scan_all().expect("scan must succeed");
3046 repo.scan_static_folder().expect("static scan must succeed");
3047
3048 let other = repo.other_files.pin();
3049 let mut urls: Vec<_> = other.iter().map(|(_, i)| i.url_path.clone()).collect();
3050 urls.sort();
3051 assert_eq!(
3052 urls,
3053 vec!["/pic.png".to_string(), "/videos/demo.mp4".to_string()],
3054 "URLs must come from the resolved overlay, not the configured spelling"
3055 );
3056 }
3057
3058 fn mem_repo(vault: crate::vault::MemVault, tag_sources: &[TagSource]) -> Repo {
3063 Repo::init_with_vault(
3064 Arc::new(vault),
3065 "static".to_string(),
3066 &["md".to_string()],
3067 &["node_modules".to_string()],
3068 &["*.tmp".to_string()],
3069 "index.md".to_string(),
3070 tag_sources,
3071 &[],
3072 )
3073 }
3074
3075 fn sample_mem_vault() -> crate::vault::MemVault {
3076 crate::vault::MemVault::new()
3077 .with_file("index.md", "---\ntitle: Home\ntags: [start]\n---\n# Home\n")
3078 .with_file("docs/guide.md", "# The Guide\n\nBody.\n")
3079 .with_file("docs/index.md", "---\ntitle: Docs\n---\n")
3080 .with_file("docs/notes.txt", "plain text body")
3081 .with_file("docs/scratch.tmp", "ignored by glob")
3082 .with_file(".hidden/secret.md", "# Secret")
3083 .with_file("node_modules/pkg/readme.md", "# Dependency")
3084 .with_file("static/img.png", "PNG bytes")
3085 .with_file("static/css/site.css", "body{}")
3086 }
3087
3088 #[test]
3089 fn test_scan_through_a_mem_vault_indexes_everything_a_disk_scan_would() {
3090 let tags = [TagSource {
3091 field: "tags".to_string(),
3092 label: None,
3093 label_plural: None,
3094 }];
3095 let repo = mem_repo(sample_mem_vault(), &tags);
3096 let root = PathBuf::from(crate::vault::MemVault::DEFAULT_ROOT);
3097 assert_eq!(repo.canonical_root, root);
3098
3099 repo.scan_all().expect("scan");
3100 let markdown_urls: Vec<String> = sorted(
3101 repo.markdown_files
3102 .pin()
3103 .iter()
3104 .map(|(_, info)| info.url_path.clone())
3105 .collect(),
3106 );
3107 assert_eq!(markdown_urls, vec!["/", "/docs/", "/docs/guide/"]);
3108
3109 let pin = repo.markdown_files.pin();
3110 let home = pin
3111 .get(&root.join("index.md"))
3112 .expect("keyed under the vault root");
3113 assert_eq!(home.raw_path, PathBuf::from("index.md"));
3114 let fm = home
3115 .frontmatter
3116 .as_ref()
3117 .expect("frontmatter read via the vault");
3118 assert_eq!(fm.get("title").and_then(|v| v.as_str()), Some("Home"));
3119 let guide = pin
3120 .get(&root.join("docs").join("guide.md"))
3121 .expect("nested file");
3122 assert_eq!(
3123 guide
3124 .frontmatter
3125 .as_ref()
3126 .and_then(|fm| fm.get("title"))
3127 .and_then(|v| v.as_str()),
3128 Some("The Guide"),
3129 "the first-H1 fallback works off vault bytes too"
3130 );
3131 assert!(guide.modified > 0 && guide.created > 0);
3132 assert_eq!(repo.tag_index.get_pages("tags", "start").len(), 1);
3133 drop(pin);
3134
3135 assert!(
3137 repo.other_files
3138 .pin()
3139 .iter()
3140 .all(|(_, info)| !info.url_path.starts_with("/img")),
3141 "static assets wait for scan_static_folder"
3142 );
3143 repo.scan_static_folder().expect("static scan");
3144 let other_urls: Vec<String> = sorted(
3145 repo.other_files
3146 .pin()
3147 .iter()
3148 .map(|(_, info)| info.url_path.clone())
3149 .collect(),
3150 );
3151 assert_eq!(
3152 other_urls,
3153 vec!["/css/site.css", "/docs/notes.txt", "/img.png"]
3154 );
3155 }
3156
3157 #[test]
3160 fn test_mem_vault_basic_metadata_is_populated_through_the_vault() {
3161 let repo = mem_repo(sample_mem_vault(), &[]);
3162 repo.scan_all().expect("scan");
3163 repo.scan_static_folder().expect("static scan");
3164 let key = repo.canonical_root.join("static").join("img.png");
3165 let size = |repo: &Repo| {
3166 repo.other_files
3167 .pin()
3168 .get(&key)
3169 .and_then(|info| info.metadata.file_size_bytes)
3170 };
3171 assert_eq!(size(&repo), None, "deferred, exactly as before the vault");
3172 repo.populate_basic_metadata();
3173 assert_eq!(size(&repo), Some(9));
3174 }
3175
3176 #[test]
3177 fn test_mem_vault_text_extraction_reads_through_the_vault() {
3178 let repo = mem_repo(sample_mem_vault(), &[]);
3179 repo.scan_all().expect("scan");
3180 repo.ensure_text_extracted();
3181 let key = repo.canonical_root.join("docs").join("notes.txt");
3182 let text = repo
3183 .other_files
3184 .pin()
3185 .get(&key)
3186 .and_then(|info| info.extracted_text.clone());
3187 assert_eq!(text.as_deref(), Some("plain text body"));
3188 }
3189
3190 #[test]
3192 fn test_mem_vault_invalidate_file_reads_through_the_vault() {
3193 use crate::change_event::ChangeEventType;
3194 let vault = Arc::new(sample_mem_vault());
3195 let repo = Repo::init_with_vault(
3196 vault.clone(),
3197 "static".to_string(),
3198 &["md".to_string()],
3199 &[],
3200 &[],
3201 "index.md".to_string(),
3202 &[],
3203 &[],
3204 );
3205 repo.scan_all().expect("scan");
3206 let key = repo.canonical_root.join("docs").join("new.md");
3207 let title = |repo: &Repo| {
3208 repo.markdown_files.pin().get(&key).and_then(|info| {
3209 info.frontmatter
3210 .as_ref()?
3211 .get("title")?
3212 .as_str()
3213 .map(str::to_string)
3214 })
3215 };
3216
3217 vault.insert_file("docs/new.md", "---\ntitle: Fresh\n---\n");
3218 repo.invalidate_file(&key, &ChangeEventType::Created);
3219 assert_eq!(title(&repo).as_deref(), Some("Fresh"));
3220
3221 vault.insert_file("docs/new.md", "---\ntitle: Edited\n---\n");
3222 repo.invalidate_file(&key, &ChangeEventType::Modified);
3223 assert_eq!(title(&repo).as_deref(), Some("Edited"));
3224
3225 repo.invalidate_file(&key, &ChangeEventType::Deleted);
3226 assert!(repo.markdown_files.pin().get(&key).is_none());
3227
3228 let outside = PathBuf::from("/somewhere/else/x.md");
3230 repo.invalidate_file(&outside, &ChangeEventType::Created);
3231 assert!(repo.markdown_files.pin().get(&outside).is_none());
3232 }
3233
3234 #[test]
3238 fn test_not_downloaded_markdown_is_indexed_without_frontmatter() {
3239 let vault = sample_mem_vault();
3240 vault.set_availability("docs/guide.md", crate::vault::Availability::NotDownloaded);
3241 let repo = mem_repo(vault, &[]);
3242 repo.scan_all().expect("scan");
3243 let key = repo.canonical_root.join("docs").join("guide.md");
3244 let pin = repo.markdown_files.pin();
3245 let info = pin.get(&key).expect("indexed from the listing");
3246 assert_eq!(info.url_path, "/docs/guide/");
3247 assert!(info.frontmatter.is_none());
3248 }
3249
3250 #[test]
3253 fn test_scan_folder_queues_subfolders_with_vault_paths() {
3254 let repo = mem_repo(sample_mem_vault(), &[]);
3255 repo.scan_folder(&PathBuf::from("docs")).expect("scan");
3256 assert!(repo.queued_folders.is_empty(), "docs has no subfolders");
3257 repo.scan_folder(&PathBuf::from(".")).expect("scan root");
3258 let queued: Vec<String> = sorted(
3259 repo.queued_folders
3260 .pin()
3261 .iter()
3262 .map(|(_, queued)| queued.path.to_string())
3263 .collect(),
3264 );
3265 assert_eq!(queued, vec!["docs", "static"]);
3268 assert!(
3269 repo.scan_folder(&PathBuf::from("../escape")).is_ok(),
3270 "a folder climbing out of the root is skipped, not an error"
3271 );
3272 }
3273
3274 #[test]
3278 fn test_local_scan_defers_static_file_details() {
3279 let dir = tempfile::tempdir().expect("temp dir");
3280 std::fs::create_dir(dir.path().join("static")).unwrap();
3281 std::fs::write(dir.path().join("static/a.png"), b"12345").unwrap();
3282 std::fs::write(dir.path().join("b.pdf"), b"123").unwrap();
3283 let repo = test_repo(dir.path());
3284 repo.scan_all().expect("scan");
3285 repo.scan_static_folder().expect("static scan");
3286 let sizes = |repo: &Repo| -> Vec<(String, Option<u64>)> {
3287 let pin = repo.other_files.pin();
3288 let mut v: Vec<_> = pin
3289 .iter()
3290 .map(|(_, i)| (i.url_path.clone(), i.metadata.file_size_bytes))
3291 .collect();
3292 v.sort();
3293 v
3294 };
3295 assert_eq!(
3296 sizes(&repo),
3297 vec![("/a.png".to_string(), None), ("/b.pdf".to_string(), None)]
3298 );
3299 repo.populate_basic_metadata();
3300 assert_eq!(
3301 sizes(&repo),
3302 vec![
3303 ("/a.png".to_string(), Some(5)),
3304 ("/b.pdf".to_string(), Some(3))
3305 ]
3306 );
3307 }
3308
3309 #[test]
3319 fn test_in_flight_media_population_does_not_report_as_complete() {
3320 let dir = tempfile::tempdir().expect("temp dir");
3321 let repo = test_repo(dir.path());
3322
3323 repo.media_population_started.store(true, Ordering::SeqCst);
3326
3327 assert!(
3328 !repo.is_media_populated(),
3329 "population in flight must not be reported as complete"
3330 );
3331 }
3332
3333 #[test]
3334 fn test_media_populated_is_published_after_population_and_reset_by_clear() {
3335 let dir = tempfile::tempdir().expect("temp dir");
3336 std::fs::write(dir.path().join("index.md"), "# Home").expect("write index");
3337 std::fs::write(dir.path().join("paper.pdf"), b"not really a pdf").expect("write paper");
3338
3339 let repo = test_repo(dir.path());
3340 repo.scan_all().expect("scan must succeed");
3341
3342 assert!(!repo.is_media_populated());
3343 assert!(!repo.media_population_started.load(Ordering::SeqCst));
3344
3345 repo.populate_media_metadata();
3346 assert!(repo.media_population_started.load(Ordering::SeqCst));
3347 assert!(
3348 repo.is_media_populated(),
3349 "flag must be published once population finished"
3350 );
3351
3352 repo.populate_media_metadata();
3354 assert!(repo.is_media_populated());
3355
3356 repo.clear();
3359 assert!(!repo.is_media_populated());
3360 assert!(!repo.media_population_started.load(Ordering::SeqCst));
3361
3362 repo.populate_media_metadata();
3363 assert!(
3364 repo.is_media_populated(),
3365 "a rescan must be able to re-populate media metadata"
3366 );
3367 }
3368
3369 #[tokio::test]
3374 async fn test_wait_for_scan_returns_immediately_when_already_complete() {
3375 let dir = tempfile::tempdir().expect("temp dir");
3376 let repo = test_repo(dir.path());
3377 repo.mark_scan_complete();
3378
3379 tokio::time::timeout(std::time::Duration::from_secs(5), repo.wait_for_scan())
3380 .await
3381 .expect("a waiter started after completion must not block");
3382 }
3383
3384 #[tokio::test]
3385 async fn test_wait_for_scan_is_woken_by_later_completion() {
3386 let dir = tempfile::tempdir().expect("temp dir");
3387 let repo = Arc::new(test_repo(dir.path()));
3388
3389 let waiting = Arc::clone(&repo);
3390 let waiter = tokio::spawn(async move { waiting.wait_for_scan().await });
3391 tokio::task::yield_now().await;
3393
3394 repo.mark_scan_complete();
3395
3396 tokio::time::timeout(std::time::Duration::from_secs(5), waiter)
3397 .await
3398 .expect("waiter must be woken by mark_scan_complete")
3399 .expect("waiter task panicked");
3400 }
3401
3402 #[tokio::test]
3403 async fn test_wait_for_media_returns_immediately_when_already_populated() {
3404 let dir = tempfile::tempdir().expect("temp dir");
3405 let repo = test_repo(dir.path());
3406 repo.populate_media_metadata();
3407 repo.notify_media_populated();
3408
3409 tokio::time::timeout(std::time::Duration::from_secs(5), repo.wait_for_media())
3410 .await
3411 .expect("a waiter started after population must not block");
3412 }
3413
3414 #[tokio::test]
3417 async fn test_wait_for_media_ignores_spurious_notify_then_wakes() {
3418 let dir = tempfile::tempdir().expect("temp dir");
3419 let repo = Arc::new(test_repo(dir.path()));
3420
3421 let waiting = Arc::clone(&repo);
3422 let mut waiter = tokio::spawn(async move { waiting.wait_for_media().await });
3423 tokio::task::yield_now().await;
3424
3425 repo.notify_media_populated();
3427 assert!(
3428 tokio::time::timeout(std::time::Duration::from_millis(100), &mut waiter)
3429 .await
3430 .is_err(),
3431 "a notification with the flag unset must not release the waiter"
3432 );
3433
3434 repo.populate_media_metadata();
3435 repo.notify_media_populated();
3436
3437 tokio::time::timeout(std::time::Duration::from_secs(5), waiter)
3438 .await
3439 .expect("waiter must be woken once media metadata is populated")
3440 .expect("waiter task panicked");
3441 }
3442
3443 #[tokio::test]
3450 async fn test_full_rescan_releases_media_waiters_even_when_scan_fails() {
3451 let dir = tempfile::tempdir().expect("temp dir");
3452 let repo = Arc::new(test_repo(dir.path()));
3453
3454 repo.populate_media_metadata();
3456 repo.notify_media_populated();
3457 assert!(repo.is_media_populated());
3458
3459 dir.close().expect("remove repo root");
3462
3463 let rescanning = Arc::clone(&repo);
3464 let rescan = tokio::task::spawn_blocking(move || rescanning.full_rescan());
3465
3466 tokio::time::timeout(std::time::Duration::from_secs(5), rescan)
3467 .await
3468 .expect("rescan must not hang")
3469 .expect("rescan task panicked");
3470
3471 tokio::time::timeout(std::time::Duration::from_secs(5), repo.wait_for_media())
3472 .await
3473 .expect("a failed rescan must still release media waiters");
3474 }
3475
3476 #[test]
3485 fn test_markdown_files_serialize_deterministically_in_url_path_order() {
3486 let build_files = || {
3487 let files = MarkdownFiles(HashMap::new());
3488 let pin = files.pin();
3489 for i in 0..64 {
3490 pin.insert(
3491 PathBuf::from(format!("/repo/notes/note{i:02}.md")),
3492 MarkdownInfo {
3493 raw_path: PathBuf::from(format!("notes/note{i:02}.md")),
3494 url_path: format!("/notes/note{i:02}/"),
3495 created: 1,
3496 modified: 2,
3497 frontmatter: None,
3498 relationships: Vec::new(),
3499 },
3500 );
3501 }
3502 drop(pin);
3503 files
3504 };
3505
3506 let first = serde_json::to_string(&build_files()).expect("serialize");
3507 let second = serde_json::to_string(&build_files()).expect("serialize");
3508 assert_eq!(
3509 first, second,
3510 "serialized bytes must not depend on map iteration order"
3511 );
3512
3513 assert_eq!(
3514 url_paths_of(&first),
3515 sorted(url_paths_of(&first)),
3516 "entries must be emitted in url_path order"
3517 );
3518 }
3519
3520 #[test]
3521 fn test_other_files_serialize_deterministically_in_url_path_order() {
3522 let build_files = || {
3523 let files = OtherFiles(HashMap::new());
3524 let pin = files.pin();
3525 for i in 0..64 {
3526 let raw_path = PathBuf::from(format!("/repo/images/img{i:02}.png"));
3527 pin.insert(
3528 raw_path.clone(),
3529 OtherFileInfo {
3530 url_path: format!("/images/img{i:02}.png"),
3531 metadata: StaticFileMetadata::empty(&raw_path),
3532 raw_path,
3533 extracted_text: None,
3534 },
3535 );
3536 }
3537 drop(pin);
3538 files
3539 };
3540
3541 let first = serde_json::to_string(&build_files()).expect("serialize");
3542 let second = serde_json::to_string(&build_files()).expect("serialize");
3543 assert_eq!(
3544 first, second,
3545 "serialized bytes must not depend on map iteration order"
3546 );
3547
3548 assert_eq!(
3549 url_paths_of(&first),
3550 sorted(url_paths_of(&first)),
3551 "entries must be emitted in url_path order"
3552 );
3553 }
3554
3555 fn url_paths_of(json: &str) -> Vec<String> {
3557 serde_json::from_str::<Vec<serde_json::Value>>(json)
3558 .expect("serialized files must be a JSON array")
3559 .iter()
3560 .map(|entry| {
3561 entry["url_path"]
3562 .as_str()
3563 .expect("every entry has a url_path")
3564 .to_string()
3565 })
3566 .collect()
3567 }
3568
3569 fn sorted(mut values: Vec<String>) -> Vec<String> {
3570 values.sort();
3571 values
3572 }
3573}
3574
3575#[cfg(test)]
3576mod proptests {
3577 use super::*;
3578 use proptest::prelude::*;
3579
3580 fn valid_name_strategy() -> impl Strategy<Value = String> {
3582 "[a-zA-Z0-9_-]{1,20}"
3583 }
3584
3585 fn extension_strategy() -> impl Strategy<Value = String> {
3587 "[a-z]{1,10}"
3588 }
3589
3590 proptest! {
3591 #[test]
3593 fn prop_should_ignore_deterministic(
3594 name in valid_name_strategy(),
3595 ignore_dirs in proptest::collection::vec(valid_name_strategy(), 0..3),
3596 ignore_globs in proptest::collection::vec("[*][.][a-z]{1,5}", 0..3),
3597 ) {
3598 let path = Path::new(&name);
3599 let result1 = should_ignore(path, &ignore_dirs, &ignore_globs, &[]);
3600 let result2 = should_ignore(path, &ignore_dirs, &ignore_globs, &[]);
3601 prop_assert_eq!(result1, result2);
3602 }
3603
3604 #[test]
3606 fn prop_hidden_files_always_ignored(name in "[.][a-zA-Z0-9]{1,15}") {
3607 let path = Path::new(&name);
3608 prop_assert!(should_ignore(path, &[], &[], &[]));
3609 }
3610
3611 #[test]
3613 fn prop_normal_files_not_ignored(name in "[a-zA-Z][a-zA-Z0-9]{0,15}") {
3614 let path = Path::new(&name);
3615 prop_assert!(!should_ignore(path, &[], &[], &[]));
3617 }
3618
3619 #[test]
3621 fn prop_is_markdown_extension_deterministic(
3622 ext in extension_strategy(),
3623 extensions in proptest::collection::vec(extension_strategy(), 1..5)
3624 ) {
3625 let result1 = is_markdown_extension(&ext, &extensions);
3626 let result2 = is_markdown_extension(&ext, &extensions);
3627 prop_assert_eq!(result1, result2);
3628 }
3629
3630 #[test]
3632 fn prop_extension_in_list_returns_true(
3633 extensions in proptest::collection::vec(extension_strategy(), 1..5)
3634 ) {
3635 if let Some(ext) = extensions.first() {
3637 prop_assert!(is_markdown_extension(ext, &extensions));
3638 }
3639 }
3640
3641 #[test]
3643 fn prop_markdown_url_starts_with_slash(
3644 subpath in proptest::collection::vec(valid_name_strategy(), 1..4),
3645 filename in valid_name_strategy(),
3646 ) {
3647 let root = PathBuf::from("/root");
3648 let mut full_path = root.clone();
3649 for component in &subpath {
3650 full_path.push(component);
3651 }
3652 full_path.push(format!("{}.md", filename));
3653
3654 let url = build_markdown_url_path(&full_path, &root, "index.md");
3655 prop_assert!(url.starts_with('/'), "URL should start with /: {}", url);
3656 prop_assert!(!url.contains('\\'), "URL must not contain a backslash: {}", url);
3657 }
3658
3659 #[test]
3661 fn prop_markdown_url_ends_with_slash(
3662 subpath in proptest::collection::vec(valid_name_strategy(), 0..4),
3663 filename in valid_name_strategy(),
3664 ) {
3665 let root = PathBuf::from("/root");
3666 let mut full_path = root.clone();
3667 for component in &subpath {
3668 full_path.push(component);
3669 }
3670 full_path.push(format!("{}.md", filename));
3671
3672 let url = build_markdown_url_path(&full_path, &root, "index.md");
3673 prop_assert!(url.ends_with('/'), "URL should end with /: {}", url);
3674 prop_assert!(!url.contains('\\'), "URL must not contain a backslash: {}", url);
3675 }
3676
3677 #[test]
3679 fn prop_static_url_starts_with_slash(
3680 subpath in proptest::collection::vec(valid_name_strategy(), 0..4),
3681 filename in valid_name_strategy(),
3682 ext in extension_strategy(),
3683 ) {
3684 let root = PathBuf::from("/root");
3685 let mut full_path = root.clone();
3686 for component in &subpath {
3687 full_path.push(component);
3688 }
3689 full_path.push(format!("{}.{}", filename, ext));
3690
3691 let url = build_static_url_path(&full_path, &root, "static");
3692 prop_assert!(url.starts_with('/'), "URL should start with /: {}", url);
3693 prop_assert!(!url.contains('\\'), "URL must not contain a backslash: {}", url);
3694 }
3695
3696 #[test]
3698 fn prop_no_double_slashes_in_markdown_url(
3699 subpath in proptest::collection::vec(valid_name_strategy(), 0..4),
3700 filename in valid_name_strategy(),
3701 ) {
3702 let root = PathBuf::from("/root");
3703 let mut full_path = root.clone();
3704 for component in &subpath {
3705 full_path.push(component);
3706 }
3707 full_path.push(format!("{}.md", filename));
3708
3709 let url = build_markdown_url_path(&full_path, &root, "index.md");
3710 prop_assert!(!url.contains("//"), "URL should not contain //: {}", url);
3711 prop_assert!(!url.contains('\\'), "URL must not contain a backslash: {}", url);
3712 }
3713 }
3714}