Skip to main content

docgen_diff/
file_tree.rs

1//! Changed-file tree grouping — a faithful port of `file-tree.ts`.
2//!
3//! Files are sorted by path and grouped by their `docs/`-relative path
4//! segments into `Group`/`File` nodes. Group ids are the cumulative
5//! `docs/<…>` path; labels drop the leading `docs` segment for display.
6
7use crate::types::{DocDiffFile, DocDiffFileTreeNode};
8
9/// Group changed files into a nested tree keyed by `docs/`-relative segments.
10pub fn build_file_tree(files: &[DocDiffFile]) -> Vec<DocDiffFileTreeNode> {
11    let mut sorted: Vec<&DocDiffFile> = files.iter().collect();
12    // `localeCompare` ≈ byte ordering for the ASCII paths under test.
13    sorted.sort_by(|a, b| a.path.cmp(&b.path));
14
15    let mut roots: Vec<DocDiffFileTreeNode> = Vec::new();
16
17    for file in sorted {
18        let segments: Vec<&str> = file.path.split('/').filter(|s| !s.is_empty()).collect();
19        let display: &[&str] = if segments.first() == Some(&"docs") {
20            &segments[1..]
21        } else {
22            &segments[..]
23        };
24        let file_name = display.last().copied().unwrap_or(file.path.as_str());
25        let group_segments = if display.is_empty() {
26            &[][..]
27        } else {
28            &display[..display.len() - 1]
29        };
30
31        let mut cursor = &mut roots;
32        let mut id_prefix = String::from("docs");
33
34        for segment in group_segments {
35            id_prefix = format!("{id_prefix}/{segment}");
36            // Find the existing group index, or create one.
37            let pos = cursor.iter().position(
38                |node| matches!(node, DocDiffFileTreeNode::Group { id, .. } if id == &id_prefix),
39            );
40            let idx = match pos {
41                Some(i) => i,
42                None => {
43                    cursor.push(DocDiffFileTreeNode::Group {
44                        id: id_prefix.clone(),
45                        label: (*segment).to_string(),
46                        children: Vec::new(),
47                    });
48                    cursor.len() - 1
49                }
50            };
51            cursor = match &mut cursor[idx] {
52                DocDiffFileTreeNode::Group { children, .. } => children,
53                _ => unreachable!("group node selected by id is always a group"),
54            };
55        }
56
57        cursor.push(DocDiffFileTreeNode::File {
58            id: file.path.clone(),
59            label: file_name.to_string(),
60            path: file.path.clone(),
61            old_path: file.old_path.clone(),
62            status: file.status,
63            added_lines: file.added_lines,
64            removed_lines: file.removed_lines,
65        });
66    }
67
68    roots
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74    use crate::types::DocDiffFileStatus;
75
76    fn file(path: &str) -> DocDiffFile {
77        DocDiffFile {
78            path: path.into(),
79            old_path: None,
80            status: DocDiffFileStatus::Modified,
81            added_lines: 1,
82            removed_lines: 2,
83            hunks: vec![],
84            blocks: None,
85        }
86    }
87
88    #[test]
89    fn groups_changed_files_by_docs_path_segments() {
90        let tree = build_file_tree(&[
91            file("docs/dev/client.svx"),
92            file("docs/game-design/world.md"),
93        ]);
94        assert_eq!(
95            tree,
96            vec![
97                DocDiffFileTreeNode::Group {
98                    id: "docs/dev".into(),
99                    label: "dev".into(),
100                    children: vec![DocDiffFileTreeNode::File {
101                        id: "docs/dev/client.svx".into(),
102                        label: "client.svx".into(),
103                        path: "docs/dev/client.svx".into(),
104                        old_path: None,
105                        status: DocDiffFileStatus::Modified,
106                        added_lines: 1,
107                        removed_lines: 2,
108                    }],
109                },
110                DocDiffFileTreeNode::Group {
111                    id: "docs/game-design".into(),
112                    label: "game-design".into(),
113                    children: vec![DocDiffFileTreeNode::File {
114                        id: "docs/game-design/world.md".into(),
115                        label: "world.md".into(),
116                        path: "docs/game-design/world.md".into(),
117                        old_path: None,
118                        status: DocDiffFileStatus::Modified,
119                        added_lines: 1,
120                        removed_lines: 2,
121                    }],
122                },
123            ]
124        );
125    }
126}