OpenCode 实践课:4 分钟用 Rust 写出一个命令行目录空间分析工具

项目介绍

在日常开发中,我们经常需要查看某个目录下哪些文件或子目录占用了最多的磁盘空间。Linux 有 du 命令,但输出不够直观。今天我们用 OpenCode 在 4 分钟内写一个 Rust 命令行工具 dirscan,它能递归扫描目录并以树形图展示空间占用情况,一目了然。

技术栈:Rust + 标准库(零外部依赖)
最终代码:约 100 行

效果预览:

$ dirscan ./myproject
 12.3 MB ./myproject
  8.1 MB ├── node_modules
  4.2 MB │   ├── react
  3.9 MB │   └── typescript
  2.5 MB ├── src
  1.2 MB │   ├── components
  1.3 MB │   └── utils
  1.7 MB └── dist

准备工作

  • Rust 环境:安装 rustup(https://rustup.rs/)
  • OpenCode:安装 npm install -g @open-code/cli 或访问 opencode.ai

安装完成后,打开终端,进入你的工作目录,启动 OpenCode。

实践过程

第一步:初始化项目

我先让 OpenCode 帮我创建项目脚手架:

> 帮我用 cargo 初始化一个名为 dirscan 的 Rust 项目

OpenCode 立即执行:

cargo new dirscan
     Created binary (application) `dirscan` package

> 展示一下项目目录结构

dirscan/
├── Cargo.toml
└── src/
    └── main.rs

项目骨架眨眼间就建好了。这看起来简单,但实际工作中你不需要记住 cargo new 的参数——OpenCode 充当了"命令翻译器"的角色,你说人话,它执行命令。

第二步:编写核心扫描逻辑

我对 OpenCode 说了需求:

> 在 src/main.rs 中编写代码,实现以下功能:递归遍历指定目录,计算每个条目的磁盘占用大小,并将结果存入 Vec 结构。使用标准库,不引入外部依赖。另外按大小降序排序,用 BTreeMap 组织父子关系以便树形展示。

OpenCode 生成了核心扫描逻辑,我看了下结构很清晰:

fn scan_dir(path: &Path) -> io::Result<Vec<Entry>> {
    let mut entries = Vec::new();
    if !path.is_dir() {
        let size = path.metadata()?.len();
        entries.push(Entry { path: path.to_path_buf(), size });
        return Ok(entries);
    }
    let mut total_size = 0u64;
    for entry in fs::read_dir(path)? {
        let entry = entry?;
        let entry_path = entry.path();
        let sub_entries = scan_dir(&entry_path)?;
        total_size += sub_entries.last().map(|e| e.size).unwrap_or(0);
        entries.extend(sub_entries);
    }
    entries.push(Entry { path: path.to_path_buf(), size: total_size });
    Ok(entries)
}

scan_dir 递归遍历目录,遇到文件就记录其大小,遇到子目录就递归进去、把子目录的总大小累加回来。最后把当前目录本身(带上汇总大小)追加到结果列表尾部——这样 last() 取到的就是当前目录的汇总值。

要点:你不需要说"用 std::fs::read_dir"这种细节,OpenCode 会在 Rust 标准库里找到正确的 API。这才是 AI 编程的核心价值——你关注"要做什么",它帮你"怎么实现"。

第三步:添加树形格式化输出

数据有了,接下来要把它变成好看的树形图。我对 OpenCode 说:

> 添加两个函数:format_size 自动转换 B/KB/MB/GB 单位并右对齐;print_tree 以树形结构打印结果,用 ├──、└──、│ 绘制连接线,子节点按大小降序排列。根目录名称打印在第一行,不缩进。

OpenCode 生成了格式化函数:

fn format_size(bytes: u64) -> String {
    const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
    let mut size = bytes as f64;
    let mut unit_idx = 0;
    while size >= 1024.0 && unit_idx < UNITS.len() - 1 {
        size /= 1024.0;
        unit_idx += 1;
    }
    if unit_idx == 0 {
        format!("{:>6} {}", bytes, UNITS[unit_idx])
    } else {
        format!("{:>6.1} {}", size, UNITS[unit_idx])
    }
}

fn print_tree(entries: &[Entry], root_path: &Path) {
    let mut children: BTreeMap<PathBuf, Vec<&Entry>> = BTreeMap::new();
    let mut root: Option<&Entry> = None;
    for entry in entries {
        if entry.path == root_path {
            root = Some(entry);
        }
        if let Some(parent) = entry.path.parent() {
            children.entry(parent.to_path_buf()).or_default().push(entry);
        }
    }
    let root = root.unwrap();
    let name = root.path.file_name().unwrap_or_default().to_string_lossy();
    println!("{} {}", format_size(root.size), name);

    fn print_recursive(entry: &Entry, prefix: &str, is_last: bool, children: &BTreeMap<PathBuf, Vec<&Entry>>) {
        let connector = if is_last { "└── " } else { "├── " };
        let name = entry.path.file_name().unwrap_or_default().to_string_lossy();
        println!("{} {}{}{}", format_size(entry.size), prefix, connector, name);
        if let Some(kids) = children.get(&entry.path) {
            let mut sorted: Vec<&&Entry> = kids.iter().collect();
            sorted.sort_by(|a, b| b.size.cmp(&a.size));
            for (i, child) in sorted.iter().enumerate() {
                let child_prefix = format!("{}{}", prefix, if is_last { "    " } else { "│   " });
                print_recursive(child, &child_prefix, i == sorted.len() - 1, children);
            }
        }
    }

    if let Some(kids) = children.get(&root.path) {
        let mut sorted: Vec<&&Entry> = kids.iter().collect();
        sorted.sort_by(|a, b| b.size.cmp(&a.size));
        for (i, child) in sorted.iter().enumerate() {
            print_recursive(child, "", i == sorted.len() - 1, &children);
        }
    }
}

这段代码让我印象深刻的是树形连接线的逻辑——print_recursive 内部函数维护了一个 prefix 参数,根据当前节点是否是最后一个兄弟来决定追加"│ "还是" "空白。这种树形打印算法手写容易踩坑(缩进错位、连接线串行),OpenCode 一次就生成对了。

要点:复杂的格式化逻辑不必自己手写。描述你想要的输出格式,OpenCode 帮你实现。这段树形打印代码如果手写调试,至少需要 15-20 分钟。

第四步:添加命令行入口与测试

最后一步,加上命令行参数解析和错误处理:

> 在 main 函数中使用 std::env::args 解析命令行参数,第一个参数为目标目录路径,默认为当前目录。添加错误处理,打印友好的错误信息。

fn main() {
    let args: Vec<String> = std::env::args().collect();
    let target = if args.len() > 1 {
        PathBuf::from(&args[1])
    } else {
        std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
    };

    match scan_dir(&target) {
        Ok(entries) => {
            if entries.is_empty() {
                println!("目录为空或不存在");
            } else {
                print_tree(&entries, &target);
            }
        }
        Err(e) => eprintln!("扫描错误: {}", e),
    }
}

我对 OpenCode 说:

> 编译并测试一下

OpenCode 执行 cargo build --release,一次编译通过。然后我让它测试:

> 扫描当前项目目录,输出磁盘占用分析

dirscan .

终端打印出漂亮的树形分析图,每个目录的大小一目了然。

要点:从需求描述到可运行的二进制,整个开发过程没有写一行代码——全部通过自然语言对话完成。编译错误?OpenCode 会自动读取编译输出并修复。

完整代码

use std::collections::BTreeMap;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};

