Skip to main content

dbmd_core/
render.rs

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