Skip to main content

mbr_core/
config.rs

1use serde::{Deserialize, Serialize, Serializer};
2use std::sync::atomic::{AtomicBool, Ordering};
3use std::{
4    net::IpAddr,
5    path::{Path, PathBuf},
6};
7
8use figment::{
9    Figment,
10    providers::{Env, Format, Serialized, Toml},
11};
12
13use crate::errors::ConfigError;
14use crate::task_query::IncludeFilter;
15
16const DEFAULT_PORT: u16 = 5200;
17const DEFAULT_OEMBED_TIMEOUT_MS: u64 = 500;
18const DEFAULT_OEMBED_CACHE_SIZE: usize = 2 * 1024 * 1024; // 2 MB
19/// Default budget for the in-memory media metadata cache: 64 MB.
20///
21/// Deliberately independent of [`DEFAULT_OEMBED_CACHE_SIZE`]: the oembed cache
22/// holds short text metadata, while this one holds full JPEG cover images
23/// (a PDF cover renders up to 1200 px wide). Sharing the 2 MB oembed budget fit
24/// only a couple of dozen covers before FIFO eviction, and setting
25/// `oembed_cache_size = 0` to turn off link previews silently disabled media
26/// caching as well.
27const DEFAULT_MEDIA_CACHE_SIZE: usize = 64 * 1024 * 1024; // 64 MB
28const DEFAULT_UPLOAD_MAX_BYTES: usize = 25 * 1024 * 1024; // 25 MiB (26214400)
29
30/// Serde default for [`Config::media_cache_size`].
31fn default_media_cache_size() -> usize {
32    DEFAULT_MEDIA_CACHE_SIZE
33}
34
35/// Configuration for a single sort field in multi-level sorting.
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
37pub struct SortField {
38    /// Field to sort by: "title", "filename", "created", "modified", or any frontmatter field
39    pub field: String,
40    /// Sort order: "asc" or "desc"
41    #[serde(default = "default_sort_order")]
42    pub order: String,
43    /// Comparison type: "string" or "numeric"
44    #[serde(default = "default_sort_compare")]
45    pub compare: String,
46}
47
48fn default_sort_order() -> String {
49    "asc".to_string()
50}
51
52fn default_sort_compare() -> String {
53    "string".to_string()
54}
55
56fn default_link_tracking() -> bool {
57    true
58}
59
60fn default_relationship_tracking() -> bool {
61    true
62}
63
64fn default_tasks_enabled() -> bool {
65    true
66}
67
68fn default_review_enabled() -> bool {
69    true
70}
71
72fn default_tasks_stamp_done() -> bool {
73    true
74}
75
76/// Default for [`Config::tasks_default_include`].
77///
78/// Checkboxes only, not the wire default of [`IncludeFilter::All`]: the panel
79/// widens to `all` on its own when a tasks-only query comes back empty, so a
80/// repository that uses `TODO:` markers and no checkboxes still opens on its
81/// work — while one that uses both is not made to read them mixed together.
82fn default_tasks_default_include() -> IncludeFilter {
83    IncludeFilter::Tasks
84}
85
86/// Default markers that flag work as incomplete.
87///
88/// A marker is highlighted anywhere in a line, case-sensitively and with a
89/// conditional word boundary on each side; see [`crate::tasks::MarkerRule`]. A
90/// block that *starts* with one is wrapped whole, any other occurrence is
91/// wrapped on its own, and the first wrapper on a source line also carries an
92/// `id="mbr-marker-{line}"` deep-link anchor.
93pub fn default_incomplete_markers() -> Vec<String> {
94    vec![
95        "TK".to_string(),
96        "TODO:".to_string(),
97        "FIXME".to_string(),
98        "XXXX".to_string(),
99    ]
100}
101
102fn default_build_tag_pages() -> bool {
103    true
104}
105
106fn default_sidebar_style() -> String {
107    "panel".to_string()
108}
109
110const DEFAULT_SIDEBAR_MAX_ITEMS: usize = 100;
111
112fn default_sidebar_max_items() -> usize {
113    DEFAULT_SIDEBAR_MAX_ITEMS
114}
115
116fn default_graph_depth() -> usize {
117    2
118}
119
120/// Default for [`Config::flashcards_concentric_threshold`]: with the default
121/// six-card stack, 70% means five of six first answers rated Good or Easy.
122const DEFAULT_FLASHCARDS_CONCENTRIC_THRESHOLD: f64 = 0.7;
123
124fn default_flashcards_concentric_threshold() -> f64 {
125    DEFAULT_FLASHCARDS_CONCENTRIC_THRESHOLD
126}
127
128fn default_flashcards_progress_indicators() -> bool {
129    true
130}
131
132/// Whether the native menu bar is shown in the window.
133///
134/// Only Linux ever consults this. macOS puts the menu in the system-wide bar
135/// at the top of the screen, where it costs the window nothing, and Windows
136/// treats an in-window menu bar as the native convention; on both, the value is
137/// ignored. On Linux the bar is a `GtkMenuBar` packed into the window itself,
138/// which under a tiling compositor with no global-menu protocol is a strip of
139/// chrome above every page.
140///
141/// Hiding it costs discoverability and nothing else: `muda` attaches the
142/// accelerator group to the *window*, not to the bar, so every shortcut keeps
143/// working while the bar is hidden. See `browser::menu_bar_starts_visible`.
144#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
145#[serde(rename_all = "lowercase")]
146pub enum MenuBarVisibility {
147    /// Follow the platform convention: hidden on Linux, shown elsewhere.
148    #[default]
149    Auto,
150    /// Always start with the bar visible.
151    Always,
152    /// Never show the bar. F10 does not reveal it.
153    Never,
154}
155
156/// Serde default for [`Config::gui_menu_bar`].
157fn default_gui_menu_bar() -> MenuBarVisibility {
158    MenuBarVisibility::Auto
159}
160
161fn default_upload_max_bytes() -> usize {
162    DEFAULT_UPLOAD_MAX_BYTES
163}
164
165/// Configuration for a tag source - a frontmatter field that contains tags.
166///
167/// # Examples
168///
169/// Basic tag source:
170/// ```toml
171/// tag_sources = [
172///     { field = "tags" }
173/// ]
174/// ```
175///
176/// Tag source with custom labels:
177/// ```toml
178/// tag_sources = [
179///     { field = "taxonomy.performers", label = "Performer", label_plural = "Performers" }
180/// ]
181/// ```
182#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
183pub struct TagSource {
184    /// The frontmatter field to extract tags from.
185    /// Supports dot-notation for nested fields (e.g., "taxonomy.tags").
186    pub field: String,
187
188    /// Singular label for the tag source (e.g., "Tag", "Performer").
189    /// Auto-derived from field name if not specified.
190    #[serde(default)]
191    pub label: Option<String>,
192
193    /// Plural label for the tag source (e.g., "Tags", "Performers").
194    /// Auto-derived from field name if not specified.
195    #[serde(default)]
196    pub label_plural: Option<String>,
197}
198
199impl TagSource {
200    /// Returns the singular label for this tag source.
201    ///
202    /// Priority:
203    /// 1. Explicit `label` field
204    /// 2. Title-cased field name (last segment for dot-notation)
205    pub fn singular_label(&self) -> String {
206        if let Some(ref label) = self.label {
207            return label.clone();
208        }
209
210        // Extract last segment for dot-notation (taxonomy.tags -> tags)
211        let field_name = self.field.rsplit('.').next().unwrap_or(&self.field);
212
213        // Title case the field name
214        title_case(field_name)
215    }
216
217    /// Returns the plural label for this tag source.
218    ///
219    /// Priority:
220    /// 1. Explicit `label_plural` field
221    /// 2. Singular label + "s"
222    pub fn plural_label(&self) -> String {
223        if let Some(ref label) = self.label_plural {
224            return label.clone();
225        }
226
227        // Simple pluralization: add "s"
228        format!("{}s", self.singular_label())
229    }
230
231    /// Returns the URL source identifier for this tag source.
232    ///
233    /// This is the normalized field name used in URLs.
234    /// For dot-notation fields, uses the full path with dots (e.g., "taxonomy.performers").
235    /// Lowercased for URL consistency and sanitized against path traversal.
236    pub fn url_source(&self) -> String {
237        crate::wikilink::sanitize_path_component(&self.field.to_lowercase())
238    }
239}
240
241/// Simple title-case conversion for a field name.
242///
243/// Converts "tags" to "Tag", "performers" to "Performer", etc.
244/// Removes trailing 's' for simple singular form.
245fn title_case(s: &str) -> String {
246    if s.is_empty() {
247        return String::new();
248    }
249
250    // Remove trailing 's' for simple singular form
251    let base = s.strip_suffix('s').unwrap_or(s);
252    if base.is_empty() {
253        return "S".to_string();
254    }
255
256    // Capitalize first letter
257    let mut chars = base.chars();
258    match chars.next() {
259        Some(first) => first.to_uppercase().chain(chars).collect(),
260        None => String::new(),
261    }
262}
263
264/// Returns the default tag sources configuration.
265///
266/// Default: a single source extracting from the "tags" frontmatter field.
267pub fn default_tag_sources() -> Vec<TagSource> {
268    vec![TagSource {
269        field: "tags".to_string(),
270        label: None,
271        label_plural: None,
272    }]
273}
274
275/// Converts tag sources to a HashSet of field names for wikilink matching.
276///
277/// The HashSet contains the field names from each TagSource, which are used
278/// to detect valid tag link patterns like `[[Tags:rust]]` or `[text](tags:value)`.
279pub fn tag_sources_to_set(sources: &[TagSource]) -> std::collections::HashSet<String> {
280    sources.iter().map(|s| s.field.clone()).collect()
281}
282
283/// Converts tag sources to a Vec of URL source identifiers.
284///
285/// Each TagSource is converted to its lowercase URL identifier via `url_source()`.
286/// This is used for path resolution to detect tag URLs like `/tags/rust/`.
287pub fn tag_sources_to_url_sources(sources: &[TagSource]) -> Vec<String> {
288    sources.iter().map(|s| s.url_source()).collect()
289}
290
291/// Configuration for a relation type used by typed note relationships.
292///
293/// A relation type names an edge predicate (e.g. "parent", "spouse") and
294/// declares its semantics for automatic reverse-edge derivation:
295/// - `symmetric = true` — the reverse reads the same (spouse, sibling).
296/// - `inverse = Some("child")` — the reverse reads as the inverse (parent ↔
297///   child). Symmetric and inverse are mutually exclusive.
298///
299/// Unknown relation types (not listed here) are tolerated: they are tracked
300/// directed with no relabelling.
301///
302/// # Examples
303///
304/// ```toml
305/// relationship_types = [
306///     { name = "parent", inverse = "child", label = "Parent", label_plural = "Parents" },
307///     { name = "child", inverse = "parent", label = "Child", label_plural = "Children" },
308///     { name = "spouse", symmetric = true, label = "Spouse", label_plural = "Spouses" },
309/// ]
310/// ```
311#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
312pub struct RelationType {
313    /// The relation-type name / predicate (e.g. "parent").
314    pub name: String,
315
316    /// Whether the relation is symmetric (the reverse reads the same).
317    #[serde(default)]
318    pub symmetric: bool,
319
320    /// The inverse relation-type name, if this is one half of an inverse pair.
321    #[serde(default)]
322    pub inverse: Option<String>,
323
324    /// Singular display label (e.g. "Parent"). Auto-derived from `name` if unset.
325    #[serde(default)]
326    pub label: Option<String>,
327
328    /// Plural display label (e.g. "Parents"). Auto-derived from the singular
329    /// label if unset. Set explicitly for irregular plurals (e.g. "Children").
330    #[serde(default)]
331    pub label_plural: Option<String>,
332
333    /// Which way the edge points in a hierarchy, read from the declaring
334    /// note's side: `up` means the **object** ranks above the subject (my
335    /// parent, my manager, my employer). `None` = not hierarchical.
336    ///
337    /// Charts orient edges by this rather than by guessing from names, so a
338    /// custom pair such as `mentor`/`mentee` draws the right way up. The two
339    /// halves of an inverse pair must point opposite ways; the registry fills
340    /// in a missing half and repairs a contradictory one (with a warning).
341    #[serde(default, skip_serializing_if = "Option::is_none")]
342    pub hierarchy: Option<Hierarchy>,
343
344    /// Free-form grouping the charts use to pick which edges they draw:
345    /// `family` (family chart, timeline) and `work` (org chart). Inherited by
346    /// the reciprocal half of an inverse pair when that half leaves it unset.
347    #[serde(default, skip_serializing_if = "Option::is_none")]
348    pub category: Option<String>,
349}
350
351/// Direction of a hierarchical relation type. See [`RelationType::hierarchy`].
352#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
353#[serde(rename_all = "lowercase")]
354pub enum Hierarchy {
355    /// The object ranks above the subject (`parent`, `reports_to`).
356    Up,
357    /// The object ranks below the subject (`child`, `manages`).
358    Down,
359}
360
361impl Hierarchy {
362    /// The direction the other half of an inverse pair points.
363    pub fn flipped(self) -> Self {
364        match self {
365            Self::Up => Self::Down,
366            Self::Down => Self::Up,
367        }
368    }
369
370    /// The config/JSON spelling (`"up"` / `"down"`).
371    pub fn as_str(self) -> &'static str {
372        match self {
373            Self::Up => "up",
374            Self::Down => "down",
375        }
376    }
377}
378
379impl RelationType {
380    /// Returns the singular label (explicit `label`, else title-cased `name`).
381    pub fn singular_label(&self) -> String {
382        self.label
383            .clone()
384            .unwrap_or_else(|| title_case_word(&self.name))
385    }
386
387    /// Returns the plural label (explicit `label_plural`, else singular + "s").
388    pub fn plural_label(&self) -> String {
389        self.label_plural
390            .clone()
391            .unwrap_or_else(|| format!("{}s", self.singular_label()))
392    }
393}
394
395/// Title-cases a single word (capitalizes the first letter), preserving the
396/// rest. Unlike [`title_case`], it does not strip a trailing plural "s".
397fn title_case_word(s: &str) -> String {
398    let mut chars = s.chars();
399    match chars.next() {
400        Some(first) => first.to_uppercase().chain(chars).collect(),
401        None => String::new(),
402    }
403}
404
405/// Returns the default relation types: family and work.
406///
407/// | name         | inverse      | symmetric | hierarchy | category |
408/// |--------------|--------------|-----------|-----------|----------|
409/// | `parent`     | `child`      |           | up        | family   |
410/// | `child`      | `parent`     |           | down      | family   |
411/// | `spouse`     |              | yes       |           | family   |
412/// | `sibling`    |              | yes       |           | family   |
413/// | `reports_to` | `manages`    |           | up        | work     |
414/// | `manages`    | `reports_to` |           | down      | work     |
415/// | `assistant`  | `assists`    |           | down      | work     |
416/// | `assists`    | `assistant`  |           | up        | work     |
417/// | `employer`   | `employee`   |           | up        | work     |
418/// | `employee`   | `employer`   |           | down      | work     |
419/// | `colleague`  |              | yes       |           | work     |
420///
421/// A repository that sets `relationship_types` replaces this list wholesale.
422pub fn default_relationship_types() -> Vec<RelationType> {
423    use Hierarchy::{Down, Up};
424    // (name, inverse, symmetric, hierarchy, category, label, label_plural)
425    type Row = (
426        &'static str,
427        Option<&'static str>,
428        bool,
429        Option<Hierarchy>,
430        &'static str,
431        &'static str,
432        &'static str,
433    );
434    const FAMILY: &str = "family";
435    const WORK: &str = "work";
436    let table: [Row; 11] = [
437        (
438            "parent",
439            Some("child"),
440            false,
441            Some(Up),
442            FAMILY,
443            "Parent",
444            "Parents",
445        ),
446        (
447            "child",
448            Some("parent"),
449            false,
450            Some(Down),
451            FAMILY,
452            "Child",
453            "Children",
454        ),
455        ("spouse", None, true, None, FAMILY, "Spouse", "Spouses"),
456        ("sibling", None, true, None, FAMILY, "Sibling", "Siblings"),
457        (
458            "reports_to",
459            Some("manages"),
460            false,
461            Some(Up),
462            WORK,
463            "Reports to",
464            "Reports to",
465        ),
466        (
467            "manages",
468            Some("reports_to"),
469            false,
470            Some(Down),
471            WORK,
472            "Manages",
473            "Manages",
474        ),
475        (
476            "assistant",
477            Some("assists"),
478            false,
479            Some(Down),
480            WORK,
481            "Assistant",
482            "Assistants",
483        ),
484        (
485            "assists",
486            Some("assistant"),
487            false,
488            Some(Up),
489            WORK,
490            "Assists",
491            "Assists",
492        ),
493        (
494            "employer",
495            Some("employee"),
496            false,
497            Some(Up),
498            WORK,
499            "Employer",
500            "Employers",
501        ),
502        (
503            "employee",
504            Some("employer"),
505            false,
506            Some(Down),
507            WORK,
508            "Employee",
509            "Employees",
510        ),
511        (
512            "colleague",
513            None,
514            true,
515            None,
516            WORK,
517            "Colleague",
518            "Colleagues",
519        ),
520    ];
521    table
522        .into_iter()
523        .map(
524            |(name, inverse, symmetric, hierarchy, category, label, label_plural)| RelationType {
525                name: name.to_string(),
526                symmetric,
527                inverse: inverse.map(str::to_string),
528                label: Some(label.to_string()),
529                label_plural: Some(label_plural.to_string()),
530                hierarchy,
531                category: Some(category.to_string()),
532            },
533        )
534        .collect()
535}
536
537impl Default for SortField {
538    fn default() -> Self {
539        Self {
540            field: "title".to_string(),
541            order: default_sort_order(),
542            compare: default_sort_compare(),
543        }
544    }
545}
546
547/// Returns the default sort configuration: title ascending, string comparison.
548pub fn default_sort_config() -> Vec<SortField> {
549    vec![SortField::default()]
550}
551
552#[derive(Debug, Clone, Copy, PartialEq)]
553pub struct IpArray(pub [u8; 4]);
554
555#[derive(Debug, Deserialize, Serialize, Clone)]
556pub struct Config {
557    pub root_dir: PathBuf,
558    /// Hidden (leading-dot) directories the user *named on the command line*,
559    /// as root-relative paths. Exempted from the scanner's leading-dot rule —
560    /// see [`explicit_hidden_dirs`] for why, and [`crate::repo::should_ignore`]
561    /// for where.
562    ///
563    /// `serde(skip)`, like nothing else in this struct: it is derived at
564    /// startup from the invocation, so `.mbr/config.toml` and `MBR_*` must not
565    /// be able to name it. A repository that could set it would be declaring
566    /// which of *its own* hidden directories the operator's scan walks into,
567    /// which is the config file asserting a fact about the command line.
568    #[serde(skip)]
569    pub explicit_hidden_dirs: Vec<PathBuf>,
570    /// The operator's kill switch for read-only symlink mounts
571    /// ([`crate::vault::MountPolicy`]): `--no-symlink-mounts` or
572    /// `MBR_NO_SYMLINK_MOUNTS`. When set, a link out of the repository is
573    /// refused exactly as before mounts existed.
574    ///
575    /// `serde(skip)` for the reason `explicit_hidden_dirs` is: the switch
576    /// protects the operator *from* the repository, so the repository's own
577    /// `.mbr/config.toml` must not be able to turn mounts back on. Skipping it
578    /// also keeps figment's `MBR_*` layer from reading it (that layer cannot be
579    /// told apart from the file by the time `Config` is extracted), so
580    /// [`Config::read`] reads the environment variable itself
581    /// ([`no_symlink_mounts_from_env`]) and the CLI sets it afterwards. Negative
582    /// on purpose: a skipped field deserializes to `false`, which must mean
583    /// "mounts on" — the default.
584    #[serde(skip)]
585    pub no_symlink_mounts: bool,
586    pub host: IpArray,
587    pub port: u16,
588    pub static_folder: String,
589    pub markdown_extensions: Vec<String>,
590    pub theme: String,
591    pub index_file: String,
592    pub ignore_dirs: Vec<String>,
593    pub ignore_globs: Vec<String>,
594    /// Directories to ignore in the file watcher. These directories will not trigger
595    /// live reload events when files inside them change.
596    pub watcher_ignore_dirs: Vec<String>,
597    /// Timeout in milliseconds for fetching oembed/OpenGraph metadata from URLs.
598    /// If the fetch doesn't complete in time, falls back to a plain link.
599    /// Set to 0 to disable oembed fetching entirely (uses plain links for all URLs
600    /// except YouTube and Giphy which are embedded without network calls).
601    pub oembed_timeout_ms: u64,
602    /// Maximum size in bytes for the oembed cache. The cache stores fetched page
603    /// metadata to avoid redundant network requests when rendering multiple files.
604    /// Set to 0 to disable caching entirely. Default: 2MB (2097152 bytes).
605    pub oembed_cache_size: usize,
606    /// Maximum size in bytes for the media metadata cache (server/GUI mode).
607    ///
608    /// Holds dynamically generated video/PDF metadata: cover images (JPEG
609    /// payloads), chapters and captions. Sized separately from
610    /// `oembed_cache_size` because a single cover can be hundreds of kilobytes,
611    /// so the 2 MB oembed budget evicted covers almost immediately — and
612    /// turning oembed caching off must not turn media caching off with it.
613    /// Set to 0 to disable caching entirely. Default: 64MB (67108864 bytes).
614    #[serde(default = "default_media_cache_size")]
615    pub media_cache_size: usize,
616    /// Optional template folder that overrides the default .mbr/ and compiled defaults.
617    /// Files found here take precedence; missing files fall back to compiled defaults.
618    #[serde(default)]
619    pub template_folder: Option<PathBuf>,
620    /// Sort configuration for file listings. Supports multi-level sorting by any field.
621    /// Default: sort by title (falling back to filename), ascending, string comparison.
622    #[serde(default = "default_sort_config")]
623    pub sort: Vec<SortField>,
624    /// Build concurrency: number of files to process in parallel during static builds.
625    /// None = auto-detect based on CPU cores (2x cores, capped at 32).
626    #[serde(default)]
627    pub build_concurrency: Option<usize>,
628    /// Enable dynamic video transcoding to serve lower-resolution variants (720p, 480p).
629    /// Only active in server/GUI mode. Videos are transcoded on-demand as HLS segments
630    /// and cached in memory. Default: false (disabled).
631    #[serde(default)]
632    pub transcode: bool,
633    /// Skip internal link validation during static site builds.
634    /// When true, the build will not check if internal links point to valid files.
635    /// Default: false (link checking enabled).
636    #[serde(default)]
637    pub skip_link_checks: bool,
638    /// Enable bidirectional link tracking (backlinks).
639    /// When enabled, generates links.json endpoints/files for each page with inbound/outbound links.
640    /// Server mode: lazy grep-based discovery on-demand with caching.
641    /// Build mode: eager collection during render, inverted for inbound links.
642    /// Default: true (enabled).
643    #[serde(default = "default_link_tracking")]
644    pub link_tracking: bool,
645    /// Tag sources configuration for extracting tags from frontmatter fields.
646    /// Supports dot-notation for nested fields (e.g., "taxonomy.tags").
647    /// Default: extract from "tags" field.
648    #[serde(default = "default_tag_sources")]
649    pub tag_sources: Vec<TagSource>,
650    /// Enable typed relationship tracking (named frontmatter relationships).
651    /// When enabled, per-note relationships are exposed via links.json and
652    /// site.json, and rendered in the info panel.
653    /// Default: true (enabled). Cheap when unused.
654    #[serde(default = "default_relationship_tracking")]
655    pub relationship_tracking: bool,
656    /// Relation types configuration for typed note relationships.
657    /// Declares symmetric / inverse-pair semantics and display labels.
658    /// Default: genealogy defaults (parent↔child, spouse, sibling).
659    #[serde(default = "default_relationship_types")]
660    pub relationship_types: Vec<RelationType>,
661    /// Generate tag landing pages during static site builds.
662    /// When enabled, creates /{source}/{value}/ pages for each tag value
663    /// and /{source}/ index pages listing all tags.
664    /// Default: true (enabled).
665    #[serde(default = "default_build_tag_pages")]
666    pub build_tag_pages: bool,
667    /// Sidebar navigation style.
668    /// - "panel": Three-pane modal browser (default, existing mbr-browse)
669    /// - "single": Persistent single-column sidebar (new mbr-browse-single)
670    #[serde(default = "default_sidebar_style")]
671    pub sidebar_style: String,
672    /// Maximum items per section in sidebar navigation.
673    /// Default: 100. Only applies when sidebar_style = "single".
674    #[serde(default = "default_sidebar_max_items")]
675    pub sidebar_max_items: usize,
676    /// Depth (hops) of the link/relationship neighborhood shown in the
677    /// sidebar mini graph. Range 1-5.
678    /// Default: 2.
679    #[serde(default = "default_graph_depth")]
680    pub graph_depth: usize,
681    /// Share of a pass's first answers that must be Good or Easy before the
682    /// flashcard deck's Concentric mode folds two more cards into the stack.
683    /// Range: greater than 0, at most 1. Default: 0.7.
684    #[serde(default = "default_flashcards_concentric_threshold")]
685    pub flashcards_concentric_threshold: f64,
686    /// Whether flashcard notes show review-progress indicators in the reading
687    /// view (a rating-coloured border on reviewed questions, a pie per heading).
688    /// Default: true.
689    #[serde(default = "default_flashcards_progress_indicators")]
690    pub flashcards_progress_indicators: bool,
691    /// Whether the GUI window shows the native menu bar. `"auto"` (default)
692    /// follows the platform convention — hidden on Linux, shown on macOS and
693    /// Windows; `"always"` and `"never"` pin it.
694    ///
695    /// GUI mode only, and only Linux reads it; see [`MenuBarVisibility`] for
696    /// why. On Linux, F10 toggles the bar at runtime unless this is `"never"`.
697    #[serde(default = "default_gui_menu_bar")]
698    pub gui_menu_bar: MenuBarVisibility,
699    /// Text to prepend to all page titles (e.g., "My Site: ").
700    /// Default: empty string (no prefix).
701    #[serde(default)]
702    pub title_prefix: String,
703    /// Text to append to all page titles (e.g., " | My Site").
704    /// Default: empty string (no suffix).
705    #[serde(default)]
706    pub title_suffix: String,
707    /// Markers that flag work as incomplete, matched anywhere in a line and
708    /// wrapped in `<span class="mbr-incomplete">…</span>`. A paragraph,
709    /// heading, list item, or table cell whose *first* text starts with one is
710    /// wrapped whole. Never matched inside code, image alt text, link
711    /// destinations or frontmatter.
712    /// Default: `["TK", "TODO", "FIXME", "XXX"]`.
713    #[serde(default = "default_incomplete_markers")]
714    pub incomplete_markers: Vec<String>,
715    /// Enable incomplete-block marking. `None` = mode default (on for
716    /// server/GUI, off for static build). CLI flags
717    /// `--mark-incomplete` / `--no-mark-incomplete` force a value.
718    #[serde(default)]
719    pub mark_incomplete: Option<bool>,
720    /// Enable the task browser (`POST /.mbr/tasks` and the task panel).
721    ///
722    /// Server/GUI only: the tasks index is built from live files, so static
723    /// builds never expose it regardless of this value. When disabled, the
724    /// endpoint returns 404 and the frontend does not offer the panel.
725    /// Default: true (enabled). CLI flag `--no-tasks` disables it.
726    #[serde(default = "default_tasks_enabled")]
727    pub tasks_enabled: bool,
728    /// Emit `data-mbr-line="{1-based source line}"` on block-level elements, so
729    /// a frontend feature can anchor a text selection back to `file.md:LINE`.
730    ///
731    /// Server/GUI only: a static build, the CLI and QuickLook pass
732    /// `ReviewLines::Omit` unconditionally rather than reading this, because an
733    /// anchor is only meaningful against a live file. Setting it in a
734    /// `.mbr/config.toml` therefore has no effect on `mbr -b`.
735    ///
736    /// Default: true (enabled). CLI flag `--no-review` disables it.
737    ///
738    /// Deliberately absent from `validate`: a bool has no invalid state, and an
739    /// entry there would be a line of code asserting `true || false`.
740    #[serde(default = "default_review_enabled")]
741    pub review_enabled: bool,
742    /// Maintain the `@done(YYYY-MM-DD HH:MM)` annotation when a task's status
743    /// is toggled through `POST /.mbr/task`: stamp it on completion, remove it
744    /// when the task is reopened or canceled.
745    ///
746    /// Turn it off to have that endpoint rewrite nothing but the marker byte,
747    /// leaving any `@done(...)` exactly as its author wrote it. Nothing else in
748    /// mbr ever writes to a markdown file on its own.
749    /// Default: true (stamp).
750    #[serde(default = "default_tasks_stamp_done")]
751    pub tasks_stamp_done: bool,
752    /// Which entries the task panel's **Show** filter starts on: `"tasks"`
753    /// (checkboxes), `"markers"` (`TODO:`-style lines), or `"all"`.
754    ///
755    /// Only the starting position — the user can change it in the ⚙ popover for
756    /// the life of that panel, and the panel widens to `all` by itself when the
757    /// configured default returns nothing (see `mbr-tasks-panel.ts`). That
758    /// fallback is why the default is the narrow `"tasks"` rather than `"all"`:
759    /// a repository with no checkboxes at all still opens on its markers, and
760    /// one with both is not made to read them interleaved.
761    ///
762    /// Typed as the real [`IncludeFilter`] rather than a `String` so serde
763    /// rejects a typo at startup, the way an out-of-range `graph_depth` is.
764    /// Config file and `MBR_TASKS_DEFAULT_INCLUDE` only; there is no CLI flag.
765    /// Default: `"tasks"`.
766    #[serde(default = "default_tasks_default_include")]
767    pub tasks_default_include: IncludeFilter,
768    /// Glob patterns whose matching files are left out of the **task index**.
769    ///
770    /// Each pattern is matched against a markdown file's repository-relative
771    /// path, always `/`-separated (`docs/templates/onboarding.md`). A file that
772    /// matches any pattern contributes nothing to the task browser: no tasks, no
773    /// folder in the folder pane, no counts.
774    ///
775    /// The use case is a folder of checklist *templates*, which is full of
776    /// deliberately unchecked boxes that are nobody's actual work. Rendering is
777    /// untouched — those files stay readable and their checkboxes stay
778    /// clickable, because in-document checkboxes exist whether or not the task
779    /// browser does.
780    ///
781    /// Patterns name **files**, not folders, so exclude a folder's contents
782    /// (`templates/**`) rather than the folder (`templates`, which matches
783    /// nothing). Default: empty, so nothing is excluded.
784    ///
785    /// Config file and `MBR_TASKS_IGNORE_GLOBS` only; there is no CLI flag.
786    #[serde(default)]
787    pub tasks_ignore_globs: Vec<String>,
788    /// Enable the in-browser markdown editing endpoints in server/GUI mode:
789    /// `/.mbr/raw` + `/.mbr/edit` (read/save an existing file) and the
790    /// file-management endpoints `/.mbr/create` (new file), `/.mbr/move`
791    /// (move/rename with repo-wide link rewrite), and `/.mbr/mkdir` (new
792    /// folder).
793    ///
794    /// The default depends on the mode: **on in the GUI** ([`Config::read_for_gui`],
795    /// a private window on loopback), **off everywhere else** ([`Config::read`]:
796    /// `mbr -s` must be asked). A value in `.mbr/config.toml` or
797    /// `MBR_EDIT_ENABLED` wins over either default; `--edit` / `--no-edit` win
798    /// over everything. Static builds never edit, whatever this says.
799    #[serde(default)]
800    pub edit_enabled: bool,
801    /// Argon2 PHC hash of the shared editing token. Required when editing is
802    /// enabled on a non-loopback host; requests must present the matching token
803    /// as `Authorization: Bearer <token>`. Generate with
804    /// `mbr --generate-edit-token`. Never sent to the frontend.
805    #[serde(default)]
806    pub edit_token_hash: Option<String>,
807    /// Require the editing token even for loopback callers. Off by default:
808    /// loopback edits are allowed without a token (still CSRF-protected).
809    #[serde(default)]
810    pub edit_require_token_on_loopback: bool,
811    /// Maximum size in bytes for a single asset uploaded via `/.mbr/upload`
812    /// (the in-browser editor's image uploader). Requests with a larger body are
813    /// rejected with `413 Payload Too Large`. Default: 25 MiB (26214400 bytes).
814    #[serde(default = "default_upload_max_bytes")]
815    pub upload_max_bytes: usize,
816}
817
818impl std::fmt::Display for IpArray {
819    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
820        let [a, b, c, d] = self.0;
821        write!(f, "{a}.{b}.{c}.{d}")
822    }
823}
824
825impl<'de> Deserialize<'de> for IpArray {
826    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
827    where
828        D: serde::Deserializer<'de>,
829    {
830        let ip_str = String::deserialize(deserializer)?;
831        let ip: IpAddr = ip_str.parse().map_err(serde::de::Error::custom)?;
832
833        match ip {
834            IpAddr::V4(v4) => Ok(IpArray(v4.octets())),
835            IpAddr::V6(_) => Err(serde::de::Error::custom("IPv6 addresses are not supported")),
836        }
837    }
838}
839
840impl Serialize for IpArray {
841    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
842    where
843        S: Serializer,
844    {
845        let ip = std::net::Ipv4Addr::from(self.0);
846        serializer.serialize_str(&ip.to_string())
847    }
848}
849
850impl Default for Config {
851    fn default() -> Self {
852        Config {
853            root_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
854            explicit_hidden_dirs: Vec::new(),
855            no_symlink_mounts: false,
856            host: IpArray([127, 0, 0, 1]),
857            port: DEFAULT_PORT,
858            static_folder: "static".to_string(),
859            markdown_extensions: vec!["md".to_string()],
860            theme: "default".to_string(),
861            index_file: "index.md".to_string(),
862            ignore_dirs: [
863                "target",
864                "result",
865                "build",
866                "node_modules",
867                "ci",
868                "templates",
869                ".git",
870                ".github",
871                "dist",
872                "out",
873                "coverage",
874            ]
875            .into_iter()
876            .map(|x| x.to_string())
877            .collect(),
878            ignore_globs: [
879                "*.log", "*.bak", "*.lock", "*.sh", "*.css", "*.scss", "*.js", "*.ts",
880            ]
881            .into_iter()
882            .map(|x| x.to_string())
883            .collect(),
884            watcher_ignore_dirs: [".direnv", ".git", "result", "target", "build"]
885                .into_iter()
886                .map(|x| x.to_string())
887                .collect(),
888            oembed_timeout_ms: DEFAULT_OEMBED_TIMEOUT_MS,
889            oembed_cache_size: DEFAULT_OEMBED_CACHE_SIZE,
890            media_cache_size: DEFAULT_MEDIA_CACHE_SIZE,
891            template_folder: None,
892            sort: default_sort_config(),
893            build_concurrency: None, // Auto-detect based on CPU cores
894            transcode: false,        // Disabled by default
895            skip_link_checks: false, // Link checking enabled by default
896            link_tracking: true,     // Bidirectional link tracking enabled by default
897            tag_sources: default_tag_sources(),
898            relationship_tracking: true, // Typed relationship tracking enabled by default
899            relationship_types: default_relationship_types(),
900            build_tag_pages: true, // Tag pages enabled by default
901            sidebar_style: default_sidebar_style(),
902            sidebar_max_items: default_sidebar_max_items(),
903            graph_depth: default_graph_depth(),
904            flashcards_concentric_threshold: default_flashcards_concentric_threshold(),
905            flashcards_progress_indicators: default_flashcards_progress_indicators(),
906            gui_menu_bar: default_gui_menu_bar(),
907            title_prefix: String::new(),
908            title_suffix: String::new(),
909            incomplete_markers: default_incomplete_markers(),
910            mark_incomplete: None,
911            tasks_enabled: true, // Task browser enabled by default (server/GUI only)
912            review_enabled: true, // `data-mbr-line` emitted by default (server/GUI only)
913            tasks_stamp_done: true, // Stamp @done(...) when a task is completed
914            tasks_default_include: default_tasks_default_include(),
915            tasks_ignore_globs: Vec::new(), // Opt-in: index every file's tasks by default
916            edit_enabled: false,
917            edit_token_hash: None,
918            edit_require_token_on_loopback: false,
919            upload_max_bytes: DEFAULT_UPLOAD_MAX_BYTES,
920        }
921    }
922}
923
924/// The user's home directory, read from the variable the platform actually sets.
925///
926/// Windows does not set `HOME`; the equivalent is `USERPROFILE`. Without this
927/// the guards below never fire there, so a stray `C:\Users\you\.git` or
928/// `.obsidian` would silently make the entire home directory the repo root and
929/// trigger a scan of everything the user owns.
930pub(crate) fn home_dir() -> Option<PathBuf> {
931    let home_var = if cfg!(windows) { "USERPROFILE" } else { "HOME" };
932    std::env::var_os(home_var).map(PathBuf::from)
933}
934
935/// Returns true if the given path is the user's home directory.
936///
937/// Compares canonical forms when both sides resolve, because callers do not
938/// agree on path shape: [`find_root_dir`] walks raw ancestors, while
939/// [`static_folder_anchor`] compares an already-canonicalized root. Without
940/// this, a `$HOME` reached through a symlink (`/home/me` -> `/mnt/users/me`)
941/// would slip past both guards.
942fn is_home_dir(path: &Path) -> bool {
943    let Some(home) = home_dir() else {
944        return false;
945    };
946    if path == home {
947        return true;
948    }
949    match (path.canonicalize(), home.canonicalize()) {
950        (Ok(path), Ok(home)) => path == home,
951        _ => false,
952    }
953}
954
955/// True when `path` is the home directory *or* a directory that contains it.
956///
957/// [`is_home_dir`] on its own was enough while the overlay could climb only one
958/// level: a root whose parent is `/home` would have to be `/home/notes`, which
959/// nobody has. At two levels it is `/home/you/notes` — an ordinary layout whose
960/// grandparent holds every account on the machine — and `is_home_dir` does not
961/// fire on `/home`, because `/home` is not `$HOME`. Ancestors of `$HOME`
962/// (`/home`, `/Users`, `C:\Users`) are therefore refused as an anchor too.
963///
964/// Nothing here helps when `HOME`/`USERPROFILE` is unset, as it can be for a
965/// service account started without a login session; the filesystem-root guard
966/// in [`static_folder_anchor`] is the only backstop then.
967fn contains_home_dir(path: &Path) -> bool {
968    if is_home_dir(path) {
969        return true;
970    }
971    // `starts_with("")` is true of every path, and an empty parent is how a
972    // *relative* root bottoms out — that case belongs to the filesystem-root
973    // guard, not to this one.
974    if path.as_os_str().is_empty() {
975        return false;
976    }
977    let Some(home) = home_dir() else {
978        return false;
979    };
980    if home.starts_with(path) {
981        return true;
982    }
983    match (path.canonicalize(), home.canonicalize()) {
984        (Ok(path), Ok(home)) => home.starts_with(path),
985        _ => false,
986    }
987}
988
989/// The environment variable that disables symlink mounts.
990pub const NO_SYMLINK_MOUNTS_ENV: &str = "MBR_NO_SYMLINK_MOUNTS";
991
992/// Set once by the binary when it was started with `--no-symlink-mounts`
993/// ([`disable_symlink_mounts_for_process`]).
994static NO_SYMLINK_MOUNTS_FOR_PROCESS: AtomicBool = AtomicBool::new(false);
995
996/// Records `--no-symlink-mounts` for every later [`Config::read`] in this
997/// process.
998///
999/// Process-wide rather than only applied to the first `Config`, because the
1000/// GUI reads configuration again when the user opens another folder (the
1001/// folder picker, `Open…`, Finder's "Open With") and those reads never see the
1002/// command line. An environment variable reaches them by construction; this is
1003/// the same guarantee for the flag. It can only ever turn mounts off.
1004pub fn disable_symlink_mounts_for_process() {
1005    NO_SYMLINK_MOUNTS_FOR_PROCESS.store(true, Ordering::Relaxed);
1006}
1007
1008/// Set once by the binary when it was started with `--no-edit`
1009/// ([`disable_editing_for_process`]).
1010static EDITING_DISABLED_FOR_PROCESS: AtomicBool = AtomicBool::new(false);
1011
1012/// Records `--no-edit` for every later [`Config::read`] /
1013/// [`Config::read_for_gui`] in this process.
1014///
1015/// Process-wide for the reason [`disable_symlink_mounts_for_process`] is: the
1016/// GUI reads configuration again for every folder it opens, those reads never
1017/// see the command line, and the GUI's default is editing *on*. It can only
1018/// ever turn editing off.
1019pub fn disable_editing_for_process() {
1020    EDITING_DISABLED_FOR_PROCESS.store(true, Ordering::Relaxed);
1021}
1022
1023/// Whether [`NO_SYMLINK_MOUNTS_ENV`] asks for symlink mounts to be disabled.
1024///
1025/// Any value other than an explicit "off" (`0`, `false`, `no`, `off`, empty —
1026/// case-insensitive) counts as set: a kill switch should fail closed on a
1027/// spelling it does not recognize.
1028pub fn no_symlink_mounts_from_env() -> bool {
1029    std::env::var_os(NO_SYMLINK_MOUNTS_ENV).is_some_and(|value| env_flag_is_set(&value))
1030}
1031
1032/// The truthiness rule of [`no_symlink_mounts_from_env`], separated so it is
1033/// testable without touching the process environment.
1034fn env_flag_is_set(value: &std::ffi::OsStr) -> bool {
1035    let value = value.to_string_lossy();
1036    !matches!(
1037        value.trim().to_ascii_lowercase().as_str(),
1038        "" | "0" | "false" | "no" | "off"
1039    )
1040}
1041
1042/// The hidden (leading-dot) directories on the chain from `root_dir` down to
1043/// `target`, as root-relative paths, in root-to-target order.
1044///
1045/// This is the counterpart to [`find_root_dir`], and exists because the two
1046/// disagree by design. Root discovery walks *upward* to the enclosing
1047/// repository, so `mbr -s .scratch` inside a git checkout correctly roots at the
1048/// checkout — but the scanner's leading-dot rule then skips `.scratch`, and mbr
1049/// indexes everything except the one directory it was asked to serve. The set
1050/// returned here is what re-admits it: a hidden directory the user *named* is
1051/// content by definition, whatever the general rule says about dot directories.
1052///
1053/// Only the chain itself is returned, never anything below it. `mbr -s .scratch`
1054/// is a statement about `.scratch`, not a blanket waiver — a `.git` or
1055/// `.obsidian` *inside* it is tooling state there for exactly the reason it is
1056/// anywhere else, and stays hidden.
1057///
1058/// Paths are root-relative because that is the one base every consumer can
1059/// rebase from cheaply, and because they are compared against the scanner's
1060/// canonicalized root rather than the CLI string. Purely lexical: a target
1061/// outside the root (`strip_prefix` fails) yields an empty set, as does a target
1062/// equal to the root. A *file* target contributes only its hidden ancestors,
1063/// since its own name is what it is — no `is_dir` probe is needed, and none is
1064/// done, which is what keeps this testable without a filesystem.
1065pub fn explicit_hidden_dirs(root_dir: &Path, target: &Path) -> Vec<PathBuf> {
1066    let Ok(relative) = target.strip_prefix(root_dir) else {
1067        return Vec::new();
1068    };
1069
1070    relative
1071        .components()
1072        .scan(PathBuf::new(), |walked, component| {
1073            walked.push(component);
1074            Some(walked.clone())
1075        })
1076        .filter(|chain| {
1077            chain
1078                .file_name()
1079                .and_then(|name| name.to_str())
1080                .is_some_and(|name| name.starts_with('.'))
1081        })
1082        .collect()
1083}
1084
1085/// Search upward from the given path to find a repository root directory.
1086///
1087/// Searches for directory markers (`.mbr`, `.git`, `.zk`, `.obsidian`) then
1088/// file markers (`book.toml`, `mkdocs.yml`, `docusaurus.config.js`) in ancestor
1089/// directories. Falls back to the start path's directory if no markers found.
1090///
1091/// Skips matches at `$HOME` to avoid using the entire home directory as root
1092/// (e.g., when `~/.git` exists for a dotfiles repo).
1093pub fn find_root_dir(start_path: &Path) -> PathBuf {
1094    const DIR_MARKERS: &[&str] = &[".mbr", ".git", ".zk", ".obsidian"];
1095    const FILE_MARKERS: &[&str] = &["book.toml", "mkdocs.yml", "docusaurus.config.js"];
1096
1097    let dir = if start_path.is_dir() {
1098        start_path
1099    } else {
1100        start_path.parent().unwrap_or(start_path)
1101    };
1102
1103    for marker in DIR_MARKERS {
1104        if let Some(root) = dir
1105            .ancestors()
1106            .find(|a| a.join(marker).is_dir())
1107            .map(|p| p.to_path_buf())
1108        {
1109            // `continue`, not `break`: only *this* marker is disqualified. A
1110            // dotfiles `~/.git` must not stop the search for the `.obsidian`
1111            // that marks the actual vault further down.
1112            if is_home_dir(&root) {
1113                continue;
1114            }
1115            return root;
1116        }
1117    }
1118
1119    for marker in FILE_MARKERS {
1120        if let Some(root) = dir
1121            .ancestors()
1122            .find(|a| a.join(marker).is_file())
1123            .map(|p| p.to_path_buf())
1124        {
1125            if is_home_dir(&root) {
1126                continue;
1127            }
1128            return root;
1129        }
1130    }
1131
1132    dir.to_path_buf()
1133}
1134
1135impl Config {
1136    /// Loads configuration for the repository containing `search_config_from`.
1137    ///
1138    /// Layers, lowest precedence first (figment's `merge` gives the *later*
1139    /// provider precedence):
1140    ///
1141    /// 1. Compiled-in defaults ([`Config::default`])
1142    /// 2. `.mbr/config.toml` in the discovered root
1143    /// 3. `MBR_*` environment variables
1144    ///
1145    /// Environment variables deliberately win over the config file: the file
1146    /// ships inside the markdown repository, so an operator serving a repo they
1147    /// did not author must be able to override it from the outside.
1148    pub fn read(search_config_from: &Path) -> Result<Self, crate::MbrError> {
1149        Self::read_with_root(
1150            find_root_dir(search_config_from),
1151            search_config_from,
1152            Config::default(),
1153        )
1154    }
1155
1156    /// [`Config::read`] for the GUI: the same layers over
1157    /// [`Config::gui_defaults`] instead of [`Config::default`], so in-browser
1158    /// editing is on unless `.mbr/config.toml` or `MBR_EDIT_ENABLED` says
1159    /// otherwise.
1160    ///
1161    /// Every GUI read must come through here — the first folder and every
1162    /// folder opened later in the window — or the default would depend on how
1163    /// the folder was opened.
1164    ///
1165    /// The default yields to the network check [`Config::validate`] enforces:
1166    /// when the configured host is not loopback and no `edit_token_hash` is
1167    /// set, an editing default that nobody asked for is dropped (with a WARN)
1168    /// rather than failing the launch. An *explicit* `edit_enabled = true`
1169    /// there still fails, exactly as in [`Config::read`].
1170    pub fn read_for_gui(search_config_from: &Path) -> Result<Self, crate::MbrError> {
1171        Self::read_with_root(
1172            find_root_dir(search_config_from),
1173            search_config_from,
1174            Config::gui_defaults(),
1175        )
1176    }
1177
1178    /// The compiled-in defaults the GUI starts from: [`Config::default`] with
1179    /// `edit_enabled` on. The one place the modes' defaults differ.
1180    pub fn gui_defaults() -> Self {
1181        Config {
1182            edit_enabled: true,
1183            ..Config::default()
1184        }
1185    }
1186
1187    /// [`Config::read`] for a repository whose root is already known: no
1188    /// upward search, so no ancestor's marker can become the root and no
1189    /// ancestor's `.mbr/config.toml` is ever opened.
1190    ///
1191    /// For an embedder whose access grant *is* the repository — the iOS app,
1192    /// where a picked folder is the whole sandbox grant and its ancestors are
1193    /// outside it. Everything else (environment overrides, validation) is
1194    /// exactly [`Config::read`]'s.
1195    pub fn read_at_root(root_dir: &Path) -> Result<Self, crate::MbrError> {
1196        Self::read_with_root(root_dir.to_path_buf(), root_dir, Config::default())
1197    }
1198
1199    fn read_with_root(
1200        root_dir: PathBuf,
1201        search_config_from: &Path,
1202        default_config: Config,
1203    ) -> Result<Self, crate::MbrError> {
1204        // The layers someone wrote down, apart from the defaults below them,
1205        // so a default can be told from an explicit value (`edit_enabled`).
1206        let explicit = Figment::new()
1207            .merge(Toml::file(root_dir.join(".mbr/config.toml")))
1208            .merge(Env::prefixed("MBR_"));
1209        let edit_enabled_is_explicit = explicit.find_value("edit_enabled").is_ok();
1210        // Kept rather than discarded: figment is the only thing that knows which
1211        // layer supplied each key, and `static_folder` is trusted differently
1212        // depending on that (see `reject_repo_supplied_absolute_static_folder`).
1213        let figment = Figment::new()
1214            .merge(Serialized::defaults(default_config))
1215            .merge(explicit);
1216        let mut config: Config = figment
1217            .extract()
1218            .map_err(|e| ConfigError::ParseFailed(Box::new(e)))?;
1219        tracing::debug!("Loaded config: {:?}", &config);
1220        if !edit_enabled_is_explicit && config.editing_needs_token() {
1221            tracing::warn!(
1222                "In-browser editing is off: it is on by default here, but host {} is not \
1223                 loopback and no edit_token_hash is configured. Set edit_token_hash (see \
1224                 `mbr --generate-edit-token`) or bind to 127.0.0.1 to edit.",
1225                config.host
1226            );
1227            config.edit_enabled = false;
1228        }
1229        if EDITING_DISABLED_FOR_PROCESS.load(Ordering::Relaxed) {
1230            config.edit_enabled = false;
1231        }
1232        config.root_dir = root_dir;
1233        // Assigned here, beside `root_dir`, because this is the only place that
1234        // holds both halves of the derivation at once: the discovered root and
1235        // the path the user actually named. Doing it in `main` would leave every
1236        // other entry point (QuickLook) silently blind to its own argument.
1237        config.explicit_hidden_dirs = explicit_hidden_dirs(&config.root_dir, search_config_from);
1238        // Operator-only, like the line above: read from the process environment
1239        // here, never from the repository's config file (see the field docs).
1240        config.no_symlink_mounts =
1241            no_symlink_mounts_from_env() || NO_SYMLINK_MOUNTS_FOR_PROCESS.load(Ordering::Relaxed);
1242        config.reject_repo_supplied_absolute_static_folder(&figment)?;
1243        config.validate()?;
1244        config.log_external_static_folder();
1245        Ok(config)
1246    }
1247
1248    /// Refuses an absolute `static_folder` that came from the repository's own
1249    /// `.mbr/config.toml`.
1250    ///
1251    /// [`Config::validate`] cannot make this call, and deliberately does not
1252    /// try: the identical value is legitimate from `MBR_STATIC_FOLDER`, which
1253    /// only whoever runs the server can set, and a `Config` carries no record of
1254    /// where its fields came from. Figment does — [`Figment::find_metadata`]
1255    /// returns the metadata of the provider that supplied the *winning* value
1256    /// for a key, and only the [`Toml`] provider reports a
1257    /// [`Source::File`](figment::Source::File). The env provider reports no
1258    /// source at all, so "has a file source" is exactly "came from the repo".
1259    fn reject_repo_supplied_absolute_static_folder(
1260        &self,
1261        figment: &Figment,
1262    ) -> Result<(), ConfigError> {
1263        if !is_rooted(Path::new(&self.static_folder)) {
1264            return Ok(());
1265        }
1266
1267        let from_file = figment
1268            .find_metadata("static_folder")
1269            .and_then(|metadata| metadata.source.as_ref())
1270            .and_then(figment::Source::file_path);
1271
1272        match from_file {
1273            Some(source) => Err(invalid_static_folder(
1274                &self.static_folder,
1275                &format!(
1276                    "an absolute path is only honored from the MBR_STATIC_FOLDER environment \
1277                     variable, not from a repository config file ({})",
1278                    source.display()
1279                ),
1280            )),
1281            None => Ok(()),
1282        }
1283    }
1284
1285    /// Emits one line when the static overlay lands outside the markdown root,
1286    /// so an external static root is never silently in effect.
1287    ///
1288    /// A *peer* overlay is an ordinary layout and only rates INFO. Reaching two
1289    /// levels up is rare and much wider — the anchor is the root's grandparent,
1290    /// so the value could have named any directory under it — so that case is
1291    /// raised to WARN, which is visible at the default log level.
1292    fn log_external_static_folder(&self) {
1293        if let Ok(StaticOverlay::External(dir)) =
1294            resolve_static_overlay(&self.root_dir, &self.static_folder)
1295        {
1296            let within_peer = self
1297                .root_dir
1298                .parent()
1299                .is_some_and(|parent| dir.starts_with(parent));
1300            if within_peer {
1301                tracing::info!(
1302                    "static_folder {:?} resolves outside the markdown root ({}): serving and indexing assets from {}",
1303                    self.static_folder,
1304                    self.root_dir.display(),
1305                    dir.display()
1306                );
1307            } else {
1308                tracing::warn!(
1309                    "static_folder {:?} resolves two levels above the markdown root ({}): serving and indexing assets from {}",
1310                    self.static_folder,
1311                    self.root_dir.display(),
1312                    dir.display()
1313                );
1314            }
1315        }
1316    }
1317
1318    /// Validates the configuration values.
1319    ///
1320    /// Checks that numeric configuration options are within valid bounds:
1321    /// - `port`: Must be 1-65535 (port 0 means "auto-assign", which isn't useful for display)
1322    /// - `sidebar_max_items`: Must be > 0
1323    /// - `graph_depth`: Must be between 1 and 5
1324    /// - `flashcards_concentric_threshold`: Must be in (0, 1]
1325    /// - `build_concurrency`: If set, must be > 0
1326    /// - `static_folder`: Must stay inside `root_dir`, or land under a directory
1327    ///   at most [`MAX_STATIC_FOLDER_ASCENT`] levels above it (see
1328    ///   [`Config::validate_static_folder`])
1329    /// - `tasks_ignore_globs`: Every pattern must compile
1330    ///
1331    /// Note: `oembed_cache_size` of 0 is valid (disables caching).
1332    pub fn validate(&self) -> Result<(), ConfigError> {
1333        self.validate_static_folder()?;
1334
1335        // Port 0 means "let OS pick a port" which isn't useful for a server URL
1336        if self.port == 0 {
1337            return Err(ConfigError::InvalidPort { port: self.port });
1338        }
1339
1340        // sidebar_max_items of 0 would show no items
1341        if self.sidebar_max_items == 0 {
1342            return Err(ConfigError::InvalidSidebarMaxItems {
1343                value: self.sidebar_max_items,
1344            });
1345        }
1346
1347        // graph_depth of 0 would show nothing; more than 5 hops fans out into
1348        // an unreadable graph (and an explosive number of links.json fetches)
1349        if !(1..=5).contains(&self.graph_depth) {
1350            return Err(ConfigError::InvalidGraphDepth {
1351                value: self.graph_depth,
1352            });
1353        }
1354
1355        // 0 (or less) would grow the stack after every pass whatever the
1356        // answers; above 1 it could never grow. NaN fails the range check too.
1357        if !(self.flashcards_concentric_threshold > 0.0
1358            && self.flashcards_concentric_threshold <= 1.0)
1359        {
1360            return Err(ConfigError::InvalidFlashcardsConcentricThreshold {
1361                value: self.flashcards_concentric_threshold,
1362            });
1363        }
1364
1365        // build_concurrency of 0 would mean no parallelism (None means auto-detect)
1366        if matches!(self.build_concurrency, Some(0)) {
1367            return Err(ConfigError::InvalidBuildConcurrency { value: 0 });
1368        }
1369
1370        // Task-ignore patterns are compiled once, at `TaskIndex` construction,
1371        // and a pattern that fails to compile there is dropped with a log line
1372        // nobody reads — silently indexing exactly the folder it was meant to
1373        // exclude. Fail here instead, while there is still a startup to abort.
1374        if let Some((pattern, reason)) = self.tasks_ignore_globs.iter().find_map(|pattern| {
1375            glob::Pattern::new(pattern)
1376                .err()
1377                .map(|e| (pattern.clone(), e.to_string()))
1378        }) {
1379            return Err(ConfigError::InvalidTasksIgnoreGlob { pattern, reason });
1380        }
1381
1382        // Refuse to expose an unauthenticated writable endpoint to the network:
1383        // editing on a non-loopback host requires a token hash.
1384        if self.editing_needs_token() {
1385            return Err(ConfigError::EditingRequiresToken);
1386        }
1387
1388        Ok(())
1389    }
1390
1391    /// Editing is on, the host is not loopback, and there is no token hash:
1392    /// the combination [`Config::validate`] refuses, because the `Host` gate
1393    /// alone cannot keep a remote caller out.
1394    pub fn editing_needs_token(&self) -> bool {
1395        self.edit_enabled
1396            && !std::net::Ipv4Addr::from(self.host.0).is_loopback()
1397            && self.edit_token_hash.is_none()
1398    }
1399
1400    /// Rejects a `static_folder` that reaches further above the markdown root
1401    /// than [`MAX_STATIC_FOLDER_ASCENT`] levels, or that resolves to a directory
1402    /// containing the root. See [`resolve_static_overlay`] for the policy itself.
1403    fn validate_static_folder(&self) -> Result<(), ConfigError> {
1404        resolve_static_overlay(&self.root_dir, &self.static_folder).map(|_| ())
1405    }
1406}
1407
1408/// Where the static overlay landed, once the policy has accepted it.
1409#[derive(Debug, Clone, PartialEq, Eq)]
1410pub enum StaticOverlay {
1411    /// The overlay is disabled, or it resolves inside the markdown root.
1412    WithinRoot,
1413    /// The overlay resolves *outside* the markdown root and is permitted there.
1414    /// Carries the resolved (canonical, when it exists) directory.
1415    External(PathBuf),
1416}
1417
1418/// Applies the `static_folder` policy, returning where the overlay resolved.
1419///
1420/// `static_folder` is merged from the repository's own `.mbr/config.toml`, so
1421/// for an untrusted repo it is attacker-controlled. The path resolver joins it
1422/// onto the root and serves files found beneath it, and the repo scanner indexes
1423/// it, so an unrestricted value turns the server into an arbitrary-file reader.
1424///
1425/// The policy, in order:
1426/// - **Empty**: the overlay is disabled — [`StaticOverlay::WithinRoot`].
1427/// - **Inside the root**: accepted (the `static/` default).
1428/// - **Rooted/absolute**: accepted, and rejected earlier by
1429///   [`Config::reject_repo_supplied_absolute_static_folder`] when it came from a
1430///   repository config file. A value that survives to here was set by the
1431///   operator via `MBR_STATIC_FOLDER` and is trusted as given.
1432/// - **Outside the root**: accepted only as a strict descendant of the *anchor*
1433///   — the directory at most [`MAX_STATIC_FOLDER_ASCENT`] levels above the root
1434///   that [`static_folder_anchor`] can reach without stepping into `$HOME` or
1435///   the filesystem root. One level makes the common `repo/content` +
1436///   `repo/static` layout work; two makes a framework layout work, where the
1437///   markdown root is pinned by the framework (SvelteKit's `<project>/src/routes`)
1438///   and the assets live at `<project>/static`.
1439/// - **Never an ancestor of the root**, even one the anchor would otherwise
1440///   admit. At two levels the root's own parent has become a legal descendant of
1441///   the anchor, so without this `static_folder = ".."` would be accepted — and
1442///   it serves every sibling of the root plus the markdown source itself as raw
1443///   static files.
1444///
1445/// The resolved directory is canonicalized when it exists, so a `static -> /etc`
1446/// symlink is judged by where it actually lands rather than by how innocent it
1447/// looks lexically. When it does not exist yet the resolution falls back to
1448/// lexical normalization, so an escape whose target has not been created is
1449/// caught too.
1450///
1451/// What two levels *does* concede, and what the `warn!` in
1452/// [`Config::log_external_static_folder`] exists to surface: an untrusted
1453/// `.mbr/config.toml` can now name any directory under the root's grandparent —
1454/// `<project>/.git` and its credentials, `<project>/node_modules`. One level
1455/// already reached `../.git`, so this widens an existing exposure rather than
1456/// opening a new class. The one genuinely new crossing is serving a repository
1457/// out of *another* user's home directory, which no `$HOME` check can catch
1458/// because `$HOME` is the operator's, not theirs.
1459///
1460/// This is the single definition of the policy on purpose: `Config::validate`
1461/// refuses a bad value at startup, and `Repo` uses the *same* call to decide
1462/// which second directory the scanner may descend into, so the two cannot drift
1463/// into the scanner accepting a root the validator would have refused.
1464pub fn resolve_static_overlay(
1465    root_dir: &Path,
1466    static_folder: &str,
1467) -> Result<StaticOverlay, ConfigError> {
1468    if static_folder.is_empty() {
1469        return Ok(StaticOverlay::WithinRoot);
1470    }
1471
1472    let root = resolve_existing_or_lexical(root_dir);
1473    // `join` handles a rooted `static_folder` for us: an absolute path replaces
1474    // the base rather than being appended to it.
1475    let dir = resolve_existing_or_lexical(&root.join(static_folder));
1476
1477    if dir.starts_with(&root) {
1478        return Ok(StaticOverlay::WithinRoot);
1479    }
1480    if is_rooted(Path::new(static_folder)) {
1481        return Ok(StaticOverlay::External(dir));
1482    }
1483
1484    let (anchor, stop) = static_folder_anchor(&root);
1485
1486    // When the climb was stopped on its first turn, `anchor` is the root itself
1487    // and nothing outside the root can satisfy this — which is exactly the
1488    // one-level behaviour for a root under `$HOME` or at the filesystem root.
1489    if !dir.starts_with(&anchor) {
1490        return Err(invalid_static_folder(static_folder, &stop.refusal(&anchor)));
1491    }
1492    // `dir == anchor` is implied by the ancestor test, since the anchor is an
1493    // ancestor of the root by construction, but it is spelled out so the rule
1494    // reads as "a strict descendant" without relying on that proof.
1495    if dir == anchor {
1496        return Err(invalid_static_folder(
1497            static_folder,
1498            &ExternalFolderRefusal::ContainsRoot(root.clone()).to_string(),
1499        ));
1500    }
1501    // The rest is the policy every external folder answers to — symlink mounts
1502    // ask the same function — so the two cannot drift. Inside the anchor the
1503    // filesystem-root and `$HOME` arms cannot fire (the anchor never climbs
1504    // into either); what this adds over the anchor is "never a directory
1505    // containing the root" and "never a hidden directory" (`../.git`).
1506    if let Some(refusal) = external_folder_refusal(&dir, &[&root]) {
1507        return Err(invalid_static_folder(static_folder, &refusal.to_string()));
1508    }
1509
1510    Ok(StaticOverlay::External(dir))
1511}
1512
1513/// Why a directory outside the markdown root may not be exposed.
1514///
1515/// See [`external_folder_refusal`].
1516#[derive(Debug, Clone, PartialEq, Eq)]
1517pub enum ExternalFolderRefusal {
1518    /// The filesystem root (`/`, `C:\`).
1519    FilesystemRoot,
1520    /// `$HOME` itself, or a directory containing it (`/Users`).
1521    HomeDir,
1522    /// A directory containing a served root (the markdown root or the static
1523    /// overlay) — it would expose every sibling of that root and, for the
1524    /// markdown root, the markdown source with its hidden files.
1525    ContainsRoot(PathBuf),
1526    /// A hidden (leading-dot) directory on the way to it, below the point it
1527    /// shares with the root: `~/.ssh`, `~/.config/app`, `<project>/.git`.
1528    Hidden(String),
1529}
1530
1531impl std::fmt::Display for ExternalFolderRefusal {
1532    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1533        match self {
1534            Self::FilesystemRoot => write!(f, "it is the filesystem root"),
1535            Self::HomeDir => write!(f, "it is the home directory, or contains it"),
1536            Self::ContainsRoot(root) => write!(
1537                f,
1538                "resolves to a directory that contains the markdown root ({}), which would \
1539                 expose every sibling of the root — and the markdown source itself; name a \
1540                 specific directory instead",
1541                root.display()
1542            ),
1543            Self::Hidden(name) => write!(
1544                f,
1545                "it is inside the hidden directory {name:?}; hidden files are never served"
1546            ),
1547        }
1548    }
1549}
1550
1551/// The policy for exposing a directory that lives **outside** the markdown
1552/// root: the static overlay ([`resolve_static_overlay`]) and read-only symlink
1553/// mounts ([`crate::vault::MountPolicy`]) both ask this one function, so they
1554/// cannot drift.
1555///
1556/// `dir` must be canonical. `protected_roots` are the canonical directories
1557/// already being served, the one the folder is being exposed *from* first
1558/// (the markdown root, or the static overlay for a link inside it). Refused,
1559/// in order:
1560///
1561/// - the filesystem root;
1562/// - `$HOME`, or any directory containing it (`/home`, `/Users`): a link to
1563///   the home directory exposes everything the user owns;
1564/// - any directory containing a protected root (`content -> ..`);
1565/// - any directory with a hidden component **below the point it shares with
1566///   `protected_roots[0]`** — `~/.ssh` for a repository in `~/notes`, or
1567///   `<project>/.git` for `<project>/content`. Only the part below the shared
1568///   ancestor counts, so a repository that itself lives inside a hidden
1569///   directory (`~/.local/share/notes`) can still reach a sibling there.
1570///
1571/// What this deliberately does *not* refuse: anything else. A directory
1572/// symlink is an explicit act by whoever created it; the policy only rules out
1573/// targets that are never a sensible thing to publish.
1574pub fn external_folder_refusal(
1575    dir: &Path,
1576    protected_roots: &[&Path],
1577) -> Option<ExternalFolderRefusal> {
1578    let home = home_dir().map(|home| home.canonicalize().unwrap_or(home));
1579    external_folder_refusal_with_home(dir, protected_roots, home.as_deref())
1580}
1581
1582/// [`external_folder_refusal`] with `$HOME` supplied, so the policy is testable
1583/// without touching the process environment. `home` must be canonical.
1584pub fn external_folder_refusal_with_home(
1585    dir: &Path,
1586    protected_roots: &[&Path],
1587    home: Option<&Path>,
1588) -> Option<ExternalFolderRefusal> {
1589    if dir.parent().is_none() {
1590        return Some(ExternalFolderRefusal::FilesystemRoot);
1591    }
1592    if home.is_some_and(|home| home.starts_with(dir)) {
1593        return Some(ExternalFolderRefusal::HomeDir);
1594    }
1595    if let Some(root) = protected_roots.iter().find(|root| root.starts_with(dir)) {
1596        return Some(ExternalFolderRefusal::ContainsRoot(root.to_path_buf()));
1597    }
1598    let shared = protected_roots.first().map_or(0, |root| {
1599        dir.components()
1600            .zip(root.components())
1601            .take_while(|(a, b)| a == b)
1602            .count()
1603    });
1604    dir.components()
1605        .skip(shared)
1606        .filter_map(|component| match component {
1607            std::path::Component::Normal(name) => name.to_str(),
1608            _ => None,
1609        })
1610        .find(|name| name.starts_with('.'))
1611        .map(|name| ExternalFolderRefusal::Hidden(name.to_string()))
1612}
1613
1614/// How far above the markdown root an outside-the-root `static_folder` may reach.
1615///
1616/// One level covers `repo/content` + `repo/static`. Two covers the framework
1617/// layout this was raised for: SvelteKit puts routes at `<project>/src/routes`
1618/// (a route's filesystem path *is* its URL, so the markdown root has to live
1619/// there) while assets live at `<project>/static`.
1620///
1621/// Three is not on the table. At three levels the anchor for a repository in a
1622/// temp directory is the *system* temp directory — which is what
1623/// `test_validate_static_folder_symlink_escape_fails` relies on being out of
1624/// reach — and the same shape generalizes: a checkout's anchor becomes the
1625/// workspace folder holding every unrelated checkout.
1626const MAX_STATIC_FOLDER_ASCENT: usize = 2;
1627
1628/// Why [`static_folder_anchor`] stopped climbing, so a refusal names the wall it
1629/// hit instead of reporting "outside the boundary" for four different reasons.
1630#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1631enum AscentStop {
1632    /// Spent the whole ascent budget; the anchor is [`MAX_STATIC_FOLDER_ASCENT`]
1633    /// levels above the root.
1634    Budget,
1635    /// The markdown root has no parent — it *is* a filesystem root.
1636    RootHasNoParent,
1637    /// The next directory up is the filesystem root.
1638    FilesystemRoot,
1639    /// The next directory up is `$HOME`, or a directory containing it.
1640    HomeDir,
1641}
1642
1643impl AscentStop {
1644    /// The reason text for a `static_folder` that landed outside `anchor`.
1645    ///
1646    /// Naming the anchor tells the operator what *would* have worked, which the
1647    /// old fixed-boundary messages could leave implicit because there was only
1648    /// ever one candidate.
1649    fn refusal(self, anchor: &Path) -> String {
1650        match self {
1651            Self::RootHasNoParent => {
1652                "resolves outside the markdown root, which has no parent directory".to_string()
1653            }
1654            Self::HomeDir => format!(
1655                "resolves outside {}, and the next directory up is the home directory",
1656                anchor.display()
1657            ),
1658            Self::FilesystemRoot => format!(
1659                "resolves outside {}, and the next directory up is the filesystem root",
1660                anchor.display()
1661            ),
1662            Self::Budget => format!(
1663                "resolves outside {}; an overlay may reach at most \
1664                 {MAX_STATIC_FOLDER_ASCENT} levels above the markdown root",
1665                anchor.display()
1666            ),
1667        }
1668    }
1669}
1670
1671/// The highest directory an outside-the-root overlay may live under, and why the
1672/// climb stopped there.
1673///
1674/// Climbs at most [`MAX_STATIC_FOLDER_ASCENT`] levels, refusing at *every* level
1675/// to step into the filesystem root or into `$HOME` (or a directory containing
1676/// it). Checking at every level rather than only at the final anchor is what
1677/// makes the first turn of the loop reproduce the old one-level policy exactly,
1678/// so the ascent can only ever widen what was already accepted, never narrow it.
1679fn static_folder_anchor(root: &Path) -> (PathBuf, AscentStop) {
1680    let mut anchor = root;
1681    for _ in 0..MAX_STATIC_FOLDER_ASCENT {
1682        // Only reachable on the first turn: every later `anchor` is a directory
1683        // this loop has already proved has a parent.
1684        let Some(parent) = anchor.parent() else {
1685            return (anchor.to_path_buf(), AscentStop::RootHasNoParent);
1686        };
1687        // Tested before the home guard because `contains_home_dir` is true of
1688        // the filesystem root on every normal system — `$HOME` starts with `/`
1689        // — and "the next directory up is the home directory" is the wrong
1690        // thing to tell someone whose root is `/notes`.
1691        if parent.parent().is_none() {
1692            return (anchor.to_path_buf(), AscentStop::FilesystemRoot);
1693        }
1694        if contains_home_dir(parent) {
1695            return (anchor.to_path_buf(), AscentStop::HomeDir);
1696        }
1697        anchor = parent;
1698    }
1699    (anchor.to_path_buf(), AscentStop::Budget)
1700}
1701
1702/// True when `path` begins at a filesystem root or a drive prefix.
1703///
1704/// Not `Path::is_absolute`: on Windows that is false for `/etc`, which is rooted
1705/// but prefixless, and a rooted path escapes the root just as thoroughly as a
1706/// fully-qualified one.
1707fn is_rooted(path: &Path) -> bool {
1708    use std::path::Component;
1709    matches!(
1710        path.components().next(),
1711        Some(Component::RootDir | Component::Prefix(_))
1712    )
1713}
1714
1715/// Canonicalizes `path`, falling back to lexical normalization when it does not
1716/// exist. Never fails, so a not-yet-created path still gets a comparable form.
1717pub(crate) fn resolve_existing_or_lexical(path: &Path) -> PathBuf {
1718    path.canonicalize()
1719        .unwrap_or_else(|_| lexically_normalize(path))
1720}
1721
1722/// Resolves `.` and `..` components without touching the filesystem.
1723///
1724/// Only sound for paths that are already canonical up to the missing tail, which
1725/// is how [`resolve_existing_or_lexical`] uses it: symlinks earlier in the path
1726/// have already been resolved, so popping a component cannot be misled by one.
1727fn lexically_normalize(path: &Path) -> PathBuf {
1728    use std::path::Component;
1729    path.components()
1730        .fold(PathBuf::new(), |mut normalized, component| {
1731            match component {
1732                Component::CurDir => {}
1733                Component::ParentDir => {
1734                    // `pop` fails both at a filesystem root (where `..` is a
1735                    // no-op) and on an empty relative accumulator (where `..`
1736                    // must be preserved). Only the latter keeps the component.
1737                    if !normalized.pop() && !is_rooted(&normalized) {
1738                        normalized.push(Component::ParentDir);
1739                    }
1740                }
1741                other => normalized.push(other),
1742            }
1743            normalized
1744        })
1745}
1746
1747/// Builds the error for a rejected `static_folder` value.
1748///
1749/// `ConfigError` has no dedicated variant for this, so the message is carried
1750/// by a synthetic `figment::Error` inside `ParseFailed` (see the followup note
1751/// in the review: a `ConfigError::InvalidStaticFolder` variant belongs in
1752/// `errors.rs`).
1753fn invalid_static_folder(value: &str, reason: &str) -> ConfigError {
1754    ConfigError::ParseFailed(Box::new(figment::Error::from(format!(
1755        "Invalid static_folder {value:?}: {reason}"
1756    ))))
1757}
1758
1759#[cfg(test)]
1760mod tests {
1761    use super::*;
1762
1763    #[test]
1764    fn test_title_case() {
1765        assert_eq!(title_case("tags"), "Tag");
1766        assert_eq!(title_case("performers"), "Performer");
1767        assert_eq!(title_case("category"), "Category");
1768        assert_eq!(title_case("Tag"), "Tag");
1769        assert_eq!(title_case("s"), "S");
1770        assert_eq!(title_case(""), "");
1771    }
1772
1773    #[test]
1774    fn test_tag_source_singular_label_explicit() {
1775        let source = TagSource {
1776            field: "taxonomy.performers".to_string(),
1777            label: Some("Performer".to_string()),
1778            label_plural: None,
1779        };
1780        assert_eq!(source.singular_label(), "Performer");
1781    }
1782
1783    #[test]
1784    fn test_tag_source_singular_label_derived() {
1785        let source = TagSource {
1786            field: "tags".to_string(),
1787            label: None,
1788            label_plural: None,
1789        };
1790        assert_eq!(source.singular_label(), "Tag");
1791    }
1792
1793    #[test]
1794    fn test_tag_source_singular_label_derived_nested() {
1795        let source = TagSource {
1796            field: "taxonomy.performers".to_string(),
1797            label: None,
1798            label_plural: None,
1799        };
1800        assert_eq!(source.singular_label(), "Performer");
1801    }
1802
1803    #[test]
1804    fn test_tag_source_plural_label_explicit() {
1805        let source = TagSource {
1806            field: "taxonomy.performers".to_string(),
1807            label: None,
1808            label_plural: Some("Performers".to_string()),
1809        };
1810        assert_eq!(source.plural_label(), "Performers");
1811    }
1812
1813    #[test]
1814    fn test_tag_source_plural_label_derived() {
1815        let source = TagSource {
1816            field: "tags".to_string(),
1817            label: None,
1818            label_plural: None,
1819        };
1820        assert_eq!(source.plural_label(), "Tags");
1821    }
1822
1823    #[test]
1824    fn test_tag_source_url_source() {
1825        let source = TagSource {
1826            field: "Tags".to_string(),
1827            label: None,
1828            label_plural: None,
1829        };
1830        assert_eq!(source.url_source(), "tags");
1831
1832        let source = TagSource {
1833            field: "taxonomy.Performers".to_string(),
1834            label: None,
1835            label_plural: None,
1836        };
1837        assert_eq!(source.url_source(), "taxonomy.performers");
1838    }
1839
1840    #[test]
1841    fn test_default_tag_sources() {
1842        let sources = default_tag_sources();
1843        assert_eq!(sources.len(), 1);
1844        assert_eq!(sources[0].field, "tags");
1845        assert_eq!(sources[0].singular_label(), "Tag");
1846        assert_eq!(sources[0].plural_label(), "Tags");
1847        assert_eq!(sources[0].url_source(), "tags");
1848    }
1849
1850    #[test]
1851    fn test_config_default_has_tag_sources() {
1852        let config = Config::default();
1853        assert_eq!(config.tag_sources.len(), 1);
1854        assert_eq!(config.tag_sources[0].field, "tags");
1855        assert!(config.build_tag_pages);
1856    }
1857
1858    #[test]
1859    fn test_config_default_has_relationship_types() {
1860        let config = Config::default();
1861        assert!(config.relationship_tracking);
1862        let names: Vec<&str> = config
1863            .relationship_types
1864            .iter()
1865            .map(|t| t.name.as_str())
1866            .collect();
1867        assert_eq!(
1868            names,
1869            [
1870                "parent",
1871                "child",
1872                "spouse",
1873                "sibling",
1874                "reports_to",
1875                "manages",
1876                "assistant",
1877                "assists",
1878                "employer",
1879                "employee",
1880                "colleague",
1881            ]
1882        );
1883    }
1884
1885    #[test]
1886    fn test_default_relationship_types_semantics() {
1887        let types = default_relationship_types();
1888        let parent = types.iter().find(|t| t.name == "parent").unwrap();
1889        assert!(!parent.symmetric);
1890        assert_eq!(parent.inverse.as_deref(), Some("child"));
1891        assert_eq!(parent.plural_label(), "Parents");
1892
1893        let child = types.iter().find(|t| t.name == "child").unwrap();
1894        assert_eq!(child.inverse.as_deref(), Some("parent"));
1895        assert_eq!(child.plural_label(), "Children");
1896
1897        let spouse = types.iter().find(|t| t.name == "spouse").unwrap();
1898        assert!(spouse.symmetric);
1899        assert!(spouse.inverse.is_none());
1900    }
1901
1902    /// Every default inverse pair must name each other and point opposite ways
1903    /// in the hierarchy, and share a category — the charts rely on all three.
1904    #[test]
1905    fn test_default_relationship_types_pairs_are_coherent() {
1906        let types = default_relationship_types();
1907        let by_name = |n: &str| types.iter().find(|t| t.name == n).unwrap();
1908        for t in &types {
1909            assert!(t.category.is_some(), "{} has no category", t.name);
1910            if t.symmetric {
1911                assert!(t.hierarchy.is_none(), "{} is symmetric", t.name);
1912                continue;
1913            }
1914            let inverse = by_name(t.inverse.as_deref().expect("inverse"));
1915            assert_eq!(inverse.inverse.as_deref(), Some(t.name.as_str()));
1916            assert_eq!(inverse.hierarchy, t.hierarchy.map(Hierarchy::flipped));
1917            assert_eq!(inverse.category, t.category);
1918        }
1919        assert_eq!(by_name("reports_to").hierarchy, Some(Hierarchy::Up));
1920        assert_eq!(by_name("assistant").hierarchy, Some(Hierarchy::Down));
1921        assert_eq!(by_name("employer").category.as_deref(), Some("work"));
1922        assert_eq!(by_name("parent").category.as_deref(), Some("family"));
1923        assert_eq!(by_name("reports_to").plural_label(), "Reports to");
1924    }
1925
1926    #[test]
1927    fn test_relation_type_hierarchy_and_category_deserialize() {
1928        let json =
1929            r#"{"name": "mentor", "inverse": "mentee", "hierarchy": "up", "category": "work"}"#;
1930        let rel: RelationType = serde_json::from_str(json).unwrap();
1931        assert_eq!(rel.hierarchy, Some(Hierarchy::Up));
1932        assert_eq!(rel.category.as_deref(), Some("work"));
1933
1934        let bad = r#"{"name": "mentor", "hierarchy": "sideways"}"#;
1935        assert!(serde_json::from_str::<RelationType>(bad).is_err());
1936    }
1937
1938    #[test]
1939    fn test_relation_type_label_derivation() {
1940        let rel = RelationType {
1941            name: "cousin".to_string(),
1942            symmetric: true,
1943            inverse: None,
1944            label: None,
1945            label_plural: None,
1946            hierarchy: None,
1947            category: None,
1948        };
1949        // Auto-derived from name.
1950        assert_eq!(rel.singular_label(), "Cousin");
1951        assert_eq!(rel.plural_label(), "Cousins");
1952    }
1953
1954    #[test]
1955    fn test_relation_type_deserialization_minimal() {
1956        let json = r#"{"name": "friend", "symmetric": true}"#;
1957        let rel: RelationType = serde_json::from_str(json).unwrap();
1958        assert_eq!(rel.name, "friend");
1959        assert!(rel.symmetric);
1960        assert!(rel.inverse.is_none());
1961        assert!(rel.label.is_none());
1962    }
1963
1964    #[test]
1965    fn test_tag_source_serialization() {
1966        let source = TagSource {
1967            field: "taxonomy.tags".to_string(),
1968            label: Some("Tag".to_string()),
1969            label_plural: Some("Tags".to_string()),
1970        };
1971
1972        let json = serde_json::to_string(&source).unwrap();
1973        let parsed: TagSource = serde_json::from_str(&json).unwrap();
1974        assert_eq!(source, parsed);
1975    }
1976
1977    #[test]
1978    fn test_tag_source_deserialization_minimal() {
1979        let json = r#"{"field": "tags"}"#;
1980        let source: TagSource = serde_json::from_str(json).unwrap();
1981        assert_eq!(source.field, "tags");
1982        assert!(source.label.is_none());
1983        assert!(source.label_plural.is_none());
1984    }
1985
1986    // ==================== Config Validation Tests ====================
1987
1988    #[test]
1989    fn test_validate_default_config_passes() {
1990        let config = Config::default();
1991        assert!(config.validate().is_ok());
1992    }
1993
1994    #[test]
1995    fn test_validate_port_zero_fails() {
1996        let config = Config {
1997            port: 0,
1998            ..Default::default()
1999        };
2000        let result = config.validate();
2001        assert!(result.is_err());
2002        let err = result.unwrap_err();
2003        assert!(matches!(err, ConfigError::InvalidPort { port: 0 }));
2004    }
2005
2006    #[test]
2007    fn test_validate_valid_ports_pass() {
2008        // Test minimum valid port
2009        let config = Config {
2010            port: 1,
2011            ..Default::default()
2012        };
2013        assert!(config.validate().is_ok());
2014
2015        // Test common ports
2016        let config = Config {
2017            port: 80,
2018            ..Default::default()
2019        };
2020        assert!(config.validate().is_ok());
2021
2022        let config = Config {
2023            port: 443,
2024            ..Default::default()
2025        };
2026        assert!(config.validate().is_ok());
2027
2028        // Test maximum valid port
2029        let config = Config {
2030            port: 65535,
2031            ..Default::default()
2032        };
2033        assert!(config.validate().is_ok());
2034    }
2035
2036    #[test]
2037    fn test_validate_sidebar_max_items_zero_fails() {
2038        let config = Config {
2039            sidebar_max_items: 0,
2040            ..Default::default()
2041        };
2042        let result = config.validate();
2043        assert!(result.is_err());
2044        let err = result.unwrap_err();
2045        assert!(matches!(
2046            err,
2047            ConfigError::InvalidSidebarMaxItems { value: 0 }
2048        ));
2049    }
2050
2051    #[test]
2052    fn test_validate_valid_sidebar_max_items_pass() {
2053        let config = Config {
2054            sidebar_max_items: 1,
2055            ..Default::default()
2056        };
2057        assert!(config.validate().is_ok());
2058
2059        let config = Config {
2060            sidebar_max_items: 100,
2061            ..Default::default()
2062        };
2063        assert!(config.validate().is_ok());
2064
2065        let config = Config {
2066            sidebar_max_items: 10000,
2067            ..Default::default()
2068        };
2069        assert!(config.validate().is_ok());
2070    }
2071
2072    #[test]
2073    fn test_default_gui_menu_bar_is_auto() {
2074        let config = Config::default();
2075        assert_eq!(config.gui_menu_bar, MenuBarVisibility::Auto);
2076    }
2077
2078    // Typed rather than a `String` so a misspelling is a startup error, not a
2079    // setting that is silently ignored for the life of the window.
2080    #[test]
2081    fn test_gui_menu_bar_parses_each_variant() {
2082        for (text, expected) in [
2083            ("auto", MenuBarVisibility::Auto),
2084            ("always", MenuBarVisibility::Always),
2085            ("never", MenuBarVisibility::Never),
2086        ] {
2087            let parsed: MenuBarVisibility = serde_json::from_str(&format!("\"{text}\"")).unwrap();
2088            assert_eq!(parsed, expected, "parsing {text}");
2089        }
2090    }
2091
2092    #[test]
2093    fn test_gui_menu_bar_rejects_unknown_value() {
2094        let parsed: Result<MenuBarVisibility, _> = serde_json::from_str("\"sometimes\"");
2095        assert!(parsed.is_err(), "an unknown visibility must not parse");
2096    }
2097
2098    // The config-file path, not just the bare enum: a `.mbr/config.toml` that
2099    // sets this must actually reach `Config`.
2100    #[test]
2101    fn test_gui_menu_bar_from_config_file() {
2102        let config: Config = Figment::from(Serialized::defaults(Config::default()))
2103            .merge(Toml::string("gui_menu_bar = \"always\""))
2104            .extract()
2105            .unwrap();
2106        assert_eq!(config.gui_menu_bar, MenuBarVisibility::Always);
2107    }
2108
2109    #[test]
2110    fn test_default_graph_depth_is_2() {
2111        let config = Config::default();
2112        assert_eq!(config.graph_depth, 2);
2113    }
2114
2115    #[test]
2116    fn test_validate_graph_depth_zero_fails() {
2117        let config = Config {
2118            graph_depth: 0,
2119            ..Default::default()
2120        };
2121        let result = config.validate();
2122        assert!(result.is_err());
2123        let err = result.unwrap_err();
2124        assert!(matches!(err, ConfigError::InvalidGraphDepth { value: 0 }));
2125    }
2126
2127    #[test]
2128    fn test_validate_graph_depth_six_fails() {
2129        let config = Config {
2130            graph_depth: 6,
2131            ..Default::default()
2132        };
2133        let result = config.validate();
2134        assert!(result.is_err());
2135        let err = result.unwrap_err();
2136        assert!(matches!(err, ConfigError::InvalidGraphDepth { value: 6 }));
2137    }
2138
2139    #[test]
2140    fn test_validate_graph_depth_bounds_pass() {
2141        let config = Config {
2142            graph_depth: 1,
2143            ..Default::default()
2144        };
2145        assert!(config.validate().is_ok());
2146
2147        let config = Config {
2148            graph_depth: 5,
2149            ..Default::default()
2150        };
2151        assert!(config.validate().is_ok());
2152    }
2153
2154    #[test]
2155    fn test_default_flashcards_options() {
2156        let config = Config::default();
2157        assert!((config.flashcards_concentric_threshold - 0.7).abs() < f64::EPSILON);
2158        assert!(config.flashcards_progress_indicators);
2159    }
2160
2161    #[test]
2162    fn test_validate_flashcards_concentric_threshold_bounds() {
2163        for bad in [0.0, -0.5, 1.01, f64::NAN, f64::INFINITY] {
2164            let config = Config {
2165                flashcards_concentric_threshold: bad,
2166                ..Default::default()
2167            };
2168            assert!(
2169                matches!(
2170                    config.validate(),
2171                    Err(ConfigError::InvalidFlashcardsConcentricThreshold { .. })
2172                ),
2173                "{bad} must be rejected"
2174            );
2175        }
2176        for good in [0.01, 0.5, 1.0] {
2177            let config = Config {
2178                flashcards_concentric_threshold: good,
2179                ..Default::default()
2180            };
2181            assert!(config.validate().is_ok(), "{good} must be accepted");
2182        }
2183    }
2184
2185    #[test]
2186    fn test_config_read_flashcards_options_from_toml() {
2187        let _guard = env_lock();
2188        let repo = repo_with_mbr_dir(Some(
2189            "flashcards_concentric_threshold = 0.85\nflashcards_progress_indicators = false\n",
2190        ));
2191
2192        let config = Config::read(repo.path()).expect("toml must load");
2193
2194        assert!((config.flashcards_concentric_threshold - 0.85).abs() < f64::EPSILON);
2195        assert!(!config.flashcards_progress_indicators);
2196    }
2197
2198    #[test]
2199    fn test_config_read_flashcards_options_from_env() {
2200        let _guard = env_lock();
2201        let repo = repo_with_mbr_dir(Some("flashcards_concentric_threshold = 0.9\n"));
2202        let _env = EnvVars::set(&[
2203            ("MBR_FLASHCARDS_CONCENTRIC_THRESHOLD", "0.6"),
2204            ("MBR_FLASHCARDS_PROGRESS_INDICATORS", "false"),
2205        ]);
2206
2207        let config = Config::read(repo.path()).expect("env must load");
2208
2209        assert!((config.flashcards_concentric_threshold - 0.6).abs() < f64::EPSILON);
2210        assert!(!config.flashcards_progress_indicators);
2211    }
2212
2213    #[test]
2214    fn test_config_read_rejects_out_of_range_flashcards_threshold() {
2215        let _guard = env_lock();
2216        let repo = repo_with_mbr_dir(Some("flashcards_concentric_threshold = 1.5\n"));
2217
2218        let err = Config::read(repo.path()).expect_err("1.5 must abort loading");
2219
2220        assert!(
2221            matches!(
2222                err,
2223                crate::MbrError::Config(ConfigError::InvalidFlashcardsConcentricThreshold { .. })
2224            ),
2225            "got: {err:?}"
2226        );
2227    }
2228
2229    #[test]
2230    fn test_validate_build_concurrency_zero_fails() {
2231        let config = Config {
2232            build_concurrency: Some(0),
2233            ..Default::default()
2234        };
2235        let result = config.validate();
2236        assert!(result.is_err());
2237        let err = result.unwrap_err();
2238        assert!(matches!(
2239            err,
2240            ConfigError::InvalidBuildConcurrency { value: 0 }
2241        ));
2242    }
2243
2244    #[test]
2245    fn test_validate_build_concurrency_none_passes() {
2246        let config = Config {
2247            build_concurrency: None, // Auto-detect
2248            ..Default::default()
2249        };
2250        assert!(config.validate().is_ok());
2251    }
2252
2253    #[test]
2254    fn test_validate_valid_build_concurrency_pass() {
2255        let config = Config {
2256            build_concurrency: Some(1),
2257            ..Default::default()
2258        };
2259        assert!(config.validate().is_ok());
2260
2261        let config = Config {
2262            build_concurrency: Some(8),
2263            ..Default::default()
2264        };
2265        assert!(config.validate().is_ok());
2266
2267        let config = Config {
2268            build_concurrency: Some(32),
2269            ..Default::default()
2270        };
2271        assert!(config.validate().is_ok());
2272    }
2273
2274    #[test]
2275    fn test_default_title_prefix_empty() {
2276        let config = Config::default();
2277        assert_eq!(config.title_prefix, "");
2278    }
2279
2280    #[test]
2281    fn test_default_title_suffix_empty() {
2282        let config = Config::default();
2283        assert_eq!(config.title_suffix, "");
2284    }
2285
2286    #[test]
2287    fn test_validate_editing_non_loopback_without_token_fails() {
2288        let config = Config {
2289            edit_enabled: true,
2290            host: IpArray([0, 0, 0, 0]),
2291            edit_token_hash: None,
2292            ..Default::default()
2293        };
2294        assert!(matches!(
2295            config.validate(),
2296            Err(ConfigError::EditingRequiresToken)
2297        ));
2298    }
2299
2300    #[test]
2301    fn test_validate_editing_non_loopback_with_token_passes() {
2302        let config = Config {
2303            edit_enabled: true,
2304            host: IpArray([0, 0, 0, 0]),
2305            edit_token_hash: Some("$argon2id$fake".to_string()),
2306            ..Default::default()
2307        };
2308        assert!(config.validate().is_ok());
2309    }
2310
2311    #[test]
2312    fn test_validate_editing_loopback_without_token_passes() {
2313        let config = Config {
2314            edit_enabled: true,
2315            host: IpArray([127, 0, 0, 1]),
2316            edit_token_hash: None,
2317            ..Default::default()
2318        };
2319        assert!(config.validate().is_ok());
2320    }
2321
2322    #[test]
2323    fn test_validate_oembed_cache_size_zero_is_valid() {
2324        // Zero means disabled, which is valid
2325        let config = Config {
2326            oembed_cache_size: 0,
2327            ..Default::default()
2328        };
2329        assert!(config.validate().is_ok());
2330    }
2331
2332    /// The media metadata cache must have its own budget: it stores JPEG cover
2333    /// payloads, not the oembed cache's short text metadata.
2334    #[test]
2335    fn test_media_cache_size_default_is_independent_of_oembed() {
2336        let config = Config::default();
2337        assert_eq!(config.media_cache_size, 64 * 1024 * 1024);
2338        assert_ne!(
2339            config.media_cache_size, config.oembed_cache_size,
2340            "media cache must not inherit the oembed text-metadata budget"
2341        );
2342    }
2343
2344    /// Disabling the oembed cache (`--oembed-cache-size 0`) must not disable
2345    /// media caching as a side effect.
2346    #[test]
2347    fn test_disabling_oembed_cache_keeps_media_cache_enabled() {
2348        let config = Config {
2349            oembed_cache_size: 0,
2350            ..Default::default()
2351        };
2352        assert!(config.validate().is_ok());
2353        assert_eq!(config.media_cache_size, 64 * 1024 * 1024);
2354    }
2355
2356    /// A `.mbr/config.toml` written before this option existed must still load
2357    /// (falling back to the default), and an explicit value must win.
2358    #[test]
2359    fn test_media_cache_size_config_file_layering() {
2360        let without: Config = Figment::new()
2361            .merge(Serialized::defaults(Config::default()))
2362            .merge(Toml::string("port = 5201"))
2363            .extract()
2364            .expect("config without the key parses");
2365        assert_eq!(without.media_cache_size, 64 * 1024 * 1024);
2366
2367        let with: Config = Figment::new()
2368            .merge(Serialized::defaults(Config::default()))
2369            .merge(Toml::string("media_cache_size = 1048576"))
2370            .extract()
2371            .expect("explicit value parses");
2372        assert_eq!(with.media_cache_size, 1024 * 1024);
2373    }
2374
2375    // ==================== find_root_dir Edge Case Tests ====================
2376
2377    #[test]
2378    fn test_find_root_dir_file_without_markers_returns_parent() {
2379        // Create a temp dir with no markers and a file inside it
2380        let tmp = tempfile::tempdir().unwrap();
2381        let file_path = tmp.path().join("test.md");
2382        std::fs::write(&file_path, "# Hello").unwrap();
2383
2384        let root = find_root_dir(&file_path);
2385
2386        // Should return the parent directory, not the file itself
2387        assert!(
2388            root.is_dir(),
2389            "root_dir should be a directory, got: {root:?}"
2390        );
2391        assert_eq!(root, tmp.path());
2392    }
2393
2394    #[test]
2395    fn test_find_root_dir_directory_without_markers_returns_itself() {
2396        let tmp = tempfile::tempdir().unwrap();
2397
2398        let root = find_root_dir(tmp.path());
2399
2400        assert!(root.is_dir());
2401        // Should return the directory itself (or CWD if it's an ancestor)
2402        // Either way, it should be a directory
2403        assert!(
2404            root.is_dir(),
2405            "root_dir should be a directory, got: {root:?}"
2406        );
2407    }
2408
2409    #[test]
2410    fn test_find_root_dir_with_git_marker_returns_marker_parent() {
2411        // When .git exists in a non-home ancestor, find_root_dir should use it
2412        let tmp = tempfile::tempdir().unwrap();
2413        let nested = tmp.path().join("sub").join("dir");
2414        std::fs::create_dir_all(&nested).unwrap();
2415        std::fs::create_dir(tmp.path().join(".git")).unwrap();
2416
2417        let file_path = nested.join("test.md");
2418        std::fs::write(&file_path, "# Hello").unwrap();
2419
2420        let root = find_root_dir(&file_path);
2421
2422        // Should find .git and return its parent (tmp root)
2423        assert_eq!(root, tmp.path().to_path_buf());
2424        assert!(root.is_dir());
2425    }
2426
2427    #[test]
2428    fn test_is_home_dir() {
2429        // Reads `HOME` twice (here and inside `is_home_dir`), so it must not
2430        // interleave with the tests below that swap `HOME` for a temp dir.
2431        let _guard = env_lock();
2432        if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) {
2433            assert!(is_home_dir(&home));
2434            assert!(!is_home_dir(Path::new("/tmp")));
2435        }
2436    }
2437
2438    // ==================== static_folder Validation Tests ====================
2439
2440    /// Builds `<tmp>/project/{content,static}` and returns the temp dir plus the
2441    /// markdown root (`content`).
2442    ///
2443    /// The nesting is deliberate. `Config::default()` uses the *current
2444    /// directory* as `root_dir`, which would make every boundary assertion below
2445    /// depend on where the checkout happens to live. Here the root's parent is
2446    /// `project` (a peer boundary), and `project`'s own parent is the temp dir,
2447    /// so neither `$HOME` nor the filesystem root is ever the boundary by
2448    /// accident.
2449    fn peer_layout() -> (tempfile::TempDir, PathBuf) {
2450        let tmp = tempfile::tempdir().unwrap();
2451        let root = tmp.path().join("project/content");
2452        std::fs::create_dir_all(&root).unwrap();
2453        std::fs::create_dir_all(tmp.path().join("project/static")).unwrap();
2454        (tmp, root)
2455    }
2456
2457    fn config_with_static(root: &Path, static_folder: &str) -> Config {
2458        Config {
2459            root_dir: root.to_path_buf(),
2460            static_folder: static_folder.to_string(),
2461            ..Default::default()
2462        }
2463    }
2464
2465    /// Retargeted twice: first from "any `..` is an escape" to a one-level
2466    /// boundary, now to a two-level one. `repo/content` + `repo/static` and
2467    /// `<project>/src/routes` + `<project>/static` are both real layouts, so
2468    /// climbing is legal up to [`MAX_STATIC_FOLDER_ASCENT`]. What this still
2469    /// covers is a hostile `.mbr/config.toml` reaching past that budget, or
2470    /// grabbing an ancestor of the root wholesale — `..` and `../..` are both
2471    /// ancestors of `project/content` and would expose the markdown source
2472    /// itself as raw static files.
2473    #[test]
2474    fn test_validate_static_folder_parent_escape_fails() {
2475        let (_tmp, root) = peer_layout();
2476        for value in [
2477            "..",
2478            "../..",
2479            "../../../assets",
2480            "assets/../../../..",
2481            "assets/../../..",
2482        ] {
2483            let err = match config_with_static(&root, value).validate() {
2484                Err(err) => err,
2485                Ok(()) => panic!("escaping static_folder must be rejected: {value:?}"),
2486            };
2487            assert!(
2488                format!("{err:?}").contains("static_folder"),
2489                "error should name static_folder, got: {err:?}"
2490            );
2491        }
2492    }
2493
2494    /// A `static_folder` that resolves into `$HOME` is refused even though it is
2495    /// only one level up, because "one level up from a note directory in your
2496    /// home folder" is the whole home folder. This is the case that keeps
2497    /// `~/notes` + `static_folder = "../.ssh"` from being servable.
2498    #[test]
2499    fn test_validate_static_folder_into_home_fails() {
2500        let _guard = env_lock();
2501        let home = tempfile::tempdir().unwrap();
2502        // macOS hands out `/var/...` temp dirs that canonicalize to
2503        // `/private/var/...`; `is_home_dir` bridges the two forms, and this
2504        // keeps the fixture honest about which form it planted.
2505        let home_str = home.path().to_string_lossy().into_owned();
2506        let _env = EnvVars::set(&[("HOME", &home_str), ("USERPROFILE", &home_str)]);
2507
2508        let root = home.path().join("notes");
2509        std::fs::create_dir(&root).unwrap();
2510        std::fs::create_dir(home.path().join(".ssh")).unwrap();
2511
2512        for value in ["..", "../.ssh"] {
2513            assert!(
2514                config_with_static(&root, value).validate().is_err(),
2515                "static_folder {value:?} resolving into $HOME must be rejected"
2516            );
2517        }
2518
2519        // The guard is about *leaving* the root, not about living under $HOME:
2520        // a normal in-root overlay still works for a vault in the home folder.
2521        std::fs::create_dir(root.join("static")).unwrap();
2522        assert!(
2523            config_with_static(&root, "static").validate().is_ok(),
2524            "an in-root static folder must still work under $HOME"
2525        );
2526
2527        // One level deeper, the ascent budget alone would reach `$HOME` on the
2528        // second step. The climb has to stop at `<home>/project`, which still
2529        // leaves the ordinary peer layout working inside it.
2530        let nested = home.path().join("project/content");
2531        std::fs::create_dir_all(&nested).unwrap();
2532        std::fs::create_dir(home.path().join("project/static")).unwrap();
2533        assert!(
2534            config_with_static(&nested, "../static").validate().is_ok(),
2535            "a peer overlay one level below $HOME must still be accepted"
2536        );
2537        for value in ["../../static", "../../.ssh", "../.."] {
2538            assert!(
2539                config_with_static(&nested, value).validate().is_err(),
2540                "static_folder {value:?} must not climb through $HOME"
2541            );
2542        }
2543    }
2544
2545    /// A markdown root sitting directly under `/` has no boundary above it that
2546    /// isn't the filesystem root itself, so nothing outside it is reachable.
2547    /// One level down, the ascent budget stops one short for the same reason.
2548    #[test]
2549    fn test_validate_static_folder_filesystem_root_boundary_fails() {
2550        let (root, nested) = if cfg!(windows) {
2551            (r"C:\notes", r"C:\srv\notes")
2552        } else {
2553            ("/notes", "/srv/notes")
2554        };
2555        assert!(
2556            config_with_static(Path::new(root), "../etc")
2557                .validate()
2558                .is_err(),
2559            "a root whose parent is the filesystem root must not reach outside itself"
2560        );
2561        assert!(
2562            config_with_static(Path::new(nested), "../../etc")
2563                .validate()
2564                .is_err(),
2565            "the climb must stop below the filesystem root rather than spending its budget"
2566        );
2567    }
2568
2569    /// Retargeted: an absolute `static_folder` is no longer rejected by
2570    /// `validate`, because the identical value is legitimate from
2571    /// `MBR_STATIC_FOLDER`. The untrusted-repo half of the threat moved to
2572    /// load time — see `test_config_read_rejects_absolute_static_folder_from_toml`.
2573    /// What is asserted here is the Windows nuance that decision depends on:
2574    /// `/etc` is rooted even though `Path::is_absolute` says otherwise there.
2575    #[test]
2576    fn test_validate_static_folder_absolute_is_deferred_to_provenance() {
2577        assert!(is_rooted(Path::new("/etc")), "`/etc` is rooted everywhere");
2578        assert!(
2579            is_rooted(Path::new(r"C:\etc")) || !cfg!(windows),
2580            "a drive-prefixed path is rooted on Windows"
2581        );
2582        assert!(!is_rooted(Path::new("static")));
2583        assert!(!is_rooted(Path::new("../static")));
2584
2585        let (_tmp, root) = peer_layout();
2586        let absolute = if cfg!(windows) { r"C:\etc" } else { "/etc" };
2587        for value in [absolute, "/etc"] {
2588            assert!(
2589                config_with_static(&root, value).validate().is_ok(),
2590                "validate must defer an absolute static_folder {value:?} to provenance"
2591            );
2592        }
2593    }
2594
2595    /// Retargeted: a `static -> ../static` symlink is now the same thing as
2596    /// writing `static_folder = "../static"`, so it is allowed. The threat that
2597    /// remains — and that this still covers — is a symlink used to reach
2598    /// somewhere the literal value could never name, past the peer boundary.
2599    #[cfg(unix)]
2600    #[test]
2601    fn test_validate_static_folder_symlink_escape_fails() {
2602        let unrelated = tempfile::tempdir().unwrap();
2603        let secrets = unrelated.path().join("secrets");
2604        std::fs::create_dir(&secrets).unwrap();
2605
2606        let (_tmp, root) = peer_layout();
2607        std::os::unix::fs::symlink(&secrets, root.join("static")).unwrap();
2608
2609        assert!(
2610            config_with_static(&root, "static").validate().is_err(),
2611            "a static_folder symlinked past the peer boundary must be rejected"
2612        );
2613    }
2614
2615    /// A symlink that lands on a legitimate peer is indistinguishable from
2616    /// naming that peer directly, so it must be accepted for the same reason
2617    /// `../static` is.
2618    #[cfg(unix)]
2619    #[test]
2620    fn test_validate_static_folder_symlink_to_peer_passes() {
2621        let (tmp, root) = peer_layout();
2622        std::os::unix::fs::symlink(tmp.path().join("project/static"), root.join("assets")).unwrap();
2623
2624        assert!(
2625            config_with_static(&root, "assets").validate().is_ok(),
2626            "a static_folder symlinked to a peer of the root must be accepted"
2627        );
2628    }
2629
2630    #[test]
2631    fn test_validate_static_folder_relative_passes() {
2632        let (_tmp, root) = peer_layout();
2633        for value in ["", "static", "assets", "public/assets", "./static"] {
2634            assert!(
2635                config_with_static(&root, value).validate().is_ok(),
2636                "relative static_folder {value:?} must be accepted"
2637            );
2638        }
2639    }
2640
2641    /// The regression this whole change exists for: `repo/content` holding the
2642    /// markdown (and the `.mbr/` folder) with `repo/static` alongside it, named
2643    /// as `static_folder = "../static"`.
2644    #[test]
2645    fn test_validate_static_folder_peer_passes() {
2646        let (tmp, root) = peer_layout();
2647        std::fs::create_dir_all(tmp.path().join("project/static/videos")).unwrap();
2648
2649        for value in ["../static", "../static/videos", "./../static"] {
2650            assert!(
2651                config_with_static(&root, value).validate().is_ok(),
2652                "peer static_folder {value:?} must be accepted"
2653            );
2654        }
2655    }
2656
2657    /// Builds `<tmp>/project/{src/routes,static}` and returns the temp dir plus
2658    /// the markdown root (`src/routes`) — the SvelteKit shape, where the
2659    /// framework pins the markdown root two levels below the project because a
2660    /// route's filesystem path *is* its URL.
2661    ///
2662    /// Nested inside a `project` directory for the same reason as
2663    /// [`peer_layout`]: the anchor two levels up must be `project`'s own parent
2664    /// (the temp dir), never `$HOME` or the filesystem root by accident.
2665    fn two_deep_layout() -> (tempfile::TempDir, PathBuf) {
2666        let tmp = tempfile::tempdir().unwrap();
2667        let root = tmp.path().join("project/src/routes");
2668        std::fs::create_dir_all(&root).unwrap();
2669        std::fs::create_dir_all(tmp.path().join("project/static/images")).unwrap();
2670        (tmp, root)
2671    }
2672
2673    /// The regression this change exists for: SvelteKit's `src/routes` holding
2674    /// the markdown (and `.mbr/`) with the project's `static/` two levels up.
2675    #[test]
2676    fn test_validate_static_folder_two_levels_up_passes() {
2677        let (_tmp, root) = two_deep_layout();
2678
2679        for value in [
2680            "../../static",
2681            "../../static/images",
2682            "./../../static",
2683            "../../not-created-yet",
2684        ] {
2685            assert!(
2686                config_with_static(&root, value).validate().is_ok(),
2687                "two-level static_folder {value:?} must be accepted"
2688            );
2689        }
2690    }
2691
2692    /// The anchor alone is not the whole rule. With two levels of ascent the
2693    /// root's own parent has become a legal *descendant* of the anchor, so
2694    /// without the ancestor check `..` would be accepted — and it serves the
2695    /// entire `src/` tree, including the markdown sources, as raw static files.
2696    #[test]
2697    fn test_validate_static_folder_ancestor_of_root_fails() {
2698        let (_tmp, root) = two_deep_layout();
2699
2700        for value in ["..", "../..", "../../src", "../../src/routes/.."] {
2701            let err = match config_with_static(&root, value).validate() {
2702                Err(err) => err,
2703                Ok(()) => panic!("an ancestor of the markdown root must be rejected: {value:?}"),
2704            };
2705            assert!(
2706                format!("{err:?}").contains("contains the markdown root"),
2707                "error should say the value contains the root, got: {err:?}"
2708            );
2709        }
2710    }
2711
2712    /// Two levels is the budget, and spending it is what the refusal must say —
2713    /// these values are neither ancestors of the root nor near `$HOME`, so the
2714    /// budget is the only thing refusing them.
2715    #[test]
2716    fn test_validate_static_folder_three_levels_up_fails() {
2717        let (_tmp, root) = two_deep_layout();
2718
2719        for value in ["../../../elsewhere", "../../../../elsewhere"] {
2720            let err = match config_with_static(&root, value).validate() {
2721                Err(err) => err,
2722                Ok(()) => {
2723                    panic!("static_folder past the ascent budget must be rejected: {value:?}")
2724                }
2725            };
2726            assert!(
2727                format!("{err:?}").contains("levels above the markdown root"),
2728                "error should name the ascent limit, got: {err:?}"
2729            );
2730        }
2731    }
2732
2733    /// The anchor is the one thing the whole policy is measured against, so it
2734    /// must always be the root or one of its ancestors, and never more than
2735    /// [`MAX_STATIC_FOLDER_ASCENT`] levels up. A bug that let it wander would
2736    /// widen every other rule at once.
2737    #[test]
2738    fn test_static_folder_anchor_never_leaves_the_root_line() {
2739        let (tmp, peer_root) = peer_layout();
2740        let (tmp2, deep_root) = two_deep_layout();
2741
2742        for root in [
2743            peer_root.as_path(),
2744            deep_root.as_path(),
2745            tmp.path(),
2746            tmp2.path(),
2747            Path::new("/"),
2748            Path::new("/notes"),
2749        ] {
2750            let (anchor, _) = static_folder_anchor(root);
2751            assert!(
2752                root.starts_with(&anchor),
2753                "anchor {} is not an ancestor of root {}",
2754                anchor.display(),
2755                root.display()
2756            );
2757            let climbed = root.components().count() - anchor.components().count();
2758            assert!(
2759                climbed <= MAX_STATIC_FOLDER_ASCENT,
2760                "anchor {} climbed {climbed} levels above {}",
2761                anchor.display(),
2762                root.display()
2763            );
2764        }
2765    }
2766
2767    /// `is_home_dir` fires only on `$HOME` itself, which was enough at one
2768    /// level. At two it is not: the anchor for `/home/you/notes` would be
2769    /// `/home`, and `../bob` would then name another account. The climb has to
2770    /// stop below any directory that *contains* `$HOME`.
2771    #[test]
2772    fn test_ascent_stops_at_a_directory_containing_home() {
2773        let _guard = env_lock();
2774        let tmp = tempfile::tempdir().unwrap();
2775        let users = tmp.path().join("Users");
2776        let home = users.join("alice");
2777        std::fs::create_dir_all(&home).unwrap();
2778        std::fs::create_dir_all(users.join("bob")).unwrap();
2779
2780        let home_str = home.to_string_lossy().into_owned();
2781        let _env = EnvVars::set(&[("HOME", &home_str), ("USERPROFILE", &home_str)]);
2782
2783        // A root sitting directly under `Users` is the case `is_home_dir` alone
2784        // misses: its parent is not `$HOME`, it merely *contains* it, and one
2785        // level of budget is all it takes to get there.
2786        let beside_home = users.join("notes");
2787        std::fs::create_dir(&beside_home).unwrap();
2788        let (anchor, stop) = static_folder_anchor(&beside_home);
2789        assert_eq!(
2790            anchor, beside_home,
2791            "the climb must not step into a directory holding $HOME"
2792        );
2793        assert_eq!(stop, AscentStop::HomeDir);
2794        assert!(
2795            config_with_static(&beside_home, "../alice")
2796                .validate()
2797                .is_err(),
2798            "$HOME must not be reachable from a sibling of it"
2799        );
2800
2801        // And the case it does catch, one level lower, still stops for the
2802        // same reason rather than spending the second level of budget.
2803        let root = home.join("notes");
2804        std::fs::create_dir(&root).unwrap();
2805        assert_eq!(static_folder_anchor(&root).1, AscentStop::HomeDir);
2806        assert!(
2807            config_with_static(&root, "../../bob").validate().is_err(),
2808            "another account under the same Users directory must not be reachable"
2809        );
2810    }
2811
2812    /// A relative `root_dir` bottoms out at an empty parent rather than at a
2813    /// filesystem root, and `contains_home_dir` must not claim that empty path
2814    /// as `$HOME` — `starts_with("")` is true of every path. Pinned because the
2815    /// distinction is invisible until someone constructs a `Config` by hand.
2816    #[test]
2817    fn test_validate_static_folder_relative_root_dir() {
2818        let root = PathBuf::from("no-such-root-8f2c/content");
2819        for value in ["../../x", "../../../x"] {
2820            assert!(
2821                config_with_static(&root, value).validate().is_err(),
2822                "a relative root must not reach outside itself: {value:?}"
2823            );
2824        }
2825    }
2826
2827    /// A UNC share root behaves exactly like `C:\`: it has no parent, so the
2828    /// climb stops there rather than reaching another share.
2829    #[cfg(windows)]
2830    #[test]
2831    fn test_validate_static_folder_unc_share_boundary() {
2832        let root = Path::new(r"\\server\share\proj\content");
2833        assert!(
2834            config_with_static(root, r"..\..\x").validate().is_err(),
2835            "the climb must stop at a UNC share root"
2836        );
2837    }
2838
2839    /// Four different walls produce four different refusals. The messages are
2840    /// the only thing an operator sees when mbr refuses to start, and "outside
2841    /// the boundary" for all four would not tell them which knob to turn.
2842    #[test]
2843    fn test_static_folder_refusal_messages_name_the_wall() {
2844        let anchor = Path::new("/project");
2845
2846        assert!(
2847            AscentStop::RootHasNoParent
2848                .refusal(anchor)
2849                .contains("no parent directory")
2850        );
2851        assert!(
2852            AscentStop::HomeDir
2853                .refusal(anchor)
2854                .contains("the home directory")
2855        );
2856        assert!(
2857            AscentStop::FilesystemRoot
2858                .refusal(anchor)
2859                .contains("the filesystem root")
2860        );
2861
2862        let budget = AscentStop::Budget.refusal(anchor);
2863        assert!(budget.contains("levels above the markdown root"));
2864        // Each message names the anchor, which is what tells the operator where
2865        // an overlay *would* have been accepted.
2866        for stop in [
2867            AscentStop::HomeDir,
2868            AscentStop::FilesystemRoot,
2869            AscentStop::Budget,
2870        ] {
2871            assert!(
2872                stop.refusal(anchor).contains("/project"),
2873                "refusal should name the anchor: {}",
2874                stop.refusal(anchor)
2875            );
2876        }
2877    }
2878
2879    /// A directory that does not exist yet must be judged the same way an
2880    /// existing one is: `canonicalize` fails, so the lexical fallback has to
2881    /// produce the same verdict rather than silently passing everything.
2882    #[test]
2883    fn test_validate_static_folder_nonexistent_paths_use_lexical_fallback() {
2884        let (_tmp, root) = peer_layout();
2885        for value in ["../not-created-yet", "../../not-created-yet"] {
2886            assert!(
2887                config_with_static(&root, value).validate().is_ok(),
2888                "a not-yet-created directory within the ascent budget is still allowed: {value:?}"
2889            );
2890        }
2891        assert!(
2892            config_with_static(&root, "../../../not-created-yet")
2893                .validate()
2894                .is_err(),
2895            "a nonexistent path past the ascent budget must still be rejected"
2896        );
2897    }
2898
2899    // ==================== find_root_dir $HOME Skip Tests ====================
2900
2901    /// Serializes tests that mutate process-global environment variables.
2902    /// Rust runs tests in parallel threads within one process, so `HOME` /
2903    /// `MBR_*` mutation would otherwise race.
2904    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
2905
2906    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
2907        // A panicking test poisons the mutex; the guarded state is only the
2908        // environment, which each test sets up for itself.
2909        ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
2910    }
2911
2912    /// Sets environment variables for the duration of a scope and restores
2913    /// their previous values on drop (including on panic).
2914    struct EnvVars {
2915        saved: Vec<(String, Option<std::ffi::OsString>)>,
2916    }
2917
2918    impl EnvVars {
2919        fn set(pairs: &[(&str, &str)]) -> Self {
2920            let saved = pairs
2921                .iter()
2922                .map(|(key, value)| {
2923                    let previous = std::env::var_os(key);
2924                    // SAFETY: all environment mutation in these tests happens
2925                    // under `ENV_LOCK`, and no other thread in the test binary
2926                    // reads the environment concurrently.
2927                    unsafe { std::env::set_var(key, value) };
2928                    ((*key).to_string(), previous)
2929                })
2930                .collect();
2931            Self { saved }
2932        }
2933    }
2934
2935    impl Drop for EnvVars {
2936        fn drop(&mut self) {
2937            for (key, previous) in &self.saved {
2938                // SAFETY: as above - still holding `ENV_LOCK`.
2939                unsafe {
2940                    match previous {
2941                        Some(value) => std::env::set_var(key, value),
2942                        None => std::env::remove_var(key),
2943                    }
2944                }
2945            }
2946        }
2947    }
2948
2949    /// Regression: a `$HOME` marker match used to `break` out of the whole
2950    /// marker loop, so a dotfiles `~/.git` hid the `.obsidian` that marks the
2951    /// real vault and the root collapsed to the leaf directory.
2952    #[test]
2953    fn test_find_root_dir_home_marker_does_not_abort_search() {
2954        let _guard = env_lock();
2955        let home = tempfile::tempdir().unwrap();
2956        let home_str = home.path().to_string_lossy().into_owned();
2957        let _env = EnvVars::set(&[("HOME", &home_str), ("USERPROFILE", &home_str)]);
2958
2959        // ~/.git (dotfiles repo) plus a real vault at ~/notes.
2960        std::fs::create_dir(home.path().join(".git")).unwrap();
2961        let vault = home.path().join("notes");
2962        std::fs::create_dir(&vault).unwrap();
2963        std::fs::create_dir(vault.join(".obsidian")).unwrap();
2964        let leaf = vault.join("projects");
2965        std::fs::create_dir(&leaf).unwrap();
2966        let note = leaf.join("plan.md");
2967        std::fs::write(&note, "# Plan").unwrap();
2968
2969        assert_eq!(
2970            find_root_dir(&note),
2971            vault,
2972            "the `.obsidian` vault must win once the `$HOME` `.git` is skipped"
2973        );
2974    }
2975
2976    /// The `$HOME` guard itself still holds: when every marker only matches at
2977    /// `$HOME`, the root falls back to the start directory.
2978    #[test]
2979    fn test_find_root_dir_only_home_marker_falls_back() {
2980        let _guard = env_lock();
2981        let home = tempfile::tempdir().unwrap();
2982        let home_str = home.path().to_string_lossy().into_owned();
2983        let _env = EnvVars::set(&[("HOME", &home_str), ("USERPROFILE", &home_str)]);
2984
2985        std::fs::create_dir(home.path().join(".git")).unwrap();
2986        let leaf = home.path().join("notes/projects");
2987        std::fs::create_dir_all(&leaf).unwrap();
2988        let note = leaf.join("plan.md");
2989        std::fs::write(&note, "# Plan").unwrap();
2990
2991        assert_eq!(find_root_dir(&note), leaf);
2992    }
2993
2994    // ==================== explicit_hidden_dirs Tests ====================
2995    //
2996    // Purely lexical, so none of these touch the filesystem.
2997
2998    #[test]
2999    fn explicit_hidden_dirs_target_equal_to_root_is_empty() {
3000        let root = Path::new("/repo");
3001        assert_eq!(explicit_hidden_dirs(root, root), Vec::<PathBuf>::new());
3002    }
3003
3004    #[test]
3005    fn explicit_hidden_dirs_target_is_a_dot_dir() {
3006        assert_eq!(
3007            explicit_hidden_dirs(Path::new("/repo"), Path::new("/repo/.scratch")),
3008            vec![PathBuf::from(".scratch")]
3009        );
3010    }
3011
3012    /// `mbr -s .scratch/sub`: the *chain* is exempt, so `.scratch` is re-admitted
3013    /// even though the user named something below it. `sub` is not hidden and so
3014    /// contributes nothing — it never needed re-admitting.
3015    #[test]
3016    fn explicit_hidden_dirs_target_nested_under_a_dot_dir() {
3017        assert_eq!(
3018            explicit_hidden_dirs(Path::new("/repo"), Path::new("/repo/.scratch/sub")),
3019            vec![PathBuf::from(".scratch")]
3020        );
3021    }
3022
3023    /// Every hidden link in the chain is returned, not just the outermost: the
3024    /// scanner prunes at each level, so exempting only `.a` would still leave
3025    /// `.a/.b` unreachable.
3026    #[test]
3027    fn explicit_hidden_dirs_returns_every_hidden_link_in_the_chain() {
3028        assert_eq!(
3029            explicit_hidden_dirs(Path::new("/repo"), Path::new("/repo/.a/plain/.b/notes.md")),
3030            vec![PathBuf::from(".a"), PathBuf::from(".a/plain/.b")]
3031        );
3032    }
3033
3034    #[test]
3035    fn explicit_hidden_dirs_target_under_a_normal_dir_is_empty() {
3036        assert_eq!(
3037            explicit_hidden_dirs(Path::new("/repo"), Path::new("/repo/docs/guide.md")),
3038            Vec::<PathBuf>::new()
3039        );
3040    }
3041
3042    /// A target that is not under the root cannot name anything the scan would
3043    /// reach, so it exempts nothing rather than exempting by coincidence of name.
3044    #[test]
3045    fn explicit_hidden_dirs_target_outside_root_is_empty() {
3046        assert_eq!(
3047            explicit_hidden_dirs(Path::new("/repo"), Path::new("/elsewhere/.scratch")),
3048            Vec::<PathBuf>::new()
3049        );
3050    }
3051
3052    /// The field is derived from the invocation, so `Config::read` must fill it
3053    /// in — and `.mbr/config.toml` must not be able to.
3054    #[test]
3055    fn test_config_read_derives_explicit_hidden_dirs_from_the_target() {
3056        let _guard = env_lock();
3057        let repo = repo_with_mbr_dir(Some("explicit_hidden_dirs = [\".sneaky\"]\n"));
3058        let scratch = repo.path().join(".scratch");
3059        std::fs::create_dir(&scratch).unwrap();
3060
3061        let config = Config::read(&scratch).expect("config must load");
3062
3063        assert_eq!(config.root_dir, repo.path());
3064        assert_eq!(config.explicit_hidden_dirs, vec![PathBuf::from(".scratch")]);
3065    }
3066
3067    /// `read_at_root` never climbs: a subfolder of a repository is its own
3068    /// root, and the enclosing repository's config file is not read.
3069    #[test]
3070    fn test_config_read_at_root_ignores_enclosing_repositories() {
3071        let _guard = env_lock();
3072        let repo = repo_with_mbr_dir(Some("index_file = \"home.md\"\n"));
3073        let sub = repo.path().join("notes");
3074        std::fs::create_dir(&sub).unwrap();
3075
3076        let climbed = Config::read(&sub).expect("config must load");
3077        assert_eq!(climbed.root_dir, repo.path());
3078        assert_eq!(climbed.index_file, "home.md");
3079
3080        let pinned = Config::read_at_root(&sub).expect("config must load");
3081        assert_eq!(pinned.root_dir, sub);
3082        assert_eq!(pinned.index_file, Config::default().index_file);
3083        assert!(pinned.explicit_hidden_dirs.is_empty());
3084    }
3085
3086    #[test]
3087    fn test_config_read_leaves_explicit_hidden_dirs_empty_for_a_plain_target() {
3088        let _guard = env_lock();
3089        let repo = repo_with_mbr_dir(None);
3090
3091        let config = Config::read(repo.path()).expect("config must load");
3092
3093        assert!(config.explicit_hidden_dirs.is_empty());
3094    }
3095
3096    // ==================== Config::read Layering Tests ====================
3097
3098    /// Creates a repo root pinned by a `.mbr/` directory so `find_root_dir`
3099    /// cannot wander into whatever markers exist above the temp directory.
3100    fn repo_with_mbr_dir(config_toml: Option<&str>) -> tempfile::TempDir {
3101        let dir = tempfile::tempdir().unwrap();
3102        let mbr = dir.path().join(".mbr");
3103        std::fs::create_dir(&mbr).unwrap();
3104        if let Some(contents) = config_toml {
3105            std::fs::write(mbr.join("config.toml"), contents).unwrap();
3106        }
3107        dir
3108    }
3109
3110    #[test]
3111    fn test_config_read_defaults_only() {
3112        let _guard = env_lock();
3113        let repo = repo_with_mbr_dir(None);
3114
3115        let config = Config::read(repo.path()).expect("defaults must load");
3116
3117        assert_eq!(config.theme, Config::default().theme);
3118        assert_eq!(config.port, DEFAULT_PORT);
3119        assert_eq!(config.root_dir, repo.path());
3120    }
3121
3122    #[test]
3123    fn test_config_read_toml_overrides_defaults() {
3124        let _guard = env_lock();
3125        let repo = repo_with_mbr_dir(Some("theme = \"amber\"\nport = 5321\n"));
3126
3127        let config = Config::read(repo.path()).expect("toml must load");
3128
3129        assert_eq!(config.theme, "amber");
3130        assert_eq!(config.port, 5321);
3131    }
3132
3133    #[test]
3134    fn test_config_read_env_overrides_defaults() {
3135        let _guard = env_lock();
3136        let repo = repo_with_mbr_dir(None);
3137        let _env = EnvVars::set(&[("MBR_THEME", "cyan"), ("MBR_PORT", "5322")]);
3138
3139        let config = Config::read(repo.path()).expect("env must load");
3140
3141        assert_eq!(config.theme, "cyan");
3142        assert_eq!(config.port, 5322);
3143    }
3144
3145    /// `EditingRequiresToken` tells operators they can supply the hash as
3146    /// `MBR_EDIT_TOKEN_HASH` instead of editing the repository's config.toml.
3147    /// Pin that the env layer really maps to `edit_token_hash`, and that it
3148    /// satisfies the non-loopback check on its own.
3149    #[test]
3150    fn test_config_read_edit_token_hash_from_env() {
3151        let _guard = env_lock();
3152        let repo = repo_with_mbr_dir(Some("edit_enabled = true\nhost = \"0.0.0.0\"\n"));
3153        let _env = EnvVars::set(&[("MBR_EDIT_TOKEN_HASH", "$argon2id$from-env")]);
3154
3155        let config = Config::read(repo.path()).expect("env-supplied hash must satisfy validate");
3156
3157        assert_eq!(
3158            config.edit_token_hash.as_deref(),
3159            Some("$argon2id$from-env")
3160        );
3161    }
3162
3163    // ==================== edit_enabled defaults per mode ====================
3164
3165    #[test]
3166    fn test_edit_enabled_defaults_off_for_read_and_on_for_gui() {
3167        let _guard = env_lock();
3168        let repo = repo_with_mbr_dir(None);
3169
3170        assert!(
3171            !Config::read(repo.path()).unwrap().edit_enabled,
3172            "server/build"
3173        );
3174        assert!(!Config::read_at_root(repo.path()).unwrap().edit_enabled);
3175        assert!(
3176            Config::read_for_gui(repo.path()).unwrap().edit_enabled,
3177            "GUI"
3178        );
3179        assert!(!Config::default().edit_enabled);
3180        assert!(Config::gui_defaults().edit_enabled);
3181    }
3182
3183    /// The GUI default is only a default: the repository's config turns it off.
3184    #[test]
3185    fn test_gui_edit_default_yields_to_config_false() {
3186        let _guard = env_lock();
3187        let repo = repo_with_mbr_dir(Some("edit_enabled = false\n"));
3188
3189        assert!(!Config::read_for_gui(repo.path()).unwrap().edit_enabled);
3190    }
3191
3192    /// ... and so does the environment, over the config file as usual.
3193    #[test]
3194    fn test_gui_edit_default_yields_to_env_false() {
3195        let _guard = env_lock();
3196        let repo = repo_with_mbr_dir(None);
3197        let _env = EnvVars::set(&[("MBR_EDIT_ENABLED", "false")]);
3198        assert!(!Config::read_for_gui(repo.path()).unwrap().edit_enabled);
3199        drop(_env);
3200
3201        let repo = repo_with_mbr_dir(Some("edit_enabled = true\n"));
3202        let _env = EnvVars::set(&[("MBR_EDIT_ENABLED", "false")]);
3203        assert!(!Config::read_for_gui(repo.path()).unwrap().edit_enabled);
3204    }
3205
3206    /// The server is off unless asked, and the config or env can ask.
3207    #[test]
3208    fn test_server_edit_enabled_by_config_or_env() {
3209        let _guard = env_lock();
3210        let repo = repo_with_mbr_dir(Some("edit_enabled = true\n"));
3211        assert!(Config::read(repo.path()).unwrap().edit_enabled);
3212
3213        let repo = repo_with_mbr_dir(None);
3214        let _env = EnvVars::set(&[("MBR_EDIT_ENABLED", "true")]);
3215        assert!(Config::read(repo.path()).unwrap().edit_enabled);
3216    }
3217
3218    /// A GUI default on a non-loopback host without a token is dropped rather
3219    /// than failing the launch; asked for explicitly, it still fails.
3220    #[test]
3221    fn test_gui_edit_default_dropped_on_a_non_loopback_host_without_a_token() {
3222        let _guard = env_lock();
3223        let repo = repo_with_mbr_dir(Some("host = \"0.0.0.0\"\n"));
3224        let config = Config::read_for_gui(repo.path()).expect("default must not fail");
3225        assert!(!config.edit_enabled);
3226
3227        let repo = repo_with_mbr_dir(Some(
3228            "host = \"0.0.0.0\"\nedit_token_hash = \"$argon2id$x\"\n",
3229        ));
3230        assert!(
3231            Config::read_for_gui(repo.path()).unwrap().edit_enabled,
3232            "token"
3233        );
3234
3235        let repo = repo_with_mbr_dir(Some("host = \"0.0.0.0\"\nedit_enabled = true\n"));
3236        assert!(matches!(
3237            Config::read_for_gui(repo.path()),
3238            Err(crate::MbrError::Config(ConfigError::EditingRequiresToken))
3239        ));
3240    }
3241
3242    #[test]
3243    fn test_editing_needs_token() {
3244        let base = Config {
3245            edit_enabled: true,
3246            host: IpArray([0, 0, 0, 0]),
3247            ..Config::default()
3248        };
3249        assert!(base.editing_needs_token());
3250        assert!(
3251            !Config {
3252                edit_token_hash: Some("h".into()),
3253                ..base.clone()
3254            }
3255            .editing_needs_token()
3256        );
3257        assert!(
3258            !Config {
3259                host: IpArray([127, 0, 0, 1]),
3260                ..base.clone()
3261            }
3262            .editing_needs_token()
3263        );
3264        assert!(
3265            !Config {
3266                edit_enabled: false,
3267                ..base
3268            }
3269            .editing_needs_token()
3270        );
3271    }
3272
3273    /// The documented precedence: `MBR_*` beats `.mbr/config.toml` beats
3274    /// defaults. This matters for security, not just ergonomics - the toml
3275    /// layer ships inside the (possibly untrusted) markdown repository, so an
3276    /// operator must be able to override it from outside.
3277    #[test]
3278    fn test_config_read_env_beats_toml_beats_defaults() {
3279        let _guard = env_lock();
3280        let repo = repo_with_mbr_dir(Some(
3281            "theme = \"amber\"\nport = 5321\nindex_file = \"home.md\"\n",
3282        ));
3283        let _env = EnvVars::set(&[("MBR_THEME", "cyan")]);
3284
3285        let config = Config::read(repo.path()).expect("all three layers must load");
3286
3287        assert_eq!(config.theme, "cyan", "env must win over the config file");
3288        assert_eq!(
3289            config.index_file, "home.md",
3290            "config file must win over defaults where env is silent"
3291        );
3292        assert_eq!(
3293            config.port, 5321,
3294            "config file must win over defaults where env is silent"
3295        );
3296        assert_eq!(
3297            config.markdown_extensions,
3298            Config::default().markdown_extensions,
3299            "untouched keys keep the compiled-in default"
3300        );
3301    }
3302
3303    // ==================== tasks_ignore_globs Tests ====================
3304
3305    /// Opt-in: an unconfigured repository indexes every file's tasks.
3306    #[test]
3307    fn test_tasks_ignore_globs_defaults_to_empty() {
3308        assert!(Config::default().tasks_ignore_globs.is_empty());
3309    }
3310
3311    #[test]
3312    fn test_config_read_tasks_ignore_globs_from_toml() {
3313        let _guard = env_lock();
3314        let repo = repo_with_mbr_dir(Some(
3315            "tasks_ignore_globs = [\"templates/**\", \"**/archive/**\"]\n",
3316        ));
3317
3318        let config = Config::read(repo.path()).expect("toml must load");
3319
3320        assert_eq!(config.tasks_ignore_globs, ["templates/**", "**/archive/**"]);
3321    }
3322
3323    /// The env layer takes a JSON array, and wins over the repository's own
3324    /// config file like every other key.
3325    #[test]
3326    fn test_config_read_tasks_ignore_globs_env_overrides_toml() {
3327        let _guard = env_lock();
3328        let repo = repo_with_mbr_dir(Some("tasks_ignore_globs = [\"templates/**\"]\n"));
3329        let _env = EnvVars::set(&[("MBR_TASKS_IGNORE_GLOBS", r#"["**/archive/**"]"#)]);
3330
3331        let config = Config::read(repo.path()).expect("env must load");
3332
3333        assert_eq!(config.tasks_ignore_globs, ["**/archive/**"]);
3334    }
3335
3336    /// A `.mbr/config.toml` written before this option existed must still load.
3337    #[test]
3338    fn test_config_read_without_tasks_ignore_globs_key_still_loads() {
3339        let _guard = env_lock();
3340        let repo = repo_with_mbr_dir(Some("theme = \"amber\"\nport = 5323\n"));
3341
3342        let config = Config::read(repo.path()).expect("a config predating the option must load");
3343
3344        assert!(config.tasks_ignore_globs.is_empty());
3345        assert_eq!(config.theme, "amber");
3346    }
3347
3348    /// Fail fast: the patterns are compiled once at `TaskIndex` construction,
3349    /// where a bad one would be dropped and silently index the folder it was
3350    /// meant to exclude.
3351    #[test]
3352    fn test_validate_rejects_a_malformed_tasks_ignore_glob() {
3353        // `**` must be a whole path component; `**bad` is not.
3354        let config = Config {
3355            tasks_ignore_globs: vec!["templates/**".to_string(), "templates/**bad".to_string()],
3356            ..Default::default()
3357        };
3358
3359        let err = config
3360            .validate()
3361            .expect_err("a malformed glob must fail validation");
3362
3363        assert!(
3364            matches!(err, ConfigError::InvalidTasksIgnoreGlob { ref pattern, .. } if pattern == "templates/**bad"),
3365            "expected InvalidTasksIgnoreGlob naming the offending pattern, got: {err:?}"
3366        );
3367        assert!(err.to_string().contains("templates/**"));
3368    }
3369
3370    #[test]
3371    fn test_validate_accepts_the_documented_glob_spellings() {
3372        let config = Config {
3373            tasks_ignore_globs: vec![
3374                "templates/**".to_string(),
3375                "**/templates/**".to_string(),
3376                "docs/*.md".to_string(),
3377            ],
3378            ..Default::default()
3379        };
3380
3381        assert!(config.validate().is_ok());
3382    }
3383
3384    /// The validation runs during load, not only when called directly.
3385    #[test]
3386    fn test_config_read_rejects_a_malformed_tasks_ignore_glob() {
3387        let _guard = env_lock();
3388        let repo = repo_with_mbr_dir(Some("tasks_ignore_globs = [\"templates/**bad\"]\n"));
3389
3390        let err = Config::read(repo.path()).expect_err("a malformed glob must abort loading");
3391
3392        assert!(
3393            matches!(
3394                err,
3395                crate::MbrError::Config(ConfigError::InvalidTasksIgnoreGlob { .. })
3396            ),
3397            "expected ConfigError::InvalidTasksIgnoreGlob, got: {err:?}"
3398        );
3399    }
3400
3401    #[test]
3402    fn default_review_enabled_is_true() {
3403        assert!(Config::default().review_enabled);
3404    }
3405
3406    /// The repository's own config file and the env layer both reach it, on the
3407    /// documented precedence (env wins).
3408    #[test]
3409    fn test_config_read_review_enabled_from_toml_and_env() {
3410        let _guard = env_lock();
3411        let repo = repo_with_mbr_dir(Some("review_enabled = false\n"));
3412
3413        let config = Config::read(repo.path()).expect("toml must load");
3414        assert!(!config.review_enabled);
3415
3416        let _env = EnvVars::set(&[("MBR_REVIEW_ENABLED", "true")]);
3417        let config = Config::read(repo.path()).expect("env must load");
3418        assert!(config.review_enabled, "env overrides .mbr/config.toml");
3419    }
3420
3421    /// A config file written before the key existed must still load.
3422    #[test]
3423    fn test_config_read_without_review_enabled_key_still_loads() {
3424        let _guard = env_lock();
3425        let repo = repo_with_mbr_dir(Some("tasks_enabled = false\n"));
3426
3427        let config = Config::read(repo.path()).expect("toml must load");
3428        assert!(config.review_enabled, "serde default applies");
3429    }
3430
3431    /// Checkboxes only, deliberately narrower than the wire default of
3432    /// [`IncludeFilter::All`]: the panel widens by itself when this returns
3433    /// nothing, so the narrow default costs a repo without checkboxes nothing.
3434    #[test]
3435    fn test_default_tasks_default_include_is_tasks() {
3436        assert_eq!(
3437            Config::default().tasks_default_include,
3438            IncludeFilter::Tasks
3439        );
3440    }
3441
3442    #[test]
3443    fn test_config_read_tasks_default_include_round_trips_every_value() {
3444        let _guard = env_lock();
3445        for (written, expected) in [
3446            ("all", IncludeFilter::All),
3447            ("tasks", IncludeFilter::Tasks),
3448            ("markers", IncludeFilter::Markers),
3449        ] {
3450            let repo = repo_with_mbr_dir(Some(&format!("tasks_default_include = \"{written}\"\n")));
3451
3452            let config = Config::read(repo.path())
3453                .unwrap_or_else(|e| panic!("{written:?} must load, got: {e:?}"));
3454
3455            assert_eq!(config.tasks_default_include, expected, "for {written:?}");
3456        }
3457    }
3458
3459    /// Typed as the real enum rather than a `String` precisely so this fails at
3460    /// startup, where it is visible, instead of silently selecting a default.
3461    #[test]
3462    fn test_config_read_rejects_an_unknown_tasks_default_include() {
3463        let _guard = env_lock();
3464        let repo = repo_with_mbr_dir(Some("tasks_default_include = \"todos\"\n"));
3465
3466        let err = Config::read(repo.path()).expect_err("an unknown value must abort loading");
3467
3468        assert!(
3469            matches!(err, crate::MbrError::Config(ConfigError::ParseFailed(_))),
3470            "expected ConfigError::ParseFailed, got: {err:?}"
3471        );
3472    }
3473
3474    /// Env beats the repository's own file, for the reason the whole `MBR_*`
3475    /// layer exists: the operator serving a repo need not be its author.
3476    #[test]
3477    fn test_config_read_tasks_default_include_env_overrides_toml() {
3478        let _guard = env_lock();
3479        let repo = repo_with_mbr_dir(Some("tasks_default_include = \"tasks\"\n"));
3480        let _env = EnvVars::set(&[("MBR_TASKS_DEFAULT_INCLUDE", "markers")]);
3481
3482        let config = Config::read(repo.path()).expect("env must load");
3483
3484        assert_eq!(config.tasks_default_include, IncludeFilter::Markers);
3485    }
3486
3487    /// A `.mbr/config.toml` written before this option existed must still load.
3488    #[test]
3489    fn test_config_read_without_tasks_default_include_key_still_loads() {
3490        let _guard = env_lock();
3491        let repo = repo_with_mbr_dir(Some("theme = \"amber\"\nport = 5324\n"));
3492
3493        let config = Config::read(repo.path()).expect("a config predating the option must load");
3494
3495        assert_eq!(config.tasks_default_include, IncludeFilter::Tasks);
3496        assert_eq!(config.theme, "amber");
3497    }
3498
3499    #[test]
3500    fn test_config_read_malformed_toml_returns_parse_failed() {
3501        let _guard = env_lock();
3502        let repo = repo_with_mbr_dir(Some("theme = \nport = [not valid toml\n"));
3503
3504        let err = Config::read(repo.path()).expect_err("malformed toml must fail");
3505
3506        assert!(
3507            matches!(err, crate::MbrError::Config(ConfigError::ParseFailed(_))),
3508            "expected ConfigError::ParseFailed, got: {err:?}"
3509        );
3510    }
3511
3512    /// The symlink-mount kill switch is the operator's alone: a repository's
3513    /// own `.mbr/config.toml` can neither turn mounts back on against
3514    /// `MBR_NO_SYMLINK_MOUNTS` nor flip the switch itself, whatever it calls
3515    /// the key.
3516    #[test]
3517    fn test_config_toml_cannot_set_the_symlink_mount_switch() {
3518        let _guard = env_lock();
3519        let toml = "no_symlink_mounts = false\nsymlink_mounts = true\n\
3520                    MBR_NO_SYMLINK_MOUNTS = false\n";
3521        let repo = repo_with_mbr_dir(Some(toml));
3522        {
3523            let _env = EnvVars::set(&[(NO_SYMLINK_MOUNTS_ENV, "1")]);
3524            assert!(
3525                Config::read(repo.path()).unwrap().no_symlink_mounts,
3526                "the operator's env var wins over anything in config.toml"
3527            );
3528        }
3529        let repo = repo_with_mbr_dir(Some("no_symlink_mounts = true\nsymlink_mounts = false\n"));
3530        let _env = EnvVars::set(&[(NO_SYMLINK_MOUNTS_ENV, "0")]);
3531        assert!(
3532            !Config::read(repo.path()).unwrap().no_symlink_mounts,
3533            "config.toml cannot disable mounts either: the key is simply not read"
3534        );
3535    }
3536
3537    #[test]
3538    fn test_no_symlink_mounts_env_values() {
3539        for on in ["1", "true", "TRUE", "yes", "on", "anything"] {
3540            assert!(env_flag_is_set(std::ffi::OsStr::new(on)), "{on}");
3541        }
3542        for off in ["", " ", "0", "false", "False", "no", "off"] {
3543            assert!(!env_flag_is_set(std::ffi::OsStr::new(off)), "{off:?}");
3544        }
3545    }
3546
3547    #[test]
3548    fn test_config_read_rejects_escaping_static_folder_from_toml() {
3549        let _guard = env_lock();
3550        let repo = repo_with_mbr_dir(Some("static_folder = \"../..\"\n"));
3551
3552        assert!(
3553            Config::read(repo.path()).is_err(),
3554            "a repo-supplied static_folder that escapes the root must be rejected"
3555        );
3556    }
3557
3558    /// The untrusted-repo half of the absolute-path rule: whoever cloned the
3559    /// repo did not choose this value, so an absolute `static_folder` in the
3560    /// repo's own `.mbr/config.toml` is refused no matter where it points.
3561    #[test]
3562    fn test_config_read_rejects_absolute_static_folder_from_toml() {
3563        let _guard = env_lock();
3564        let absolute = if cfg!(windows) { r"C:\\etc" } else { "/etc" };
3565        let repo = repo_with_mbr_dir(Some(&format!("static_folder = \"{absolute}\"\n")));
3566
3567        let err = Config::read(repo.path())
3568            .expect_err("an absolute static_folder from the repo config must be rejected");
3569
3570        assert!(
3571            format!("{err:?}").contains("static_folder"),
3572            "error should name static_folder, got: {err:?}"
3573        );
3574    }
3575
3576    /// The operator half of the same rule: `MBR_STATIC_FOLDER` is set by whoever
3577    /// runs the server, not by the repository, so an absolute value there is a
3578    /// deliberate choice and is honored.
3579    #[test]
3580    fn test_config_read_accepts_absolute_static_folder_from_env() {
3581        let _guard = env_lock();
3582        let repo = repo_with_mbr_dir(None);
3583        let assets = tempfile::tempdir().unwrap();
3584        let assets_str = assets.path().to_string_lossy().into_owned();
3585        let _env = EnvVars::set(&[("MBR_STATIC_FOLDER", &assets_str)]);
3586
3587        let config = Config::read(repo.path())
3588            .expect("an absolute static_folder from the environment must be accepted");
3589
3590        assert_eq!(config.static_folder, assets_str);
3591    }
3592
3593    /// Env wins over the config file, and provenance follows the winner: the
3594    /// operator's absolute value is honored even when the repo also names one.
3595    #[test]
3596    fn test_config_read_env_absolute_static_folder_overrides_toml() {
3597        let _guard = env_lock();
3598        let repo = repo_with_mbr_dir(Some("static_folder = \"/etc\"\n"));
3599        let assets = tempfile::tempdir().unwrap();
3600        let assets_str = assets.path().to_string_lossy().into_owned();
3601        let _env = EnvVars::set(&[("MBR_STATIC_FOLDER", &assets_str)]);
3602
3603        let config = Config::read(repo.path())
3604            .expect("the operator's absolute static_folder must win over the repo's");
3605
3606        assert_eq!(config.static_folder, assets_str);
3607    }
3608
3609    /// The user-reported regression, exercised through the real loader: a
3610    /// `content/` markdown root holding `.mbr/config.toml` with a `static/`
3611    /// directory alongside it must load.
3612    #[test]
3613    fn test_config_read_accepts_peer_static_folder_from_toml() {
3614        let _guard = env_lock();
3615        let project = tempfile::tempdir().unwrap();
3616        let content = project.path().join("content");
3617        std::fs::create_dir_all(content.join(".mbr")).unwrap();
3618        std::fs::write(
3619            content.join(".mbr/config.toml"),
3620            "static_folder = \"../static\"\n",
3621        )
3622        .unwrap();
3623        std::fs::create_dir(project.path().join("static")).unwrap();
3624
3625        let config = Config::read(&content).expect("a peer static folder must load");
3626
3627        assert_eq!(config.static_folder, "../static");
3628        assert_eq!(config.root_dir, content);
3629    }
3630}