Intermediate Projects
Recursive Directory Scanner
A recursive directory scanner (modeled after disk-usage analyzers like du, ncdu, and dust) walks a directory hierarchy bottom-up to aggregate total file counts, subdirectory counts, byte usage, and per-extension disk breakdowns, rendering an indented ASCII tree of the largest directories.
Bottom-Up Directory Tree Aggregation (`DirNode`):
project/ (Total: 4,210 B, 3 files)
├── Cargo.toml (110 B)
└── src/ (Total: 4,100 B, 2 files)
├── lib.rs (1,000 B)
└── engine.rs (3,100 B)
Tree Node Design and Symlink Safety
| Struct / Method | Responsibility |
|---|---|
DirSummary |
Recursive tree node storing name, total_bytes, file_count, dir_count, and sorted children: Vec<DirSummary>. |
ExtensionBreakdown |
Aggregates (file_count, total_bytes) per file extension across the entire scanned tree. |
scan_tree(path) |
Uses fs::symlink_metadata (rather than fs::metadata) so symbolic links are never followed into infinite recursive loops! |
Complete Runnable Implementation
use std::cmp::Reverse;
use std::collections::HashMap;
use std::env;
use std::fs;
use std::io;
use std::path::Path;
#[derive(Debug, Clone)]
pub struct DirSummary {
pub name: String,
pub total_bytes: u64,
pub file_count: usize,
pub dir_count: usize,
pub children: Vec<DirSummary>,
}
impl DirSummary {
pub fn scan(path: impl AsRef<Path>, ext_stats: &mut HashMap<String, (usize, u64)>) -> io::Result<Self> {
let path = path.as_ref();
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string());
let mut total_bytes = 0u64;
let mut file_count = 0usize;
let mut dir_count = 0usize;
let mut children = Vec::new();
for entry_res in fs::read_dir(path)? {
let entry = entry_res?;
let ftype = entry.file_type()?;
if ftype.is_symlink() {
continue; // Do not follow symlinks
}
let child_path = entry.path();
if ftype.is_dir() {
dir_count += 1;
let sub = Self::scan(&child_path, ext_stats)?;
total_bytes += sub.total_bytes;
file_count += sub.file_count;
dir_count += sub.dir_count;
children.push(sub);
} else if ftype.is_file() {
let len = entry.metadata()?.len();
total_bytes += len;
file_count += 1;
let ext = child_path
.extension()
.map(|e| e.to_string_lossy().to_ascii_lowercase())
.unwrap_or_else(|| "<no_ext>".to_string());
let stat = ext_stats.entry(ext).or_insert((0, 0));
stat.0 += 1;
stat.1 += len;
}
}
// Sort child directories largest-first for disk-usage visualization:
children.sort_unstable_by_key(|c| (Reverse(c.total_bytes), c.name.clone()));
Ok(Self {
name,
total_bytes,
file_count,
dir_count,
children,
})
}
pub fn render_tree(&self, indent: usize, out: &mut String) {
let pad = " ".repeat(indent);
out.push_str(&format!(
"{pad}{}/ ({} B across {} files, {} dirs)\n",
self.name, self.total_bytes, self.file_count, self.dir_count
));
for child in &self.children {
child.render_tree(indent + 1, out);
}
}
}
fn main() -> io::Result<()> {
let root = env::temp_dir().join("rust_ch67_scanner_demo");
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join("src"))?;
fs::create_dir_all(root.join("docs"))?;
fs::write(root.join("Cargo.toml"), vec![b'a'; 100])?;
fs::write(root.join("src/main.rs"), vec![b'b'; 500])?;
fs::write(root.join("src/lib.rs"), vec![b'c'; 300])?;
fs::write(root.join("docs/guide.md"), vec![b'd'; 200])?;
let mut ext_stats = HashMap::new();
let summary = DirSummary::scan(&root, &mut ext_stats)?;
assert_eq!(summary.total_bytes, 1100);
assert_eq!(summary.file_count, 4);
assert_eq!(summary.dir_count, 2);
assert_eq!(ext_stats.get("rs"), Some(&(2, 800)));
let mut tree_output = String::new();
summary.render_tree(0, &mut tree_output);
print!("{tree_output}");
fs::remove_dir_all(&root)?;
Ok(())
}
rust_ch67_scanner_demo/ (1100 B across 4 files, 2 dirs)
src/ (800 B across 2 files, 0 dirs)
docs/ (200 B across 1 files, 0 dirs)