Skip to main content

mcp_methods/
list_dir.rs

1//! Tree-formatted directory listing with optional per-entry annotation.
2//!
3//! Pure Rust. The Python wrapper in `mcp-methods-py` bridges a Python
4//! callable into the [`ListDirOpts::annotate`] slot.
5
6use ignore::overrides::OverrideBuilder;
7use ignore::WalkBuilder;
8use std::collections::{BTreeMap, HashMap, HashSet};
9use std::path::{Path, PathBuf};
10use std::sync::LazyLock;
11
12const DEFAULT_SKIP_DIRS: &[&str] = &[
13    ".git",
14    "node_modules",
15    "__pycache__",
16    ".tox",
17    ".mypy_cache",
18    ".pytest_cache",
19    "dist",
20    "build",
21    ".eggs",
22    "venv",
23    ".venv",
24    "target",
25    ".cargo",
26    ".ruff_cache",
27];
28
29static DEFAULT_SKIP_SET: LazyLock<HashSet<&'static str>> =
30    LazyLock::new(|| DEFAULT_SKIP_DIRS.iter().copied().collect());
31
32/// An entry in the directory tree.
33struct Entry {
34    name: String,
35    full_path: PathBuf,
36    is_dir: bool,
37    size: u64,
38    children: BTreeMap<String, Entry>,
39}
40
41impl Entry {
42    fn new_dir(name: String, full_path: PathBuf) -> Self {
43        Self {
44            name,
45            full_path,
46            is_dir: true,
47            size: 0,
48            children: BTreeMap::new(),
49        }
50    }
51
52    fn new_file(name: String, full_path: PathBuf, size: u64) -> Self {
53        Self {
54            name,
55            full_path,
56            is_dir: false,
57            size,
58            children: BTreeMap::new(),
59        }
60    }
61}
62
63/// Optional knobs for [`list_dir`].
64#[derive(Default)]
65pub struct ListDirOpts<'a> {
66    /// Recursion depth (default 1 = flat ls; 2+ = nested tree).
67    pub depth: Option<usize>,
68    pub glob: Option<&'a str>,
69    pub dirs_only: bool,
70    pub relative_to: Option<&'a str>,
71    /// Respect `.gitignore` / `.git/info/exclude` rules (default `true`).
72    pub respect_gitignore: bool,
73    /// Custom directory names to skip. When `None`, a built-in list
74    /// (`.git`, `node_modules`, `target`, …) is used.
75    pub skip_dirs: Option<&'a [String]>,
76    pub include_size: bool,
77    /// Per-entry annotation callback. Receives the entry's path
78    /// relative to `relative_to` (or the root if unset) and returns
79    /// `Some(annotation)` to append after the entry, or `None` to skip.
80    /// Used by the Python wrapper to bridge a Python callable.
81    pub annotate: Option<&'a AnnotateFn>,
82}
83
84/// Closure signature for [`ListDirOpts::annotate`].
85pub type AnnotateFn = dyn Fn(&str) -> Option<String>;
86
87/// List directory contents with tree-formatted output.
88pub fn list_dir(path: &str, opts: &ListDirOpts) -> Result<String, String> {
89    let root = PathBuf::from(path)
90        .canonicalize()
91        .map_err(|e| format!("Cannot resolve '{}': {}", path, e))?;
92    if !root.is_dir() {
93        return Ok(format!("Error: '{}' is not a directory.", path));
94    }
95
96    let depth = opts.depth.unwrap_or(1);
97    let respect_gitignore = opts.respect_gitignore;
98    let glob = opts.glob;
99    let relative_to = opts.relative_to;
100    let dirs_only = opts.dirs_only;
101    let include_size = opts.include_size;
102
103    let custom_skip: Option<HashSet<String>> =
104        opts.skip_dirs.map(|dirs| dirs.iter().cloned().collect());
105
106    let mut tree = Entry::new_dir(dir_display_name(&root, relative_to), root.clone());
107    let mut leaf_counts: BTreeMap<PathBuf, (usize, usize)> = BTreeMap::new();
108
109    {
110        let mut builder = WalkBuilder::new(&root);
111        builder.max_depth(Some(depth + 1));
112        builder.hidden(false);
113        builder.git_ignore(respect_gitignore);
114        builder.git_global(respect_gitignore);
115        builder.git_exclude(respect_gitignore);
116
117        if let Some(glob_pat) = glob {
118            let mut overrides = OverrideBuilder::new(&root);
119            overrides.add("*/").map_err(|e| format!("{}", e))?;
120            overrides.add(glob_pat).map_err(|e| format!("{}", e))?;
121            let built = overrides.build().map_err(|e| format!("{}", e))?;
122            builder.overrides(built);
123        }
124
125        builder.filter_entry(move |entry| {
126            if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
127                if let Some(name) = entry.file_name().to_str() {
128                    return match &custom_skip {
129                        Some(set) => !set.contains(name),
130                        None => !DEFAULT_SKIP_SET.contains(name),
131                    };
132                }
133            }
134            true
135        });
136
137        for entry in builder.build().flatten() {
138            let entry_path = entry.path().to_path_buf();
139            if entry_path == root {
140                continue;
141            }
142            let rel = match entry_path.strip_prefix(&root) {
143                Ok(r) => r,
144                Err(_) => continue,
145            };
146            let comp_count = rel.components().count();
147            let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
148
149            if comp_count <= depth {
150                // Build tree entry
151                if dirs_only && !is_dir {
152                    continue;
153                }
154                let components: Vec<String> = rel
155                    .components()
156                    .map(|c| c.as_os_str().to_string_lossy().to_string())
157                    .collect();
158                let size = if include_size && !is_dir {
159                    entry.metadata().map(|m| m.len()).unwrap_or(0)
160                } else {
161                    0
162                };
163                insert_entry(&mut tree, &components, is_dir, size, &entry_path);
164            } else {
165                // depth+1: count for leaf directory summaries
166                if let Some(parent) = entry_path.parent() {
167                    let counter = leaf_counts.entry(parent.to_path_buf()).or_insert((0, 0));
168                    if is_dir {
169                        counter.0 += 1;
170                    } else {
171                        counter.1 += 1;
172                    }
173                }
174            }
175        }
176    }
177
178    // Prune empty dirs when glob is active
179    if glob.is_some() && !dirs_only {
180        prune_empty_dirs(&mut tree);
181    }
182
183    if tree.children.is_empty() {
184        return Ok(format!("{}/ (empty)", tree.name));
185    }
186
187    // Collect annotations if callback provided
188    let annotations = if let Some(annotate_fn) = opts.annotate {
189        let mut map = HashMap::new();
190        // Use relative_to base (canonicalized) so callback receives paths like
191        // "networkx/algorithms/bridges.py" instead of just "bridges.py".
192        // Falls back to root when relative_to is not set.
193        let base = relative_to
194            .and_then(|r| PathBuf::from(r).canonicalize().ok())
195            .unwrap_or_else(|| root.clone());
196        collect_annotations(&tree, &base, annotate_fn, &mut map);
197        map
198    } else {
199        HashMap::new()
200    };
201
202    let mut output = Vec::new();
203    output.push(format!("{}/", tree.name));
204    render_tree(
205        &tree,
206        "",
207        &mut output,
208        include_size,
209        &leaf_counts,
210        &annotations,
211    );
212    Ok(output.join("\n"))
213}
214
215/// Recursively collect annotations for all entries in the tree.
216fn collect_annotations(
217    entry: &Entry,
218    base: &Path,
219    annotate_fn: &AnnotateFn,
220    map: &mut HashMap<PathBuf, String>,
221) {
222    for child in entry.children.values() {
223        let rel_path = child
224            .full_path
225            .strip_prefix(base)
226            .unwrap_or(&child.full_path)
227            .to_string_lossy()
228            .to_string();
229        if let Some(annotation) = annotate_fn(&rel_path) {
230            map.insert(child.full_path.clone(), annotation);
231        }
232        if child.is_dir && !child.children.is_empty() {
233            collect_annotations(child, base, annotate_fn, map);
234        }
235    }
236}
237
238fn insert_entry(
239    tree: &mut Entry,
240    components: &[String],
241    is_dir: bool,
242    size: u64,
243    full_path: &Path,
244) {
245    let mut node = tree;
246    for (i, comp) in components.iter().enumerate() {
247        if i == components.len() - 1 {
248            node.children.entry(comp.clone()).or_insert_with(|| {
249                if is_dir {
250                    Entry::new_dir(comp.clone(), full_path.to_path_buf())
251                } else {
252                    Entry::new_file(comp.clone(), full_path.to_path_buf(), size)
253                }
254            });
255        } else {
256            // Intermediate directory — construct its path
257            let intermediate_path: PathBuf = full_path
258                .components()
259                .take(full_path.components().count() - (components.len() - 1 - i))
260                .collect();
261            node = node
262                .children
263                .entry(comp.clone())
264                .or_insert_with(|| Entry::new_dir(comp.clone(), intermediate_path));
265        }
266    }
267}
268
269fn prune_empty_dirs(entry: &mut Entry) -> bool {
270    if !entry.is_dir {
271        return true;
272    }
273    entry.children.retain(|_, child| prune_empty_dirs(child));
274    !entry.children.is_empty()
275}
276
277fn render_tree(
278    entry: &Entry,
279    prefix: &str,
280    output: &mut Vec<String>,
281    include_size: bool,
282    leaf_counts: &BTreeMap<PathBuf, (usize, usize)>,
283    annotations: &HashMap<PathBuf, String>,
284) {
285    let len = entry.children.len();
286
287    // Pre-compute max name width for annotation alignment
288    let max_name_width = if !annotations.is_empty() {
289        entry
290            .children
291            .values()
292            .map(|child| {
293                let base = child.name.len() + if child.is_dir { 1 } else { 0 }; // +1 for "/"
294                if include_size && !child.is_dir {
295                    base + 2 + format_size(child.size).len() + 1 // "  (NNN B)"
296                } else {
297                    base
298                }
299            })
300            .max()
301            .unwrap_or(0)
302    } else {
303        0
304    };
305
306    for (i, child) in entry.children.values().enumerate() {
307        let is_last = i == len - 1;
308        let connector = if is_last { "└── " } else { "├── " };
309        let child_prefix = if is_last { "    " } else { "│   " };
310
311        if child.is_dir {
312            let summary = if child.children.is_empty() {
313                leaf_counts
314                    .get(&child.full_path)
315                    .map(|&(d, f)| format_summary(d, f))
316                    .unwrap_or_default()
317            } else {
318                String::new()
319            };
320            let annotation = annotations.get(&child.full_path);
321            if let Some(ann) = annotation {
322                let name_part = format!("{}/", child.name);
323                let pad = if max_name_width > name_part.len() {
324                    max_name_width - name_part.len()
325                } else {
326                    0
327                };
328                output.push(format!(
329                    "{}{}{}{}{}  {}",
330                    prefix,
331                    connector,
332                    name_part,
333                    summary,
334                    " ".repeat(pad),
335                    ann
336                ));
337            } else {
338                output.push(format!("{}{}{}/{}", prefix, connector, child.name, summary));
339            }
340            if !child.children.is_empty() {
341                render_tree(
342                    child,
343                    &format!("{}{}", prefix, child_prefix),
344                    output,
345                    include_size,
346                    leaf_counts,
347                    annotations,
348                );
349            }
350        } else {
351            let size_str = if include_size {
352                format!("  ({})", format_size(child.size))
353            } else {
354                String::new()
355            };
356            let annotation = annotations.get(&child.full_path);
357            if let Some(ann) = annotation {
358                let name_part = format!("{}{}", child.name, size_str);
359                let pad = if max_name_width > name_part.len() {
360                    max_name_width - name_part.len()
361                } else {
362                    0
363                };
364                output.push(format!(
365                    "{}{}{}{}  {}",
366                    prefix,
367                    connector,
368                    name_part,
369                    " ".repeat(pad),
370                    ann
371                ));
372            } else {
373                output.push(format!("{}{}{}{}", prefix, connector, child.name, size_str));
374            }
375        }
376    }
377}
378
379fn format_summary(dirs: usize, files: usize) -> String {
380    match (dirs, files) {
381        (0, 0) => String::new(),
382        (0, f) => format!("           [{} file{}]", f, if f == 1 { "" } else { "s" }),
383        (d, 0) => format!("           [{} dir{}]", d, if d == 1 { "" } else { "s" }),
384        (d, f) => format!(
385            "           [{} dir{}, {} file{}]",
386            d,
387            if d == 1 { "" } else { "s" },
388            f,
389            if f == 1 { "" } else { "s" }
390        ),
391    }
392}
393
394fn format_size(bytes: u64) -> String {
395    if bytes < 1024 {
396        format!("{} B", bytes)
397    } else if bytes < 1024 * 1024 {
398        format!("{:.1} KB", bytes as f64 / 1024.0)
399    } else if bytes < 1024 * 1024 * 1024 {
400        format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
401    } else {
402        format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
403    }
404}
405
406fn dir_display_name(path: &Path, relative_to: Option<&str>) -> String {
407    if let Some(base) = relative_to {
408        let base_path = PathBuf::from(base);
409        if let Ok(rel) = path.strip_prefix(&base_path) {
410            let s = rel.to_string_lossy().to_string();
411            if s.is_empty() {
412                return path
413                    .file_name()
414                    .map(|n| n.to_string_lossy().to_string())
415                    .unwrap_or_else(|| ".".to_string());
416            }
417            return s;
418        }
419    }
420    path.file_name()
421        .map(|n| n.to_string_lossy().to_string())
422        .unwrap_or_else(|| ".".to_string())
423}