콘텐츠로 이동

Rust로 만드는 할 일 목록 CLI

이 프로젝트에서는 터미널에서 동작하는 작은 할 일 목록을 만들어요. todo add 우유 사기라고 입력하면 프로그램이 기억해 둬요. 터미널을 닫고 다음 날 다시 와서 todo list를 입력해도 할 일이 그대로 남아 있어요.

진짜 도구처럼 보이기 때문에 두 번째 프로젝트로 딱 좋아요. 구조체, 열거형, 벡터, 명령줄 인자, 파일 입출력, 그리고 ?와 함께 쓰는 Result를 사용해요. 부품을 하나씩 만들어서 Playground에서 시험해 보고, 마지막에 모두 합쳐 볼게요.

완성된 도구는 이렇게 사용해요.

Terminal window
$ 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이에요.

먼저 새 프로젝트를 만들어요.

Terminal window
cargo new todo
cd todo

할 일마다 번호(나중에 가리킬 수 있도록), 완료 여부, 그리고 내용이 있어요. 이런 데이터에는 구조체가 딱 맞아요. 메서드도 두 개 만들어요. 하나는 할 일을 파일에 쓸 한 줄의 텍스트로 바꾸고, 다른 하나는 한 줄의 텍스트에서 할 일을 다시 읽어 와요.

#[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으로 바꿔서 ?를 쓸 수 있게 해 줘요.

이 도구가 이해하는 명령어는 네 가지예요. 각 명령어는 열거형의 배리언트(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가 돼요. 그걸 어떻게 처리할지는 호출하는 쪽이 정해요.

모든 할 일은 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)은 각 줄을 읽어서 제대로 해석된 줄만 남겨요. 이 코드는 망가진 줄을 조용히 건너뛴다는 점을 기억해 두세요. 누군가 파일을 직접 고치다가 오타를 냈을 때는 친절한 동작이지만, 실수를 아무도 모르고 지나칠 수도 있어요. 아래 확장 아이디어 중 하나가 이런 줄을 경고로 알려 주는 거예요.

파일을 읽고 쓰는 방법은 파일 입출력에서 더 자세히 다뤄요.

이제 모두 합쳐 볼게요. 아래 코드를 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(영어) 크레이트에게 이 일을 맡겨요. 다음 명령으로 추가하세요.

Terminal window
cargo add serde --features derive
cargo 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