1use serde::Serialize;
7use std::collections::{BTreeMap, BTreeSet, HashSet};
8
9use crate::types::PageListEntry;
10
11#[derive(Debug, Serialize, Clone)]
13pub struct TreeRow {
14 pub slug: String,
15 pub title: String,
16 pub depth: usize,
17 pub is_folder: bool,
18 pub is_placeholder: bool,
19 pub is_expanded: bool,
20 pub file_path: Option<String>,
21}
22
23pub fn build_tree(pages: &[PageListEntry], expanded: &HashSet<String>) -> Vec<TreeRow> {
29 let mut folder_pages: BTreeMap<String, Vec<&PageListEntry>> = BTreeMap::new();
32 let mut all_folder_paths: BTreeSet<String> = BTreeSet::new();
33
34 for page in pages {
35 let folder = if let Some(ref fp) = page.file_path {
36 let fp_normalized = fp.replace('\\', "/");
37 match fp_normalized.rfind('/') {
38 Some(pos) => {
39 let folder = fp_normalized[..pos].to_string();
40 register_folder_hierarchy(&folder, &mut all_folder_paths);
42 folder
43 }
44 None => String::new(), }
46 } else {
47 String::new() };
49
50 folder_pages.entry(folder).or_default().push(page);
51 }
52
53 for pages_in_folder in folder_pages.values_mut() {
55 pages_in_folder.sort_by(|a, b| {
56 a.title
57 .to_lowercase()
58 .cmp(&b.title.to_lowercase())
59 .then_with(|| a.title.cmp(&b.title))
60 });
61 }
62
63 let mut result = Vec::new();
65 emit_level(
66 "", 0,
68 &all_folder_paths,
69 &folder_pages,
70 expanded,
71 &mut result,
72 );
73
74 result
75}
76
77pub fn collect_all_folders(pages: &[PageListEntry]) -> HashSet<String> {
80 let mut folders = HashSet::new();
81 for page in pages {
82 if let Some(ref fp) = page.file_path {
83 let fp_normalized = fp.replace('\\', "/");
84 if let Some(pos) = fp_normalized.rfind('/') {
85 let folder = fp_normalized[..pos].to_string();
86 register_folder_hierarchy(&folder, &mut folders);
87 }
88 }
89 }
90 folders
91}
92
93fn register_folder_hierarchy(folder: &str, set: &mut impl InsertSet) {
96 if folder.is_empty() || set.has(folder) {
97 return;
98 }
99 set.add(folder.to_string());
100 if let Some(pos) = folder.rfind('/') {
101 register_folder_hierarchy(&folder[..pos], set);
102 }
103}
104
105trait InsertSet {
107 fn add(&mut self, value: String);
108 fn has(&self, value: &str) -> bool;
109}
110
111impl InsertSet for BTreeSet<String> {
112 fn add(&mut self, value: String) { self.insert(value); }
113 fn has(&self, value: &str) -> bool { self.contains(value) }
114}
115
116impl InsertSet for HashSet<String> {
117 fn add(&mut self, value: String) { self.insert(value); }
118 fn has(&self, value: &str) -> bool { self.contains(value) }
119}
120
121fn emit_level(
123 parent: &str,
124 depth: usize,
125 all_folders: &BTreeSet<String>,
126 folder_pages: &BTreeMap<String, Vec<&PageListEntry>>,
127 expanded: &HashSet<String>,
128 result: &mut Vec<TreeRow>,
129) {
130 let child_folders: Vec<&String> = all_folders
132 .iter()
133 .filter(|f| is_direct_child(f, parent))
134 .collect();
135
136 for folder_path in &child_folders {
138 let folder_name = match folder_path.rfind('/') {
139 Some(pos) => &folder_path[pos + 1..],
140 None => folder_path.as_str(),
141 };
142
143 let is_expanded = expanded.contains(folder_path.as_str());
144
145 result.push(TreeRow {
146 slug: String::new(),
147 title: folder_name.to_string(),
148 depth,
149 is_folder: true,
150 is_placeholder: false,
151 is_expanded,
152 file_path: Some((*folder_path).clone()),
153 });
154
155 if is_expanded {
157 emit_level(folder_path, depth + 1, all_folders, folder_pages, expanded, result);
158 }
159 }
160
161 let folder_key = parent.to_string();
163 if let Some(pages) = folder_pages.get(&folder_key) {
164 for page in pages {
165 result.push(TreeRow {
166 slug: page.slug.clone(),
167 title: page.title.clone(),
168 depth,
169 is_folder: false,
170 is_placeholder: page.is_placeholder,
171 is_expanded: false,
172 file_path: page.file_path.clone(),
173 });
174 }
175 }
176}
177
178fn is_direct_child(folder: &str, parent: &str) -> bool {
181 if parent.is_empty() {
182 !folder.contains('/')
184 } else {
185 match folder.strip_prefix(parent) {
187 Some(rest) => {
188 rest.starts_with('/') && !rest[1..].contains('/')
189 }
190 None => false,
191 }
192 }
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198 use crate::types::PageListEntry;
199
200 fn entry(slug: &str, title: &str, file_path: Option<&str>) -> PageListEntry {
201 PageListEntry {
202 slug: slug.to_string(),
203 title: title.to_string(),
204 is_placeholder: file_path.is_none(),
205 file_path: file_path.map(|s| s.to_string()),
206 }
207 }
208
209 #[test]
210 fn flat_pages_no_folders() {
211 let pages = vec![
212 entry("bravo", "Bravo", Some("Bravo.md")),
213 entry("alpha", "Alpha", Some("Alpha.md")),
214 ];
215 let expanded = HashSet::new();
216 let tree = build_tree(&pages, &expanded);
217
218 assert_eq!(tree.len(), 2);
219 assert_eq!(tree[0].title, "Alpha");
220 assert_eq!(tree[1].title, "Bravo");
221 assert!(!tree[0].is_folder);
222 assert_eq!(tree[0].depth, 0);
223 }
224
225 #[test]
226 fn folder_collapsed_hides_children() {
227 let pages = vec![
228 entry("notes/hello", "Hello", Some("notes/Hello.md")),
229 entry("root-page", "Root Page", Some("Root Page.md")),
230 ];
231 let expanded = HashSet::new(); let tree = build_tree(&pages, &expanded);
233
234 assert_eq!(tree.len(), 2);
236 assert!(tree[0].is_folder);
237 assert_eq!(tree[0].title, "notes");
238 assert!(!tree[0].is_expanded);
239 assert_eq!(tree[1].title, "Root Page");
240 }
241
242 #[test]
243 fn folder_expanded_shows_children() {
244 let pages = vec![
245 entry("notes/hello", "Hello", Some("notes/Hello.md")),
246 entry("root-page", "Root Page", Some("Root Page.md")),
247 ];
248 let mut expanded = HashSet::new();
249 expanded.insert("notes".to_string());
250 let tree = build_tree(&pages, &expanded);
251
252 assert_eq!(tree.len(), 3);
254 assert!(tree[0].is_folder);
255 assert_eq!(tree[0].title, "notes");
256 assert!(tree[0].is_expanded);
257 assert_eq!(tree[1].title, "Hello");
258 assert_eq!(tree[1].depth, 1);
259 assert_eq!(tree[2].title, "Root Page");
260 assert_eq!(tree[2].depth, 0);
261 }
262
263 #[test]
264 fn nested_folders() {
265 let pages = vec![
266 entry("a/b/deep", "Deep", Some("a/b/Deep.md")),
267 entry("a/shallow", "Shallow", Some("a/Shallow.md")),
268 ];
269 let mut expanded = HashSet::new();
270 expanded.insert("a".to_string());
271 expanded.insert("a/b".to_string());
272 let tree = build_tree(&pages, &expanded);
273
274 assert_eq!(tree.len(), 4);
276 assert_eq!(tree[0].title, "a");
277 assert_eq!(tree[0].depth, 0);
278 assert!(tree[0].is_folder);
279 assert_eq!(tree[1].title, "b");
280 assert_eq!(tree[1].depth, 1);
281 assert!(tree[1].is_folder);
282 assert_eq!(tree[2].title, "Deep");
283 assert_eq!(tree[2].depth, 2);
284 assert_eq!(tree[3].title, "Shallow");
285 assert_eq!(tree[3].depth, 1);
286 }
287
288 #[test]
289 fn placeholders_at_root() {
290 let pages = vec![
291 entry("notes/real", "Real", Some("notes/Real.md")),
292 entry("phantom", "Phantom", None), ];
294 let expanded = HashSet::new();
295 let tree = build_tree(&pages, &expanded);
296
297 assert_eq!(tree.len(), 2);
299 assert!(tree[0].is_folder);
300 assert_eq!(tree[1].title, "Phantom");
301 assert!(tree[1].is_placeholder);
302 assert_eq!(tree[1].depth, 0);
303 }
304
305 #[test]
306 fn folders_sorted_before_pages() {
307 let pages = vec![
308 entry("zebra", "Zebra", Some("Zebra.md")),
309 entry("archive/old", "Old", Some("archive/Old.md")),
310 entry("alpha", "Alpha", Some("Alpha.md")),
311 ];
312 let expanded = HashSet::new();
313 let tree = build_tree(&pages, &expanded);
314
315 assert_eq!(tree.len(), 3);
317 assert!(tree[0].is_folder);
318 assert_eq!(tree[0].title, "archive");
319 assert_eq!(tree[1].title, "Alpha");
320 assert_eq!(tree[2].title, "Zebra");
321 }
322
323 #[test]
324 fn collect_all_folders_works() {
325 let pages = vec![
326 entry("a/b/c", "C", Some("a/b/C.md")),
327 entry("x/y", "Y", Some("x/Y.md")),
328 ];
329 let folders = collect_all_folders(&pages);
330 assert!(folders.contains("a"));
331 assert!(folders.contains("a/b"));
332 assert!(folders.contains("x"));
333 assert_eq!(folders.len(), 3);
334 }
335}