#[derive(Debug)]
struct Entry {
    path: PathBuf,
    size: u64,
}

fn main() {
    let args: Vec<String> = std::env::args().collect();
    let target = if args.len() > 1 {
        PathBuf::from(&args[1])
    } else {
        std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
    };

    match scan_dir(&target) {
        Ok(entries) => {
            if entries.is_empty() {
                println!("目录为空或不存在");
            } else {
                print_tree(&entries, &target);
            }
        }
        Err(e) => eprintln!("扫描错误: {}", e),
    }
}

fn scan_dir(path: &Path) -> io::Result<Vec<Entry>> {
    let mut entries = Vec::new();
    if !path.is_dir() {
        let size = path.metadata()?.len();
        entries.push(Entry { path: path.to_path_buf(), size });
        return Ok(entries);
    }
    let mut total_size = 0u64;
    for entry in fs::read_dir(path)? {
        let entry = entry?;
        let entry_path = entry.path();
        let sub_entries = scan_dir(&entry_path)?;
        total_size += sub_entries.last().map(|e| e.size).unwrap_or(0);
        entries.extend(sub_entries);
    }
    entries.push(Entry { path: path.to_path_buf(), size: total_size });
    Ok(entries)
}

fn format_size(bytes: u64) -> String {
    const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
    let mut size = bytes as f64;
    let mut unit_idx = 0;
    while size >= 1024.0 && unit_idx < UNITS.len() - 1 {
        size /= 1024.0;
        unit_idx += 1;
    }
    if unit_idx == 0 {
        format!("{:>6} {}", bytes, UNITS[unit_idx])
    } else {
        format!("{:>6.1} {}", size, UNITS[unit_idx])
    }
}

