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