Skip to main content

docgen_core/
tree.rs

1use std::collections::BTreeMap;
2
3use crate::model::{Doc, TreeNode};
4
5#[derive(Default)]
6struct Builder {
7    dirs: BTreeMap<String, Builder>,
8    docs: BTreeMap<String, (String, String)>, // leaf name -> (slug, title)
9    /// Folder note: an `index.md` directly inside this directory. Stored as its
10    /// slug instead of being added to `docs`, so the folder itself links to it.
11    note: Option<String>,
12}
13
14fn insert(node: &mut Builder, parts: &[&str], slug: &str, title: &str, depth: usize) {
15    match parts {
16        // An `index.md` *inside* a folder (depth > 0) is that folder's note, not a
17        // child entry. A root-level `index.md` (depth 0) is the site home and stays
18        // an ordinary doc.
19        [leaf] if *leaf == "index" && depth > 0 => {
20            node.note = Some(slug.to_string());
21        }
22        [leaf] => {
23            node.docs
24                .insert(leaf.to_string(), (slug.to_string(), title.to_string()));
25        }
26        [head, rest @ ..] => {
27            insert(
28                node.dirs.entry(head.to_string()).or_default(),
29                rest,
30                slug,
31                title,
32                depth + 1,
33            );
34        }
35        [] => {}
36    }
37}
38
39fn to_nodes(builder: Builder) -> Vec<TreeNode> {
40    let mut out = Vec::new();
41    // Directories first (BTreeMap keeps them name-sorted), then loose docs.
42    for (name, child) in builder.dirs {
43        let slug = child.note.clone();
44        out.push(TreeNode::Dir {
45            name,
46            slug,
47            children: to_nodes(child),
48        });
49    }
50    for (name, (slug, title)) in builder.docs {
51        out.push(TreeNode::Doc { name, slug, title });
52    }
53    out
54}
55
56/// Build a name-sorted sidebar tree from documents, keyed off their slugs.
57/// A folder's `index.md` becomes the folder's note (the folder links to it)
58/// rather than a separate `index` child entry.
59pub fn build_tree(docs: &[Doc]) -> Vec<TreeNode> {
60    let mut root = Builder::default();
61    for doc in docs {
62        let parts: Vec<&str> = doc.slug.split('/').collect();
63        insert(&mut root, &parts, &doc.slug, &doc.title, 0);
64    }
65    to_nodes(root)
66}
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71    use crate::model::{Doc, TreeNode};
72
73    fn doc(slug: &str, title: &str) -> Doc {
74        Doc {
75            rel_path: format!("{slug}.md"),
76            slug: slug.into(),
77            title: title.into(),
78            description: None,
79            body_html: String::new(),
80            has_math: false,
81            has_mermaid: false,
82            components_used: Default::default(),
83            headings: Vec::new(),
84        }
85    }
86
87    #[test]
88    fn groups_docs_under_directories() {
89        let docs = vec![doc("index", "Home"), doc("guide/intro", "Intro")];
90        let tree = build_tree(&docs);
91
92        // Directories come before loose docs; both sorted by name.
93        assert_eq!(tree.len(), 2);
94        match &tree[0] {
95            TreeNode::Dir { name, children, .. } => {
96                assert_eq!(name, "guide");
97                assert_eq!(children.len(), 1);
98                assert!(
99                    matches!(&children[0], TreeNode::Doc { slug, .. } if slug == "guide/intro")
100                );
101            }
102            other => panic!("expected dir, got {other:?}"),
103        }
104        assert!(matches!(&tree[1], TreeNode::Doc { slug, .. } if slug == "index"));
105    }
106
107    #[test]
108    fn dirs_come_before_docs_even_when_doc_sorts_first() {
109        // "aaa" sorts before "zzz_dir" alphabetically; dirs-first must still win.
110        let docs = vec![doc("aaa", "A"), doc("zzz_dir/page", "Page")];
111        let tree = build_tree(&docs);
112        assert_eq!(tree.len(), 2);
113        assert!(matches!(&tree[0], TreeNode::Dir { name, .. } if name == "zzz_dir"));
114        assert!(matches!(&tree[1], TreeNode::Doc { slug, .. } if slug == "aaa"));
115    }
116
117    #[test]
118    fn multiple_dirs_and_docs_each_sorted_within_group() {
119        let docs = vec![
120            doc("m_doc", "M"),
121            doc("b_dir/x", "X"),
122            doc("a_doc", "A"),
123            doc("a_dir/y", "Y"),
124        ];
125        let tree = build_tree(&docs);
126        // Dirs first (a_dir, b_dir), then docs (a_doc, m_doc).
127        assert!(matches!(&tree[0], TreeNode::Dir { name, .. } if name == "a_dir"));
128        assert!(matches!(&tree[1], TreeNode::Dir { name, .. } if name == "b_dir"));
129        assert!(matches!(&tree[2], TreeNode::Doc { name, .. } if name == "a_doc"));
130        assert!(matches!(&tree[3], TreeNode::Doc { name, .. } if name == "m_doc"));
131    }
132
133    #[test]
134    fn groups_nested_directories() {
135        let docs = vec![doc("a/b/c", "Deep")];
136        let tree = build_tree(&docs);
137        // a -> b -> c (doc), three levels deep.
138        let a = match &tree[0] {
139            TreeNode::Dir { name, children, .. } if name == "a" => children,
140            other => panic!("expected dir a, got {other:?}"),
141        };
142        let b = match &a[0] {
143            TreeNode::Dir { name, children, .. } if name == "b" => children,
144            other => panic!("expected dir b, got {other:?}"),
145        };
146        assert!(matches!(&b[0], TreeNode::Doc { slug, .. } if slug == "a/b/c"));
147    }
148
149    #[test]
150    fn folder_index_becomes_folder_note_not_child() {
151        // `guide/index.md` is the "guide" folder's note: the dir carries its slug
152        // and `index` is NOT a separate child. `guide/intro` stays a child.
153        let docs = vec![doc("guide/index", "Guide"), doc("guide/intro", "Intro")];
154        let tree = build_tree(&docs);
155        assert_eq!(tree.len(), 1);
156        match &tree[0] {
157            TreeNode::Dir {
158                name,
159                slug,
160                children,
161            } => {
162                assert_eq!(name, "guide");
163                assert_eq!(slug.as_deref(), Some("guide/index"));
164                // Only `intro` is a child — no `index` entry.
165                assert_eq!(children.len(), 1);
166                assert!(
167                    matches!(&children[0], TreeNode::Doc { slug, .. } if slug == "guide/intro")
168                );
169            }
170            other => panic!("expected dir, got {other:?}"),
171        }
172    }
173
174    #[test]
175    fn root_index_stays_an_ordinary_doc() {
176        // A top-level `index.md` is the site home, not a folder note — it must
177        // remain a normal doc node (depth 0).
178        let docs = vec![doc("index", "Home"), doc("guide/intro", "Intro")];
179        let tree = build_tree(&docs);
180        assert!(tree
181            .iter()
182            .any(|n| matches!(n, TreeNode::Doc { slug, .. } if slug == "index")));
183    }
184}