Skip to main content

dbmd_core/
render.rs

1//! `render` — data structures for the structural views, **no output
2//! formatting**.
3//!
4//! [`Tree`] groups the store by layer → type → file; [`Outline`] groups one
5//! file by its `##` sections. Both are pure data; `dbmd-cli` formats them to
6//! text or JSON. Keeping formatting out of the library lets every db.md-aware
7//! tool render these structures its own way.
8
9use std::path::{Path, PathBuf};
10
11use crate::parser::Section;
12use crate::store::{Layer, Store, StoreError};
13
14/// The store as a tree, grouped layer → type-folder → file.
15#[derive(Debug, Clone, Default, PartialEq, Eq)]
16pub struct Tree {
17    /// One branch per non-empty layer.
18    pub layers: Vec<TreeLayer>,
19}
20
21/// A layer branch of a [`Tree`].
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct TreeLayer {
24    /// Which layer this branch is.
25    pub layer: Layer,
26    /// One branch per non-empty type-folder under the layer.
27    pub type_folders: Vec<TreeTypeFolder>,
28}
29
30/// A type-folder branch of a [`Tree`], aggregated across date-shards.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct TreeTypeFolder {
33    /// The type-folder's store-relative path (e.g. `records/contacts`).
34    pub path: PathBuf,
35    /// The store-relative file paths under it (across shards).
36    pub files: Vec<PathBuf>,
37}
38
39/// One file's section hierarchy: the file path plus its `##` sections and their
40/// sub-sections.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct Outline {
43    /// The store-relative path of the outlined file.
44    pub file: PathBuf,
45    /// The file's sections, in document order (depth carried on each
46    /// [`Section`]).
47    pub sections: Vec<Section>,
48}
49
50/// **SWEEP.** Build a [`Tree`] of the whole store (layer → type-folder → file),
51/// optionally scoped to one layer and/or one type. Off the interactive loop.
52///
53/// The grouping mirrors the db.md content model: a *type-folder* is an immediate
54/// child directory of a layer (`records/contacts`, `sources/emails`); its files
55/// are every `.md` content file beneath it, **aggregated across date-shards**
56/// (`sources/emails/2026/05/*.md`). Meta files never appear: the per-folder
57/// `index.md`, the root `DB.md`, and `log.md` / the `log/` archive dir are all
58/// skipped, as are hidden dot-dirs. A loose `.md` file sitting directly under a
59/// layer (with no enclosing type-folder) has no slot in the layer → type-folder
60/// → file model and is therefore not listed.
61///
62/// Ordering is total and deterministic so two runs — and a human vs. a machine
63/// reader — never disagree: layers in canonical [`Layer::all`] order, then
64/// type-folders by store-relative path ascending, then files by store-relative
65/// path ascending. Empty layers and empty type-folders are omitted.
66pub fn tree(store: &Store, layer: Option<Layer>, type_: Option<&str>) -> Result<Tree, StoreError> {
67    let mut layers = Vec::new();
68
69    for l in Layer::all() {
70        if let Some(want) = layer {
71            if l != want {
72                continue;
73            }
74        }
75
76        let layer_abs = store.root.join(layer_dir_name(l));
77        if !layer_abs.is_dir() {
78            continue;
79        }
80
81        // Each immediate sub-directory of the layer is a type-folder. Sort the
82        // type-folder names for a stable branch order.
83        let mut type_dir_names: Vec<String> = Vec::new();
84        for entry in std::fs::read_dir(&layer_abs)? {
85            let entry = entry?;
86            let file_type = entry.file_type()?;
87            if !file_type.is_dir() {
88                continue;
89            }
90            let name = entry.file_name().to_string_lossy().into_owned();
91            if is_skipped_dir(&name) {
92                continue;
93            }
94            type_dir_names.push(name);
95        }
96        type_dir_names.sort();
97
98        let mut type_folders = Vec::new();
99        for type_name in type_dir_names {
100            let type_abs = layer_abs.join(&type_name);
101            let mut files: Vec<PathBuf> = Vec::new();
102            collect_content_files(&store.root, &type_abs, &mut files)?;
103
104            // `--type` restricts to a single frontmatter `type` (matching every
105            // other `--type` flag in the binary), NOT the folder directory
106            // name. Canonical folders are pluralized (`contact` lives under
107            // `records/contacts/`), so a folder-name match would make
108            // `--type contact` empty on a canonical store; reading each file's
109            // frontmatter `type` is what the flag's help text promises.
110            if let Some(want) = type_ {
111                files.retain(|rel| file_type_matches(&store.root, rel, want));
112            }
113
114            if files.is_empty() {
115                continue;
116            }
117            files.sort();
118
119            type_folders.push(TreeTypeFolder {
120                path: PathBuf::from(layer_dir_name(l)).join(&type_name),
121                files,
122            });
123        }
124
125        if type_folders.is_empty() {
126            continue;
127        }
128
129        layers.push(TreeLayer {
130            layer: l,
131            type_folders,
132        });
133    }
134
135    Ok(Tree { layers })
136}
137
138/// The on-disk folder name for a layer. A render-local copy of the canonical
139/// layer→dir mapping so the walk never depends on store-side helpers; the names
140/// are fixed by the db.md spec (`sources` / `records` / `wiki`).
141fn layer_dir_name(layer: Layer) -> &'static str {
142    match layer {
143        Layer::Sources => "sources",
144        Layer::Records => "records",
145        Layer::Wiki => "wiki",
146    }
147}
148
149/// Directory names skipped during the store walk: hidden dot-dirs and the
150/// rotated-log archive folder.
151fn is_skipped_dir(name: &str) -> bool {
152    name == "log" || name.starts_with('.')
153}
154
155/// True if a file name is a content file we list in the tree: a `.md` file that
156/// is not a per-folder `index.md` meta file. `index.jsonl`, `.DS_Store`, and
157/// any non-`.md` artifact are not content.
158fn is_content_md(name: &str) -> bool {
159    name.ends_with(".md") && name != "index.md"
160}
161
162/// Recursively collect content `.md` files beneath a type-folder, descending
163/// through date-shard subdirectories, into `out` as store-relative paths.
164/// Skips hidden dirs and any nested `index.md` meta files.
165fn collect_content_files(
166    store_root: &Path,
167    dir: &Path,
168    out: &mut Vec<PathBuf>,
169) -> Result<(), StoreError> {
170    for entry in std::fs::read_dir(dir)? {
171        let entry = entry?;
172        let file_type = entry.file_type()?;
173        let name = entry.file_name().to_string_lossy().into_owned();
174
175        if file_type.is_dir() {
176            if name.starts_with('.') {
177                continue;
178            }
179            collect_content_files(store_root, &entry.path(), out)?;
180        } else if file_type.is_file() && is_content_md(&name) {
181            let abs = entry.path();
182            let rel = abs.strip_prefix(store_root).unwrap_or(&abs).to_path_buf();
183            out.push(rel);
184        }
185    }
186    Ok(())
187}
188
189/// True if the content file at store-relative `rel` declares the frontmatter
190/// `type` `want`. Lenient by design: a file that can't be read, has no
191/// frontmatter, or has no `type:` key simply doesn't match (it is not an error)
192/// — a `--type` filter never fails the whole tree over one unreadable file.
193///
194/// Self-contained (does not route through the crate's parser, which would error
195/// on malformed frontmatter): split off the leading `---` block and read the
196/// `type` key as a string, mirroring `stats`'s frontmatter-type reader.
197fn file_type_matches(store_root: &Path, rel: &Path, want: &str) -> bool {
198    let abs = store_root.join(rel);
199    let text = match std::fs::read_to_string(&abs) {
200        Ok(t) => t,
201        Err(_) => return false,
202    };
203    frontmatter_type(&text).as_deref() == Some(want)
204}
205
206/// Read the `type:` value from a file's leading YAML frontmatter block, if any.
207/// Returns `None` when there's no frontmatter or no `type` key. Tolerant of a
208/// leading BOM; requires `---` as the first line and a closing `---`.
209fn frontmatter_type(text: &str) -> Option<String> {
210    let text = text.strip_prefix('\u{feff}').unwrap_or(text);
211    let mut lines = text.lines();
212    if lines.next()?.trim_end() != "---" {
213        return None;
214    }
215    let mut yaml = String::new();
216    let mut closed = false;
217    for line in lines {
218        if line.trim_end() == "---" {
219            closed = true;
220            break;
221        }
222        yaml.push_str(line);
223        yaml.push('\n');
224    }
225    if !closed {
226        return None;
227    }
228    let value: serde_norway::Value = serde_norway::from_str(&yaml).ok()?;
229    let s = value
230        .as_mapping()?
231        .get(serde_norway::Value::String("type".to_string()))?
232        .as_str()?
233        .trim();
234    if s.is_empty() {
235        None
236    } else {
237        Some(s.to_string())
238    }
239}
240
241/// Build the [`Outline`] of a single file from its `##` (and deeper) sections.
242/// Loop-fast (one file).
243///
244/// `file` may be given store-relative or absolute; the read resolves against
245/// [`Store::root`] when relative, and [`Outline::file`] is always normalized to
246/// the store-relative form. Sections are extracted over the file **body** (after
247/// the YAML frontmatter), so [`Section::line`] is 1-based within the body — the
248/// same frame [`crate::parser::extract_sections`] uses. Only `##` and deeper
249/// headings are sections (a single leading `#` title is not a section); headings
250/// inside fenced code blocks are not mistaken for real headings.
251pub fn outline(store: &Store, file: &Path) -> Result<Outline, StoreError> {
252    let abs = if file.is_absolute() {
253        file.to_path_buf()
254    } else {
255        store.root.join(file)
256    };
257
258    let rel = abs.strip_prefix(&store.root).unwrap_or(file).to_path_buf();
259
260    let text = std::fs::read_to_string(&abs)?;
261    let body = strip_frontmatter(&text);
262    let sections = parse_sections(body);
263
264    Ok(Outline {
265        file: rel,
266        sections,
267    })
268}
269
270/// Return the file body with a leading YAML frontmatter block removed, so
271/// section line numbers count from the first body line (matching the parser's
272/// body frame). If the text does not open with a `---` fence, it is all body.
273/// Lenient by design: an outline never fails just because a file is missing
274/// frontmatter.
275fn strip_frontmatter(text: &str) -> &str {
276    // The opening fence must be the very first line, exactly `---`.
277    let after_open = match text.strip_prefix("---\n") {
278        Some(rest) => rest,
279        None => match text.strip_prefix("---\r\n") {
280            Some(rest) => rest,
281            None => return text,
282        },
283    };
284
285    // Find the closing `---` line; the body is everything after it.
286    let mut search_from = 0usize;
287    while let Some(rel_idx) = after_open[search_from..].find("---") {
288        let idx = search_from + rel_idx;
289        let at_line_start = idx == 0 || after_open.as_bytes()[idx - 1] == b'\n';
290        let after = &after_open[idx + 3..];
291        let line_ends = after.is_empty()
292            || after.starts_with('\n')
293            || after.starts_with("\r\n")
294            || after.starts_with('\r');
295        if at_line_start && line_ends {
296            // Skip past the closing fence's own line terminator.
297            if let Some(stripped) = after.strip_prefix("\r\n") {
298                return stripped;
299            }
300            if let Some(stripped) = after.strip_prefix('\n') {
301                return stripped;
302            }
303            if let Some(stripped) = after.strip_prefix('\r') {
304                return stripped;
305            }
306            return after; // closing fence is the last line, no trailing body
307        }
308        search_from = idx + 3;
309    }
310
311    // Unterminated frontmatter: treat the whole thing as body rather than error.
312    text
313}
314
315/// Parse the `##`-and-deeper sections of a markdown body into a flat list in
316/// document order, with each section's body spanning from its heading line to
317/// the next sibling-or-shallower heading (exclusive). Headings inside fenced
318/// code blocks (``` / ~~~) are ignored.
319fn parse_sections(body: &str) -> Vec<Section> {
320    // Split into lines, remembering each line's start byte so we can slice the
321    // original body verbatim (preserving its exact newlines).
322    let lines: Vec<&str> = body.split_inclusive('\n').collect();
323
324    // First pass: classify every line's heading level (0 = not a heading),
325    // honoring fenced-code-block state so fenced `## x` is not a heading.
326    let mut levels: Vec<u8> = Vec::with_capacity(lines.len());
327    let mut fence: Option<(u8, usize)> = None; // (fence byte, run length)
328    for line in &lines {
329        let content = line.trim_end_matches(['\n', '\r']);
330        if let Some(f) = fence {
331            if is_closing_fence(content, f) {
332                fence = None;
333            }
334            levels.push(0);
335            continue;
336        }
337        if let Some(opened) = opening_fence(content) {
338            fence = Some(opened);
339            levels.push(0);
340            continue;
341        }
342        levels.push(heading_level(content));
343    }
344
345    // Second pass: for each `##`+ heading, find the next heading at an
346    // equal-or-shallower level; the section body is the inclusive line range
347    // [heading, that next heading).
348    let mut sections = Vec::new();
349    for (i, &lvl) in levels.iter().enumerate() {
350        if lvl < 2 {
351            continue;
352        }
353        let heading_line = lines[i].trim_end_matches(['\n', '\r']);
354        let heading = heading_text(heading_line, lvl);
355
356        let mut end = lines.len();
357        for (j, &other) in levels.iter().enumerate().skip(i + 1) {
358            if other != 0 && other <= lvl {
359                end = j;
360                break;
361            }
362        }
363
364        let body_slice: String = lines[i..end].concat();
365
366        sections.push(Section {
367            heading,
368            level: lvl,
369            line: (i + 1) as u32,
370            body: body_slice,
371        });
372    }
373
374    sections
375}
376
377/// The ATX heading level of a line (number of leading `#`), or 0 if the line is
378/// not a heading. Allows up to three leading spaces (CommonMark), requires a
379/// space (or end-of-line) after the `#` run, and caps the run at six.
380fn heading_level(line: &str) -> u8 {
381    let indent = line.len() - line.trim_start_matches(' ').len();
382    if indent > 3 {
383        return 0;
384    }
385    let rest = &line[indent..];
386    let hashes = rest.len() - rest.trim_start_matches('#').len();
387    if hashes == 0 || hashes > 6 {
388        return 0;
389    }
390    let after = &rest[hashes..];
391    if after.is_empty() || after.starts_with(' ') || after.starts_with('\t') {
392        hashes as u8
393    } else {
394        0
395    }
396}
397
398/// The heading text of a heading line: the content after the `#` run, trimmed,
399/// with any trailing closing `#` sequence removed (ATX closing fence).
400///
401/// Per CommonMark, an ATX *closing* sequence of `#` is only a closing fence when
402/// it is preceded by a space or tab (or is the whole content): `## Title ##`
403/// yields `Title`, but `## C#` yields `C#` — the `#` there is part of the
404/// heading text, not a closing fence. So the trailing `#` run is stripped only
405/// when it is preceded by whitespace (or is the entire trimmed content).
406fn heading_text(line: &str, level: u8) -> String {
407    let indent = line.len() - line.trim_start_matches(' ').len();
408    let after_hashes = &line[indent + level as usize..];
409    let trimmed = after_hashes.trim();
410    // Length of the trailing run of `#`.
411    let trailing_hashes = trimmed.len() - trimmed.trim_end_matches('#').len();
412    if trailing_hashes == 0 {
413        return trimmed.to_string();
414    }
415    let before_run = &trimmed[..trimmed.len() - trailing_hashes];
416    // A trailing `#` run is an ATX closing fence only when preceded by
417    // whitespace or when it is the entire content (`## ##` -> empty heading).
418    // Otherwise it belongs to the heading text (`## C#`).
419    if before_run.is_empty() || before_run.ends_with([' ', '\t']) {
420        before_run.trim_end().to_string()
421    } else {
422        trimmed.to_string()
423    }
424}
425
426/// If `line` opens a fenced code block, return its `(fence byte, run length)`.
427/// A fence is at least three backticks or tildes, with up to three leading
428/// spaces of indentation.
429fn opening_fence(line: &str) -> Option<(u8, usize)> {
430    let indent = line.len() - line.trim_start_matches(' ').len();
431    if indent > 3 {
432        return None;
433    }
434    let rest = &line[indent..];
435    let byte = rest.bytes().next()?;
436    if byte != b'`' && byte != b'~' {
437        return None;
438    }
439    let run = rest.len() - rest.trim_start_matches(byte as char).len();
440    if run < 3 {
441        return None;
442    }
443    // A backtick fence's info string may not itself contain a backtick.
444    if byte == b'`' && rest[run..].contains('`') {
445        return None;
446    }
447    Some((byte, run))
448}
449
450/// True if `line` closes the currently open fence `(byte, len)`: same fence
451/// char, a run at least as long, and nothing else but trailing whitespace.
452fn is_closing_fence(line: &str, fence: (u8, usize)) -> bool {
453    let (byte, open_len) = fence;
454    let indent = line.len() - line.trim_start_matches(' ').len();
455    if indent > 3 {
456        return false;
457    }
458    let rest = &line[indent..];
459    let run = rest.len() - rest.trim_start_matches(byte as char).len();
460    if run < open_len {
461        return false;
462    }
463    rest[run..].trim().is_empty()
464}
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469    use crate::parser::Config;
470    use std::fs;
471    use tempfile::TempDir;
472
473    // ── Fixtures ────────────────────────────────────────────────────────────
474
475    /// A real temp store on disk plus an opened [`Store`] pointed at it.
476    ///
477    /// We construct the `Store` from its public fields rather than `Store::open`
478    /// so these tests exercise *render* against real files without depending on
479    /// store-side parsing.
480    struct Fixture {
481        _dir: TempDir,
482        store: Store,
483    }
484
485    impl Fixture {
486        fn new() -> Self {
487            let dir = tempfile::tempdir().expect("tempdir");
488            // A real store is marked by a DB.md at the root.
489            fs::write(dir.path().join("DB.md"), "---\ntype: db\n---\n").expect("write DB.md");
490            let store = Store {
491                root: dir.path().to_path_buf(),
492                config: Config::default(),
493            };
494            Fixture { _dir: dir, store }
495        }
496
497        /// Write `contents` to a store-relative path, creating parent dirs.
498        fn write(&self, rel: &str, contents: &str) {
499            let abs = self.store.root.join(rel);
500            if let Some(parent) = abs.parent() {
501                fs::create_dir_all(parent).expect("create parents");
502            }
503            fs::write(abs, contents).expect("write file");
504        }
505
506        fn mkdir(&self, rel: &str) {
507            fs::create_dir_all(self.store.root.join(rel)).expect("mkdir");
508        }
509    }
510
511    /// A minimal valid content file body (frontmatter + a heading).
512    fn doc(summary: &str) -> String {
513        format!("---\ntype: contact\nsummary: {summary}\n---\n\nbody\n")
514    }
515
516    /// Collect a tree's `(type-folder path, [file paths])` as strings, in the
517    /// order the tree presents them — the structure under test.
518    fn shape(tree: &Tree) -> Vec<(Layer, String, Vec<String>)> {
519        let mut out = Vec::new();
520        for layer in &tree.layers {
521            for tf in &layer.type_folders {
522                let files = tf
523                    .files
524                    .iter()
525                    .map(|p| p.to_string_lossy().into_owned())
526                    .collect();
527                out.push((layer.layer, tf.path.to_string_lossy().into_owned(), files));
528            }
529        }
530        out
531    }
532
533    // ── tree() ──────────────────────────────────────────────────────────────
534
535    #[test]
536    fn tree_groups_by_layer_then_type_folder_in_canonical_order() {
537        let fx = Fixture::new();
538        // Deliberately seed wiki before records before sources on disk by name
539        // so a naive readdir order would be alphabetical (records, sources,
540        // wiki) — the tree must instead emit the canonical Sources→Records→Wiki.
541        fx.write("wiki/people/sarah.md", &doc("sarah bio"));
542        fx.write("records/contacts/sarah-chen.md", &doc("sarah contact"));
543        fx.write("sources/emails/a.md", &doc("an email"));
544
545        let tree = tree(&fx.store, None, None).expect("tree");
546        let layer_order: Vec<Layer> = tree.layers.iter().map(|l| l.layer).collect();
547        assert_eq!(
548            layer_order,
549            vec![Layer::Sources, Layer::Records, Layer::Wiki],
550            "layers must come back in canonical order regardless of on-disk name order"
551        );
552
553        assert_eq!(
554            shape(&tree),
555            vec![
556                (
557                    Layer::Sources,
558                    "sources/emails".to_string(),
559                    vec!["sources/emails/a.md".to_string()]
560                ),
561                (
562                    Layer::Records,
563                    "records/contacts".to_string(),
564                    vec!["records/contacts/sarah-chen.md".to_string()]
565                ),
566                (
567                    Layer::Wiki,
568                    "wiki/people".to_string(),
569                    vec!["wiki/people/sarah.md".to_string()]
570                ),
571            ]
572        );
573    }
574
575    #[test]
576    fn tree_type_folders_and_files_are_sorted_ascending() {
577        let fx = Fixture::new();
578        // Two type-folders, out of alphabetical order on creation.
579        fx.write("records/expenses/z.md", &doc("z"));
580        fx.write("records/contacts/b.md", &doc("b"));
581        fx.write("records/contacts/a.md", &doc("a"));
582
583        let tree = tree(&fx.store, None, None).expect("tree");
584        let records = tree
585            .layers
586            .iter()
587            .find(|l| l.layer == Layer::Records)
588            .expect("records layer");
589
590        let folder_paths: Vec<String> = records
591            .type_folders
592            .iter()
593            .map(|tf| tf.path.to_string_lossy().into_owned())
594            .collect();
595        assert_eq!(
596            folder_paths,
597            vec![
598                "records/contacts".to_string(),
599                "records/expenses".to_string()
600            ],
601            "type-folders sorted by path ascending"
602        );
603
604        let contacts = &records.type_folders[0];
605        let files: Vec<String> = contacts
606            .files
607            .iter()
608            .map(|p| p.to_string_lossy().into_owned())
609            .collect();
610        assert_eq!(
611            files,
612            vec![
613                "records/contacts/a.md".to_string(),
614                "records/contacts/b.md".to_string()
615            ],
616            "files sorted by store-relative path ascending"
617        );
618    }
619
620    #[test]
621    fn tree_aggregates_files_across_date_shards_into_one_type_folder() {
622        let fx = Fixture::new();
623        fx.write("sources/emails/2026/05/newer.md", &doc("newer"));
624        fx.write("sources/emails/2026/04/older.md", &doc("older"));
625        fx.write("sources/emails/loose.md", &doc("loose at folder root"));
626
627        let tree = tree(&fx.store, None, None).expect("tree");
628        let emails: Vec<&TreeTypeFolder> = tree
629            .layers
630            .iter()
631            .flat_map(|l| &l.type_folders)
632            .filter(|tf| tf.path == Path::new("sources/emails"))
633            .collect();
634
635        assert_eq!(
636            emails.len(),
637            1,
638            "all shards of one type fold into a single type-folder branch, not one per shard"
639        );
640        let files: Vec<String> = emails[0]
641            .files
642            .iter()
643            .map(|p| p.to_string_lossy().into_owned())
644            .collect();
645        assert_eq!(
646            files,
647            vec![
648                "sources/emails/2026/04/older.md".to_string(),
649                "sources/emails/2026/05/newer.md".to_string(),
650                "sources/emails/loose.md".to_string(),
651            ],
652            "every file under the type-folder, across shards, appears once"
653        );
654    }
655
656    #[test]
657    fn tree_excludes_index_and_log_and_db_meta_files() {
658        let fx = Fixture::new();
659        // Real content.
660        fx.write("records/contacts/sarah.md", &doc("sarah"));
661        // Meta files at every level that must NOT show up as content.
662        fx.write("index.md", "---\ntype: index\n---\n"); // root index
663        fx.write("records/index.md", "---\ntype: index\n---\n"); // layer index
664        fx.write("records/contacts/index.md", "---\ntype: index\n---\n"); // type-folder index
665        fx.write("records/contacts/index.jsonl", "{}\n"); // machine twin
666        fx.write("log.md", "log\n"); // active log
667        fx.write("log/2026-04.md", "rotated\n"); // rotated log archive
668
669        let tree = tree(&fx.store, None, None).expect("tree");
670        let all_files: Vec<String> = tree
671            .layers
672            .iter()
673            .flat_map(|l| &l.type_folders)
674            .flat_map(|tf| &tf.files)
675            .map(|p| p.to_string_lossy().into_owned())
676            .collect();
677
678        assert_eq!(
679            all_files,
680            vec!["records/contacts/sarah.md".to_string()],
681            "only the real content file survives; no index.md/index.jsonl/log files"
682        );
683        // The `log/` dir at the root is not a layer, so it never produces a branch.
684        assert!(tree
685            .layers
686            .iter()
687            .all(|l| matches!(l.layer, Layer::Sources | Layer::Records | Layer::Wiki)));
688    }
689
690    #[test]
691    fn tree_omits_empty_layers_and_empty_type_folders() {
692        let fx = Fixture::new();
693        fx.write("records/contacts/a.md", &doc("a"));
694        // An empty type-folder (dir exists, no content files).
695        fx.mkdir("records/companies");
696        // An empty layer (dir exists, nothing under it).
697        fx.mkdir("wiki");
698        // A type-folder holding only a meta file is effectively empty content.
699        fx.write("sources/emails/index.md", "---\ntype: index\n---\n");
700
701        let tree = tree(&fx.store, None, None).expect("tree");
702
703        let layers: Vec<Layer> = tree.layers.iter().map(|l| l.layer).collect();
704        assert_eq!(
705            layers,
706            vec![Layer::Records],
707            "empty wiki layer and meta-only sources layer are omitted"
708        );
709        let folders: Vec<String> = tree.layers[0]
710            .type_folders
711            .iter()
712            .map(|tf| tf.path.to_string_lossy().into_owned())
713            .collect();
714        assert_eq!(
715            folders,
716            vec!["records/contacts".to_string()],
717            "the empty companies type-folder is omitted"
718        );
719    }
720
721    #[test]
722    fn tree_layer_filter_restricts_to_one_layer() {
723        let fx = Fixture::new();
724        fx.write("sources/emails/a.md", &doc("a"));
725        fx.write("records/contacts/b.md", &doc("b"));
726        fx.write("wiki/people/c.md", &doc("c"));
727
728        let tree = tree(&fx.store, Some(Layer::Records), None).expect("tree");
729        let layers: Vec<Layer> = tree.layers.iter().map(|l| l.layer).collect();
730        assert_eq!(
731            layers,
732            vec![Layer::Records],
733            "only the requested layer is walked"
734        );
735    }
736
737    /// A content file body with an explicit frontmatter `type`.
738    fn typed(type_: &str, summary: &str) -> String {
739        format!("---\ntype: {type_}\nsummary: {summary}\n---\n\nbody\n")
740    }
741
742    #[test]
743    fn tree_type_filter_matches_frontmatter_type_across_layers() {
744        let fx = Fixture::new();
745        // Same `note` type filed under two layers (in folders whose names are
746        // NOT the type), plus a contact to exclude.
747        fx.write("sources/inbox/s.md", &typed("note", "source note"));
748        fx.write("wiki/scratch/w.md", &typed("note", "wiki note"));
749        fx.write("records/contacts/c.md", &typed("contact", "contact"));
750
751        let tree = tree(&fx.store, None, Some("note")).expect("tree");
752        let files: Vec<String> = tree
753            .layers
754            .iter()
755            .flat_map(|l| &l.type_folders)
756            .flat_map(|tf| &tf.files)
757            .map(|p| p.to_string_lossy().into_owned())
758            .collect();
759        assert_eq!(
760            files,
761            vec![
762                "sources/inbox/s.md".to_string(),
763                "wiki/scratch/w.md".to_string()
764            ],
765            "type filter matches the frontmatter type across layers, regardless of folder name"
766        );
767    }
768
769    #[test]
770    fn tree_type_filter_uses_frontmatter_type_not_folder_name() {
771        // Regression (finding #43): `--type contact` must list a record whose
772        // frontmatter `type: contact` lives in the canonical, pluralized folder
773        // `records/contacts/`. Pre-fix the filter compared the folder NAME
774        // (`contacts`) to the requested type (`contact`) and returned nothing.
775        let fx = Fixture::new();
776        fx.write("records/contacts/sarah.md", &typed("contact", "sarah"));
777        fx.write("wiki/people/sarah.md", &typed("wiki-page", "sarah bio"));
778
779        // The documented frontmatter type matches.
780        let by_type = tree(&fx.store, None, Some("contact")).expect("tree");
781        let files: Vec<String> = by_type
782            .layers
783            .iter()
784            .flat_map(|l| &l.type_folders)
785            .flat_map(|tf| &tf.files)
786            .map(|p| p.to_string_lossy().into_owned())
787            .collect();
788        assert_eq!(
789            files,
790            vec!["records/contacts/sarah.md".to_string()],
791            "--type contact lists the contact in the pluralized canonical folder"
792        );
793
794        // The folder name (`contacts`) no longer matches — it is not a type.
795        let by_folder_name = tree(&fx.store, None, Some("contacts")).expect("tree");
796        assert!(
797            by_folder_name.layers.is_empty(),
798            "the folder directory name is not the frontmatter type and must not match"
799        );
800
801        // `wiki-page` records, filed under topic folders, are now reachable.
802        let wiki = tree(&fx.store, None, Some("wiki-page")).expect("tree");
803        let wiki_files: Vec<String> = wiki
804            .layers
805            .iter()
806            .flat_map(|l| &l.type_folders)
807            .flat_map(|tf| &tf.files)
808            .map(|p| p.to_string_lossy().into_owned())
809            .collect();
810        assert_eq!(
811            wiki_files,
812            vec!["wiki/people/sarah.md".to_string()],
813            "--type wiki-page matches the frontmatter type under a topic folder"
814        );
815    }
816
817    #[test]
818    fn tree_type_filter_skips_untyped_and_unmatched_files() {
819        // A file with no frontmatter type, and one with a different type, are
820        // both excluded by a `--type` filter without erroring the tree.
821        let fx = Fixture::new();
822        fx.write("records/contacts/sarah.md", &typed("contact", "sarah"));
823        fx.write("records/contacts/no-type.md", "no frontmatter at all\n");
824        fx.write("records/contacts/other.md", &typed("company", "acme"));
825
826        let tree = tree(&fx.store, None, Some("contact")).expect("tree");
827        let files: Vec<String> = tree
828            .layers
829            .iter()
830            .flat_map(|l| &l.type_folders)
831            .flat_map(|tf| &tf.files)
832            .map(|p| p.to_string_lossy().into_owned())
833            .collect();
834        assert_eq!(
835            files,
836            vec!["records/contacts/sarah.md".to_string()],
837            "only the file whose frontmatter type matches survives; untyped/other are skipped"
838        );
839    }
840
841    #[test]
842    fn tree_excludes_loose_files_directly_under_a_layer() {
843        let fx = Fixture::new();
844        fx.write("records/contacts/real.md", &doc("real"));
845        // A loose .md directly under the layer, not in any type-folder.
846        fx.write("records/stray.md", &doc("stray"));
847
848        let tree = tree(&fx.store, None, None).expect("tree");
849        let all_files: Vec<String> = tree
850            .layers
851            .iter()
852            .flat_map(|l| &l.type_folders)
853            .flat_map(|tf| &tf.files)
854            .map(|p| p.to_string_lossy().into_owned())
855            .collect();
856        assert_eq!(
857            all_files,
858            vec!["records/contacts/real.md".to_string()],
859            "a layer-direct file has no type-folder slot and is not listed"
860        );
861    }
862
863    #[test]
864    fn tree_skips_hidden_directories() {
865        let fx = Fixture::new();
866        fx.write("records/contacts/a.md", &doc("a"));
867        // A hidden type-folder and a hidden shard inside a real one.
868        fx.write(".git/objects/x.md", &doc("vcs junk"));
869        fx.write("records/.hidden/h.md", &doc("hidden type folder"));
870        fx.write("sources/emails/.tmp/draft.md", &doc("hidden shard"));
871
872        let tree = tree(&fx.store, None, None).expect("tree");
873        let all_files: Vec<String> = tree
874            .layers
875            .iter()
876            .flat_map(|l| &l.type_folders)
877            .flat_map(|tf| &tf.files)
878            .map(|p| p.to_string_lossy().into_owned())
879            .collect();
880        assert_eq!(
881            all_files,
882            vec!["records/contacts/a.md".to_string()],
883            "hidden dirs are skipped at the type-folder and shard levels"
884        );
885    }
886
887    #[test]
888    fn tree_paths_are_store_relative_not_absolute() {
889        let fx = Fixture::new();
890        fx.write("records/contacts/a.md", &doc("a"));
891
892        let tree = tree(&fx.store, None, None).expect("tree");
893        let tf = &tree.layers[0].type_folders[0];
894        assert!(
895            tf.path.is_relative() && tf.files[0].is_relative(),
896            "tree paths must be store-relative"
897        );
898        // And they must not leak the absolute root prefix.
899        let root_str = fx.store.root.to_string_lossy().into_owned();
900        assert!(!tf.files[0].to_string_lossy().contains(&root_str));
901    }
902
903    #[test]
904    fn tree_on_store_with_no_layers_is_empty() {
905        let fx = Fixture::new(); // only DB.md, no layer dirs
906        let tree = tree(&fx.store, None, None).expect("tree");
907        assert!(
908            tree.layers.is_empty(),
909            "a store with no content has an empty tree"
910        );
911    }
912
913    // ── outline() ─────────────────────────────────────────────────────────────
914
915    /// Heading text + level + 1-based body line, for compact assertions.
916    fn headings(o: &Outline) -> Vec<(String, u8, u32)> {
917        o.sections
918            .iter()
919            .map(|s| (s.heading.clone(), s.level, s.line))
920            .collect()
921    }
922
923    #[test]
924    fn outline_extracts_sections_with_levels_and_body_relative_lines() {
925        let fx = Fixture::new();
926        // 4-line frontmatter block; the body starts at the blank line after it.
927        // Body line 1: ""   2: "# Title"  3: ""  4: "## Alpha"  5: "text"
928        //      6: "### Sub"  7: "more"  8: "## Beta"  9: "end"
929        let file = "---\ntype: note\nsummary: s\n---\n\n# Title\n\n## Alpha\ntext\n### Sub\nmore\n## Beta\nend\n";
930        fx.write("wiki/notes/n.md", file);
931
932        let o = outline(&fx.store, Path::new("wiki/notes/n.md")).expect("outline");
933        assert_eq!(
934            headings(&o),
935            vec![
936                ("Alpha".to_string(), 2, 4),
937                ("Sub".to_string(), 3, 6),
938                ("Beta".to_string(), 2, 8),
939            ],
940            "only ##+ headings, with body-relative 1-based line numbers; the # title is not a section"
941        );
942        assert_eq!(o.file, PathBuf::from("wiki/notes/n.md"));
943    }
944
945    #[test]
946    fn outline_section_body_spans_to_next_sibling_or_shallower_heading() {
947        let fx = Fixture::new();
948        let file = "---\nx: 1\n---\n## Alpha\na1\na2\n### Sub\ns1\n## Beta\nb1\n";
949        fx.write("wiki/notes/n.md", file);
950
951        let o = outline(&fx.store, Path::new("wiki/notes/n.md")).expect("outline");
952        let alpha = &o.sections[0];
953        // Alpha (##) absorbs its own lines AND the nested ### Sub, stopping at ## Beta.
954        assert_eq!(alpha.heading, "Alpha");
955        assert_eq!(
956            alpha.body, "## Alpha\na1\na2\n### Sub\ns1\n",
957            "a ## body runs through deeper headings up to the next sibling-or-shallower heading"
958        );
959
960        let sub = &o.sections[1];
961        assert_eq!(sub.heading, "Sub");
962        assert_eq!(
963            sub.body, "### Sub\ns1\n",
964            "the nested ### body stops at the next ## (shallower) heading"
965        );
966
967        let beta = &o.sections[2];
968        assert_eq!(
969            beta.body, "## Beta\nb1\n",
970            "the trailing ## body runs to end of file"
971        );
972    }
973
974    #[test]
975    fn outline_shallower_heading_terminates_a_section_body() {
976        let fx = Fixture::new();
977        // A later level-1 `#` is shallower than `##` and must close the ## body.
978        let file = "---\nx: 1\n---\n## Sec\nbody1\n# NewTitle\nafter\n";
979        fx.write("wiki/notes/n.md", file);
980
981        let o = outline(&fx.store, Path::new("wiki/notes/n.md")).expect("outline");
982        assert_eq!(headings(&o), vec![("Sec".to_string(), 2, 1)]);
983        assert_eq!(
984            o.sections[0].body, "## Sec\nbody1\n",
985            "the level-1 heading is shallower and ends the section, and is itself not a section"
986        );
987    }
988
989    #[test]
990    fn outline_ignores_headings_inside_fenced_code_blocks() {
991        let fx = Fixture::new();
992        let file = "---\nx: 1\n---\n## Real\n```\n## fake heading in code\n### also fake\n```\nafter\n## AlsoReal\n";
993        fx.write("wiki/notes/n.md", file);
994
995        let o = outline(&fx.store, Path::new("wiki/notes/n.md")).expect("outline");
996        // Body lines: 1 `## Real`, 2 ```, 3/4 fenced fakes, 5 ```, 6 `after`,
997        // 7 `## AlsoReal` — so AlsoReal is heading on body line 7.
998        assert_eq!(
999            headings(&o),
1000            vec![("Real".to_string(), 2, 1), ("AlsoReal".to_string(), 2, 7)],
1001            "## inside a ``` fence is code, not a heading"
1002        );
1003        // The fenced lines belong to Real's body, not their own sections.
1004        assert!(o.sections[0].body.contains("## fake heading in code"));
1005    }
1006
1007    #[test]
1008    fn outline_ignores_tilde_fences_too() {
1009        let fx = Fixture::new();
1010        let file = "---\nx: 1\n---\n## Real\n~~~\n## fake\n~~~\ntail\n";
1011        fx.write("wiki/notes/n.md", file);
1012
1013        let o = outline(&fx.store, Path::new("wiki/notes/n.md")).expect("outline");
1014        assert_eq!(headings(&o), vec![("Real".to_string(), 2, 1)]);
1015    }
1016
1017    #[test]
1018    fn outline_rejects_non_heading_hash_lines() {
1019        let fx = Fixture::new();
1020        // `#tag` (no space) is not a heading; 7 hashes exceeds ATX max of 6.
1021        let file = "---\nx: 1\n---\n#nospace\n####### sevenhashes\n## Good\n";
1022        fx.write("wiki/notes/n.md", file);
1023
1024        let o = outline(&fx.store, Path::new("wiki/notes/n.md")).expect("outline");
1025        assert_eq!(
1026            headings(&o),
1027            vec![("Good".to_string(), 2, 3)],
1028            "only the well-formed ## heading counts"
1029        );
1030    }
1031
1032    #[test]
1033    fn outline_strips_atx_closing_hashes_from_heading_text() {
1034        let fx = Fixture::new();
1035        let file = "---\nx: 1\n---\n## Title ##\n";
1036        fx.write("wiki/notes/n.md", file);
1037
1038        let o = outline(&fx.store, Path::new("wiki/notes/n.md")).expect("outline");
1039        assert_eq!(o.sections[0].heading, "Title");
1040    }
1041
1042    #[test]
1043    fn outline_keeps_unspaced_trailing_hash_in_heading_text() {
1044        // Regression (finding #46): a trailing `#` with no preceding space is
1045        // part of the heading text, not an ATX closing fence (`## C#` -> "C#").
1046        // `## Ada ##` (space before the run) is still a closing fence -> "Ada",
1047        // and a bare `## ##` is an empty heading.
1048        let fx = Fixture::new();
1049        let file = "---\nx: 1\n---\n## C#\n## F#\n## Ada ##\n## ##\n";
1050        fx.write("wiki/notes/langs.md", file);
1051
1052        let o = outline(&fx.store, Path::new("wiki/notes/langs.md")).expect("outline");
1053        let texts: Vec<String> = o.sections.iter().map(|s| s.heading.clone()).collect();
1054        assert_eq!(
1055            texts,
1056            vec![
1057                "C#".to_string(),
1058                "F#".to_string(),
1059                "Ada".to_string(),
1060                "".to_string(),
1061            ],
1062            "unspaced trailing # stays; a space-preceded # run is a closing fence"
1063        );
1064    }
1065
1066    #[test]
1067    fn outline_handles_file_without_frontmatter_numbering_from_line_one() {
1068        let fx = Fixture::new();
1069        // No `---` block at all; the whole file is body, so ## is on line 1.
1070        let file = "## First\ntext\n## Second\n";
1071        fx.write("wiki/notes/n.md", file);
1072
1073        let o = outline(&fx.store, Path::new("wiki/notes/n.md")).expect("outline");
1074        assert_eq!(
1075            headings(&o),
1076            vec![("First".to_string(), 2, 1), ("Second".to_string(), 2, 3)],
1077            "with no frontmatter the body is the whole file and lines count from 1"
1078        );
1079    }
1080
1081    #[test]
1082    fn outline_accepts_absolute_path_and_returns_store_relative_file() {
1083        let fx = Fixture::new();
1084        fx.write("records/contacts/x.md", "---\nx: 1\n---\n## H\n");
1085        let abs = fx.store.root.join("records/contacts/x.md");
1086
1087        let o = outline(&fx.store, &abs).expect("outline");
1088        assert_eq!(
1089            o.file,
1090            PathBuf::from("records/contacts/x.md"),
1091            "an absolute input path is normalized to store-relative in the Outline"
1092        );
1093        assert_eq!(o.sections.len(), 1);
1094    }
1095
1096    #[test]
1097    fn outline_of_a_file_with_no_headings_is_empty() {
1098        let fx = Fixture::new();
1099        fx.write(
1100            "wiki/notes/n.md",
1101            "---\nx: 1\n---\njust prose, no headings\n",
1102        );
1103
1104        let o = outline(&fx.store, Path::new("wiki/notes/n.md")).expect("outline");
1105        assert!(
1106            o.sections.is_empty(),
1107            "a heading-free body yields no sections"
1108        );
1109    }
1110
1111    #[test]
1112    fn outline_missing_file_is_an_io_error() {
1113        let fx = Fixture::new();
1114        let err = outline(&fx.store, Path::new("wiki/notes/does-not-exist.md"))
1115            .expect_err("missing file should error");
1116        assert!(
1117            matches!(err, StoreError::Io(_)),
1118            "a missing file surfaces as a StoreError::Io, got {err:?}"
1119        );
1120    }
1121
1122    #[test]
1123    fn outline_handles_crlf_frontmatter_and_indented_headings() {
1124        let fx = Fixture::new();
1125        // CRLF frontmatter terminator + a heading indented up to 3 spaces (still
1126        // a heading per CommonMark) and one indented 4 (a code indent — not).
1127        let file = "---\r\nx: 1\r\n---\r\n   ## Indented3\nbody\n    ## Indented4Code\n";
1128        fx.write("wiki/notes/n.md", file);
1129
1130        let o = outline(&fx.store, Path::new("wiki/notes/n.md")).expect("outline");
1131        assert_eq!(
1132            headings(&o),
1133            vec![("Indented3".to_string(), 2, 1)],
1134            "<=3 leading spaces is a heading; 4 spaces is indented code, not a heading"
1135        );
1136    }
1137}