Rust로 만드는 할 일 목록 CLI
이 프로젝트에서는 터미널에서 동작하는 작은 할 일 목록을 만들어요. todo add 우유 사기라고 입력하면 프로그램이 기억해 둬요. 터미널을 닫고 다음 날 다시 와서 todo list를 입력해도 할 일이 그대로 남아 있어요.
진짜 도구처럼 보이기 때문에 두 번째 프로젝트로 딱 좋아요. 구조체, 열거형, 벡터, 명령줄 인자, 파일 입출력, 그리고 ?와 함께 쓰는 Result를 사용해요. 부품을 하나씩 만들어서 Playground에서 시험해 보고, 마지막에 모두 합쳐 볼게요.
무엇을 만들까요
섹션 제목: “무엇을 만들까요”완성된 도구는 이렇게 사용해요.
$ cargo run -- add 우유 사기할 일 1번을 추가했어요.$ cargo run -- add 할머니께 전화하기할 일 2번을 추가했어요.$ cargo run -- done 1할 일 1번을 완료했어요.$ cargo run -- list[x] 1: 우유 사기[ ] 2: 할머니께 전화하기$ cargo run -- remove 1할 일 1번을 삭제했어요: 우유 사기-- 뒤에 오는 내용은 Cargo가 아니라 여러분의 프로그램에 전달돼요. 할 일은 tasks.txt라는 파일에 한 줄에 하나씩 저장돼요.
2|0|할머니께 전화하기각 줄은 id|done|text 형식이에요. done은 끝난 일이면 1, 아직 안 끝난 일이면 0이에요.
먼저 새 프로젝트를 만들어요.
cargo new todocd todo1단계: Task 구조체
섹션 제목: “1단계: Task 구조체”할 일마다 번호(나중에 가리킬 수 있도록), 완료 여부, 그리고 내용이 있어요. 이런 데이터에는 구조체가 딱 맞아요. 메서드도 두 개 만들어요. 하나는 할 일을 파일에 쓸 한 줄의 텍스트로 바꾸고, 다른 하나는 한 줄의 텍스트에서 할 일을 다시 읽어 와요.
#[derive(Debug)]struct Task { id: u32, done: bool, text: String,}
impl Task { // 할 일을 "1|0|우유 사기" 같은 한 줄로 바꿔요. fn to_line(&self) -> String { let done = if self.done { 1 } else { 0 }; format!("{}|{}|{}", self.id, done, self.text) }
// 한 줄에서 할 일을 다시 읽어 와요. 줄이 망가져 있으면 None을 돌려줘요. fn from_line(line: &str) -> Option<Task> { let mut parts = line.splitn(3, '|'); let id = parts.next()?.parse().ok()?; let done = parts.next()? == "1"; let text = parts.next()?.to_string(); Some(Task { id, done, text }) }}
fn main() { let task = Task { id: 1, done: false, text: String::from("우유 사기") }; let line = task.to_line(); println!("{line}");
println!("{:?}", Task::from_line("2|1|할머니께 전화하기")); println!("{:?}", Task::from_line("3|0|A|B 테스트 공부하기")); println!("{:?}", Task::from_line("이 줄은 망가졌어요"));}실행 결과:
1|0|우유 사기Some(Task { id: 2, done: true, text: "할머니께 전화하기" })Some(Task { id: 3, done: false, text: "A|B 테스트 공부하기" })None여기서 두 가지는 천천히 짚고 넘어갈게요.
splitn(3, '|')는 최대 3조각으로 나눠요. 처음 두 개의|가 필드를 구분하고, 그 뒤는 내용 안에|가 들어 있더라도 전부 내용이 돼요. 그냥split('|')을 쓰면 “A|B 테스트 공부하기”라는 할 일은 내용의 절반을 잃어버려요.from_line은Option을 돌려주고, 단계마다?를 써요. 조각이 하나라도 없거나 id가 숫자가 아니면 함수 전체가None을 돌려줘요..ok()는parse()가 돌려준Result를Option으로 바꿔서?를 쓸 수 있게 해 줘요.
2단계: Command 열거형
섹션 제목: “2단계: Command 열거형”이 도구가 이해하는 명령어는 네 가지예요. 각 명령어는 열거형의 배리언트(variant)가 되고, 데이터를 품는 배리언트도 있어요. Add에는 내용이, Done과 Remove에는 할 일 번호가 필요해요.
#[derive(Debug)]enum Command { Add(String), List, Done(u32), Remove(u32),}
fn parse_id(arg: Option<&String>) -> Result<u32, String> { let arg = arg.ok_or(String::from("할 일 번호를 입력해 주세요"))?; arg.parse().map_err(|_| format!("할 일 번호가 아니에요: '{arg}'"))}
fn parse_command(args: &[String]) -> Result<Command, String> { match args.first().map(|s| s.as_str()) { None | Some("list") => Ok(Command::List), Some("add") => { let text = args[1..].join(" "); if text.is_empty() { Err(String::from("사용법: todo add <할 일 내용>")) } else { Ok(Command::Add(text)) } } Some("done") => parse_id(args.get(1)).map(Command::Done), Some("remove") => parse_id(args.get(1)).map(Command::Remove), Some(other) => Err(format!("알 수 없는 명령어예요: '{other}'")), }}
fn main() { let examples = [ vec!["add", "우유", "사기"], vec!["list"], vec![], vec!["done", "2"], vec!["done", "two"], vec!["remove"], vec!["dance"], ];
for words in examples { let args: Vec<String> = words.iter().map(|w| w.to_string()).collect(); println!("{:?} -> {:?}", words, parse_command(&args)); }}실행 결과:
["add", "우유", "사기"] -> Ok(Add("우유 사기"))["list"] -> Ok(List)[] -> Ok(List)["done", "2"] -> Ok(Done(2))["done", "two"] -> Err("할 일 번호가 아니에요: 'two'")["remove"] -> Err("할 일 번호를 입력해 주세요")["dance"] -> Err("알 수 없는 명령어예요: 'dance'")실제 프로그램에서 args는 명령줄에서 와요. 명령줄 인자 페이지에서 봤듯이 std::env::args()의 첫 번째 항목은 프로그램 자신의 이름이라서 건너뛰어요.
let args: Vec<String> = std::env::args().skip(1).collect();add에서는 왜 args[1..].join(" ")을 쓸까요? 셸은 todo add 우유 사기를 add, 우유, 사기라는 낱말로 따로따로 나눠요. add 뒤의 낱말을 모두 이어 붙이면 할 일 내용이 원래대로 돌아오니까, 따옴표로 감쌀 필요가 없어요.
parse_command는 절대 프로그램을 멈추지 않는다는 점도 눈여겨보세요. 잘못된 입력은 친절한 메시지를 담은 Err가 돼요. 그걸 어떻게 처리할지는 호출하는 쪽이 정해요.
3단계: 할 일 목록 다루기
섹션 제목: “3단계: 할 일 목록 다루기”모든 할 일은 Vec<Task> 안에 들어 있어요. 명령어마다 벡터를 바꾸는 작은 함수를 하나씩 만들어요.
- 추가(Add): 지금까지 가장 큰 id를 찾아서 1을 더하고, 새 할 일을 넣어요.
- 완료(Done): 그 id를 가진 할 일을 찾아서
done = true로 바꿔요. - 삭제(Remove): 할 일의 위치를 찾아서 지워요.
- 목록(List): 모든 할 일을 출력해요.
#[derive(Debug)]struct Task { id: u32, done: bool, text: String,}
fn add_task(tasks: &mut Vec<Task>, text: String) -> u32 { let id = tasks.iter().map(|t| t.id).max().unwrap_or(0) + 1; tasks.push(Task { id, done: false, text }); id}
fn mark_done(tasks: &mut [Task], id: u32) -> Result<(), String> { match tasks.iter_mut().find(|t| t.id == id) { Some(task) => { task.done = true; Ok(()) } None => Err(format!("{id}번 할 일이 없어요")), }}
fn remove_task(tasks: &mut Vec<Task>, id: u32) -> Result<Task, String> { match tasks.iter().position(|t| t.id == id) { Some(index) => Ok(tasks.remove(index)), None => Err(format!("{id}번 할 일이 없어요")), }}
fn print_tasks(tasks: &[Task]) { if tasks.is_empty() { println!("할 일이 없어요. 좋은 하루 보내세요!"); return; } for task in tasks { let mark = if task.done { "x" } else { " " }; println!("[{mark}] {}: {}", task.id, task.text); }}
fn main() { let mut tasks = Vec::new(); add_task(&mut tasks, String::from("우유 사기")); add_task(&mut tasks, String::from("할머니께 전화하기")); add_task(&mut tasks, String::from("Rust 공부하기"));
mark_done(&mut tasks, 3).unwrap(); let removed = remove_task(&mut tasks, 1).unwrap(); println!("삭제했어요: {}", removed.text);
if let Err(message) = mark_done(&mut tasks, 99) { println!("에러: {message}"); }
print_tasks(&tasks);}실행 결과:
삭제했어요: 우유 사기에러: 99번 할 일이 없어요[ ] 2: 할머니께 전화하기[x] 3: Rust 공부하기몇 가지는 일부러 이렇게 만들었어요.
- id는
tasks.len() + 1이 아니라 지금 있는 가장 큰 id로 정해요. 1번 할 일을 지운 다음 새 할 일을 추가하면, 길이를 기준으로 할 때 id가 겹치게 돼요. mark_done은&mut [Task](슬라이스)를 받아요. 항목을 바꾸기만 하니까요.add_task와remove_task는 길이를 바꾸기 때문에&mut Vec<Task>를 받아요.- 에러 타입은
Result<_, String>이에요.String에러는 동작하는 가장 간단한 방법이에요. 더 큰 프로그램이라면 사용자 정의 에러를 참고하세요.
iter_mut().find(...)는 조건에 맞는 할 일의 가변 참조자를 돌려줘요. 그래서 task.done = true가 벡터 안의 할 일을 직접 바꿀 수 있어요. find와 position에 대해서는 반복자에서 더 알아볼 수 있어요.
4단계: 파일에 저장하고 불러오기
섹션 제목: “4단계: 파일에 저장하고 불러오기”이제 프로그램이 끝난 뒤에도 할 일이 남아 있어야 해요. std::fs::write로 저장하고 std::fs::read_to_string으로 불러와요. 둘 다 실패할 수 있어서 Result를 돌려주고, ? 연산자가 에러를 호출한 쪽으로 넘겨줘요.
use std::fs;use std::io;
fn save_tasks(path: &str, tasks: &[Task]) -> Result<(), io::Error> { let mut text = String::new(); for task in tasks { text.push_str(&task.to_line()); text.push('\n'); } fs::write(path, text)?; Ok(())}
fn load_tasks(path: &str) -> Result<Vec<Task>, io::Error> { let text = match fs::read_to_string(path) { Ok(text) => text, // 프로그램을 처음 실행할 때는 아직 파일이 없어요. // 그건 에러가 아니에요. 그냥 "할 일이 없다"는 뜻이에요. Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()), Err(e) => return Err(e), }; let tasks = text.lines().filter_map(Task::from_line).collect(); Ok(tasks)}NotFound 처리는 보기보다 중요해요. 이게 없으면 아직 아무것도 저장하지 않았기 때문에 맨 처음 todo add부터 “No such file or directory”로 실패해요. 그 밖의 에러(예를 들어 “permission denied”)는 그대로 위로 넘겨요.
filter_map(Task::from_line)은 각 줄을 읽어서 제대로 해석된 줄만 남겨요. 이 코드는 망가진 줄을 조용히 건너뛴다는 점을 기억해 두세요. 누군가 파일을 직접 고치다가 오타를 냈을 때는 친절한 동작이지만, 실수를 아무도 모르고 지나칠 수도 있어요. 아래 확장 아이디어 중 하나가 이런 줄을 경고로 알려 주는 거예요.
파일을 읽고 쓰는 방법은 파일 입출력에서 더 자세히 다뤄요.
5단계: 완성된 프로그램
섹션 제목: “5단계: 완성된 프로그램”이제 모두 합쳐 볼게요. 아래 코드를 src/main.rs에 넣고 cargo run -- add 우유 사기, cargo run -- list 같은 식으로 실행해 보세요.
핵심 로직은 run_command에 있어요. 이 함수는 Vec<Task>만 다루고 파일에 대해서는 전혀 몰라요. main은 파일을 불러오고, run_command를 부르고, 저장하는 일만 해요. 이렇게 나눠 두면 로직을 테스트하기 쉽고, 다음 단계에서 Playground용 버전을 만들 수 있는 것도 이 덕분이에요.
use std::env;use std::error::Error;use std::fs;use std::io;use std::process;
const FILE: &str = "tasks.txt";
#[derive(Debug)]struct Task { id: u32, done: bool, text: String,}
impl Task { fn to_line(&self) -> String { let done = if self.done { 1 } else { 0 }; format!("{}|{}|{}", self.id, done, self.text) }
fn from_line(line: &str) -> Option<Task> { let mut parts = line.splitn(3, '|'); let id = parts.next()?.parse().ok()?; let done = parts.next()? == "1"; let text = parts.next()?.to_string(); Some(Task { id, done, text }) }}
#[derive(Debug)]enum Command { Add(String), List, Done(u32), Remove(u32),}
fn parse_id(arg: Option<&String>) -> Result<u32, String> { let arg = arg.ok_or(String::from("할 일 번호를 입력해 주세요"))?; arg.parse().map_err(|_| format!("할 일 번호가 아니에요: '{arg}'"))}
fn parse_command(args: &[String]) -> Result<Command, String> { match args.first().map(|s| s.as_str()) { None | Some("list") => Ok(Command::List), Some("add") => { let text = args[1..].join(" "); if text.is_empty() { Err(String::from("사용법: todo add <할 일 내용>")) } else { Ok(Command::Add(text)) } } Some("done") => parse_id(args.get(1)).map(Command::Done), Some("remove") => parse_id(args.get(1)).map(Command::Remove), Some(other) => Err(format!("알 수 없는 명령어예요: '{other}'")), }}
fn add_task(tasks: &mut Vec<Task>, text: String) -> u32 { let id = tasks.iter().map(|t| t.id).max().unwrap_or(0) + 1; tasks.push(Task { id, done: false, text }); id}
fn mark_done(tasks: &mut [Task], id: u32) -> Result<(), String> { match tasks.iter_mut().find(|t| t.id == id) { Some(task) => { task.done = true; Ok(()) } None => Err(format!("{id}번 할 일이 없어요")), }}
fn remove_task(tasks: &mut Vec<Task>, id: u32) -> Result<Task, String> { match tasks.iter().position(|t| t.id == id) { Some(index) => Ok(tasks.remove(index)), None => Err(format!("{id}번 할 일이 없어요")), }}
fn print_tasks(tasks: &[Task]) { if tasks.is_empty() { println!("할 일이 없어요. 좋은 하루 보내세요!"); return; } for task in tasks { let mark = if task.done { "x" } else { " " }; println!("[{mark}] {}: {}", task.id, task.text); }}
fn run_command(args: &[String], tasks: &mut Vec<Task>) -> Result<(), String> { match parse_command(args)? { Command::Add(text) => { let id = add_task(tasks, text); println!("할 일 {id}번을 추가했어요."); } Command::List => print_tasks(tasks), Command::Done(id) => { mark_done(tasks, id)?; println!("할 일 {id}번을 완료했어요."); } Command::Remove(id) => { let task = remove_task(tasks, id)?; println!("할 일 {id}번을 삭제했어요: {}", task.text); } } Ok(())}
fn save_tasks(path: &str, tasks: &[Task]) -> Result<(), io::Error> { let mut text = String::new(); for task in tasks { text.push_str(&task.to_line()); text.push('\n'); } fs::write(path, text)?; Ok(())}
fn load_tasks(path: &str) -> Result<Vec<Task>, io::Error> { let text = match fs::read_to_string(path) { Ok(text) => text, Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()), Err(e) => return Err(e), }; let tasks = text.lines().filter_map(Task::from_line).collect(); Ok(tasks)}
fn run() -> Result<(), Box<dyn Error>> { let args: Vec<String> = env::args().skip(1).collect(); let mut tasks = load_tasks(FILE)?; run_command(&args, &mut tasks)?; save_tasks(FILE, &tasks)?; Ok(())}
fn main() { if let Err(e) = run() { eprintln!("에러: {e}"); process::exit(1); }}run() -> Result<(), Box<dyn Error>>에 대해 조금 설명할게요. 우리 함수들은 서로 다른 두 가지 에러 타입을 돌려줘요. 파일 함수는 io::Error를, 명령어 함수는 String을 돌려주죠. Box<dyn Error>는 어떤 에러 타입이든 담을 수 있는 상자라서 두 경우 모두에 ?를 쓸 수 있어요. 그다음 main은 eprintln!(에러 출력에 쓰는 매크로)으로 에러를 출력하고, 문제가 생겼다는 일반적인 신호인 종료 코드 1로 끝나요.
6단계: Playground에서 실행할 수 있는 버전
섹션 제목: “6단계: Playground에서 실행할 수 있는 버전”Playground에서는 명령줄 인자를 넘길 수 없고, 파일에 의존하게 만들고 싶지도 않아요. 그래서 이 버전은 같은 run_command를 그대로 쓰되, 미리 정해 둔 명령어 목록을 차례로 넣어 주고 마지막에 tasks.txt에 들어갈 내용을 출력해요.
#[derive(Debug)]struct Task { id: u32, done: bool, text: String,}
impl Task { fn to_line(&self) -> String { let done = if self.done { 1 } else { 0 }; format!("{}|{}|{}", self.id, done, self.text) }}
#[derive(Debug)]enum Command { Add(String), List, Done(u32), Remove(u32),}
fn parse_id(arg: Option<&String>) -> Result<u32, String> { let arg = arg.ok_or(String::from("할 일 번호를 입력해 주세요"))?; arg.parse().map_err(|_| format!("할 일 번호가 아니에요: '{arg}'"))}
fn parse_command(args: &[String]) -> Result<Command, String> { match args.first().map(|s| s.as_str()) { None | Some("list") => Ok(Command::List), Some("add") => { let text = args[1..].join(" "); if text.is_empty() { Err(String::from("사용법: todo add <할 일 내용>")) } else { Ok(Command::Add(text)) } } Some("done") => parse_id(args.get(1)).map(Command::Done), Some("remove") => parse_id(args.get(1)).map(Command::Remove), Some(other) => Err(format!("알 수 없는 명령어예요: '{other}'")), }}
fn add_task(tasks: &mut Vec<Task>, text: String) -> u32 { let id = tasks.iter().map(|t| t.id).max().unwrap_or(0) + 1; tasks.push(Task { id, done: false, text }); id}
fn mark_done(tasks: &mut [Task], id: u32) -> Result<(), String> { match tasks.iter_mut().find(|t| t.id == id) { Some(task) => { task.done = true; Ok(()) } None => Err(format!("{id}번 할 일이 없어요")), }}
fn remove_task(tasks: &mut Vec<Task>, id: u32) -> Result<Task, String> { match tasks.iter().position(|t| t.id == id) { Some(index) => Ok(tasks.remove(index)), None => Err(format!("{id}번 할 일이 없어요")), }}
fn print_tasks(tasks: &[Task]) { if tasks.is_empty() { println!("할 일이 없어요. 좋은 하루 보내세요!"); return; } for task in tasks { let mark = if task.done { "x" } else { " " }; println!("[{mark}] {}: {}", task.id, task.text); }}
fn run_command(args: &[String], tasks: &mut Vec<Task>) -> Result<(), String> { match parse_command(args)? { Command::Add(text) => { let id = add_task(tasks, text); println!("할 일 {id}번을 추가했어요."); } Command::List => print_tasks(tasks), Command::Done(id) => { mark_done(tasks, id)?; println!("할 일 {id}번을 완료했어요."); } Command::Remove(id) => { let task = remove_task(tasks, id)?; println!("할 일 {id}번을 삭제했어요: {}", task.text); } } Ok(())}
fn main() { // 각 줄은 `todo` 뒤에 입력하는 내용이에요. let script = [ "list", "add 우유 사기", "add 할머니께 전화하기", "add Rust 공부하기", "done 3", "remove 1", "done 9", "jump", "list", ];
let mut tasks: Vec<Task> = Vec::new();
for line in script { println!("$ todo {line}"); let args: Vec<String> = line.split_whitespace().map(String::from).collect(); if let Err(message) = run_command(&args, &mut tasks) { println!("에러: {message}"); } }
println!(); println!("tasks.txt에 저장될 내용:"); for task in &tasks { println!("{}", task.to_line()); }}실행 결과:
$ todo list할 일이 없어요. 좋은 하루 보내세요!$ todo add 우유 사기할 일 1번을 추가했어요.$ todo add 할머니께 전화하기할 일 2번을 추가했어요.$ todo add Rust 공부하기할 일 3번을 추가했어요.$ todo done 3할 일 3번을 완료했어요.$ todo remove 1할 일 1번을 삭제했어요: 우유 사기$ todo done 9에러: 9번 할 일이 없어요$ todo jump에러: 알 수 없는 명령어예요: 'jump'$ todo list[ ] 2: 할머니께 전화하기[x] 3: Rust 공부하기
tasks.txt에 저장될 내용:2|0|할머니께 전화하기3|1|Rust 공부하기스크립트를 바꿔서 다시 실행해 보세요. 할 일을 하나 지운 뒤에 새 할 일을 추가하고, 새 id가 겹치지 않는지 확인해 보세요.
업그레이드: serde로 JSON 저장하기
섹션 제목: “업그레이드: serde로 JSON 저장하기”우리가 만든 id|done|text 형식은 읽기 쉽지만, to_line과 from_line을 직접 작성해야 했어요. 할 일에 마감일, 우선순위, 태그 같은 정보가 늘어나면 손으로 쓴 코드도 함께 늘어나요.
실제 프로젝트에서는 보통 serde(영어) 크레이트에게 이 일을 맡겨요. 다음 명령으로 추가하세요.
cargo add serde --features derivecargo add serde_json그러면 derive 한 줄만으로 저장과 불러오기가 공짜로 생겨요.
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]struct Task { id: u32, done: bool, text: String,}
fn main() { let tasks = vec![ Task { id: 1, done: false, text: String::from("우유 사기") }, Task { id: 2, done: true, text: String::from("할머니께 전화하기") }, ];
let json = serde_json::to_string(&tasks).unwrap(); println!("{json}");
let loaded: Vec<Task> = serde_json::from_str(&json).unwrap(); println!("할 일 {}개를 다시 불러왔어요", loaded.len());}실행 결과:
[{"id":1,"done":false,"text":"우유 사기"},{"id":2,"done":true,"text":"할머니께 전화하기"}]할 일 2개를 다시 불러왔어요이 JSON 문자열은 앞에서처럼 fs::write로 tasks.json에 쓰면 돼요. 자세한 내용은 serde로 JSON 다루기 페이지에서 다뤄요.
자주 하는 실수
섹션 제목: “자주 하는 실수”아무것도 돌려주지 않는 main에서 ? 쓰기. 이렇게 쓰고 싶어지기 쉬워요.
use std::fs;
fn main() { let text = fs::read_to_string("tasks.txt")?; println!("{text}");}error[E0277]: the `?` operator can only be used in a function that returns `Result` or `Option` (or another type that implements `FromResidual`) --> src/main.rs:4:47 |3 | fn main() { | --------- this function should return `Result` or `Option` to accept `?`4 | let text = fs::read_to_string("tasks.txt")?; | ^ cannot use the `?` operator in a function that returns `()`컴파일러는 “? 연산자는 Result나 Option을 돌려주는 함수 안에서만 쓸 수 있다”고 말하고 있어요. ?는 “에러라면 지금 함수에서 그 에러를 돌려준다”는 뜻이에요. 평범한 main은 ()를 돌려주니까 에러를 보낼 곳이 없어요. main에 Result<(), Box<dyn Error>> 같은 반환 타입을 주거나, 우리 프로그램처럼 Result를 돌려주는 run() 함수에 작업을 넣고 main에서 에러를 한 번에 처리하세요.
파일이 없는 걸 실패로 취급하기. load_tasks가 그냥 fs::read_to_string(path)?만 했다면, 파일이 아직 없는 맨 처음 실행에서 프로그램이 실패해요. io::ErrorKind::NotFound인지 확인하고, 그럴 때는 빈 목록으로 시작하세요.
목록의 길이를 다음 id로 쓰기. 1, 2, 3번 할 일이 있을 때 1번을 지우면 두 개가 남아요. 그러면 len() + 1은 다시 3이 되고, 두 할 일이 같은 id를 갖게 돼요. 가장 큰 id에 1을 더해서 쓰세요.
확장 아이디어
섹션 제목: “확장 아이디어”- “완료” 되돌리기.
done을 다시false로 바꾸는undone <id>명령어를 추가해 보세요. - 할 일 수정하기.
edit 2 오늘 밤 할머니께 전화하기로 2번 할 일의 내용을 바꿔요. - 끝난 할 일 정리하기.
clear로 완료된 할 일을 모두 지워요.Vec::retain(영어)을 살펴보세요. - 망가진 줄 경고하기. 잘못된 줄을 조용히 건너뛰는 대신 줄 번호와 함께 경고를 출력해 보세요.
help명령어를 만들어서 모든 명령어와 사용법을 출력해 보세요.- 테스트 작성하기.
parse_command와run_command는 파일을 건드리지 않으니까 테스트하기 쉬워요. - 제대로 된 인자 파서 쓰기. 명령줄 인자 페이지에서 소개한
clap(영어) 크레이트를 쓰면--help와 하위 명령어가 공짜로 생겨요.
Task구조체가 데이터를 담아요.to_line과from_line이 할 일을 텍스트로 바꾸고 다시 되돌려요.Command열거형은 사용자가 무엇을 요청했는지 나타내요.parse_command는 인자를Command로 바꾸거나, 친절한 메시지를 담은Err를 돌려줘요.Vec<Task>가 목록을 담아요. 작은 함수들이 할 일을 추가하고, 찾고, 바꾸고, 지워요.fs::read_to_string과fs::write는Result를 돌려주고,?가 에러를 위로 넘겨요. 처음 실행할 때 파일이 없는 경우는 “할 일 없음”으로 처리해요.- 로직(
run_command)을 파일이나 인자와 분리해 두면 테스트하기도, Playground에서 실행하기도 쉬워요.
이해도 점검
섹션 제목: “이해도 점검”이해도 점검
1. from_line은 왜 split('|') 대신 splitn(3, '|')를 쓸까요?
2. tasks.txt가 아직 없을 때 load_tasks는 어떻게 할까요?
3. 반환 타입이 없는 fn main() 안에서 ?를 바로 쓸 수 없는 이유는 무엇일까요?
4. 구조체를 JSON으로 저장할 때 보통 업그레이드로 쓰는 크레이트는 무엇일까요?
점수: 0 / 4
더 알아보기
섹션 제목: “더 알아보기”- I/O 프로젝트: 커맨드 라인 프로그램 만들기 — The Rust Programming Language 한국어판: 더 큰 CLI 프로젝트를 단계별로 따라가요.
main을 테스트하기 쉬운 작은 조각으로 나누는 좋은 조언도 들어 있어요. std::fs— std 문서(영어): 저장하고 불러오는 코드에 쓴read_to_string,write와 그 밖의 파일 함수들이에요.std::env::args— std 문서(영어): 명령줄 인자가 어떻게 들어오는지, 왜 첫 번째 인자가 프로그램 자신의 이름인지 설명해요.str::splitn— std 문서(영어): 한 줄을 최대 N조각으로 나누는 방법이에요.|가 들어 있는 할 일 내용을 한 조각으로 지켜 주는 비결이죠.serde_json— docs.rs(영어): 업그레이드 절에서 쓴 JSON 크레이트예요.to_string과from_str예제가 있어요. derive 속성은 serde 가이드(영어)에서 설명해요.