Skip to main content

wowlab_docgen_cli/infra/tree/
mod.rs

1// #t(file: rust_alloc_in_loop) tree building uses format! per entry by design
2
3use wowlab_common::markdown;
4use wowlab_fs::path::Path;
5
6use crate::WorkspaceIndex;
7
8mod doc;
9
10use doc::extract_file_doc;
11
12const MAX_DEPTH: usize = 5;
13
14/// Build a markdown code block tree for `paths`, auto-discovering dirs when empty.
15#[must_use]
16pub fn build(
17    workspace: &WorkspaceIndex,
18    dir: &Path,
19    paths: &[String],
20    max_depth: Option<u32>,
21) -> String {
22    let depth_limit = max_depth.map_or(MAX_DEPTH, |depth| {
23        usize::try_from(depth).unwrap_or(usize::MAX)
24    });
25    let mut lines = Vec::new();
26
27    let owned: Vec<String>;
28    let effective: &[String] = if paths.is_empty() {
29        owned = discover_source_dirs(workspace, dir);
30
31        &owned
32    } else {
33        paths
34    };
35
36    for path in effective {
37        let target = dir.join(path);
38
39        if workspace.is_dir(&target) {
40            lines.push(format!("{path}/"));
41            collect_entries(workspace, &target, &mut lines, 1, depth_limit);
42        }
43    }
44
45    if lines.is_empty() {
46        return String::new();
47    }
48
49    markdown::code_block("", lines.join("\n"))
50}
51
52fn is_dir_empty(workspace: &WorkspaceIndex, dir: &Path) -> bool {
53    !workspace.has_files_below(dir)
54}
55
56fn discover_source_dirs(workspace: &WorkspaceIndex, dir: &Path) -> Vec<String> {
57    let mut names: Vec<String> = workspace
58        .child_dirs(dir)
59        .filter_map(|path| path.file_name()?.to_str().map(str::to_owned))
60        .collect();
61
62    names.sort();
63
64    names
65}
66
67// #t(fn: rust_recursive_fn) bounded by max_depth (default 5), CLI-only (never WASM)
68fn collect_entries(
69    workspace: &WorkspaceIndex,
70    root: &Path,
71    lines: &mut Vec<String>,
72    depth: usize,
73    max_depth: usize,
74) {
75    if depth > max_depth {
76        return;
77    }
78
79    let mut dirs = Vec::new();
80    let mut files = Vec::new();
81
82    for path in workspace.child_dirs(root) {
83        let Some(os_name) = path.file_name() else {
84            continue;
85        };
86        let name = os_name.to_string_lossy();
87
88        dirs.push((name.into_owned(), path.to_path_buf()));
89    }
90
91    for path in workspace.files_in(root) {
92        let Some(os_name) = path.file_name() else {
93            continue;
94        };
95
96        files.push((os_name.to_string_lossy().into_owned(), path.to_path_buf()));
97    }
98
99    dirs.sort_by(|a, b| a.0.cmp(&b.0));
100    files.sort_by(|a, b| a.0.cmp(&b.0));
101
102    let indent = "  ".repeat(depth);
103
104    for (name, path) in &dirs {
105        if is_dir_empty(workspace, path) {
106            continue;
107        }
108
109        let mut child_lines = Vec::new();
110
111        collect_entries(workspace, path, &mut child_lines, depth + 1, max_depth);
112
113        let desc = extract_file_doc(workspace, &path.join("mod.rs"))
114            .or_else(|| extract_file_doc(workspace, &path.join("lib.rs")));
115
116        match desc {
117            Some(d) => lines.push(format!("{indent}{name}/ — {d}")),
118            None => lines.push(format!("{indent}{name}/")),
119        }
120
121        lines.extend(child_lines);
122    }
123
124    for (name, path) in &files {
125        let is_index = name == "mod.rs" || name == "lib.rs";
126
127        if is_index {
128            lines.push(format!("{indent}{name}"));
129        } else {
130            match extract_file_doc(workspace, path) {
131                Some(d) => lines.push(format!("{indent}{name} — {d}")),
132                None => lines.push(format!("{indent}{name}")),
133            }
134        }
135    }
136}
137
138#[cfg(test)]
139mod tests;