Skip to main content

lore_engine/engine/
folder_tree.rs

1//! Folder tree builder for the sidebar.
2//!
3//! Takes a flat list of pages and produces a flattened depth-annotated tree
4//! suitable for rendering in a single `ListView`. Pure engine code — no Qt.
5
6use serde::Serialize;
7use std::collections::{BTreeMap, BTreeSet, HashSet};
8
9use crate::types::PageListEntry;
10
11/// A single row in the flattened tree output.
12#[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
23/// Build a flattened tree from a list of pages.
24///
25/// `expanded` is the set of folder paths currently expanded.
26/// Returns rows in display order: at each level, folders first (alpha-sorted),
27/// then pages (alpha-sorted by title). Only emits children of expanded folders.
28pub fn build_tree(pages: &[PageListEntry], expanded: &HashSet<String>) -> Vec<TreeRow> {
29    // Group pages by their folder path.
30    // Key = folder path (empty string for root), Value = list of pages in that folder.
31    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 this folder and all intermediate parents
41                    register_folder_hierarchy(&folder, &mut all_folder_paths);
42                    folder
43                }
44                None => String::new(), // root-level file
45            }
46        } else {
47            String::new() // placeholders go to root
48        };
49
50        folder_pages.entry(folder).or_default().push(page);
51    }
52
53    // Sort pages within each folder by title (case-insensitive)
54    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    // Build the tree depth-first
64    let mut result = Vec::new();
65    emit_level(
66        "", // parent path (empty = root level)
67        0,
68        &all_folder_paths,
69        &folder_pages,
70        expanded,
71        &mut result,
72    );
73
74    result
75}
76
77/// Collect all unique folder paths from a page list.
78/// Used to populate `expanded_folders` for "expand all" behavior.
79pub 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
93/// Register a folder and all its ancestors into a set.
94/// Works with both `BTreeSet` and `HashSet` via the `InsertSet` trait.
95fn 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
105/// Trait for sets that support insert + contains (`BTreeSet`, `HashSet`).
106trait 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
121/// Recursively emit rows for a given level of the tree.
122fn 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    // Find child folders at this level
131    let child_folders: Vec<&String> = all_folders
132        .iter()
133        .filter(|f| is_direct_child(f, parent))
134        .collect();
135
136    // Emit folder rows first (already sorted since BTreeSet is ordered)
137    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 expanded, recurse into children
156        if is_expanded {
157            emit_level(folder_path, depth + 1, all_folders, folder_pages, expanded, result);
158        }
159    }
160
161    // Emit page rows at this level
162    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
178/// Check if `folder` is a direct child of `parent`.
179/// E.g., "notes" is a direct child of "" (root), "notes/sub" is a direct child of "notes".
180fn is_direct_child(folder: &str, parent: &str) -> bool {
181    if parent.is_empty() {
182        // Direct child of root: no '/' in the folder path
183        !folder.contains('/')
184    } else {
185        // Must start with "parent/" and have no further '/' after that
186        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(); // notes folder NOT expanded
232        let tree = build_tree(&pages, &expanded);
233
234        // Should show: folder "notes" (collapsed) + "Root Page"
235        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        // Should show: folder "notes" (expanded), "Hello" (depth 1), "Root Page" (depth 0)
253        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        // a (folder, depth 0) → a/b (folder, depth 1) → Deep (depth 2) → Shallow (depth 1)
275        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), // placeholder
293        ];
294        let expanded = HashSet::new();
295        let tree = build_tree(&pages, &expanded);
296
297        // notes (folder), Phantom (placeholder at root)
298        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        // archive (folder), Alpha (page), Zebra (page)
316        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}