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