用Claude写一个完整的CLI工具

尝试用 Claude 从零写一个 Rust CLI 工具——文件搜索替换器。记录完整的 AI 辅助开发过程,包括哪些地方 AI 做得好、哪些地方需要人工介入。

目标

实现一个文件搜索替换命令行工具,功能类似 sed 但更易用:

  • 支持正则表达式搜索和替换
  • 支持递归搜索目录
  • 支持文件类型过滤(比如只处理 .rs 文件)
  • 预览模式:显示将要做的修改但不实际写入
  • 统计信息:匹配数量、修改文件数

用 Rust 实现,使用 clap 做命令行解析。

第一轮:让 AI 生成项目骨架

给 Claude 的提示是:"用 Rust 实现一个文件搜索替换 CLI 工具,使用 clap derive API,支持正则搜索、目录递归、文件类型过滤、预览模式"。

Claude 给出的初始代码结构:

use clap::Parser;
use regex::Regex;
use std::fs;
use std::path::PathBuf;
use walkdir::WalkDir;

#[derive(Parser, Debug)]
#[command(name = "srp", about = "Search and replace in files")]
struct Cli {
    /// Search pattern (regex)
    #[arg(short, long)]
    pattern: String,

    /// Replacement string
    #[arg(short, long)]
    replace: String,

    /// Target directory or file
    #[arg(short, long, default_value = ".")]
    target: PathBuf,

    /// File extensions to include (e.g., rs,toml)
    #[arg(short, long)]
    extensions: Option<String>,

    /// Preview mode (don't write changes)
    #[arg(long, default_value_t = false)]
    dry_run: bool,

    /// Show context lines around matches
    #[arg(short, long, default_value_t = 2)]
    context: usize,
}

