Skip to main content

tokmd_export_tree/
lib.rs

1//! Deterministic tree renderers from `ExportData`.
2
3#![forbid(unsafe_code)]
4
5use std::collections::BTreeMap;
6
7use tokmd_types::{ExportData, FileKind, FileRow};
8
9#[derive(Default)]
10struct AnalysisNode {
11    children: BTreeMap<String, AnalysisNode>,
12    lines: usize,
13    tokens: usize,
14    is_file: bool,
15}
16
17fn insert_analysis(node: &mut AnalysisNode, parts: &[&str], lines: usize, tokens: usize) {
18    node.lines += lines;
19    node.tokens += tokens;
20    if let Some((head, tail)) = parts.split_first() {
21        let child = node.children.entry((*head).to_string()).or_default();
22        insert_analysis(child, tail, lines, tokens);
23    } else {
24        node.is_file = true;
25    }
26}
27
28fn render_analysis(node: &AnalysisNode, name: &str, indent: &str, out: &mut String) {
29    if !name.is_empty() {
30        out.push_str(&format!(
31            "{}{} (lines: {}, tokens: {})\n",
32            indent, name, node.lines, node.tokens
33        ));
34    }
35    let next_indent = if name.is_empty() {
36        indent.to_string()
37    } else {
38        format!("{indent}  ")
39    };
40    for (child_name, child) in &node.children {
41        render_analysis(child, child_name, &next_indent, out);
42    }
43}
44
45/// Render the analysis tree used by `analysis.tree`.
46///
47/// Behavior:
48/// - Includes only `FileKind::Parent` rows.
49/// - Includes file leaves.
50/// - Emits `(lines, tokens)` for each node.
51/// - Orders siblings lexicographically for deterministic output.
52#[must_use]
53pub fn render_analysis_tree(export: &ExportData) -> String {
54    let mut root = AnalysisNode::default();
55    for row in export.rows.iter().filter(|r| r.kind == FileKind::Parent) {
56        let parts: Vec<&str> = row.path.split('/').filter(|seg| !seg.is_empty()).collect();
57        insert_analysis(&mut root, &parts, row.lines, row.tokens);
58    }
59
60    let mut out = String::new();
61    render_analysis(&root, "", "", &mut out);
62    out
63}
64
65#[derive(Default)]
66struct HandoffNode {
67    children: BTreeMap<String, HandoffNode>,
68    files: usize,
69    lines: usize,
70    tokens: usize,
71}
72
73fn insert_handoff(node: &mut HandoffNode, parts: &[&str], lines: usize, tokens: usize) {
74    node.files += 1;
75    node.lines += lines;
76    node.tokens += tokens;
77    if let Some((head, tail)) = parts.split_first()
78        && !tail.is_empty()
79    {
80        let child = node.children.entry((*head).to_string()).or_default();
81        insert_handoff(child, tail, lines, tokens);
82    }
83}
84
85fn render_handoff(
86    node: &HandoffNode,
87    name: &str,
88    indent: &str,
89    depth: usize,
90    max_depth: usize,
91    out: &mut String,
92) {
93    let display = if name.is_empty() {
94        "".to_string()
95    } else if name == "(root)" {
96        name.to_string()
97    } else {
98        format!("{name}/")
99    };
100
101    if !display.is_empty() {
102        out.push_str(&format!(
103            "{}{} (files: {}, lines: {}, tokens: {})\n",
104            indent, display, node.files, node.lines, node.tokens
105        ));
106    }
107
108    if depth >= max_depth {
109        return;
110    }
111
112    let next_indent = format!("{indent}  ");
113    for (child_name, child) in &node.children {
114        render_handoff(child, child_name, &next_indent, depth + 1, max_depth, out);
115    }
116}
117
118/// Render the handoff intelligence tree.
119///
120/// Behavior:
121/// - Includes only `FileKind::Parent` rows.
122/// - Includes root line and directory nodes only (no file leaves).
123/// - Emits `(files, lines, tokens)` for each node.
124/// - Stops descending at `max_depth`.
125/// - Orders siblings lexicographically for deterministic output.
126#[must_use]
127pub fn render_handoff_tree(export: &ExportData, max_depth: usize) -> String {
128    let parents: Vec<&FileRow> = export
129        .rows
130        .iter()
131        .filter(|r| r.kind == FileKind::Parent)
132        .collect();
133    if parents.is_empty() {
134        return String::new();
135    }
136
137    let mut root = HandoffNode::default();
138    for row in parents {
139        let parts: Vec<&str> = row.path.split('/').filter(|seg| !seg.is_empty()).collect();
140        insert_handoff(&mut root, &parts, row.lines, row.tokens);
141    }
142
143    let mut out = String::new();
144    render_handoff(&root, "(root)", "", 0, max_depth, &mut out);
145    out
146}
147
148#[cfg(test)]
149mod tests {
150    use tokmd_types::ChildIncludeMode;
151
152    use super::*;
153
154    fn row(path: &str, kind: FileKind, lines: usize, tokens: usize) -> FileRow {
155        FileRow {
156            path: path.to_string(),
157            module: "src".to_string(),
158            lang: "Rust".to_string(),
159            kind,
160            code: lines,
161            comments: 0,
162            blanks: 0,
163            lines,
164            bytes: lines * 10,
165            tokens,
166        }
167    }
168
169    fn export(rows: Vec<FileRow>) -> ExportData {
170        ExportData {
171            rows,
172            module_roots: vec!["crates".to_string()],
173            module_depth: 2,
174            children: ChildIncludeMode::ParentsOnly,
175        }
176    }
177
178    #[test]
179    fn analysis_tree_empty_export_returns_empty() {
180        let out = render_analysis_tree(&export(vec![]));
181        assert!(out.is_empty());
182    }
183
184    #[test]
185    fn analysis_tree_includes_file_leaves() {
186        let out = render_analysis_tree(&export(vec![row("src/main.rs", FileKind::Parent, 12, 24)]));
187        assert!(out.contains("src (lines: 12, tokens: 24)"));
188        assert!(out.contains("main.rs (lines: 12, tokens: 24)"));
189    }
190
191    #[test]
192    fn analysis_tree_ignores_child_rows() {
193        let out = render_analysis_tree(&export(vec![
194            row("src/main.rs", FileKind::Parent, 12, 24),
195            row("src/main.rs::embedded", FileKind::Child, 30, 90),
196        ]));
197        assert!(out.contains("main.rs (lines: 12, tokens: 24)"));
198        assert!(!out.contains("embedded"));
199    }
200
201    #[test]
202    fn handoff_tree_empty_export_returns_empty() {
203        let out = render_handoff_tree(&export(vec![]), 3);
204        assert!(out.is_empty());
205    }
206
207    #[test]
208    fn handoff_tree_depth_limit_and_no_file_leaves() {
209        let out = render_handoff_tree(
210            &export(vec![row("a/b/c/file.rs", FileKind::Parent, 10, 20)]),
211            1,
212        );
213        assert!(out.contains("(root) (files: 1, lines: 10, tokens: 20)"));
214        assert!(out.contains("a/ (files: 1, lines: 10, tokens: 20)"));
215        assert!(!out.contains("b/"));
216        assert!(!out.contains("file.rs"));
217    }
218}