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}
13
14fn insert(node: &mut Builder, parts: &[&str], slug: &str, title: &str, depth: usize) {
15 match parts {
16 [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 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
56pub 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 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 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 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 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 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 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 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}