Rust 초급 12편: 파일을 검색하는 CLI 프로젝트 완성하기
마지막 편에서는 앞에서 배운 내용을 파일 검색 도구 하나로 모읍니다. 터미널에서 검색어와 파일 경로를 받고, 일치하는 줄에 줄 번호를 붙여 출력합니다. 파일을 읽지 못하거나 인자가 잘못됐을 때도 원인을 알 수 있게 만듭니다.

마지막 편에서는 앞에서 배운 내용을 파일 검색 도구 하나로 모읍니다. 터미널에서 검색어와 파일 경로를 받고, 일치하는 줄에 줄 번호를 붙여 출력합니다. 파일을 읽지 못하거나 인자가 잘못됐을 때도 원인을 알 수 있게 만듭니다.
완성할 기능과 범위
- UTF-8 텍스트 파일을 읽습니다.
- 대소문자를 구분해 검색어가 포함된 줄을 찾습니다.
- 일치한 줄을 원래 순서와 줄 번호로 출력합니다.
- 잘못된 인자와 파일 오류는 표준 에러로 알립니다.
- 검색 함수는 파일 접근과 분리해 테스트합니다.
정규식 검색과 매우 큰 파일의 스트리밍 처리는 이번 범위에서 제외합니다. 완성한 도구는 초급 실습용이며, 파일 전체를 메모리에 읽습니다.
1. 프로젝트와 샘플 파일 준비
cargo new study_find
cd study_find프로젝트 루트, 즉 Cargo.toml과 같은 폴더에 notes.txt를 만듭니다. 편집기에서 아래 세 줄을 넣고 UTF-8로 저장하세요.
Rust 소유권 복습
CLI 프로젝트 만들기
Rust 오류 처리 연습이 프로젝트에는 src/main.rs와 새로 만들 src/lib.rs가 함께 들어갑니다. main.rs는 실행과 오류 보고를, lib.rs는 검색 계산을 담당합니다.
2. 검색 함수와 테스트 작성
src/lib.rs를 새로 만들고 아래 전체 코드를 넣습니다.
pub fn search(query: &str, contents: &str) -> Vec<String> {
let mut matches = Vec::new();
for (index, line) in contents.lines().enumerate() {
if line.contains(query) {
matches.push(format!("{}: {line}", index + 1));
}
}
matches
}
#[cfg(test)]
mod tests {
use super::search;
#[test]
fn finds_matching_lines_with_numbers() {
let text = "Rust 소유권 복습\nCLI 프로젝트 만들기\nRust 오류 처리 연습";
assert_eq!(search("Rust", text), vec![
String::from("1: Rust 소유권 복습"),
String::from("3: Rust 오류 처리 연습"),
]);
}
#[test]
fn no_match_returns_empty_list() {
assert!(search("Python", "Rust 공부").is_empty());
}
#[test]
fn empty_file_returns_empty_list() {
assert!(search("Rust", "").is_empty());
}
#[test]
fn matching_is_case_sensitive() {
assert!(search("rust", "Rust 공부").is_empty());
}
}lines()는 텍스트를 줄 단위로 순회합니다. enumerate()는 0부터 시작하는 순번을 붙이므로, 사람에게 보여줄 줄 번호에는 1을 더했습니다. contains는 부분 문자열 포함 여부를 검사합니다.
검색 결과는 Vec<String>으로 소유하는 문자열을 만듭니다. 원본을 빌린 슬라이스를 반환하는 설계도 가능하지만, 이번에는 포맷된 결과를 독립적으로 보관하는 방법을 선택했습니다. 그만큼 일치한 줄을 위한 메모리 사용이 생깁니다.
3. 인자와 파일 처리 연결
src/main.rs 전체를 다음으로 바꿉니다.
use std::{env, fs, process::ExitCode};
use study_find::search;
fn run() -> Result<(), String> {
let args: Vec<String> = env::args().skip(1).collect();
let [query, path] = args.as_slice() else {
return Err(String::from("사용법: study_find <검색어> <파일경로>"));
};
if query.is_empty() {
return Err(String::from("검색어를 비워 둘 수 없습니다"));
}
let contents = fs::read_to_string(path)
.map_err(|error| format!("파일을 읽지 못했습니다: {path}: {error}"))?;
for line in search(query, &contents) {
println!("{line}");
}
Ok(())
}
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(message) => {
eprintln!("{message}");
ExitCode::FAILURE
}
}
}study_find::search는 같은 패키지의 라이브러리 크레이트에서 공개한 함수입니다. 프로젝트 이름을 바꾸었다면 이 경로도 함께 맞춰야 합니다. mod lib;를 추가할 필요는 없습니다.
env::args()의 첫 값은 프로그램 실행 경로입니다. skip(1)로 이를 건너뛰고 나머지를 Vec에 모았습니다. collect가 어떤 모음을 만들지 알 수 있도록 Vec<String> 타입을 적었습니다.
let [query, path] = args.as_slice() else는 슬라이스 패턴과 let-else 문법입니다. 인자가 정확히 두 개일 때만 이름을 붙이고, 아니면 else에서 오류를 반환합니다. 7편의 패턴 매칭을 인자 검증에 적용한 것입니다.
readtostring은 파일 내용을 String으로 읽으며, 파일이 없거나 읽기 권한이 없거나 UTF-8로 해석되지 않으면 오류를 반환할 수 있습니다. maperr로 문맥을 붙이고 ?로 run의 호출자에게 전달했습니다. [파일 읽기 공식 API](https://doc.rust-lang.org/std/fs/fn.readto_string.html)
4. 정상 실행 확인
먼저 테스트합니다.
cargo test검색 함수의 테스트 네 개가 통과해야 합니다. 이어 실제 파일로 실행합니다.
cargo run -- Rust notes.txtCargo의 빌드 안내를 제외한 프로그램 출력입니다.
1: Rust 소유권 복습
3: Rust 오류 처리 연습-- 뒤의 값은 Cargo가 아닌 프로그램에 전달됩니다. 파일 경로에 공백이 있으면 "my notes.txt"처럼 따옴표로 묶습니다. 검색어도 공백이 있으면 같은 방법을 씁니다.
검색어 Python으로 실행하면 일치하는 줄이 없어 아무 결과 줄도 출력하지 않습니다. 이번 도구는 이 경우도 정상 종료인 0으로 처리합니다. 다른 검색 도구의 종료 코드 규칙과 같다고 가정하지 마세요.
5. 실패 경로도 실행하기
cargo run -- Rust
cargo run -- Rust missing-file.txt첫 명령은 사용법을, 두 번째는 파일을 읽지 못한 이유를 표시하고 실패로 종료합니다. 운영체제에 따라 파일 오류의 세부 문구는 달라질 수 있습니다.
println!은 검색 결과를 표준 출력으로 보내고, eprintln!은 오류를 표준 에러로 보냅니다. 검색 결과를 다른 파일로 보낼 때도 오류와 섞이지 않게 나눴습니다. main이 ExitCode를 반환하므로 오류를 출력하고도 성공한 것처럼 끝나지 않습니다.
파일 이름과 인자는 이 예제에서 Unicode 문자열로 처리합니다. 임의의 비Unicode 운영체제 경로까지 다뤄야 하는 도구라면 args_os와 PathBuf 등을 사용하는 설계가 필요합니다. 대용량 파일은 BufRead로 줄씩 읽는 방식도 다음 단계의 과제입니다.
연습과 해설: 대소문자 구분 없이 검색하기
검색 함수가 rust와 Rust를 같은 검색어로 처리하도록 바꿔 보세요. 먼저 matchingiscase_sensitive 테스트를 새로운 요구사항에 맞는 테스트로 바꾸고 구현을 수정합니다.
해설: 반복 전에 let query = query.tolowercase();를 만들고 조건을 line.tolowercase().contains(&query)로 바꾸면 단순한 대소문자 무시 검색을 실습할 수 있습니다. 출력에는 원래 line을 사용해 대소문자를 보존합니다. 모든 언어의 정교한 대소문자 동등성까지 처리하는 구현은 아닙니다.
추가 연습으로 일치한 줄 수를 마지막에 출력해 보세요. 함수가 반환한 Vec를 변수에 저장하고 len()을 확인한 뒤 순회하면 됩니다. 값이 이동하기 전에 길이를 읽는 순서도 복습할 수 있습니다.
12편을 마치며
이제 변수와 함수에서 시작해 소유권·참조·구조체·열거형·오류·컬렉션·모듈·테스트를 하나의 프로그램에서 사용했습니다. 소유권 오류가 나오면 clone부터 붙이기 전에 누가 값을 소유하고 어느 함수가 읽기만 하는지 확인하세요. 실패할 수 있는 계산에는 Result를, 없는 값에는 Option을 사용해 상황을 드러내면 됩니다.
다음 학습으로는 제네릭과 trait bound, 라이프타임, 반복자와 클로저, 비동기 처리 등을 이어갈 수 있습니다. 이번 도구를 라이브러리와 실행 파일로 나눈 상태가 있으므로 작은 기능과 테스트를 추가하며 확장해 보세요.
공식 참고: 명령행 I/O 프로젝트, 명령행 인자, 프로세스 종료 코드.
실행 확인
이 글의 핵심 실습은 Rust 1.99.1, 2024 Edition의 Linux 환경에서 확인했습니다. 의도적으로 실패하는 예제는 설명한 오류가 발생하는지 구분해 검사했습니다. 운영체제별 오류 문구는 다를 수 있습니다.



Discussion
No comments yet