Skip to main content

mbr_core/
repo.rs

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    /// Where the repository is stored. Every read the scan makes goes through
29    /// it; see [`crate::vault`].
30    #[serde(skip)]
31    vault: Arc<dyn Vault>,
32    /// Repository root, **always canonicalized**: it is [`Vault::root`] of
33    /// [`Self::vault`], which a [`LocalVault`] canonicalizes at construction.
34    ///
35    /// This must stay canonical. `scan_folder` walks from the vault's
36    /// canonicalization of each folder, and every path it lists is relativized
37    /// back against this same value. If the two were
38    /// allowed to differ — a raw root here and a canonicalized one there —
39    /// `pathdiff::diff_paths` would find no common prefix and hand back an
40    /// effectively absolute path, so every `url_path` would embed the whole
41    /// filesystem path.
42    ///
43    /// That is not hypothetical: on Windows `canonicalize` returns a verbatim
44    /// prefix (`\\?\D:\x`), and `Prefix::VerbatimDisk != Prefix::Disk`, so a
45    /// raw `D:\x` base silently failed to match. On Unix the same thing happens
46    /// through a symlinked temp dir (`/tmp` vs `/private/tmp`).
47    ///
48    /// Note this is deliberately *not* the same value as `Config::root_dir`,
49    /// which stays as the user supplied it (it feeds template globs and
50    /// user-facing output, where a verbatim prefix would cause its own
51    /// problems).
52    #[serde(skip)]
53    canonical_root: PathBuf,
54    #[serde(skip)]
55    static_folder: String,
56    /// The static overlay, as a vault of its own, when it legitimately
57    /// resolves *outside* `canonical_root` — `static_folder = "../static"` for
58    /// the `repo/content` + `repo/static` layout, or `"../../static"` for a
59    /// framework layout whose markdown root is pinned two levels down
60    /// (SvelteKit's `<project>/src/routes` alongside `<project>/static`). Its
61    /// [`Vault::root`] is the overlay's canonical directory.
62    ///
63    /// A separate vault because a [`VaultPath`] cannot leave its vault: the
64    /// overlay is a second root, not a path under the first. `None` when the
65    /// overlay is disabled, resolves inside the root, or was refused by the
66    /// policy — so this is the only storage besides [`Self::vault`] that the
67    /// scanner may descend into. Decided once, by
68    /// [`crate::config::resolve_static_overlay`], which is the same call
69    /// `Config::validate` makes: the scanner must never accept a root the
70    /// validator would have refused.
71    #[serde(skip)]
72    static_vault: Option<Arc<dyn Vault>>,
73    #[serde(skip)]
74    markdown_extensions: Vec<String>,
75    /// The configured index file name (e.g., "index.md" or "_index.md").
76    /// Exposed in site.json for frontend use.
77    pub index_file: String,
78    #[serde(skip)]
79    ignore_dirs: Vec<String>,
80    #[serde(skip)]
81    #[allow(dead_code)] // Kept for debugging/logging; compiled version used for matching
82    ignore_globs: Vec<String>,
83    #[serde(skip)]
84    compiled_ignore_globs: Vec<glob::Pattern>,
85    /// Hidden directories the leading-dot rule must let through, as absolute
86    /// paths under `canonical_root`. Rebased once in
87    /// [`Repo::with_explicit_hidden_dirs`] from the root-relative form
88    /// `Config` carries, so the per-entry check is a plain path comparison
89    /// against the keys the scan already builds. Empty for almost every run.
90    #[serde(skip)]
91    exempt_hidden_dirs: Vec<PathBuf>,
92    /// [`Self::exempt_hidden_dirs`] as vault paths, for the path resolver's
93    /// hidden-file rule ([`crate::vault::has_hidden_segment`]).
94    #[serde(skip)]
95    exempt_hidden_vault_paths: Vec<VaultPath>,
96    /// Every folder walked so far, by its **canonical** key, so a directory
97    /// reached twice (a symlink to a folder that is also walked directly) is
98    /// listed once.
99    #[serde(skip)]
100    pub scanned_folders: HashSet<PathBuf>,
101    /// Folders found but not walked yet, by key (the folder's path as listed,
102    /// *not* canonicalized — the next hop canonicalizes it).
103    #[serde(skip)]
104    pub queued_folders: HashMap<PathBuf, QueuedFolder>,
105    /// Folders whose listing failed [`VaultError::NotDownloaded`] — on iOS, an
106    /// iCloud Drive folder nothing has listed yet — by canonical key, as of
107    /// the last scan or [`Repo::record_diff`]. Their download was requested;
108    /// their files are indexed by the refresh that first lists them.
109    #[serde(skip)]
110    not_downloaded_folders: HashSet<PathBuf>,
111    pub markdown_files: MarkdownFiles,
112    pub other_files: OtherFiles,
113    /// Thread-safe index of tagged pages.
114    #[serde(skip)]
115    pub tag_index: Arc<TagIndex>,
116    /// Thread-safe index of typed relationships between notes.
117    #[serde(skip)]
118    pub relationship_index: Arc<RelationshipIndex>,
119    /// Thread-safe global name index for body-wikilink (`[[Name]]`) resolution.
120    /// Always built (independent of relationship tracking).
121    #[serde(skip)]
122    pub wikilink_index: Arc<WikilinkIndex>,
123    /// Configured tag sources for frontmatter extraction.
124    #[serde(skip)]
125    tag_sources: Vec<TagSource>,
126    /// Whether text extraction has been performed for searchable files.
127    #[serde(skip)]
128    text_extracted: Arc<AtomicBool>,
129    /// Whether media metadata population has *finished* (phase 2 of scan).
130    ///
131    /// Readers (`is_media_populated`, `wait_for_media`, the `media.json`
132    /// handler) treat this as "the metadata is there", so it is published only
133    /// once every entry has been probed.
134    #[serde(skip)]
135    media_populated: Arc<AtomicBool>,
136    /// Whether media metadata population has *started* (run-once guard).
137    ///
138    /// Deliberately separate from `media_populated`: using one flag for both
139    /// meant the very first instruction of `populate_media_metadata` announced
140    /// completion, so a `media.json` request arriving during the probing window
141    /// skipped the wait and returned entries with no duration or dimensions.
142    #[serde(skip)]
143    media_population_started: Arc<AtomicBool>,
144    /// Whether initial scan_all has completed.
145    #[serde(skip)]
146    scan_complete: Arc<AtomicBool>,
147    /// Notifier for scan completion (signals waiters when scan_all finishes).
148    #[serde(skip)]
149    scan_notify: Arc<tokio::sync::Notify>,
150    /// Notifier for media metadata population (signals waiters when populate_media_metadata finishes).
151    #[serde(skip)]
152    media_notify: Arc<tokio::sync::Notify>,
153    /// Per-file [`FileStamp`]s, recorded by the scan only with
154    /// [`Repo::with_change_tracking`] (the mobile app, which has no watcher
155    /// and diffs on foreground instead). `None` on the desktop, which then
156    /// pays one branch per listed file and nothing else.
157    #[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
170/// Serializes in `url_path` order rather than map order.
171///
172/// `papaya::HashMap` uses a randomly seeded `RandomState`, so iteration order
173/// differs between processes: two builds of the same repository produced
174/// byte-different `site.json`/`media.json`, which churns committed `build/`
175/// directories and invalidates content-hash/ETag caches on a no-op rebuild.
176/// `raw_path` breaks ties, because distinct files can share a `url_path`
177/// (`docs/index.md` and `docs.md` both render at `/docs/`).
178impl 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}
207/// Serializes in `url_path` order — see [`MarkdownFiles`]'s impl for why.
208impl 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    /// Path to the source file, **relative to the repo root**.
232    ///
233    /// Relative rather than absolute for two reasons: it is serialized into
234    /// `site.json`, where an absolute path would leak the build machine's
235    /// directory layout into every published static site; and consumers
236    /// (including `.mbr/` customizations) split it on `/` to recover the file
237    /// name. It is serialized through [`crate::url_path::serialize_as_url`] so
238    /// the JSON stays `/`-separated even on Windows.
239    ///
240    /// Join it with the repo root before doing any file I/O — see
241    /// `SearchEngine::search_file_content`. The `markdown_files` map is still
242    /// keyed by absolute path.
243    #[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    /// The simplified frontmatter, complete in memory (search matches on all
249    /// of it) but serialized **without** contact details — `emails`, `phones`,
250    /// `urls`, `social`, `im`, `addresses` and their dot-key variants. This
251    /// struct is what both server and static `site.json` serialize, so the one
252    /// attribute covers both; see [`crate::contact::is_public_frontmatter_key`].
253    #[serde(serialize_with = "crate::contact::serialize_public_frontmatter")]
254    pub frontmatter: Option<crate::markdown::SimpleMetadata>,
255    /// Typed relationships declared in frontmatter (unresolved endpoints).
256    /// Skipped in serialization — resolved relationships are exposed via the
257    /// relationship index in site.json/links.json instead.
258    #[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    /// Extracted text content for searchable files (PDFs, text files).
269    /// Only populated for files under the size limit.
270    #[serde(skip)]
271    pub extracted_text: Option<String>,
272}
273
274/// Maximum file size (in bytes) for text extraction (10 MB).
275const MAX_TEXT_EXTRACTION_SIZE: u64 = 10 * 1024 * 1024;
276
277impl OtherFileInfo {
278    /// Returns the file type as a string for search results.
279    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    /// Returns true if this file type is searchable (has extractable text).
291    pub fn is_searchable(&self) -> bool {
292        matches!(
293            &self.metadata.kind,
294            StaticFileKind::Pdf { .. } | StaticFileKind::Text
295        )
296    }
297
298    /// Extract text content from the file if it's a searchable type.
299    /// Respects file size limit for performance.
300    ///
301    /// `vault`/`path` locate the file ([`Repo::locate`]); `raw_path` is only
302    /// used to name it in logs.
303    fn extract_text(&self, vault: &dyn Vault, path: &VaultPath) -> Option<String> {
304        // Check file size first
305        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    /// Extract text from a PDF file using lopdf.
324    ///
325    /// Loaded from its local path when there is one (lopdf reads it lazily);
326    /// otherwise from bytes read through the vault.
327    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    /// Extract text from a plain text file.
364    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    /// Absolute path to the source file.
381    ///
382    /// Used internally to probe the file for kind-specific metadata (see
383    /// `populate_basic` / `populate_full`), so it must stay absolute.
384    ///
385    /// **Not serialized.** It reaches both `site.json` (static builds) and
386    /// `/.mbr/media.json` (server mode), where an absolute path would publish
387    /// the build machine's directory layout to every visitor. Nothing consumes
388    /// it on the wire — the frontend reads only `kind`, `created`, `modified`
389    /// and `file_size_bytes`.
390    #[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
429/* impl Serialize for Repo {
430    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
431    where
432        S: Serializer,
433    {
434        let mut s = serializer.serialize_struct("Site", 2)?;
435        s.serialize_field("markdown", &self.markdown_files)?;
436        s.serialize_field("other", &self.other_files)?;
437        s.end()
438    }
439}
440
441impl Serialize for papaya::HashMap<PathBuf, MarkdownInfo> {
442    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
443    where
444        S: Serializer,
445    {
446        let mut map = serializer.serialize_map(Some(self.x.len()))?;
447        for (k, v) in &self.x {
448            map.serialize_entry(&k.to_string(), &v)?;
449        }
450        map.end()
451    }
452} */
453
454impl StaticFileMetadata {
455    pub fn empty<P: Into<std::path::PathBuf>>(file: P) -> Self {
456        let file: PathBuf = file.into();
457        // We'll silently ignore errors and always return something
458        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    /// Populate basic file metadata (size, timestamps) without expensive media extraction.
522    ///
523    /// Reads `path` from the local filesystem. The repository's own pass,
524    /// [`Repo::populate_basic_metadata`], reads through its vault instead.
525    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    /// Sets `(size, created, modified)`; `None` clears all three, which is
537    /// what a failed `stat` has always recorded.
538    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    /// Populate media-specific metadata (ffmpeg, lopdf) - expensive operation.
550    #[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                // Extract genre from tags (case-insensitive search)
595                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                // Extract album from tags (case-insensitive search)
603                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    /// Full populate: basic + media metadata (used by build mode).
631    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    /// Turns read-only symlink mounts off (`enabled == false`): the
661    /// operator's `--no-symlink-mounts` / `MBR_NO_SYMLINK_MOUNTS`. The vaults
662    /// are rebuilt as plain [`LocalVault`]s on the same (canonical) roots, so a
663    /// link out of the repository or the overlay is `OutsideRoot` — 404 on the
664    /// server, not placed by a build — exactly as before mounts. Hidden-file
665    /// blocking is unaffected. Logs one line when it disables anything.
666    ///
667    /// Call before scanning: it replaces the storage the scan reads through.
668    #[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    /// Re-admits the hidden directories the user named on the command line
685    /// (`Config::explicit_hidden_dirs`) to the scan.
686    ///
687    /// A builder rather than an [`Self::init`] parameter: `init` already carries
688    /// eight positional arguments, and every caller but the two live entry
689    /// points wants the default of "no exemptions".
690    ///
691    /// Rebasing happens here, once, and onto `canonical_root` rather than the
692    /// configured root — the scan descends from the canonicalized root, so an
693    /// exemption built from the *configured* spelling would silently never match
694    /// wherever the two differ (`/tmp` vs `/private/tmp` on macOS).
695    #[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    /// The hidden directories the user named on the command line, as vault
710    /// paths of [`Self::vault`] — the exemption to "hidden files are never
711    /// served" (see [`crate::vault::has_hidden_segment`]).
712    pub fn exempt_hidden_dirs(&self) -> &[VaultPath] {
713        &self.exempt_hidden_vault_paths
714    }
715
716    /// A repository on the local filesystem at `root_dir`, stored through a
717    /// [`LocalVault`] (plus a second one for an external static overlay).
718    ///
719    /// Both vaults serve symlinks out of them as read-only **mounts**
720    /// ([`crate::vault::MountPolicy`]), each protecting the other's root, so
721    /// the server and the static build — which both start here — mount
722    /// exactly the same folders and refuse exactly the same targets.
723    #[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        // Ask the config policy, rather than re-deriving containment here, so a
738        // refused overlay is never scannable. A policy error is not re-reported:
739        // `Config::validate` already aborted startup on it, and the only callers
740        // that reach here with a bad value are tests constructing a `Repo`
741        // directly.
742        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    /// A repository stored in `vault`, which may be anything — the desktop's
777    /// [`LocalVault`], a test's [`crate::vault::MemVault`], a platform vault.
778    ///
779    /// `static_folder` is honoured when it names a folder *inside* the vault;
780    /// an external overlay is a second vault, given with
781    /// [`Self::with_static_vault`] ([`Self::init`] does both for a local
782    /// repository).
783    #[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        // Pre-compile glob patterns for efficient matching during scans
795        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    /// Sets the vault of an *external* static overlay (see the field docs).
840    /// Its assets are indexed with the overlay prefix stripped from their
841    /// URLs, and markdown inside it is skipped.
842    #[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    /// The repository's storage.
849    pub fn vault(&self) -> &Arc<dyn Vault> {
850        &self.vault
851    }
852
853    /// The external static overlay's storage, when there is one.
854    pub fn static_vault(&self) -> Option<&Arc<dyn Vault>> {
855        self.static_vault.as_ref()
856    }
857
858    /// The external static overlay's canonical directory, when there is one.
859    pub fn canonical_static_root(&self) -> Option<&Path> {
860        self.static_vault.as_deref().map(|vault| vault.root())
861    }
862
863    /// Which vault holds the file behind an index key, and where in it.
864    ///
865    /// `None` for a key under neither — which no scan produces, but the watcher
866    /// can report (a configured template folder outside the root).
867    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    /// [`Self::locate`], with the vault shared rather than borrowed — for a
876    /// caller that reads it from another task (a streamed response body).
877    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    /// The symlink mounts both vaults have accepted so far, each with the index
889    /// key of its location (where the link is). Grows as the first scan
890    /// discovers links; see [`crate::vault::Mount`].
891    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    /// The URL of the static file behind an index key: inside an external
904    /// overlay, its path in the overlay vault; anywhere else,
905    /// [`build_static_url_path`] against the root.
906    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    /// Size and `(created, modified)` seconds for the file behind `key`, read
919    /// through its vault; `None` when it cannot be stat'ed or located.
920    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    /// The vault a queued folder belongs to.
928    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    /// Converts an absolute file path into the repo-relative form stored in
936    /// [`MarkdownInfo::raw_path`].
937    ///
938    /// Uses `diff_paths` rather than `strip_prefix` so a file that somehow sits
939    /// outside the root still yields a *relative* result (`../outside.md`)
940    /// instead of an absolute one — `site.json` must never contain an absolute
941    /// host path. Both inputs are absolute in practice, so the fallback is
942    /// unreachable.
943    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    /// The static folder as a path inside the root vault, when it is one
949    /// (see [`crate::vault::configured_folder`]).
950    fn static_folder_path(&self) -> Option<VaultPath> {
951        crate::vault::configured_folder(self.vault.as_ref(), &self.static_folder)
952    }
953
954    /// The vault holding the static folder and the folder's path in it: the
955    /// external overlay from its root, or the configured folder inside the
956    /// repository. `None` when there is no static folder.
957    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    /// The canonical key of the static folder, wherever it lives.
965    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    /// Scans one folder of the root vault (non-recursively: subfolders are
980    /// queued in [`Self::queued_folders`], not walked).
981    ///
982    /// `relative_folder_path` is relative to the root. One that climbs out of
983    /// it is skipped with a warning, as a folder that *resolves* out of it is.
984    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    /// [`Self::scan_folder`] for a folder given as a vault path.
996    pub fn scan_vault_folder(&self, folder: &VaultPath) -> Result<(), RepoError> {
997        self.scan_dir(ScanLocation::WithinRoot, folder)
998    }
999
1000    /// Scans one folder of the vault at `location`.
1001    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        // A directory symlink can resolve outside the vault, and canonicalizing
1007        // re-roots the walk at its target. Every file found below it would then
1008        // relativize to `../…`, which `url_path::path_to_url` deliberately
1009        // preserves — so the URL escapes the site and, in build mode,
1010        // `output_dir.join(url_path)` writes pages outside `--output`. Refuse to
1011        // descend instead.
1012        //
1013        // The validated static overlay is the one exception, and a narrow one:
1014        // it is a specific directory the config policy already approved, scanned
1015        // through a vault of its own — not a general loosening. A link out of
1016        // the overlay is refused by that vault the same way.
1017        //
1018        // A link out of a vault built with mounts (every production vault) was
1019        // judged by the vault itself as it canonicalized: accepted, it comes
1020        // back as a path inside the vault and is walked like any folder;
1021        // refused, the vault has already logged why, once per link.
1022        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        // Skip if already scanned
1043        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        // One listing per folder, every child already stat'ed (kind, size,
1050        // times). A folder that cannot be listed contributes nothing, as the
1051        // old `WalkDir` loop's error entries did.
1052        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            // A file provider's folder whose listing is not on the device
1059            // (iCloud Drive on iOS keeps a folder *dataless* until something
1060            // lists it on a thread allowed to wait, and mbr's threads are
1061            // not). Ask for it, and let the folder be walked again: the
1062            // refresh that first lists it indexes its files.
1063            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            // A folder that cannot be listed contributes nothing, as the old
1074            // `WalkDir` loop's error entries did.
1075            Err(e) => {
1076                tracing::debug!("Could not list {}: {e}", start_key.display());
1077                Vec::new()
1078            }
1079        };
1080
1081        if start.is_root() {
1082            // What the root lists decides everything below it, and a file
1083            // provider can list less than its own UI shows: say what we got.
1084            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            // The key is the path the old walk produced: the canonical folder
1099            // plus the child's own name, so a symlinked *file* keeps its own
1100            // name, and ignore globs keep matching the same absolute paths.
1101            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                // Queue subdirectory for later scanning. Whether it may be
1115                // walked is decided on the next hop, when it is canonicalized.
1116                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                // Markdown inside an *external* static overlay has no
1127                // representable URL: `build_markdown_url_path` relativizes it to
1128                // `../static/…`, `url_path::path_to_url` preserves the `..` on
1129                // purpose, and build mode then joins that onto `output_dir` —
1130                // writing the page outside `--output`. That escape is exactly
1131                // what this pass fixed in `build.rs`, so the file is skipped
1132                // rather than indexed with a broken URL. Static assets are
1133                // unaffected: their URL strips the overlay prefix.
1134                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                // Process markdown file
1142                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                // Process static file
1159                //
1160                // Inside the overlay the URL is the path within the overlay
1161                // vault — the *resolved* directory stripped, never the
1162                // configured string. `build_static_url_path` strips the raw
1163                // value with a component-wise `strip_prefix`, so `../../static`
1164                // and the equivalent `./../../static` — both accepted by the
1165                // config policy — do not both match, and the second would leave
1166                // a `..` in the URL.
1167                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                // Basic metadata (size, timestamps) stays deferred to
1179                // `populate_basic_metadata()`, as it always was: the static
1180                // build never runs that pass, so recording the listing's
1181                // numbers here would change its `media.json`.
1182                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        // Parallel processing: extract frontmatter from markdown files and build tag index
1201        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                    // Extract tags from frontmatter for each configured tag source
1210                    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                        // Look up the field (supports dot notation like "taxonomy.tags")
1218                        if let Some(tag_value_json) = frontmatter.get(&tag_source.field) {
1219                            // Extract tag values (handles both arrays and comma-separated strings)
1220                            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    /// Walks every queued folder, one parallel batch per directory level,
1267    /// until nothing is queued.
1268    fn drain_queued_folders(&self) {
1269        while !self.queued_folders.is_empty() {
1270            // TODO: make sure this doesn't deadlock
1271            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                    }) // ignores errors
1284            });
1285        }
1286    }
1287
1288    pub fn scan_all(&self) -> Result<(), RepoError> {
1289        let scan_all_start = Instant::now();
1290
1291        // Pre-mark static folder as scanned to skip it during content scan.
1292        // This defers static file registration to scan_static_folder() so
1293        // mark_scan_complete() fires faster (search only needs markdown).
1294        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        // Un-mark static folder so scan_static_folder() can scan it later
1302        if let Some(ref sp) = static_deferred {
1303            self.scanned_folders.pin().remove(sp);
1304        }
1305
1306        // Log file counts and type breakdown
1307        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    /// Scan the static folder and its subdirectories.
1346    /// Deferred from scan_all() so mark_scan_complete() fires faster.
1347    pub fn scan_static_folder(&self) -> Result<(), RepoError> {
1348        let start = Instant::now();
1349        // An external overlay is its own vault, scanned from its root; an
1350        // in-root static folder is a folder of the root vault.
1351        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    /// Mark the initial scan as complete and notify any waiters.
1379    pub fn mark_scan_complete(&self) {
1380        self.scan_complete.store(true, Ordering::SeqCst);
1381        self.scan_notify.notify_waiters();
1382    }
1383
1384    /// Drop every cached view of the repository and rebuild it from disk.
1385    ///
1386    /// Used by the watcher when a change batch is too large to invalidate file
1387    /// by file. Blocking; call from `spawn_blocking`.
1388    ///
1389    /// **No step may return early.** `clear()` resets `media_populated`, so any
1390    /// path that skips [`Repo::notify_media_populated`] leaves every later
1391    /// [`Repo::wait_for_media`] — that is, `/.mbr/media.json` — blocked for the
1392    /// life of the process. A failed scan is logged and the pass continues on
1393    /// whatever was indexed: `clear()` already discarded the previous contents,
1394    /// so the real choice is between serving partial data and hanging.
1395    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    /// Populate basic file metadata (size, timestamps) for all other files.
1412    /// Deferred from scan_folder() to avoid blocking scan_all() completion,
1413    /// so search (which only needs markdown files) can proceed sooner.
1414    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    /// Returns true if the initial scan has completed.
1445    pub fn is_scan_complete(&self) -> bool {
1446        self.scan_complete.load(Ordering::SeqCst)
1447    }
1448
1449    /// Wait for the initial scan to complete. Returns immediately if already done.
1450    ///
1451    /// `mark_scan_complete()` fires exactly once and `Notify::notify_waiters()`
1452    /// stores no permit, so a notification observed between a flag check and the
1453    /// registration of the waiter would be lost forever — and `/.mbr/site.json`
1454    /// would block for the life of the process. Registering interest *before*
1455    /// re-checking closes that window (same pattern as the in-flight waits in
1456    /// `server.rs`).
1457    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            // Re-checked by the loop: `notified()` also returns on spurious wakeups.
1468            notified.await;
1469        }
1470    }
1471
1472    /// Returns true if media metadata has been populated.
1473    pub fn is_media_populated(&self) -> bool {
1474        self.media_populated.load(Ordering::Acquire)
1475    }
1476
1477    /// Notify waiters that media metadata population is complete.
1478    pub fn notify_media_populated(&self) {
1479        self.media_notify.notify_waiters();
1480    }
1481
1482    /// Wait for media metadata to be populated. Returns immediately if already done.
1483    ///
1484    /// Registers interest before re-checking the flag, for the same reason as
1485    /// [`Repo::wait_for_scan`].
1486    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    /// Populate media metadata (ffmpeg/lopdf) for all static files.
1505    /// This is deferred from initial scan to avoid blocking the first site.json response.
1506    pub fn populate_media_metadata(&self) {
1507        if self.media_population_started.swap(true, Ordering::SeqCst) {
1508            return; // Already running, or already finished
1509        }
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        // ffmpeg and pdfium open files by path, so only files with a
1516        // `local_path` can be probed; the rest keep their basic metadata.
1517        #[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        // Publish "finished" only now that every entry carries its metadata.
1541        // Callers invoke `notify_media_populated()` immediately after this
1542        // returns; waiters re-read the flag after being woken, so the store must
1543        // happen first.
1544        self.media_populated.store(true, Ordering::Release);
1545
1546        tracing::info!("populate_media_metadata completed in {:?}", start.elapsed());
1547    }
1548
1549    /// Extract text from searchable files (PDFs, text files) for search indexing.
1550    /// Deferred from initial scan since text is only needed for search.
1551    pub fn ensure_text_extracted(&self) {
1552        if self.text_extracted.swap(true, Ordering::SeqCst) {
1553            return; // Already extracted
1554        }
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    /// Clear all cached data, forcing a full rescan on next scan_all() call.
1575    ///
1576    /// Call this when files are added, removed, or modified to ensure
1577    /// the next scan picks up the changes.
1578    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        // Must be reset too, or the run-once guard would make the rescan's
1593        // `populate_media_metadata()` a no-op and leave `media_populated` false
1594        // forever, hanging every later `wait_for_media()`.
1595        self.media_population_started.store(false, Ordering::SeqCst);
1596        // Note: scan_complete is NOT reset here. It tracks whether the initial background
1597        // scan finished. After clear(), scan_all() will re-scan synchronously in handlers.
1598    }
1599
1600    /// The vault, vault path and `(created, modified)` seconds of the markdown
1601    /// file behind `key`, when it can be located and stat'ed — the
1602    /// `file_details_from_path` check `invalidate_file` used to make.
1603    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    /// Surgically invalidate a single file, updating only the affected cache entries.
1611    ///
1612    /// Much cheaper than `clear()` + `scan_all()` for small batches of file changes.
1613    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    /// Keeps a change-tracked file's stamp in step with an applied change —
1619    /// a watcher event, the server's own write, a refresh — so the next
1620    /// [`Self::diff_vault`] does not report it again. One `stat`, and only
1621    /// with change tracking.
1622    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        // Markdown outside the root (in an external overlay, or a mount inside
1652        // one) has no page URL, so the scanner never indexes it; neither may an
1653        // event. The same goes for any hidden path.
1654        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                        // Add tags from frontmatter
1686                        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                    // Re-extract frontmatter and update
1739                    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                    // Update basic metadata for modified static files
1765                    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    /// Rebuild the tag index from the current cached markdown files.
1780    ///
1781    /// Call this after surgical file invalidation when tags may have changed
1782    /// (e.g., after deleting or modifying markdown files).
1783    /// Returns the configured tag sources.
1784    pub fn tag_sources(&self) -> &[TagSource] {
1785        &self.tag_sources
1786    }
1787
1788    /// Rebuild the relationship index from the current cached markdown files.
1789    ///
1790    /// Must run after a scan (or file-change invalidation) once all note titles
1791    /// are known, since endpoint resolution matches on titles and filename
1792    /// stems across the whole repo. Modelled on [`Self::rebuild_tag_index`].
1793    pub fn build_relationship_index(&self) {
1794        // Short-circuit when no note declares any relationship: avoid the
1795        // per-note NoteRelInput cloning and the O(n log n) sort / name-index /
1796        // rebuild in `build_relationship_map` (mirrors the gated tag-index
1797        // rebuild). An empty rebuild is otherwise pure overhead.
1798        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(&notes, &self.markdown_extensions);
1810    }
1811
1812    /// Rebuild the global wikilink name index from the current cached markdown
1813    /// files.
1814    ///
1815    /// Unlike [`Self::build_relationship_index`] this is **ungated** — the index
1816    /// powers Obsidian-style `[[Name]]` body-link resolution for every repo, so
1817    /// it is rebuilt whenever the repo is (re)scanned or a file changes. Must run
1818    /// after a scan once all note titles/stems are known.
1819    pub fn build_wikilink_index(&self) {
1820        let notes = self.collect_note_inputs();
1821        self.wikilink_index.rebuild(&notes);
1822    }
1823
1824    /// Assembles one [`NoteRelInput`] per cached markdown file (url, title,
1825    /// filename stem, frontmatter aliases, index flag, declared relationships).
1826    ///
1827    /// Shared by [`Self::build_relationship_index`] and
1828    /// [`Self::build_wikilink_index`] so the per-note title/stem/alias derivation
1829    /// lives in one place.
1830    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                // Alternate names (e.g. maiden names) that also resolve to this
1852                // note — labeled ones included; see `contact::alias_names`.
1853                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
1907/// Returns file_size, created_secs, modified_secs
1908pub 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    // Modified time
1918    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    // Created time (birth time) is not available on all filesystems (e.g. older Linux
1927    // kernels, some NFS mounts). Fall back to the modified time rather than failing, so
1928    // the file is never silently dropped from listings/search when btime is unavailable.
1929    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// ============================================================================
1940// Pure helper functions for repo scanning (extracted for testability)
1941// ============================================================================
1942
1943/// Returns the repo-relative form of `abs_path`, or `None` when it resolves
1944/// *outside* `root`.
1945///
1946/// Containment is decided component-wise, not with `Path::starts_with`: the
1947/// relative path is what the rest of the pipeline consumes, and a single
1948/// `ParentDir` in it means the file is not in the repository at all. A
1949/// `RootDir`/`Prefix` component means [`pathdiff::diff_paths`] could not
1950/// relativize the two at all (mixed absolute/relative inputs, or different
1951/// Windows drives) and handed back something absolute.
1952///
1953/// This is the scan-side half of the containment guard: `url_path::path_to_url`
1954/// preserves `..` on purpose, so an escaping path stays escaped all the way into
1955/// `site.json` URLs and into `output_dir.join(url_path)` during a static build.
1956/// Which of the scanner's two legitimate roots a path belongs to.
1957///
1958/// There are exactly two: the repository root, and the validated external static
1959/// overlay. Anything else is not scannable at all, so this enum has no third
1960/// variant by design.
1961#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1962pub enum ScanLocation {
1963    /// The repository's own vault.
1964    WithinRoot,
1965    /// The external static overlay's vault.
1966    StaticOverlay,
1967}
1968
1969/// A folder found by a scan but not walked yet: which vault it is in, and
1970/// where. See [`Repo::queued_folders`].
1971#[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/// The leading-dot rule, and its one exemption.
1991///
1992/// Hidden entries are skipped because a repository's dot directories are tooling
1993/// state rather than content — `.git`, `.direnv`, `.obsidian`. The exemption is
1994/// for the case where that inference is provably wrong: the user *named* the
1995/// directory on the command line. `mbr -s .scratch` still roots at the enclosing
1996/// checkout (root discovery walks upward, and must — see
1997/// [`crate::config::find_root_dir`]), so without this the scan would index the
1998/// whole repository *except* the one folder that was asked for.
1999///
2000/// Only the chain from the root down to that target is exempt. A dot directory
2001/// nested *inside* it was not named and gets no waiver: naming `.scratch` says
2002/// something about `.scratch`, not about a `.git` that happens to live under it.
2003///
2004/// `exempt_hidden_dirs` is empty for every invocation that named no hidden
2005/// directory, and non-empty ones hold one or two entries. `starts_with('.')`
2006/// short-circuits ahead of the scan, so on the per-entry hot path this costs the
2007/// same character comparison it always did — no allocation, no canonicalization.
2008#[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
2013/// Checks if a path should be ignored based on the given rules.
2014///
2015/// A path is ignored if:
2016/// - Its name starts with '.', unless it is one of `exempt_hidden_dirs`
2017///   (see [`is_ignored_hidden`])
2018/// - It's a directory matching one of the ignore_dirs
2019/// - It matches one of the ignore_globs patterns
2020///
2021/// `exempt_hidden_dirs` must be expressed in the same base as `path`: callers
2022/// that pass absolute paths pass absolute exemptions, and the watcher — which
2023/// works entirely in repo-relative space so its glob matching stays anchored at
2024/// the root — passes repo-relative ones.
2025pub 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    // Hidden files/dirs (starting with .)
2034    if is_ignored_hidden(path, file_name, exempt_hidden_dirs) {
2035        return true;
2036    }
2037
2038    // Directory matching ignore list
2039    if path.is_dir() && ignore_dirs.iter().any(|x| x.as_str() == file_name) {
2040        return true;
2041    }
2042
2043    // Glob pattern match
2044    ignore_globs.iter().any(|pat| {
2045        glob::Pattern::new(pat)
2046            .map(|pat| pat.matches_path(path))
2047            .unwrap_or(false)
2048    })
2049}
2050
2051/// Checks if a path should be ignored using pre-compiled glob patterns.
2052/// This is more efficient than `should_ignore` when processing many files.
2053///
2054/// `is_dir` comes from the listing that produced `path` (the target's kind,
2055/// symlinks followed), so the check costs no `stat` of its own.
2056fn 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    // Hidden files/dirs (starting with .)
2066    if is_ignored_hidden(path, file_name, exempt_hidden_dirs) {
2067        return true;
2068    }
2069
2070    // Directory matching ignore list
2071    if is_dir && ignore_dirs.iter().any(|x| x.as_str() == file_name) {
2072        return true;
2073    }
2074
2075    // Pre-compiled glob pattern match
2076    compiled_patterns.iter().any(|pat| pat.matches_path(path))
2077}
2078
2079/// Builds a URL path for a markdown file.
2080///
2081/// Converts a filesystem path relative to root into a URL path:
2082/// - Ensures leading slash
2083/// - Removes index file from path (e.g., /docs/index.md → /docs/)
2084/// - Replaces file extension with trailing slash
2085pub fn build_markdown_url_path(path: &Path, root_dir: &Path, index_file: &str) -> String {
2086    // `path_to_url` (not `to_string_lossy`) so the separators below are `/` on
2087    // every platform; otherwise the `rsplit('/')` and `contains('/')` checks
2088    // that follow silently stop matching on Windows.
2089    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    // Remove index file from path — only when the final path component (file name)
2093    // exactly equals the index file, not merely when it's a suffix substring.
2094    // Otherwise "docs/myindex.md" would be wrongly truncated for index_file "index.md".
2095    if url.rsplit('/').next() == Some(index_file) {
2096        url.truncate(url.len() - index_file.len());
2097    }
2098
2099    // Replace extension with trailing slash
2100    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
2110/// Builds a URL path for a static file.
2111///
2112/// Converts a filesystem path relative to root into a URL path:
2113/// - Removes static folder prefix
2114/// - Ensures leading slash
2115pub 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    // Strip only a *leading* `{static_folder}/` path component, not any substring
2119    // occurrence of the folder name. A plain `.replacen` would corrupt paths like
2120    // "notes/static-analysis/img.png" (with static_folder="static").
2121    let stripped = relative.strip_prefix(static_folder).unwrap_or(&relative);
2122
2123    format!("/{}", crate::url_path::path_to_url(stripped))
2124}
2125
2126/// Checks if a file has a markdown extension.
2127pub fn is_markdown_extension(extension: &str, markdown_extensions: &[String]) -> bool {
2128    markdown_extensions.iter().any(|x| x.as_str() == extension)
2129}
2130
2131/// Parses a comma-separated string of tag values into individual tags.
2132///
2133/// Handles whitespace around commas and filters empty values.
2134///
2135/// # Examples
2136///
2137/// ```
2138/// use mbr_core::repo::parse_tag_values;
2139///
2140/// let tags: Vec<String> = parse_tag_values("rust, programming, web dev").collect();
2141/// assert_eq!(tags, vec!["rust", "programming", "web dev"]);
2142/// ```
2143pub 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
2150/// Extract tag values from a serde_json::Value (supports both arrays and comma-separated strings).
2151///
2152/// # Examples
2153///
2154/// ```
2155/// use mbr_core::repo::extract_tag_values;
2156///
2157/// // From array
2158/// let val = serde_json::json!(["rust", "python"]);
2159/// assert_eq!(extract_tag_values(&val), vec!["rust", "python"]);
2160///
2161/// // From comma-separated string
2162/// let val = serde_json::json!("rust, python");
2163/// assert_eq!(extract_tag_values(&val), vec!["rust", "python"]);
2164/// ```
2165pub 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
2177/// Gets the page title from frontmatter or falls back to filename.
2178///
2179/// Priority:
2180/// 1. `title` field in frontmatter
2181/// 2. Filename stem (without extension)
2182fn 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    /// `Repo` must canonicalize the root it is handed, so that the base used to
2200    /// relativize scanned paths is identical to the one the scan starts from.
2201    /// If these drift, `diff_paths` returns an effectively absolute path and
2202    /// every `url_path` embeds the whole filesystem path.
2203    #[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    /// Canonicalization must not lose a root that does not exist yet; falling
2226    /// back to the given path keeps the two sides consistent.
2227    #[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    // ---- the leading-dot exemption ------------------------------------------
2267
2268    /// The whole point: a hidden directory the user named is not tooling state.
2269    #[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    /// Naming `.scratch` says nothing about a dot directory *inside* it, which
2281    /// stays tooling state and stays skipped.
2282    #[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    /// Exemption is by exact path, not by name: a same-named hidden directory
2294    /// elsewhere in the repository was never on the target's chain.
2295    #[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    /// The exemption waives *only* the leading-dot rule; the other two rules are
2307    /// untouched, so an exempt directory still loses to an ignore glob.
2308    #[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    // Regression (Bug #8): the static folder name appearing as a substring of a
2369    // path component must NOT be stripped. Only a leading `{static_folder}/`
2370    // component is removed. Previously `.replacen` corrupted these paths.
2371    #[test]
2372    fn test_build_static_url_path_preserves_static_substring() {
2373        let root = Path::new("/root");
2374
2375        // "static" appears inside a directory name, not as a leading component.
2376        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        // "static" appears inside a nested (non-leading) directory name.
2383        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        // Correctly-prefixed paths still have the leading component stripped.
2390        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    // Regression (Bug #9): a file whose name merely ends with the index file
2398    // name (e.g. "myindex.md" for index_file "index.md") must NOT be treated as
2399    // an index file. Previously `ends_with` truncated it to "/docs/my/".
2400    #[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        // A genuine index file is still collapsed to a trailing slash.
2411        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    // Regression (Bug #10): file_details_from_path must not fail (which would drop
2419    // the file from listings/search) when birth time is unavailable. It falls back
2420    // to the modified time. On platforms that do support btime this still succeeds.
2421    #[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        // Both timestamps must be populated; created is either the real btime or,
2432        // when unavailable, the modified time (never causing an error/drop).
2433        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    // ==================== Root containment ====================
2538
2539    /// Builds a `Repo` rooted at `root` with the defaults these tests need.
2540    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    /// The bug this exemption exists for: `mbr -s .scratch` roots at the
2554    /// enclosing repository (correctly), and the scan then skipped the one
2555    /// directory the user asked for — `site.json` and the directory listing for
2556    /// `/.scratch/` both came back empty while `/.scratch/alpha/` served 200,
2557    /// because `path_resolver` hits the disk and only the *index* was blind.
2558    #[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        // Nested, and therefore never named: still tooling state, still skipped.
2566        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        // A sibling hidden directory the user did not name.
2570        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    /// Without the exemption the same tree indexes nothing — this is the
2595    /// pre-fix behaviour, kept so the test above cannot pass vacuously.
2596    #[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        // The root itself relativizes to the empty path, which is contained.
2621        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        // A sibling directory: relativizes to `../elsewhere/secret.md`.
2629        assert_eq!(
2630            repo_relative_within_root(root, Path::new("/elsewhere/secret.md")),
2631            None
2632        );
2633        // An unresolved upward traversal.
2634        assert_eq!(
2635            repo_relative_within_root(root, &root.join("..").join("secret.md")),
2636            None
2637        );
2638        // Mixed absolute/relative inputs: `diff_paths` hands back something
2639        // absolute, which must not be mistaken for a repo-relative path.
2640        assert_eq!(
2641            repo_relative_within_root(Path::new("repo"), Path::new("/repo/a.md")),
2642            None
2643        );
2644    }
2645
2646    /// A visible temp dir: `tempfile`'s default `.tmpXXXX` name is hidden, and
2647    /// a symlink target under a hidden directory is refused as a mount.
2648    #[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    /// A directory symlinked out of the repository is a read-only **mount**:
2657    /// indexed at the link's location, with in-root URLs and raw paths.
2658    ///
2659    /// The escape this used to guard against still cannot happen. The walk was
2660    /// once re-rooted at the symlink target, so every file underneath
2661    /// relativized to `../…`, `path_to_url` preserved that, and the static
2662    /// builder joined it onto `--output` — writing pages outside the requested
2663    /// output directory. A mount's paths are the link's, so nothing climbs.
2664    /// (Until mounts, the link was skipped outright.)
2665    #[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    /// A link to a refused target (here: a hidden directory) still
2721    /// contributes nothing.
2722    #[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    // ==================== External static overlay ====================
2747
2748    /// Builds `<tmp>/project/{content,static}` and a `Repo` rooted at `content`
2749    /// with `static_folder = "../static"` — the `repo/content` + `repo/static`
2750    /// layout. Returns the temp dir (kept alive), the project dir and the repo.
2751    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    /// The user's layout: `static_folder = "../static"` with
2775    /// `static/videos -> <elsewhere>`. The link is a mount of the *overlay*
2776    /// vault, so its files are indexed at overlay URLs (`/videos/clip.mp4`),
2777    /// hidden files in it are not, and a watcher event for one keeps its URL.
2778    #[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        // A file appearing in the target, reported at the link's location (as
2824        // the watcher translates it), is indexed at the overlay URL.
2825        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    /// The regression: a peer static folder serves over HTTP but used to index
2846    /// nothing, so its assets were invisible to `media.json`, the media browser,
2847    /// the editor's media picker, search, and the media-metadata pass.
2848    #[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    /// Markdown inside an external overlay has no representable URL — it would
2866    /// relativize to `../static/…`, which `path_to_url` preserves and the static
2867    /// builder joins onto `--output`. It is skipped rather than indexed with a
2868    /// broken URL, so no `..` can reach `site.json`.
2869    #[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        // The assets alongside it are still indexed, with clean URLs.
2903        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    /// The overlay exception must stay an exception. A directory symlinked out
2914    /// of the root is still refused even when an external overlay is configured,
2915    /// so the "build writes pages outside --output" fix cannot regress through
2916    /// the new second scan root.
2917    #[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        // One escape from inside the root, one from inside the overlay itself.
2926        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    /// A `static_folder` the config policy would refuse must not become a scan
2954    /// root either — the scanner and the validator share one decision.
2955    #[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        // Two shapes the policy refuses for different reasons: an ancestor of
2962        // the markdown root, and a target past the ascent budget. Both must
2963        // leave the scanner with nothing extra to walk.
2964        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    /// Builds `<tmp>/project/{src/routes,static}` and a `Repo` rooted at
2985    /// `src/routes` — the SvelteKit shape, where the overlay is two levels up.
2986    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    /// A two-level overlay must index exactly like a peer one: the whole
3010    /// overlay prefix stripped, no `..` left in any URL. This is what catches a
3011    /// `strip_prefix` regression on a two-component prefix, which a one-level
3012    /// fixture cannot distinguish from a working implementation.
3013    #[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    /// `../../static` and `./../../static` name the same directory, and the
3037    /// config policy accepts both — so indexing must not depend on which
3038    /// spelling was written. Stripping the raw configured string does depend on
3039    /// it, because `Path::strip_prefix` compares components and keeps a leading
3040    /// `.`, which used to leave a `..` in the URL.
3041    #[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    // ==================== Non-local vaults ====================
3059
3060    /// A repository in a `MemVault`: no `local_path`, so every read the scan
3061    /// makes has to go through the vault.
3062    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        // The static folder is deferred to its own pass, as on disk.
3136        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    /// Basic metadata stays deferred, as on disk, and the deferred pass reads
3158    /// it through the vault — there is no file to `stat`.
3159    #[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    /// Watcher-driven invalidation reads the changed file through the vault.
3191    #[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        // A key outside the vault cannot be read through it and is not indexed.
3229        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    /// A listed-but-not-downloaded note is still indexed (its URL and times
3235    /// come from the listing); only its frontmatter is missing until it is
3236    /// readable.
3237    #[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    /// The server's non-recursive directory scan queues subfolders with their
3251    /// vault paths.
3252    #[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        // `.hidden` and `node_modules` are ignored; `docs` is already scanned
3266        // but still listed as a child.
3267        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    /// On disk, the scan itself leaves other files' basic metadata unset — the
3275    /// static build, which never runs `populate_basic_metadata`, publishes
3276    /// exactly that in `media.json` — and the deferred pass fills it in.
3277    #[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    // ==================== Media metadata flags ====================
3310
3311    /// Claiming the run-once guard must not announce completion.
3312    ///
3313    /// `media_populated` is what `is_media_populated()`/`wait_for_media()` and
3314    /// the `media.json` handler read as "the metadata is there". While it
3315    /// doubled as the "already started" guard, a request arriving during the
3316    /// probing window skipped the wait and got entries with no duration or
3317    /// dimensions.
3318    #[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        // Exactly the state a reader sees mid-population: guard claimed, work
3324        // not finished.
3325        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        // Run-once guard: a repeat call is a no-op and must not unset "finished".
3353        repo.populate_media_metadata();
3354        assert!(repo.is_media_populated());
3355
3356        // `clear()` must reset both flags, otherwise the full-rescan path would
3357        // skip population and leave every later `wait_for_media()` blocked.
3358        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    // ==================== Scan/media waiters ====================
3370
3371    /// `Notify::notify_waiters()` stores no permit, so a waiter created after the
3372    /// single completion notification has already fired must still return.
3373    #[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        // Let the waiter register its interest before completion fires.
3392        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    /// A notification that arrives with the flag still unset is spurious: the
3415    /// waiter must go back to waiting rather than report populated metadata.
3416    #[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        // Notification without population: must not release the waiter.
3426        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    /// A rescan whose scan step fails must still release `wait_for_media()`.
3444    ///
3445    /// `full_rescan()` calls `clear()` first, which resets `media_populated`.
3446    /// When the scan error caused an early return, `notify_media_populated()`
3447    /// never ran and `/.mbr/media.json` blocked for the life of the process —
3448    /// one unreadable repository root wedged the endpoint permanently.
3449    #[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        // Populate once so the flag starts set, as it would on a live server.
3455        repo.populate_media_metadata();
3456        repo.notify_media_populated();
3457        assert!(repo.is_media_populated());
3458
3459        // Delete the root out from under the repo so `scan_all()` errors. The
3460        // canonical root was resolved at construction, so it still points here.
3461        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    // ==================== Deterministic serialization ====================
3477
3478    /// Two builds of an unchanged repository must serialize to identical bytes.
3479    ///
3480    /// `papaya::HashMap` seeds its `RandomState` per instance, so iteration
3481    /// order differs between processes: `site.json` churned in `git diff` and
3482    /// content-hash/ETag caches invalidated on no-op rebuilds. Two
3483    /// independently built maps stand in for two builds.
3484    #[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    /// Extracts the `url_path` of every entry in a serialized file sequence.
3556    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    // Strategy for valid file/directory names (no path separators or special chars)
3581    fn valid_name_strategy() -> impl Strategy<Value = String> {
3582        "[a-zA-Z0-9_-]{1,20}"
3583    }
3584
3585    // Strategy for valid extensions
3586    fn extension_strategy() -> impl Strategy<Value = String> {
3587        "[a-z]{1,10}"
3588    }
3589
3590    proptest! {
3591        /// should_ignore is deterministic - same input always gives same output
3592        #[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        /// Hidden files (starting with .) are always ignored
3605        #[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        /// Non-hidden files without matching globs are not ignored
3612        #[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            // No ignore patterns configured
3616            prop_assert!(!should_ignore(path, &[], &[], &[]));
3617        }
3618
3619        /// is_markdown_extension is deterministic
3620        #[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        /// Extension in list returns true
3631        #[test]
3632        fn prop_extension_in_list_returns_true(
3633            extensions in proptest::collection::vec(extension_strategy(), 1..5)
3634        ) {
3635            // Pick the first extension from the list
3636            if let Some(ext) = extensions.first() {
3637                prop_assert!(is_markdown_extension(ext, &extensions));
3638            }
3639        }
3640
3641        /// build_markdown_url_path always returns path starting with /
3642        #[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        /// build_markdown_url_path always returns path ending with /
3660        #[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        /// build_static_url_path always returns path starting with /
3678        #[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        /// URL paths don't contain double slashes
3697        #[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}