agentic_navigation_guide/
dumper.rs

1//! Directory dumping functionality for generating navigation guides
2
3use crate::errors::Result;
4use globset::{Glob, GlobSet, GlobSetBuilder};
5use std::path::{Path, PathBuf};
6use walkdir::{DirEntry, WalkDir};
7
8/// Dumper for creating navigation guides from directory structures
9pub struct Dumper {
10    /// Root path to dump from
11    root_path: PathBuf,
12    /// Maximum depth to traverse
13    max_depth: Option<usize>,
14    /// Glob patterns to exclude
15    exclude_globs: Option<GlobSet>,
16    /// Number of spaces for indentation
17    indent_size: usize,
18}
19
20impl Dumper {
21    /// Create a new dumper for the given root path
22    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    /// Set the maximum depth to traverse
32    pub fn with_max_depth(mut self, max_depth: Option<usize>) -> Self {
33        self.max_depth = max_depth;
34        self
35    }
36
37    /// Set exclude patterns
38    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    /// Set the indent size
52    pub fn with_indent_size(mut self, indent_size: usize) -> Self {
53        self.indent_size = indent_size;
54        self
55    }
56
57    /// Dump the directory structure as a navigation guide
58    pub fn dump(&self) -> Result<String> {
59        let mut output = String::new();
60
61        // Get directory entries
62        let entries = self.collect_entries()?;
63
64        // Build the tree structure
65        let tree = self.build_tree(entries);
66
67        // Format as markdown
68        self.format_tree(&tree, &mut output, 0);
69
70        Ok(output)
71    }
72
73    /// Dump with XML wrapper tags
74    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    /// Collect all directory entries respecting depth and exclusion rules
82    fn collect_entries(&self) -> Result<Vec<DirEntry>> {
83        let mut walker = WalkDir::new(&self.root_path)
84            .min_depth(1) // Skip the root itself
85            .sort_by_file_name();
86
87        if let Some(max_depth) = self.max_depth {
88            walker = walker.max_depth(max_depth + 1); // +1 because we skip root
89        }
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            // Check exclusion patterns
96            if let Some(ref globs) = exclude_globs {
97                let path = entry.path();
98                if let Ok(relative_path) = path.strip_prefix(&root_path) {
99                    // Check the full relative path
100                    if globs.is_match(relative_path) {
101                        return false;
102                    }
103
104                    // For directories, check if any parent component matches
105                    // This prevents descending into excluded directories
106                    let mut current_path = PathBuf::new();
107                    for component in relative_path.components() {
108                        current_path.push(component);
109                        if globs.is_match(&current_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    /// Build a tree structure from flat entries
129    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    /// Insert a path into the tree structure
150    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            // Leaf node
159            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            // Find or create intermediate directory
167            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    /// Format the tree as markdown
190    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
208/// Internal tree node structure
209struct 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        // Create test structure
227        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        // Create nested structure
245        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}