Skip to main content

dbmd_core/
stats.rs

1//! `stats` — store overview, **computed on demand** (a SWEEP, like `du` —
2//! never a maintained or precomputed cache).
3//!
4//! Serves both the human (how big is my brain, what's the shape) and the agent
5//! (orientation). Deliberately excludes graph density / degree / top-linked
6//! analytics — low agent value, and a human who wants graph metrics opens the
7//! store in Obsidian, so we never build the full graph just for stats.
8
9use std::collections::{BTreeMap, HashSet};
10use std::path::{Path, PathBuf};
11
12use regex::Regex;
13
14use crate::store::{Layer, Store};
15
16/// A point-in-time overview of a store. Pure data; the CLI formats it to text
17/// or JSON.
18#[derive(Debug, Clone, Default, PartialEq)]
19pub struct Stats {
20    /// Total content-file count across all layers.
21    pub total_files: usize,
22    /// File count per layer.
23    pub files_per_layer: BTreeMap<Layer, usize>,
24    /// Total size on disk, in bytes.
25    pub total_size_bytes: u64,
26    /// Count per `type:` value (the type distribution).
27    pub type_distribution: BTreeMap<String, usize>,
28    /// Number of orphan files (no incoming and no outgoing wiki-links).
29    pub orphan_count: usize,
30    /// Number of broken wiki-links (target file doesn't exist).
31    pub broken_link_count: usize,
32    /// Top types by count, descending (ties broken by type name ascending).
33    pub top_types: Vec<(String, usize)>,
34}
35
36/// How many entries [`Stats::top_types`] holds.
37const TOP_TYPES_LIMIT: usize = 10;
38
39/// One content file discovered by the SWEEP, with everything `stats` needs:
40/// where it lives, how big it is, its declared `type`, and the wiki-link
41/// targets it emits (store-relative, `.md` stripped, short-form excluded).
42struct FileFacts {
43    /// Store-relative path *without* the `.md` extension — the node id used to
44    /// resolve wiki-links and detect orphans.
45    node_id: PathBuf,
46    /// The layer this file lives under.
47    layer: Layer,
48    /// File size on disk, in bytes.
49    size_bytes: u64,
50    /// The declared `type:`, if the frontmatter has one.
51    type_: Option<String>,
52    /// Every wiki-link target this file emits, store-relative with any trailing
53    /// `.md` stripped, in source order (not deduped, short-form included).
54    /// Resolved against the complete node set in a second pass.
55    raw_targets: Vec<PathBuf>,
56}
57
58impl FileFacts {
59    /// The subset of [`raw_targets`](FileFacts::raw_targets) that could resolve
60    /// to a store node: full store-relative paths. Short-form targets (no `/`)
61    /// are dropped — they're a `WIKI_LINK_SHORT_FORM` validation error, not a
62    /// graph edge, so stats neither counts them as broken nor lets them wire a
63    /// file out of orphan status.
64    fn resolvable_targets(&self) -> impl Iterator<Item = &PathBuf> {
65        self.raw_targets.iter().filter(|t| is_full_path(t))
66    }
67}
68
69/// **SWEEP.** Walk the store once and compute its [`Stats`]. Run occasionally
70/// (overview / orientation), never on the interactive loop.
71pub fn compute(store: &Store) -> crate::Result<Stats> {
72    let link_re = wiki_link_regex();
73
74    // First pass: walk every layer once, recording per-file facts and the set
75    // of node ids that exist on disk. Link resolution waits for the second
76    // pass, once every node's existence is known.
77    let mut existing_nodes: HashSet<PathBuf> = HashSet::new();
78    let mut facts: Vec<FileFacts> = Vec::new();
79
80    for layer in Layer::all() {
81        let layer_root = store.root.join(layer_dir_name(layer));
82        for abs in walk_layer_content_files(&layer_root)? {
83            let rel = abs.strip_prefix(&store.root).unwrap_or(&abs).to_path_buf();
84            let node_id = strip_md(&rel);
85            existing_nodes.insert(node_id.clone());
86
87            let size_bytes = std::fs::metadata(&abs).map(|m| m.len()).unwrap_or(0);
88            let text = std::fs::read_to_string(&abs).unwrap_or_default();
89            let type_ = parse_type(&text);
90            let raw_targets = extract_link_targets(&text, &link_re);
91
92            facts.push(FileFacts {
93                node_id,
94                layer,
95                size_bytes,
96                type_,
97                raw_targets,
98            });
99        }
100    }
101
102    // Second pass: classify every file's links against the complete node set,
103    // counting broken links (full-path targets with no file on disk) and
104    // recording which nodes receive an incoming edge. Short-form targets are a
105    // validation error elsewhere, not a stats edge, so they're skipped here:
106    // they neither wire a file in nor count as broken.
107    let mut stats = Stats::default();
108    let mut linked_to: HashSet<PathBuf> = HashSet::new();
109    for file in &facts {
110        for target in file.resolvable_targets() {
111            // A self-link is not a graph edge — skip it (matches `graph::orphans`,
112            // so the two surfaces agree on whether a self-only-linking file is an
113            // orphan). It is neither incoming nor broken.
114            if target == &file.node_id {
115                continue;
116            }
117            if existing_nodes.contains(target) {
118                linked_to.insert(target.clone());
119            } else if target_resolves_on_disk(&store.root, target) {
120                // A link to an existing non-`.md` source artifact (a `.eml`,
121                // `.pdf`, …) is a live edge, not a broken one — `sources/` holds
122                // such files by design and `graph` resolves them on disk. The
123                // target has no `.md` node, so it can't be `linked_to` (no `.md`
124                // file is un-orphaned by it), but it must NOT be counted broken.
125            } else {
126                // Broken links count occurrences, not distinct targets.
127                stats.broken_link_count += 1;
128            }
129        }
130    }
131
132    // Third pass: roll the per-file facts up into the aggregate Stats. A file is
133    // an orphan iff it has neither a resolvable outgoing edge nor an incoming one.
134    for file in &facts {
135        stats.total_files += 1;
136        *stats.files_per_layer.entry(file.layer).or_insert(0) += 1;
137        stats.total_size_bytes += file.size_bytes;
138
139        if let Some(t) = &file.type_ {
140            *stats.type_distribution.entry(t.clone()).or_insert(0) += 1;
141        }
142
143        let has_outgoing = file.resolvable_targets().any(|t| {
144            t != &file.node_id
145                && (existing_nodes.contains(t) || target_resolves_on_disk(&store.root, t))
146        });
147        let has_incoming = linked_to.contains(&file.node_id);
148        if !has_outgoing && !has_incoming {
149            stats.orphan_count += 1;
150        }
151    }
152
153    stats.top_types = top_types(&stats.type_distribution, TOP_TYPES_LIMIT);
154
155    Ok(stats)
156}
157
158/// On-disk folder name for a layer. Local copy so `stats` doesn't couple to
159/// [`Layer::dir_name`].
160fn layer_dir_name(layer: Layer) -> &'static str {
161    match layer {
162        Layer::Sources => "sources",
163        Layer::Records => "records",
164        Layer::Wiki => "wiki",
165    }
166}
167
168/// Recursively collect the `.md` **content** files under one layer root,
169/// skipping hidden entries (`.git`, dotfiles), the layer's immediate `log/`
170/// archive directory, and the `index.md` catalog meta files. Returns absolute
171/// paths. A missing layer root yields an empty list (a store need not have all
172/// three layers).
173///
174/// Only an immediate child of the layer named `log` (`sources/log/`) is the
175/// rotation-archive directory and skipped — matching `render::tree`, which
176/// skips `log` only as an immediate layer child, and the indexer, which indexes
177/// `log` dirs nested deeper. A directory named `log` nested under a type-folder
178/// (`sources/emails/log/`) is ordinary content and is counted, so stats agrees
179/// with `tree` / `index` / `query` instead of making the subtree invisible.
180fn walk_layer_content_files(layer_root: &Path) -> crate::Result<Vec<PathBuf>> {
181    let mut out = Vec::new();
182    if !layer_root.is_dir() {
183        return Ok(out);
184    }
185    let walker = walkdir::WalkDir::new(layer_root)
186        .into_iter()
187        .filter_entry(|e| {
188            // Skip hidden dirs/files. `depth()` is relative to the layer root
189            // (root = 0), so the layer's immediate `log/` archive is depth 1.
190            let name = e.file_name().to_string_lossy();
191            if name.starts_with('.') {
192                return false;
193            }
194            if e.file_type().is_dir() && name == "log" && e.depth() == 1 {
195                return false;
196            }
197            true
198        });
199    for entry in walker {
200        let entry = entry.map_err(|e| {
201            crate::Error::Io(
202                e.into_io_error()
203                    .unwrap_or_else(|| std::io::Error::other("walk error")),
204            )
205        })?;
206        if !entry.file_type().is_file() {
207            continue;
208        }
209        let path = entry.path();
210        let name = entry.file_name().to_string_lossy();
211        // Content files are `.md`; `index.md` is a meta catalog file, not
212        // content, and `index.jsonl` / other sidecars aren't `.md` at all.
213        if !name.ends_with(".md") || name == "index.md" {
214            continue;
215        }
216        out.push(path.to_path_buf());
217    }
218    out.sort();
219    Ok(out)
220}
221
222/// The wiki-link matcher: `[[target]]` or `[[target|display]]`. Captures the
223/// target (group 1), excluding `]` and `|`. Anchored on the literal brackets so
224/// it ignores `[markdown](links)`.
225fn wiki_link_regex() -> Regex {
226    // `[^\[\]|]+` keeps the target free of brackets and the display pipe.
227    Regex::new(r"\[\[([^\[\]|]+)(?:\|[^\]]*)?\]\]").expect("static wiki-link regex is valid")
228}
229
230/// Every wiki-link target in a file's full text (frontmatter + body), trimmed,
231/// with any trailing `.md` removed. Order-preserving; not deduped.
232///
233/// Fenced code blocks (```/~~~) are skipped, mirroring
234/// `validate::extract_wiki_links`: a `[[...]]` that lives only inside a code
235/// fence is illustrative syntax in a doc, not a graph edge, so stats must not
236/// count it as broken or use it to un-orphan a file. (Frontmatter never carries
237/// code fences, so this scan stays line-based over the whole file without
238/// dropping the frontmatter links stats deliberately counts as edges.)
239fn extract_link_targets(text: &str, re: &Regex) -> Vec<PathBuf> {
240    let mut out = Vec::new();
241    // Track the open fence as `(fence byte, run length)`, not a single boolean:
242    // an inner fence of the *other* character (a `~~~` line inside an open ```
243    // block, or vice versa) — or a shorter run — is content, and must NOT close
244    // the block. A naive toggle inverts the fence state on such a line and then
245    // mis-classifies every link for the rest of the file. Mirrors `render`'s
246    // `opening_fence` / `is_closing_fence`.
247    let mut fence: Option<(u8, usize)> = None;
248    for line in text.lines() {
249        let content = line.trim_end_matches(['\n', '\r']);
250        if let Some(f) = fence {
251            if is_closing_fence(content, f) {
252                fence = None;
253            }
254            continue;
255        }
256        if let Some(opened) = opening_fence(content) {
257            fence = Some(opened);
258            continue;
259        }
260        for cap in re.captures_iter(line) {
261            if let Some(m) = cap.get(1) {
262                let raw = m.as_str().trim();
263                out.push(strip_md(Path::new(raw)));
264            }
265        }
266    }
267    out
268}
269
270/// If `line` opens a fenced code block, return its `(fence byte, run length)`.
271/// A fence is at least three backticks or tildes, with up to three leading
272/// spaces of indentation. Mirrors `render::opening_fence`.
273fn opening_fence(line: &str) -> Option<(u8, usize)> {
274    let indent = line.len() - line.trim_start_matches(' ').len();
275    if indent > 3 {
276        return None;
277    }
278    let rest = &line[indent..];
279    let byte = rest.bytes().next()?;
280    if byte != b'`' && byte != b'~' {
281        return None;
282    }
283    let run = rest.len() - rest.trim_start_matches(byte as char).len();
284    if run < 3 {
285        return None;
286    }
287    // A backtick fence's info string may not itself contain a backtick.
288    if byte == b'`' && rest[run..].contains('`') {
289        return None;
290    }
291    Some((byte, run))
292}
293
294/// True if `line` closes the currently open fence `(byte, len)`: same fence
295/// char, a run at least as long, and nothing else but trailing whitespace.
296/// Mirrors `render::is_closing_fence`.
297fn is_closing_fence(line: &str, fence: (u8, usize)) -> bool {
298    let (byte, open_len) = fence;
299    let indent = line.len() - line.trim_start_matches(' ').len();
300    if indent > 3 {
301        return false;
302    }
303    let rest = &line[indent..];
304    let run = rest.len() - rest.trim_start_matches(byte as char).len();
305    if run < open_len {
306        return false;
307    }
308    rest[run..].trim().is_empty()
309}
310
311/// Drop a trailing `.md` from a path, leaving everything else intact.
312fn strip_md(path: &Path) -> PathBuf {
313    let s = path.to_string_lossy();
314    match s.strip_suffix(".md") {
315        Some(stem) => PathBuf::from(stem),
316        None => path.to_path_buf(),
317    }
318}
319
320/// True if a wiki-link target is a full store-relative path: it has a path
321/// separator AND its first segment is a recognized layer (`sources`/`records`/
322/// `wiki`) with a non-empty remainder. Short-form targets like `sarah-chen`
323/// are false, and so are non-layer multi-segment targets like
324/// `contacts/sarah-chen` (a missing layer prefix). Doctrine: only true
325/// store-relative paths resolve to a node.
326///
327/// This mirrors `validate::is_full_store_path` so `stats.broken_link_count`
328/// agrees with `validate`'s `WIKI_LINK_BROKEN` total: a non-layer target like
329/// `[[contacts/sarah]]` is a short-form error in `validate` (never broken), and
330/// must likewise be excluded here rather than counted as a broken edge.
331fn is_full_path(target: &Path) -> bool {
332    let mut parts = target.components();
333    let first = match parts.next() {
334        Some(std::path::Component::Normal(s)) => s.to_string_lossy(),
335        _ => return false,
336    };
337    let has_rest = parts.next().is_some();
338    matches!(first.as_ref(), "sources" | "records" | "wiki") && has_rest
339}
340
341/// True if `target` stays inside the store: every component is `Normal` (a
342/// `CurDir` `.` is harmless and allowed), with no `..` (`ParentDir`), absolute
343/// (`RootDir`), or platform-prefix component. Mirrors
344/// `graph::is_within_store_target` and validate's `is_safe_store_relative_path`,
345/// so the containment decision is identical across the three surfaces. Used to
346/// gate any on-disk probe in [`target_resolves_on_disk`] before a `join`.
347fn is_within_store_target(target: &Path) -> bool {
348    target.components().all(|c| {
349        matches!(
350            c,
351            std::path::Component::Normal(_) | std::path::Component::CurDir
352        )
353    })
354}
355
356/// True if a full-path wiki-link `target` (already `.md`-stripped, store-
357/// relative) resolves to a real **non-`.md`** file on disk — a source artifact
358/// like a `.eml` or `.pdf` under `sources/`. Called only after the `.md` node
359/// set has already been checked, so this exists to reconcile stats with `graph`
360/// (which resolves on disk) and `validate`: a link to an existing source file
361/// is a live edge, never a broken link or an orphan-maker.
362///
363/// Two on-disk shapes are recognized, mirroring `graph::resolve_existing` plus
364/// the bare-stem case sources use:
365///
366/// - the target as written is itself a real file (`[[sources/emails/msg.eml]]`
367///   → `sources/emails/msg.eml`);
368/// - the target is a bare stem and a sibling file shares that stem with a
369///   non-`.md` extension (`[[sources/emails/msg]]` → `sources/emails/msg.eml`).
370///
371/// A bare `.md` target is *not* handled here (an existing `.md` file is already
372/// a node in `existing_nodes`); this is strictly the non-`.md` source case.
373///
374/// **Containment gate.** A target that escapes the store root (any `..`,
375/// absolute, or platform-prefix component) is never probed: it returns `false`
376/// before any `join`/`is_file`/`read_dir`, so `[[sources/../../secret]]` can
377/// never reach the filesystem as a live edge or existence oracle outside the
378/// store. This mirrors `graph::is_within_store_target` and validate's
379/// `is_safe_store_relative_path` (which reject `..` before any probe), keeping
380/// the broken-link surface in agreement: an escaping target is counted broken
381/// (validate's `WIKI_LINK_BROKEN`), never silently treated as resolved.
382fn target_resolves_on_disk(store_root: &Path, target: &Path) -> bool {
383    // Reject any non-`Normal` component (`..`, RootDir, Prefix) up front — never
384    // let a wiki-link turn a stats probe into a filesystem escape.
385    if !is_within_store_target(target) {
386        return false;
387    }
388    // The target as written points at a real file (e.g. an explicit `.eml`).
389    let literal = store_root.join(target);
390    if literal.is_file() {
391        return true;
392    }
393    // Bare-stem case: look for a sibling `<stem>.<ext>` with a non-`.md`
394    // extension in the target's parent directory. Restricted to the bare form
395    // (no extension on the target) so an explicit but missing `.pdf` link still
396    // reads as broken rather than silently matching a different file.
397    if target.extension().is_some() {
398        return false;
399    }
400    let stem = match target.file_name() {
401        Some(name) => name,
402        None => return false,
403    };
404    let parent_abs = store_root.join(match target.parent() {
405        Some(p) => p,
406        None => return false,
407    });
408    let entries = match std::fs::read_dir(&parent_abs) {
409        Ok(e) => e,
410        Err(_) => return false,
411    };
412    for entry in entries.flatten() {
413        let path = entry.path();
414        if !path.is_file() {
415            continue;
416        }
417        // Same stem, and an extension that is present and not `.md`.
418        if path.file_stem() == Some(stem) {
419            match path.extension().and_then(|e| e.to_str()) {
420                Some("md") | None => continue,
421                Some(_) => return true,
422            }
423        }
424    }
425    false
426}
427
428/// Read the `type:` value from a file's leading YAML frontmatter block, if the
429/// file has one. Returns `None` when there's no frontmatter or no `type` key.
430/// Self-contained (does not route through the crate's parser): split on the
431/// `---` fences, parse the block as a YAML mapping, read `type` as a string.
432fn parse_type(text: &str) -> Option<String> {
433    let yaml = frontmatter_block(text)?;
434    let value: serde_norway::Value = serde_norway::from_str(&yaml).ok()?;
435    let mapping = value.as_mapping()?;
436    let type_val = mapping.get(serde_norway::Value::String("type".to_string()))?;
437    let s = type_val.as_str()?.trim();
438    if s.is_empty() {
439        None
440    } else {
441        Some(s.to_string())
442    }
443}
444
445/// Extract the raw YAML between a leading `---` fence and its closing `---`.
446/// The opening fence must be the very first line of the file (the universal
447/// frontmatter contract: frontmatter is the first thing in the file).
448fn frontmatter_block(text: &str) -> Option<String> {
449    // Normalize away a leading BOM, but require `---` as the first line.
450    let text = text.strip_prefix('\u{feff}').unwrap_or(text);
451    let mut lines = text.lines();
452    let first = lines.next()?;
453    if first.trim_end() != "---" {
454        return None;
455    }
456    let mut body = String::new();
457    for line in lines {
458        if line.trim_end() == "---" {
459            return Some(body);
460        }
461        body.push_str(line);
462        body.push('\n');
463    }
464    // No closing fence: not a valid frontmatter block.
465    None
466}
467
468/// Sort a type distribution into the top `limit` types by count descending,
469/// ties broken by type name ascending.
470fn top_types(dist: &BTreeMap<String, usize>, limit: usize) -> Vec<(String, usize)> {
471    let mut pairs: Vec<(String, usize)> = dist.iter().map(|(k, v)| (k.clone(), *v)).collect();
472    // BTreeMap iteration is already name-ascending; a stable sort by count
473    // descending therefore yields (count desc, name asc).
474    pairs.sort_by_key(|p| std::cmp::Reverse(p.1));
475    pairs.truncate(limit);
476    pairs
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482    use crate::parser::Config;
483    use std::fs;
484    use tempfile::TempDir;
485
486    /// Build a `Store` rooted at a fresh tempdir with an empty `DB.md` marker.
487    /// Bypasses `Store::open` by constructing the struct directly —
488    /// `stats::compute` only reads `store.root`.
489    fn temp_store() -> (TempDir, Store) {
490        let dir = TempDir::new().expect("tempdir");
491        fs::write(dir.path().join("DB.md"), "---\ntype: db-md\n---\n").expect("write DB.md");
492        let store = Store {
493            root: dir.path().to_path_buf(),
494            config: Config::default(),
495        };
496        (dir, store)
497    }
498
499    /// Like [`temp_store`], but roots the store one level *inside* the tempdir
500    /// (`<tempdir>/store`) so `store.root.parent()` is the test's own private
501    /// tempdir rather than the shared OS temp root. Tests that plant a file
502    /// "above the store root" must use this — writing into `store.root.parent()`
503    /// of a top-level `TempDir` lands in `$TMPDIR`, which is shared across every
504    /// parallel test (and across test binaries under `cargo test --workspace`),
505    /// so two such tests collide on the same path and race.
506    fn temp_store_nested() -> (TempDir, Store) {
507        let dir = TempDir::new().expect("tempdir");
508        let root = dir.path().join("store");
509        fs::create_dir_all(&root).expect("create store root");
510        fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n").expect("write DB.md");
511        let store = Store {
512            root,
513            config: Config::default(),
514        };
515        (dir, store)
516    }
517
518    /// Write a content file at a store-relative path, creating parent dirs.
519    fn write_rel(store: &Store, rel: &str, contents: &str) {
520        let abs = store.root.join(rel);
521        if let Some(parent) = abs.parent() {
522            fs::create_dir_all(parent).expect("mkdir parents");
523        }
524        fs::write(abs, contents).expect("write content file");
525    }
526
527    /// A minimal content file body: frontmatter with the given type, no links.
528    fn doc(type_: &str, summary: &str) -> String {
529        format!("---\ntype: {type_}\nsummary: \"{summary}\"\n---\n\nbody\n")
530    }
531
532    #[test]
533    fn empty_store_is_all_zeros() {
534        let (_d, store) = temp_store();
535        let s = compute(&store).expect("compute");
536        assert_eq!(s.total_files, 0);
537        assert_eq!(s.total_size_bytes, 0);
538        assert!(s.files_per_layer.is_empty());
539        assert!(s.type_distribution.is_empty());
540        assert_eq!(s.orphan_count, 0);
541        assert_eq!(s.broken_link_count, 0);
542        assert!(s.top_types.is_empty());
543    }
544
545    #[test]
546    fn counts_files_per_layer_and_total() {
547        let (_d, store) = temp_store();
548        write_rel(&store, "sources/emails/a.md", &doc("email", "a"));
549        write_rel(&store, "sources/emails/b.md", &doc("email", "b"));
550        write_rel(&store, "records/contacts/c.md", &doc("contact", "c"));
551        write_rel(&store, "wiki/people/p.md", &doc("wiki-page", "p"));
552
553        let s = compute(&store).expect("compute");
554        assert_eq!(s.total_files, 4);
555        assert_eq!(s.files_per_layer.get(&Layer::Sources), Some(&2));
556        assert_eq!(s.files_per_layer.get(&Layer::Records), Some(&1));
557        assert_eq!(s.files_per_layer.get(&Layer::Wiki), Some(&1));
558    }
559
560    #[test]
561    fn ignores_meta_files_and_non_md_and_dotdirs_and_log() {
562        let (_d, store) = temp_store();
563        // Real content.
564        write_rel(&store, "records/contacts/real.md", &doc("contact", "real"));
565        // Meta + non-content that must NOT be counted.
566        write_rel(
567            &store,
568            "records/contacts/index.md",
569            "---\ntype: index\nscope: type-folder\n---\n",
570        );
571        write_rel(&store, "records/contacts/index.jsonl", "{}\n");
572        write_rel(&store, "records/notes.txt", "not markdown\n");
573        // `log/` archive tree under a layer is skipped wholesale.
574        write_rel(&store, "sources/log/2026-04.md", &doc("email", "archived"));
575        // Hidden dir contents are skipped.
576        write_rel(
577            &store,
578            "wiki/.obsidian/cache.md",
579            &doc("wiki-page", "hidden"),
580        );
581
582        let s = compute(&store).expect("compute");
583        assert_eq!(s.total_files, 1, "only the one real content file counts");
584        assert_eq!(s.files_per_layer.get(&Layer::Records), Some(&1));
585        assert_eq!(s.files_per_layer.get(&Layer::Sources), None);
586        assert_eq!(s.files_per_layer.get(&Layer::Wiki), None);
587    }
588
589    #[test]
590    fn total_size_is_sum_of_content_file_bytes() {
591        let (_d, store) = temp_store();
592        let a = doc("email", "a");
593        let b = "---\ntype: contact\nsummary: x\n---\n\nlonger body text here\n".to_string();
594        write_rel(&store, "sources/emails/a.md", &a);
595        write_rel(&store, "records/contacts/b.md", &b);
596        // A skipped file's bytes must not be included.
597        write_rel(
598            &store,
599            "records/contacts/index.md",
600            "---\ntype: index\n---\nbig meta file padding padding\n",
601        );
602
603        let s = compute(&store).expect("compute");
604        let expected = a.len() as u64 + b.len() as u64;
605        assert_eq!(s.total_size_bytes, expected);
606    }
607
608    #[test]
609    fn type_distribution_counts_each_type_value() {
610        let (_d, store) = temp_store();
611        write_rel(&store, "sources/emails/a.md", &doc("email", "a"));
612        write_rel(&store, "sources/emails/b.md", &doc("email", "b"));
613        write_rel(&store, "sources/emails/c.md", &doc("email", "c"));
614        write_rel(&store, "records/contacts/d.md", &doc("contact", "d"));
615        write_rel(&store, "records/proposals/e.md", &doc("proposal", "e"));
616
617        let s = compute(&store).expect("compute");
618        assert_eq!(s.type_distribution.get("email"), Some(&3));
619        assert_eq!(s.type_distribution.get("contact"), Some(&1));
620        assert_eq!(s.type_distribution.get("proposal"), Some(&1));
621        assert_eq!(s.type_distribution.len(), 3);
622    }
623
624    #[test]
625    fn file_without_type_is_counted_in_totals_but_not_distribution() {
626        let (_d, store) = temp_store();
627        // A content file with frontmatter but no `type:` key.
628        write_rel(
629            &store,
630            "wiki/themes/x.md",
631            "---\nsummary: no type here\n---\n\nbody\n",
632        );
633        // A content file with no frontmatter at all.
634        write_rel(&store, "wiki/themes/y.md", "just a body, no frontmatter\n");
635
636        let s = compute(&store).expect("compute");
637        assert_eq!(s.total_files, 2, "untyped files still count toward totals");
638        assert_eq!(s.files_per_layer.get(&Layer::Wiki), Some(&2));
639        assert!(
640            s.type_distribution.is_empty(),
641            "no type key => no distribution entry, not an empty-string bucket"
642        );
643    }
644
645    #[test]
646    fn top_types_orders_by_count_desc_then_name_asc() {
647        let (_d, store) = temp_store();
648        // contact x3, email x3 (tie), decision x1.
649        write_rel(&store, "records/contacts/c1.md", &doc("contact", "1"));
650        write_rel(&store, "records/contacts/c2.md", &doc("contact", "2"));
651        write_rel(&store, "records/contacts/c3.md", &doc("contact", "3"));
652        write_rel(&store, "sources/emails/e1.md", &doc("email", "1"));
653        write_rel(&store, "sources/emails/e2.md", &doc("email", "2"));
654        write_rel(&store, "sources/emails/e3.md", &doc("email", "3"));
655        write_rel(&store, "records/decisions/d1.md", &doc("decision", "1"));
656
657        let s = compute(&store).expect("compute");
658        assert_eq!(
659            s.top_types,
660            vec![
661                ("contact".to_string(), 3),
662                ("email".to_string(), 3),
663                ("decision".to_string(), 1),
664            ],
665            "ties (contact, email both 3) break by name ascending; decision trails"
666        );
667    }
668
669    #[test]
670    fn top_types_is_capped_at_ten() {
671        let (_d, store) = temp_store();
672        // 12 distinct custom types, each one file.
673        for i in 0..12 {
674            let t = format!("type{i:02}");
675            write_rel(&store, &format!("records/{t}/f.md"), &doc(&t, "x"));
676        }
677        let s = compute(&store).expect("compute");
678        assert_eq!(s.top_types.len(), 10, "top_types caps at 10");
679        assert_eq!(
680            s.type_distribution.len(),
681            12,
682            "distribution keeps all types"
683        );
684    }
685
686    #[test]
687    fn orphans_are_files_with_no_incoming_and_no_outgoing_links() {
688        let (_d, store) = temp_store();
689        // a -> b (a has outgoing, b has incoming). c is isolated => orphan.
690        write_rel(
691            &store,
692            "records/contacts/a.md",
693            "---\ntype: contact\nsummary: a\n---\n\nSee [[records/contacts/b]].\n",
694        );
695        write_rel(&store, "records/contacts/b.md", &doc("contact", "b"));
696        write_rel(&store, "records/contacts/c.md", &doc("contact", "c"));
697
698        let s = compute(&store).expect("compute");
699        assert_eq!(s.orphan_count, 1, "only c is an orphan");
700    }
701
702    #[test]
703    fn a_file_with_only_a_self_link_is_an_orphan_matching_graph() {
704        let (_d, store) = temp_store();
705        // A file that links only to ITSELF has no real graph edge, so it must be
706        // an orphan — consistent with `graph::orphans` (which skips self-links).
707        write_rel(
708            &store,
709            "records/contacts/solo.md",
710            "---\ntype: contact\nsummary: solo\n---\n\nSee [[records/contacts/solo]].\n",
711        );
712        let s = compute(&store).expect("compute");
713        assert_eq!(
714            s.orphan_count, 1,
715            "a self-only-linking file is an orphan: {s:?}"
716        );
717    }
718
719    #[test]
720    fn a_file_with_only_an_incoming_link_is_not_an_orphan() {
721        let (_d, store) = temp_store();
722        // b has no outgoing links, but a links to it => b is NOT an orphan.
723        // a itself has an outgoing link => also not an orphan. Zero orphans.
724        write_rel(
725            &store,
726            "wiki/people/a.md",
727            "---\ntype: wiki-page\nsummary: a\n---\n\n[[wiki/people/b]]\n",
728        );
729        write_rel(&store, "wiki/people/b.md", &doc("wiki-page", "b"));
730
731        let s = compute(&store).expect("compute");
732        assert_eq!(s.orphan_count, 0);
733    }
734
735    #[test]
736    fn frontmatter_wiki_links_count_as_edges_for_orphans() {
737        let (_d, store) = temp_store();
738        // The link lives in a frontmatter field, not the body. It must still
739        // wire `contact` -> `company`, so neither is an orphan.
740        write_rel(
741            &store,
742            "records/contacts/sarah.md",
743            "---\ntype: contact\nsummary: s\ncompany: [[records/companies/acme]]\n---\n\nbody\n",
744        );
745        write_rel(&store, "records/companies/acme.md", &doc("company", "acme"));
746
747        let s = compute(&store).expect("compute");
748        assert_eq!(
749            s.orphan_count, 0,
750            "a frontmatter wiki-link is a real edge; neither endpoint is orphaned"
751        );
752    }
753
754    #[test]
755    fn broken_links_count_targets_that_do_not_exist() {
756        let (_d, store) = temp_store();
757        // Two links: one to an existing file, one to a missing file.
758        write_rel(
759            &store,
760            "wiki/people/a.md",
761            "---\ntype: wiki-page\nsummary: a\n---\n\n[[wiki/people/b]] and [[records/contacts/ghost]]\n",
762        );
763        write_rel(&store, "wiki/people/b.md", &doc("wiki-page", "b"));
764
765        let s = compute(&store).expect("compute");
766        assert_eq!(s.broken_link_count, 1, "only the ghost target is broken");
767    }
768
769    #[test]
770    fn broken_link_resolves_with_md_extension_stripped() {
771        let (_d, store) = temp_store();
772        // Link written WITH a `.md` extension still resolves to the real file
773        // (the parser accepts `.md`; validate only warns). Not broken.
774        write_rel(
775            &store,
776            "wiki/people/a.md",
777            "---\ntype: wiki-page\nsummary: a\n---\n\n[[wiki/people/b.md]]\n",
778        );
779        write_rel(&store, "wiki/people/b.md", &doc("wiki-page", "b"));
780
781        let s = compute(&store).expect("compute");
782        assert_eq!(
783            s.broken_link_count, 0,
784            "a `.md`-suffixed target resolves to the same node and is not broken"
785        );
786    }
787
788    #[test]
789    fn short_form_links_are_not_broken_and_do_not_wire_the_graph() {
790        let (_d, store) = temp_store();
791        // `[[b]]` is a short-form (no `/`): a validation error elsewhere, but
792        // for stats it neither counts as broken (it doesn't resolve to a node)
793        // nor wires `a` into the graph. So `a` (no other links) is an orphan.
794        write_rel(
795            &store,
796            "records/contacts/a.md",
797            "---\ntype: contact\nsummary: a\n---\n\n[[b]]\n",
798        );
799        write_rel(&store, "records/contacts/b.md", &doc("contact", "b"));
800
801        let s = compute(&store).expect("compute");
802        assert_eq!(
803            s.broken_link_count, 0,
804            "short-form links are not counted as broken by stats"
805        );
806        // a has only a short-form link (not an edge) => orphan. b has no links
807        // and no real incoming edge => orphan. Both orphaned.
808        assert_eq!(s.orphan_count, 2);
809    }
810
811    #[test]
812    fn display_alias_links_resolve_to_the_target_not_the_alias() {
813        let (_d, store) = temp_store();
814        // `[[wiki/people/b|Bob]]` targets b, displays "Bob". The alias must be
815        // stripped: the edge goes to b (exists), so it's not broken and b is
816        // not an orphan.
817        write_rel(
818            &store,
819            "wiki/people/a.md",
820            "---\ntype: wiki-page\nsummary: a\n---\n\nmet [[wiki/people/b|Bob]] today\n",
821        );
822        write_rel(&store, "wiki/people/b.md", &doc("wiki-page", "b"));
823
824        let s = compute(&store).expect("compute");
825        assert_eq!(s.broken_link_count, 0, "alias target resolves and exists");
826        assert_eq!(s.orphan_count, 0, "a links out, b is linked to");
827    }
828
829    #[test]
830    fn duplicate_links_in_one_file_count_broken_per_occurrence() {
831        let (_d, store) = temp_store();
832        // The same missing target twice => two broken-link occurrences.
833        write_rel(
834            &store,
835            "wiki/people/a.md",
836            "---\ntype: wiki-page\nsummary: a\n---\n\n[[records/contacts/ghost]] [[records/contacts/ghost]]\n",
837        );
838        let s = compute(&store).expect("compute");
839        assert_eq!(
840            s.broken_link_count, 2,
841            "broken links count occurrences, not distinct targets"
842        );
843    }
844
845    #[test]
846    fn markdown_links_are_not_treated_as_wiki_links() {
847        let (_d, store) = temp_store();
848        // A standard markdown link to an external URL must not register as a
849        // wiki edge (so this file stays an orphan) nor as a broken link.
850        write_rel(
851            &store,
852            "wiki/people/a.md",
853            "---\ntype: wiki-page\nsummary: a\n---\n\nSee [Acme](https://acme.io/path).\n",
854        );
855        let s = compute(&store).expect("compute");
856        assert_eq!(s.broken_link_count, 0, "markdown links aren't graph edges");
857        assert_eq!(s.orphan_count, 1, "the file has no wiki-links => orphan");
858    }
859
860    #[test]
861    fn regression_non_layer_multi_segment_link_is_not_broken() {
862        // Finding #20: a target like `[[contacts/sarah-chen]]` omits the layer
863        // prefix. It has a `/` but its first segment (`contacts`) is not a
864        // recognized layer, so it's a short-form error in `validate`, NOT a
865        // broken link. stats must agree: it counts neither as broken nor as an
866        // outgoing edge. Pre-fix `is_full_path` (components().count() > 1)
867        // accepted it and reported broken_link_count = 1.
868        let (_d, store) = temp_store();
869        write_rel(
870            &store,
871            "records/contacts/a.md",
872            "---\ntype: contact\nsummary: a\n---\n\nSee [[contacts/sarah-chen]].\n",
873        );
874        let s = compute(&store).expect("compute");
875        assert_eq!(
876            s.broken_link_count, 0,
877            "a non-layer multi-segment target is a short-form error, not broken"
878        );
879        // The non-layer link is not a graph edge, so `a` has no outgoing edge
880        // and is an orphan — matching how validate/graph treat it.
881        assert_eq!(
882            s.orphan_count, 1,
883            "the non-layer link does not wire `a` out of orphan status"
884        );
885    }
886
887    #[test]
888    fn regression_wiki_links_in_code_fences_are_ignored() {
889        // Finding #21: a wiki-link that appears only inside a fenced code block
890        // is illustrative syntax, not a graph edge. validate skips fenced
891        // regions; stats must too. Pre-fix the regex ran over the whole file
892        // with no fence tracking, so the fenced ghost link inflated
893        // broken_link_count to 1 and the fenced real link un-orphaned the page.
894        let (_d, store) = temp_store();
895        // A howto page whose ONLY wiki-links live inside ``` and ~~~ fences:
896        // one to a missing target, one to an existing target.
897        write_rel(
898            &store,
899            "wiki/pages/howto.md",
900            "---\ntype: wiki-page\nsummary: howto\n---\n\
901             \nWrite links like this:\n\
902             \n```\n[[records/contacts/ghost]]\n```\n\
903             \nor this:\n\
904             \n~~~\n[[wiki/pages/real]]\n~~~\n",
905        );
906        write_rel(&store, "wiki/pages/real.md", &doc("wiki-page", "real"));
907        let s = compute(&store).expect("compute");
908        assert_eq!(
909            s.broken_link_count, 0,
910            "a `[[...]]` inside a code fence is not a real (broken) edge"
911        );
912        // howto has no real edges => orphan. real is not linked-to by any real
913        // edge => orphan. Both orphaned (2), proving the fenced link to `real`
914        // did not wire either file out of orphan status.
915        assert_eq!(
916            s.orphan_count, 2,
917            "fenced wiki-links do not wire files out of orphan status: {s:?}"
918        );
919    }
920
921    #[test]
922    fn a_link_to_an_existing_file_in_another_layer_resolves() {
923        let (_d, store) = temp_store();
924        // wiki page links to a source file in a different layer; cross-layer
925        // full-path links resolve like any other.
926        write_rel(
927            &store,
928            "wiki/people/a.md",
929            "---\ntype: wiki-page\nsummary: a\n---\n\nfrom [[sources/emails/2026/05/m]]\n",
930        );
931        write_rel(&store, "sources/emails/2026/05/m.md", &doc("email", "m"));
932
933        let s = compute(&store).expect("compute");
934        assert_eq!(s.broken_link_count, 0);
935        assert_eq!(s.orphan_count, 0, "both endpoints are wired");
936    }
937
938    #[test]
939    fn regression_tilde_line_inside_backtick_fence_does_not_invert_state() {
940        // Finding #44/#11: a `~~~` line inside an open ``` fence (or any inner
941        // fence of the other char / a shorter run) must NOT close the block.
942        // Pre-fix a single boolean toggled on it, inverting fence state so the
943        // fenced ghost link counted broken and the real link after the fence
944        // was dropped. With (byte, run-length) tracking the block only closes on
945        // a matching ``` fence.
946        let (_d, store) = temp_store();
947        write_rel(&store, "wiki/people/bob.md", &doc("wiki-page", "bob"));
948        // ```text … ~~~ x (inner tilde line) … [[ghost]] … ``` then a real link.
949        write_rel(
950            &store,
951            "wiki/pages/howto.md",
952            "---\ntype: wiki-page\nsummary: howto\n---\n\
953             \n```text\n~~~ x\n[[wiki/people/ghost]]\n```\n\
954             \nReal: [[wiki/people/bob]]\n",
955        );
956
957        let s = compute(&store).expect("compute");
958        assert_eq!(
959            s.broken_link_count, 0,
960            "the fenced ghost link is inside the unbroken ``` block, not broken: {s:?}"
961        );
962        // bob is linked from howto (a real edge after the fence closes), and
963        // howto links out — neither is an orphan.
964        assert_eq!(
965            s.orphan_count, 0,
966            "the real post-fence link wires both files: {s:?}"
967        );
968    }
969
970    #[test]
971    fn regression_nested_log_directory_is_counted_not_skipped() {
972        // Finding #45: only the layer's IMMEDIATE `log/` archive is skipped. A
973        // directory named `log` nested under a type-folder is ordinary content
974        // and must be counted, matching tree/index/query. Pre-fix any `log` dir
975        // at any depth was pruned, making the whole subtree invisible to stats.
976        let (_d, store) = temp_store();
977        write_rel(
978            &store,
979            "sources/emails/log/maillog.md",
980            &doc(
981                "email",
982                "an archived mail log entry under a log subdirectory",
983            ),
984        );
985        // The layer-immediate `log/` archive is still skipped.
986        write_rel(&store, "sources/log/2026-04.md", &doc("email", "rotated"));
987
988        let s = compute(&store).expect("compute");
989        assert_eq!(
990            s.total_files, 1,
991            "the nested sources/emails/log file counts; the layer-immediate sources/log is skipped: {s:?}"
992        );
993        assert_eq!(s.files_per_layer.get(&Layer::Sources), Some(&1));
994        assert_eq!(s.type_distribution.get("email"), Some(&1));
995    }
996
997    #[test]
998    fn regression_link_to_existing_non_md_source_is_a_live_edge() {
999        // Finding (high): a record that wiki-links to an existing non-`.md`
1000        // source artifact (a `.eml`) must read as a LIVE edge, not broken, and
1001        // the record is not an orphan. `sources/` holds such files by design.
1002        let (_d, store) = temp_store();
1003        // A real .eml source file (not a .md content file).
1004        write_rel(
1005            &store,
1006            "sources/emails/msg.eml",
1007            "From: someone@example.com\nSubject: Renewal\n\nBody text.\n",
1008        );
1009        // A record with the SPEC-canonical bare link to that source.
1010        write_rel(
1011            &store,
1012            "records/contacts/sarah.md",
1013            "---\ntype: contact\nsummary: s\n---\n\nLinked source: [[sources/emails/msg]]\n",
1014        );
1015
1016        let s = compute(&store).expect("compute");
1017        assert_eq!(
1018            s.broken_link_count, 0,
1019            "a link to an existing .eml source is live, not broken: {s:?}"
1020        );
1021        assert_eq!(
1022            s.orphan_count, 0,
1023            "the linking record has a resolvable outgoing edge to the source: {s:?}"
1024        );
1025        // The explicit-extension form resolves the same way.
1026        write_rel(
1027            &store,
1028            "records/contacts/sarah.md",
1029            "---\ntype: contact\nsummary: s\n---\n\nLinked source: [[sources/emails/msg.eml]]\n",
1030        );
1031        let s2 = compute(&store).expect("compute");
1032        assert_eq!(s2.broken_link_count, 0, "explicit .eml target resolves too");
1033        assert_eq!(s2.orphan_count, 0);
1034    }
1035
1036    #[test]
1037    fn regression_traversal_target_is_broken_not_a_filesystem_escape() {
1038        // SECURITY regression: a `..`-laden wiki-link target must never turn a
1039        // stats probe into a read of a file OUTSIDE the store. Pre-fix
1040        // `target_resolves_on_disk` joined the raw target onto the store root and
1041        // probed `is_file` / `read_dir` with no containment check, so
1042        // `[[sources/../../outside-secret]]` reached a file above the store and
1043        // was silently counted as a LIVE edge (un-orphaning the linker and never
1044        // counted broken) — diverging from validate (which flags it
1045        // WIKI_LINK_BROKEN) and graph (which drops it). The gate now rejects any
1046        // non-`Normal` component before any join, so it counts broken.
1047        // Nested store: `store.root.parent()` is this test's private tempdir,
1048        // never the shared `$TMPDIR` (which the sibling traversal test would also
1049        // write into, racing on the same filename under `--workspace`).
1050        let (_d, store) = temp_store_nested();
1051        // Every store has a `sources/` dir; the traversal needs its first
1052        // component to be a recognized layer to pass `is_full_path`.
1053        fs::create_dir_all(store.root.join("sources/emails")).unwrap();
1054        // Plant a secret ABOVE the store root (the parent of the store dir).
1055        let outside_dir = store.root.parent().expect("store has a parent");
1056        fs::write(outside_dir.join("outside-secret.txt"), "TOP SECRET\n").unwrap();
1057
1058        // Bare-stem traversal (would hit the `read_dir` parent branch) and the
1059        // explicit-extension traversal (would hit the `is_file` literal branch).
1060        for target in [
1061            "sources/../../outside-secret",
1062            "sources/../../outside-secret.txt",
1063        ] {
1064            write_rel(
1065                &store,
1066                "records/contacts/a.md",
1067                &format!("---\ntype: contact\nsummary: s\n---\n\nEscape: [[{target}]]\n"),
1068            );
1069            let s = compute(&store).expect("compute");
1070            assert_eq!(
1071                s.broken_link_count, 1,
1072                "a `..` target escaping the store must be broken, not a live edge ({target}): {s:?}"
1073            );
1074            assert_eq!(
1075                s.orphan_count, 1,
1076                "an escaping link must NOT wire the linker out of orphan status ({target}): {s:?}"
1077            );
1078        }
1079        // The secret outside the store is untouched (we never followed the link).
1080        assert_eq!(
1081            fs::read_to_string(outside_dir.join("outside-secret.txt")).unwrap(),
1082            "TOP SECRET\n"
1083        );
1084    }
1085
1086    #[test]
1087    fn regression_target_resolves_on_disk_rejects_traversal_before_any_probe() {
1088        // SECURITY regression at the helper level: `target_resolves_on_disk`
1089        // must return `false` for any `..`-laden / absolute / prefix target
1090        // BEFORE it joins, `is_file`s, or `read_dir`s — so a wiki-link can never
1091        // turn a stats existence-probe into a read of a file OUTSIDE the store.
1092        // Pre-fix the helper joined the raw target onto the store root with no
1093        // containment gate, so a real file above the store made it return
1094        // `true`. This asserts the gate directly on the helper (the end-to-end
1095        // `compute()` path is covered separately above), exercising BOTH on-disk
1096        // branches: the literal `is_file` branch (explicit extension) and the
1097        // bare-stem `read_dir` branch.
1098        // Nested store: `store.root.parent()` is this test's private tempdir, so
1099        // the "above the store" files below never land in the shared `$TMPDIR`
1100        // and can never collide with the sibling traversal test's identically
1101        // named planted files when both run in parallel.
1102        let (_d, store) = temp_store_nested();
1103        // A real `sources/` tree exists (the literal/parent joins would have
1104        // something to land near), matching a real store.
1105        fs::create_dir_all(store.root.join("sources/emails")).unwrap();
1106        // Plant matching files ABOVE the store root: one with the exact name the
1107        // explicit-extension target points at, and one whose stem the bare-stem
1108        // target would discover via `read_dir` of the (escaped) parent dir.
1109        let outside_dir = store.root.parent().expect("store has a parent");
1110        fs::write(outside_dir.join("outside-secret.txt"), "TOP SECRET\n").unwrap();
1111        fs::write(outside_dir.join("outside-secret.eml"), "secret mail\n").unwrap();
1112
1113        // Explicit-extension traversal -> would hit the literal `is_file` branch.
1114        assert!(
1115            !target_resolves_on_disk(
1116                &store.root,
1117                &strip_md(Path::new("sources/../../outside-secret.txt"))
1118            ),
1119            "an explicit-extension `..` target escaping the store must not resolve on disk"
1120        );
1121        // Bare-stem traversal -> would hit the `read_dir(parent)` branch, where a
1122        // sibling `outside-secret.eml` (non-`.md`) sits beside the escaped parent.
1123        assert!(
1124            !target_resolves_on_disk(
1125                &store.root,
1126                &strip_md(Path::new("sources/../../outside-secret"))
1127            ),
1128            "a bare-stem `..` target escaping the store must not resolve on disk"
1129        );
1130        // A `..` that stays nominally under a layer prefix is still an escape and
1131        // is rejected before any probe.
1132        assert!(
1133            !target_resolves_on_disk(&store.root, Path::new("records/../wiki/secret")),
1134            "any `..` component is rejected before a probe, even one re-entering a layer"
1135        );
1136
1137        // Sanity: a legitimate in-store non-`.md` source DOES still resolve, so
1138        // the gate did not over-reject and break the finding #117 behavior.
1139        write_rel(
1140            &store,
1141            "sources/emails/msg.eml",
1142            "From: a@b.com\nSubject: x\n\nbody\n",
1143        );
1144        assert!(
1145            target_resolves_on_disk(&store.root, Path::new("sources/emails/msg")),
1146            "a legitimate in-store bare-stem source link still resolves on disk"
1147        );
1148
1149        // The secrets outside the store are untouched (we never followed a link).
1150        assert_eq!(
1151            fs::read_to_string(outside_dir.join("outside-secret.txt")).unwrap(),
1152            "TOP SECRET\n"
1153        );
1154    }
1155
1156    #[test]
1157    fn regression_link_to_truly_missing_source_is_still_broken() {
1158        // Guard the source-resolution fix doesn't over-resolve: a bare link
1159        // whose target has NO file of any extension on disk is still broken.
1160        let (_d, store) = temp_store();
1161        write_rel(
1162            &store,
1163            "records/contacts/sarah.md",
1164            "---\ntype: contact\nsummary: s\n---\n\nLinked: [[sources/emails/missing]]\n",
1165        );
1166        let s = compute(&store).expect("compute");
1167        assert_eq!(
1168            s.broken_link_count, 1,
1169            "a target with no on-disk file in any form is broken: {s:?}"
1170        );
1171    }
1172}