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