尝试用 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 的用法正确,参数设计也合理。但有几个小问题我手动调了:
default_value_t = false对布尔 flag 多余,clap 的 flag 默认就是 false- 添加了
--verbose选项用于调试 - 把
-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,
}
把 pattern 和 replace 改成了位置参数(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 处理:
- 二进制文件检测——避免对二进制文件做文本替换
- 大文件处理——设置文件大小上限
- 编码问题——非 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, ®ex, "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, ®ex, "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, ®ex, "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, ®ex, "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 命令舒服多了。