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