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)