agentic_navigation_guide/
dumper.rs1use crate::errors::Result;
4use globset::{Glob, GlobSet, GlobSetBuilder};
5use std::path::{Path, PathBuf};
6use walkdir::{DirEntry, WalkDir};
7
8pub struct Dumper {
10 root_path: PathBuf,
12 max_depth: Option<usize>,
14 exclude_globs: Option<GlobSet>,
16 indent_size: usize,
18}
19
20impl Dumper {
21 pub fn new(root_path: &Path) -> Self {
23 Self {
24 root_path: root_path.to_path_buf(),
25 max_depth: None,
26 exclude_globs: None,
27 indent_size: 2,
28 }
29 }
30
31 pub fn with_max_depth(mut self, max_depth: Option<usize>) -> Self {
33 self.max_depth = max_depth;
34 self
35 }
36
37 pub fn with_exclude_patterns(mut self, patterns: &[String]) -> Result<Self> {
39 if patterns.is_empty() {
40 self.exclude_globs = None;
41 } else {
42 let mut builder = GlobSetBuilder::new();
43 for pattern in patterns {
44 builder.add(Glob::new(pattern)?);
45 }
46 self.exclude_globs = Some(builder.build()?);
47 }
48 Ok(self)
49 }
50
51 pub fn with_indent_size(mut self, indent_size: usize) -> Self {
53 self.indent_size = indent_size;
54 self
55 }
56
57 pub fn dump(&self) -> Result<String> {
59 let mut output = String::new();
60
61 let entries = self.collect_entries()?;
63
64 let tree = self.build_tree(entries);
66
67 self.format_tree(&tree, &mut output, 0);
69
70 Ok(output)
71 }
72
73 pub fn dump_with_wrapper(&self) -> Result<String> {
75 let content = self.dump()?;
76 Ok(format!(
77 "<agentic-navigation-guide>\n{content}</agentic-navigation-guide>"
78 ))
79 }
80
81 fn collect_entries(&self) -> Result<Vec<DirEntry>> {
83 let mut walker = WalkDir::new(&self.root_path)
84 .min_depth(1) .sort_by_file_name();
86
87 if let Some(max_depth) = self.max_depth {
88 walker = walker.max_depth(max_depth + 1); }
90
91 let exclude_globs = self.exclude_globs.clone();
92 let root_path = self.root_path.clone();
93
94 let walker = walker.into_iter().filter_entry(move |entry| {
95 if let Some(ref globs) = exclude_globs {
97 let path = entry.path();
98 if let Ok(relative_path) = path.strip_prefix(&root_path) {
99 if globs.is_match(relative_path) {
101 return false;
102 }
103
104 let mut current_path = PathBuf::new();
107 for component in relative_path.components() {
108 current_path.push(component);
109 if globs.is_match(¤t_path) {
110 return false;
111 }
112 }
113 }
114 }
115 true
116 });
117
118 let mut entries = Vec::new();
119
120 for entry in walker {
121 let entry = entry?;
122 entries.push(entry);
123 }
124
125 Ok(entries)
126 }
127
128 fn build_tree(&self, entries: Vec<DirEntry>) -> TreeNode {
130 let mut root = TreeNode {
131 name: String::new(),
132 is_dir: true,
133 children: Vec::new(),
134 };
135
136 for entry in entries {
137 let path = entry.path();
138 let relative_path = path
139 .strip_prefix(&self.root_path)
140 .unwrap_or(path)
141 .to_path_buf();
142
143 self.insert_into_tree(&mut root, &relative_path, entry.file_type().is_dir());
144 }
145
146 root
147 }
148
149 fn insert_into_tree(&self, node: &mut TreeNode, path: &Path, is_dir: bool) {
151 let components: Vec<_> = path.components().collect();
152
153 if components.is_empty() {
154 return;
155 }
156
157 if components.len() == 1 {
158 let name = path.file_name().unwrap().to_string_lossy().to_string();
160 node.children.push(TreeNode {
161 name,
162 is_dir,
163 children: Vec::new(),
164 });
165 } else {
166 let first = components[0].as_os_str().to_string_lossy().to_string();
168 let rest = components[1..].iter().collect::<PathBuf>();
169
170 let child = if let Some(existing) = node
171 .children
172 .iter_mut()
173 .find(|c| c.name == first && c.is_dir)
174 {
175 existing
176 } else {
177 node.children.push(TreeNode {
178 name: first.clone(),
179 is_dir: true,
180 children: Vec::new(),
181 });
182 node.children.last_mut().unwrap()
183 };
184
185 self.insert_into_tree(child, &rest, is_dir);
186 }
187 }
188
189 fn format_tree(&self, node: &TreeNode, output: &mut String, depth: usize) {
191 for child in &node.children {
192 let indent = " ".repeat(depth * self.indent_size);
193 let name = if child.is_dir {
194 format!("{}/", child.name)
195 } else {
196 child.name.clone()
197 };
198
199 output.push_str(&format!("{indent}- {name}\n"));
200
201 if !child.children.is_empty() {
202 self.format_tree(child, output, depth + 1);
203 }
204 }
205 }
206}
207
208struct TreeNode {
210 name: String,
211 is_dir: bool,
212 children: Vec<TreeNode>,
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218 use std::fs;
219 use tempfile::TempDir;
220
221 #[test]
222 fn test_dump_simple_directory() {
223 let temp_dir = TempDir::new().unwrap();
224 let root = temp_dir.path();
225
226 fs::create_dir(root.join("src")).unwrap();
228 fs::write(root.join("src/main.rs"), "").unwrap();
229 fs::write(root.join("Cargo.toml"), "").unwrap();
230
231 let dumper = Dumper::new(root);
232 let output = dumper.dump().unwrap();
233
234 assert!(output.contains("- src/"));
235 assert!(output.contains(" - main.rs"));
236 assert!(output.contains("- Cargo.toml"));
237 }
238
239 #[test]
240 fn test_dump_with_max_depth() {
241 let temp_dir = TempDir::new().unwrap();
242 let root = temp_dir.path();
243
244 fs::create_dir_all(root.join("a/b/c")).unwrap();
246 fs::write(root.join("a/b/c/deep.txt"), "").unwrap();
247
248 let dumper = Dumper::new(root).with_max_depth(Some(2));
249 let output = dumper.dump().unwrap();
250
251 assert!(output.contains("- a/"));
252 assert!(output.contains(" - b/"));
253 assert!(!output.contains("deep.txt"));
254 }
255}