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    /// `sidebar: false` on this directory's `index.md` — hide the entire subtree.
13    hidden: bool,
14}
15
16fn insert(node: &mut Builder, parts: &[&str], slug: &str, title: &str, depth: usize, hidden: bool) {
17    match parts {
18        // An `index.md` *inside* a folder (depth > 0) is that folder's note, not a
19        // child entry. A root-level `index.md` (depth 0) is the site home and stays
20        // an ordinary doc. A hidden folder note hides the whole directory subtree.
21        [leaf] if *leaf == "index" && depth > 0 => {
22            if hidden {
23                node.hidden = true;
24            } else {
25                node.note = Some(slug.to_string());
26            }
27        }
28        // A hidden leaf is dropped from the tree entirely (it still builds + is
29        // reachable by URL; it just doesn't appear in the sidebar).
30        [_] if hidden => {}
31        [leaf] => {
32            node.docs
33                .insert(leaf.to_string(), (slug.to_string(), title.to_string()));
34        }
35        [head, rest @ ..] => {
36            insert(
37                node.dirs.entry(head.to_string()).or_default(),
38                rest,
39                slug,
40                title,
41                depth + 1,
42                hidden,
43            );
44        }
45        [] => {}
46    }
47}
48
49fn to_nodes(builder: Builder) -> Vec<TreeNode> {
50    let mut out = Vec::new();
51    // Directories first (BTreeMap keeps them name-sorted), then loose docs.
52    for (name, child) in builder.dirs {
53        // A directory hidden via its `index.md` drops with its whole subtree.
54        if child.hidden {
55            continue;
56        }
57        let slug = child.note.clone();
58        let children = to_nodes(child);
59        // Prune a directory left empty by hidden children: with no children and
60        // no folder note it would render as a dangling, non-navigable group.
61        if children.is_empty() && slug.is_none() {
62            continue;
63        }
64        out.push(TreeNode::Dir {
65            name,
66            slug,
67            children,
68        });
69    }
70    for (name, (slug, title)) in builder.docs {
71        out.push(TreeNode::Doc { name, slug, title });
72    }
73    out
74}
75
76/// Build a name-sorted sidebar tree from documents, keyed off their slugs.
77/// A folder's `index.md` becomes the folder's note (the folder links to it)
78/// rather than a separate `index` child entry. A doc with `sidebar: false`
79/// (`hidden_from_sidebar`) is omitted; on a folder's `index.md` that hides the
80/// whole subtree, and a directory left empty by hidden children is pruned.
81pub fn build_tree(docs: &[Doc]) -> Vec<TreeNode> {
82    let mut root = Builder::default();
83    for doc in docs {
84        let parts: Vec<&str> = doc.slug.split('/').collect();
85        insert(
86            &mut root,
87            &parts,
88            &doc.slug,
89            &doc.title,
90            0,
91            doc.hidden_from_sidebar,
92        );
93    }
94    to_nodes(root)
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use crate::model::{Doc, TreeNode};
101
102    fn doc(slug: &str, title: &str) -> Doc {
103        Doc {
104            rel_path: format!("{slug}.md"),
105            slug: slug.into(),
106            title: title.into(),
107            description: None,
108            body_html: String::new(),
109            has_math: false,
110            has_mermaid: false,
111            components_used: Default::default(),
112            headings: Vec::new(),
113            hidden_from_sidebar: false,
114        }
115    }
116
117    /// Like [`doc`], but flagged `sidebar: false`.
118    fn hidden_doc(slug: &str, title: &str) -> Doc {
119        Doc {
120            hidden_from_sidebar: true,
121            ..doc(slug, title)
122        }
123    }
124
125    #[test]
126    fn groups_docs_under_directories() {
127        let docs = vec![doc("index", "Home"), doc("guide/intro", "Intro")];
128        let tree = build_tree(&docs);
129
130        // Directories come before loose docs; both sorted by name.
131        assert_eq!(tree.len(), 2);
132        match &tree[0] {
133            TreeNode::Dir { name, children, .. } => {
134                assert_eq!(name, "guide");
135                assert_eq!(children.len(), 1);
136                assert!(
137                    matches!(&children[0], TreeNode::Doc { slug, .. } if slug == "guide/intro")
138                );
139            }
140            other => panic!("expected dir, got {other:?}"),
141        }
142        assert!(matches!(&tree[1], TreeNode::Doc { slug, .. } if slug == "index"));
143    }
144
145    #[test]
146    fn dirs_come_before_docs_even_when_doc_sorts_first() {
147        // "aaa" sorts before "zzz_dir" alphabetically; dirs-first must still win.
148        let docs = vec![doc("aaa", "A"), doc("zzz_dir/page", "Page")];
149        let tree = build_tree(&docs);
150        assert_eq!(tree.len(), 2);
151        assert!(matches!(&tree[0], TreeNode::Dir { name, .. } if name == "zzz_dir"));
152        assert!(matches!(&tree[1], TreeNode::Doc { slug, .. } if slug == "aaa"));
153    }
154
155    #[test]
156    fn multiple_dirs_and_docs_each_sorted_within_group() {
157        let docs = vec![
158            doc("m_doc", "M"),
159            doc("b_dir/x", "X"),
160            doc("a_doc", "A"),
161            doc("a_dir/y", "Y"),
162        ];
163        let tree = build_tree(&docs);
164        // Dirs first (a_dir, b_dir), then docs (a_doc, m_doc).
165        assert!(matches!(&tree[0], TreeNode::Dir { name, .. } if name == "a_dir"));
166        assert!(matches!(&tree[1], TreeNode::Dir { name, .. } if name == "b_dir"));
167        assert!(matches!(&tree[2], TreeNode::Doc { name, .. } if name == "a_doc"));
168        assert!(matches!(&tree[3], TreeNode::Doc { name, .. } if name == "m_doc"));
169    }
170
171    #[test]
172    fn groups_nested_directories() {
173        let docs = vec![doc("a/b/c", "Deep")];
174        let tree = build_tree(&docs);
175        // a -> b -> c (doc), three levels deep.
176        let a = match &tree[0] {
177            TreeNode::Dir { name, children, .. } if name == "a" => children,
178            other => panic!("expected dir a, got {other:?}"),
179        };
180        let b = match &a[0] {
181            TreeNode::Dir { name, children, .. } if name == "b" => children,
182            other => panic!("expected dir b, got {other:?}"),
183        };
184        assert!(matches!(&b[0], TreeNode::Doc { slug, .. } if slug == "a/b/c"));
185    }
186
187    #[test]
188    fn folder_index_becomes_folder_note_not_child() {
189        // `guide/index.md` is the "guide" folder's note: the dir carries its slug
190        // and `index` is NOT a separate child. `guide/intro` stays a child.
191        let docs = vec![doc("guide/index", "Guide"), doc("guide/intro", "Intro")];
192        let tree = build_tree(&docs);
193        assert_eq!(tree.len(), 1);
194        match &tree[0] {
195            TreeNode::Dir {
196                name,
197                slug,
198                children,
199            } => {
200                assert_eq!(name, "guide");
201                assert_eq!(slug.as_deref(), Some("guide/index"));
202                // Only `intro` is a child — no `index` entry.
203                assert_eq!(children.len(), 1);
204                assert!(
205                    matches!(&children[0], TreeNode::Doc { slug, .. } if slug == "guide/intro")
206                );
207            }
208            other => panic!("expected dir, got {other:?}"),
209        }
210    }
211
212    #[test]
213    fn hidden_leaf_is_omitted_from_sidebar() {
214        // `sidebar: false` drops the page from the tree; its siblings remain.
215        let docs = vec![
216            doc("guide/intro", "Intro"),
217            hidden_doc("guide/secret", "Secret"),
218        ];
219        let tree = build_tree(&docs);
220        let children = match &tree[0] {
221            TreeNode::Dir { children, .. } => children,
222            other => panic!("expected dir, got {other:?}"),
223        };
224        assert_eq!(children.len(), 1);
225        assert!(matches!(&children[0], TreeNode::Doc { slug, .. } if slug == "guide/intro"));
226    }
227
228    #[test]
229    fn directory_emptied_by_hidden_children_is_pruned() {
230        // Every page under `releases/` is hidden and there's no folder note, so the
231        // whole `releases` group vanishes — while a sibling top-level doc remains.
232        let docs = vec![
233            hidden_doc("releases/v0-1-0", "0.1.0"),
234            hidden_doc("releases/v0-2-0", "0.2.0"),
235            doc("releases", "Releases"), // the `.base` page — a loose top-level doc
236        ];
237        let tree = build_tree(&docs);
238        // No `releases` Dir node; only the loose `releases` Doc (the base page).
239        assert_eq!(tree.len(), 1);
240        assert!(matches!(&tree[0], TreeNode::Doc { slug, .. } if slug == "releases"));
241    }
242
243    #[test]
244    fn hidden_folder_note_hides_whole_subtree() {
245        // `sidebar: false` on a directory's `index.md` hides the entire subtree,
246        // even pages that are not themselves flagged.
247        let docs = vec![
248            hidden_doc("archive/index", "Archive"),
249            doc("archive/old", "Old"),
250            doc("guide/intro", "Intro"),
251        ];
252        let tree = build_tree(&docs);
253        assert_eq!(tree.len(), 1);
254        assert!(matches!(&tree[0], TreeNode::Dir { name, .. } if name == "guide"));
255    }
256
257    #[test]
258    fn root_index_stays_an_ordinary_doc() {
259        // A top-level `index.md` is the site home, not a folder note — it must
260        // remain a normal doc node (depth 0).
261        let docs = vec![doc("index", "Home"), doc("guide/intro", "Intro")];
262        let tree = build_tree(&docs);
263        assert!(tree
264            .iter()
265            .any(|n| matches!(n, TreeNode::Doc { slug, .. } if slug == "index")));
266    }
267}