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)>, note: Option<String>,
12 hidden: bool,
14}
15
16fn insert(node: &mut Builder, parts: &[&str], slug: &str, title: &str, depth: usize, hidden: bool) {
17 match parts {
18 [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 [_] 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 for (name, child) in builder.dirs {
53 if child.hidden {
55 continue;
56 }
57 let slug = child.note.clone();
58 let children = to_nodes(child);
59 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
76pub 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 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 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 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 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 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 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 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 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 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"), ];
237 let tree = build_tree(&docs);
238 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 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 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}