这部分 AI 做得不错,clap derive 的用法正确,参数设计也合理。但有几个小问题我手动调了:

  1. default_value_t = false 对布尔 flag 多余,clap 的 flag 默认就是 false
  2. 添加了 --verbose 选项用于调试
  3. -p 短选项改成了搜索 pattern 的短选项(AI 给 pattern 和 preview 都用了 -p

修改后的定义:

#[derive(Parser, Debug)]
#[command(name = "srp", version, about = "Search and replace in files")]
struct Cli {
    /// Search pattern (regex)
    pattern: String,

    /// Replacement string
    replace: String,

    /// Target directory or file
    #[arg(default_value = ".")]
    target: PathBuf,

    /// File extensions to include (comma-separated, e.g., rs,toml)
    #[arg(short, long)]
    ext: Option<String>,

    /// Preview mode - show changes without writing
    #[arg(long)]
    dry_run: bool,

    /// Context lines around matches
    #[arg(short, long, default_value_t = 2)]
    context: usize,

    /// Verbose output
    #[arg(short, long)]
    verbose: bool,
}

patternreplace 改成了位置参数(positional argument),使用起来更自然:srp "old_func" "new_func" ./src

第二轮:核心搜索替换逻辑

让 Claude 实现文件遍历和搜索替换的核心逻辑。生成的代码:

fn process_file(
    path: &std::path::Path,
    regex: &Regex,
    replacement: &str,
    dry_run: bool,
    context: usize,
) -> Result<FileResult, Box<dyn std::error::Error>> {
    let content = fs::read_to_string(path)?;
    let mut matches = Vec::new();
    let lines: Vec<&str> = content.lines().collect();

    for (line_num, line) in lines.iter().enumerate() {
        if regex.is_match(line) {
            let replaced = regex.replace_all(line, replacement);
            matches.push(Match {
                line_number: line_num + 1,
                original: line.to_string(),
                replaced: replaced.to_string(),
                context_before: get_context(&lines, line_num, context, true),
                context_after: get_context(&lines, line_num, context, false),
            });
        }
    }

    if matches.is_empty() {
        return Ok(FileResult { path: path.to_path_buf(), matches, modified: false });
    }

    if !dry_run {
        let new_content = regex.replace_all(&content, replacement);
        fs::write(path, new_content.as_ref())?;
    }

    Ok(FileResult {
        path: path.to_path_buf(),
        matches,
        modified: !dry_run,
    })
}

这里有一个 bug:AI 先按行匹配来收集匹配信息,但实际替换时对整个文件内容做 replace_all。如果正则表达式跨行匹配,行级检测会漏掉。而且行级替换和全文替换的结果可能不一致。

修复方案:统一用行级处理,替换也按行来做:

if !dry_run {
    let mut new_lines: Vec<String> = Vec::with_capacity(lines.len());
    for line in &lines {
        let replaced = regex.replace_all(line, replacement);
        new_lines.push(replaced.into_owned());
    }
    // 保留原始换行符风格
    let new_content = new_lines.join("
");
    let new_content = if content.ends_with('
') {
        format!("{}
", new_content)
    } else {
        new_content
    };
    fs::write(path, &new_content)?;
}

第三轮:输出格式化

让 Claude 实现彩色终端输出。这部分做得很好,使用 colored crate:

use colored::*;

fn print_match(file_path: &std::path::Path, m: &Match) {
    println!("  {}:{}", file_path.display(), m.line_number.to_string().cyan());

    for ctx in &m.context_before {
        println!("    {}", ctx.dimmed());
    }

    println!("  {} {}", "-".red(), m.original.red());
    println!("  {} {}", "+".green(), m.replaced.green());

    for ctx in &m.context_after {
        println!("    {}", ctx.dimmed());
    }
    println!();
}

fn print_summary(results: &[FileResult]) {
    let total_files = results.len();
    let modified_files = results.iter().filter(|r| r.modified).count();
    let total_matches: usize = results.iter().map(|r| r.matches.len()).sum();

    println!("{}", "--- Summary ---".bold());
    println!("  Files scanned:  {}", total_files);
    println!("  Files modified: {}", modified_files.to_string().yellow());
    println!("  Total matches:  {}", total_matches.to_string().yellow());
}

颜色方案合理,减号红色、加号绿色符合 diff 的视觉习惯。

第四轮:边界情况处理

我提了几个边界情况让 Claude 处理:

  1. 二进制文件检测——避免对二进制文件做文本替换
  2. 大文件处理——设置文件大小上限
  3. 编码问题——非 UTF-8 文件的处理

Claude 给出的二进制文件检测:

fn is_binary(path: &std::path::Path) -> bool {
    let mut file = match fs::File::open(path) {
        Ok(f) => f,
        Err(_) => return true, // 打不开就跳过
    };
    let mut buffer = [0u8; 8192];
    let bytes_read = match file.read(&mut buffer) {
        Ok(n) => n,
        Err(_) => return true,
    };
    buffer[..bytes_read].contains(&0)
}

简单粗暴——检查前 8KB 有没有 null 字节。对大多数情况够用了,但会误判包含 null 字符的文本文件。考虑到这种情况极少,暂时接受。

对于非 UTF-8 文件,修改 process_file 开头的读取逻辑:

let content = match fs::read_to_string(path) {
    Ok(c) => c,
    Err(e) if e.kind() == std::io::ErrorKind::InvalidData => {
        if verbose {
            eprintln!("Skipping non-UTF-8 file: {}", path.display());
        }
        return Ok(FileResult { path: path.to_path_buf(), matches: vec![], modified: false });
    }
    Err(e) => return Err(e.into()),
};

第五轮:测试

让 Claude 生成单元测试。这部分效率很高,AI 生成测试用例的能力确实强:

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Write;
    use tempfile::TempDir;

    fn create_test_file(dir: &TempDir, name: &str, content: &str) -> PathBuf {
        let path = dir.path().join(name);
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).unwrap();
        }
        let mut file = fs::File::create(&path).unwrap();
        file.write_all(content.as_bytes()).unwrap();
        path
    }

    #[test]
    fn test_simple_replace() {
        let dir = TempDir::new().unwrap();
        let path = create_test_file(&dir, "test.txt", "hello world
hello rust
");
        let regex = Regex::new("hello").unwrap();

        let result = process_file(&path, &regex, "hi", false, 0).unwrap();

        assert_eq!(result.matches.len(), 2);
        assert!(result.modified);
        let content = fs::read_to_string(&path).unwrap();
        assert_eq!(content, "hi world
hi rust
");
    }

    #[test]
    fn test_dry_run_no_modification() {
        let dir = TempDir::new().unwrap();
        let path = create_test_file(&dir, "test.txt", "hello world
");
        let regex = Regex::new("hello").unwrap();

        let result = process_file(&path, &regex, "hi", true, 0).unwrap();

        assert_eq!(result.matches.len(), 1);
        assert!(!result.modified);
        let content = fs::read_to_string(&path).unwrap();
        assert_eq!(content, "hello world
"); // 未修改
    }

    #[test]
    fn test_regex_pattern() {
        let dir = TempDir::new().unwrap();
        let path = create_test_file(&dir, "test.txt", "foo123bar
foo456bar
baz
");
        let regex = Regex::new(r"foo(\d+)bar").unwrap();

        let result = process_file(&path, &regex, "num:$1", false, 0).unwrap();

        assert_eq!(result.matches.len(), 2);
        let content = fs::read_to_string(&path).unwrap();
        assert_eq!(content, "num:123
num:456
baz
");
    }

    #[test]
    fn test_no_match() {
        let dir = TempDir::new().unwrap();
        let path = create_test_file(&dir, "test.txt", "hello world
");
        let regex = Regex::new("xyz").unwrap();

        let result = process_file(&path, &regex, "abc", false, 0).unwrap();

        assert!(result.matches.is_empty());
        assert!(!result.modified);
    }
}

Claude 生成了 12 个测试用例,覆盖了正常路径和边界情况。我只手动添加了一个中文内容的测试和一个正则特殊字符的测试。

收获与反思

AI 做得好的部分

  • 项目骨架和依赖选择(clap、regex、walkdir、colored 都是正确的选择)
  • 测试用例生成——速度快、覆盖面广
  • 输出格式化——配色方案、信息布局比我自己设计的好
  • 错误处理的模式——Result 传播链路基本正确

需要人工介入的部分

  • API 设计决策(位置参数 vs 命名参数、短选项冲突)
  • 逻辑一致性(行级匹配 vs 全文替换的不一致 bug)
  • 边界情况的完整性(AI 给出了方案但不够全面,需要追问)
  • 性能考量(初始版本没考虑大文件,需要提示)

开发效率:整个工具从零到可用大概花了 3 小时,其中 AI 生成代码占 60%,人工审查和修改占 40%。如果纯手写,估计需要 8-10 小时。效率提升接近 3 倍,但前提是开发者有能力审查和修正 AI 的输出。

一个重要感受:AI 辅助开发不是"描述需求 -> 得到成品"的线性过程,而是"生成 -> 审查 -> 修正 -> 生成"的多轮迭代。AI 是一个输出速度极快但需要持续校准的协作者。对于熟悉的领域,你可以快速判断 AI 输出的质量并纠偏;对于不熟悉的领域,你可能无法发现 AI 的错误,这时候 AI 反而可能带来风险。

最终的工具代码约 350 行,我放到了日常工具箱里在用。比写 sed 命令舒服多了。