1#![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#[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#[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}