fn print_tree(entries: &[Entry], root_path: &Path) {
    let mut children: BTreeMap<PathBuf, Vec<&Entry>> = BTreeMap::new();
    let mut root: Option<&Entry> = None;
    for entry in entries {
        if entry.path == root_path {
            root = Some(entry);
        }
        if let Some(parent) = entry.path.parent() {
            children.entry(parent.to_path_buf()).or_default().push(entry);
        }
    }
    if root.is_none() {
        return;
    }
    let root = root.unwrap();
    let name = root.path.file_name().unwrap_or_default().to_string_lossy();
    println!("{} {}", format_size(root.size), name);

    fn print_recursive(
        entry: &Entry,
        prefix: &str,
        is_last: bool,
        children: &BTreeMap<PathBuf, Vec<&Entry>>,
    ) {
        let connector = if is_last { "└── " } else { "├── " };
        let name = entry.path.file_name().unwrap_or_default().to_string_lossy();
        println!("{} {}{}{}", format_size(entry.size), prefix, connector, name);
        if let Some(kids) = children.get(&entry.path) {
            let mut sorted: Vec<&&Entry> = kids.iter().collect();
            sorted.sort_by(|a, b| b.size.cmp(&a.size));
            for (i, child) in sorted.iter().enumerate() {
                let child_prefix = format!("{}{}", prefix, if is_last { "    " } else { "│   " });
                print_recursive(child, &child_prefix, i == sorted.len() - 1, children);
            }
        }
    }

    if let Some(kids) = children.get(&root.path) {
        let mut sorted: Vec<&&Entry> = kids.iter().collect();
        sorted.sort_by(|a, b| b.size.cmp(&a.size));
        for (i, child) in sorted.iter().enumerate() {
            print_recursive(child, "", i == sorted.len() - 1, &children);
        }
    }
}

小结

整个开发过程不到 4 分钟,实际手写代码行数为 0——100% 由 OpenCode 生成。如果手动编写,这个项目至少需要 30 分钟:查阅标准库 API 文档、调试树形输出的缩进逻辑、处理各种边界情况。

OpenCode 带来的效率提升体现在三个方面:

零记忆成本:不用记住 fs::read_dir 的返回类型、BTreeMap 的 API 签名、树形递归的写法——描述需求即可

对话式迭代:不满意输出格式?直接说"改成这样",OpenCode 帮你调整

编译→修复循环自动化:编译错误时 OpenCode 会自动读取错误信息并修复,你不需要手动排查

对于日常开发中的小工具、脚本、原型验证,OpenCode 真正做到了"说一个想法,得到一个可运行的结果"——这正是 AI 编程助手最理想的形态。