Lewati ke konten

Proyek: Daftar Tugas CLI dengan Rust

Di proyek ini kamu akan membuat daftar tugas (to-do list) kecil yang tinggal di terminal. Kamu ketik todo add Beli susu, dan program mengingatnya. Tutup terminal, kembali besok, ketik todo list, dan tugas-tugasmu masih ada.

Ini proyek kedua yang pas karena hasilnya terasa seperti alat sungguhan. Proyek ini memakai struct, enum, vektor, argumen command-line, baca-tulis file, dan Result dengan ?. Kita bangun setiap bagian satu per satu, mengujinya di Playground, lalu menggabungkan semuanya.

Begini tampilan alat yang sudah jadi saat dipakai:

Terminal window
$ cargo run -- add Beli susu
Tugas 1 ditambahkan.
$ cargo run -- add Telepon nenek
Tugas 2 ditambahkan.
$ cargo run -- done 1
Tugas 1 ditandai selesai.
$ cargo run -- list
[x] 1: Beli susu
[ ] 2: Telepon nenek
$ cargo run -- remove 1
Tugas 1 dihapus: Beli susu

Semua yang ada setelah -- diteruskan ke programmu, bukan ke Cargo. Nama perintahnya (add, list, done, dan remove) sengaja dibiarkan dalam bahasa Inggris, seperti kebanyakan alat terminal. Kalau kamu lebih suka tambah atau lihat, cukup ganti di parse_command (Langkah 2).

Tugas-tugas disimpan di file bernama tasks.txt, satu tugas per baris:

2|0|Telepon nenek

Setiap baris berbentuk id|done|text, dengan done bernilai 1 kalau tugas sudah selesai dan 0 kalau belum.

Mulai proyek baru dengan:

Terminal window
cargo new todo
cd todo

Setiap tugas punya nomor (supaya bisa kamu sebut), tanda selesai, dan teks. Struct adalah pilihan yang paling pas. Kita juga memberinya dua method: satu untuk mengubah tugas menjadi satu baris teks untuk file, dan satu lagi untuk membaca tugas kembali dari sebuah baris.

#[derive(Debug)]
struct Task {
id: u32,
done: bool,
text: String,
}
impl Task {
// Ubah tugas menjadi satu baris, misalnya "1|0|Beli susu".
fn to_line(&self) -> String {
let done = if self.done { 1 } else { 0 };
format!("{}|{}|{}", self.id, done, self.text)
}
// Baca tugas kembali dari sebuah baris. Mengembalikan None kalau barisnya rusak.
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("Beli susu") };
let line = task.to_line();
println!("{line}");
println!("{:?}", Task::from_line("2|1|Telepon nenek"));
println!("{:?}", Task::from_line("3|0|Baca tentang A|B testing"));
println!("{:?}", Task::from_line("baris ini rusak"));
}

Output:

1|0|Beli susu
Some(Task { id: 2, done: true, text: "Telepon nenek" })
Some(Task { id: 3, done: false, text: "Baca tentang A|B testing" })
None
▶ Coba sendiri

Ada dua detail yang perlu kita bahas pelan-pelan:

  • splitn(3, '|') memecah teks menjadi paling banyak 3 bagian. Dua karakter | pertama memisahkan kolom-kolomnya, dan semua sisanya adalah teks, walaupun teks itu sendiri berisi |. Dengan split('|') biasa, tugas “Baca tentang A|B testing” akan kehilangan separuh teksnya.
  • from_line mengembalikan Option, dan memakai ? di setiap langkah. Kalau ada bagian yang hilang atau id-nya bukan angka, seluruh fungsi mengembalikan None. .ok() mengubah Result dari parse() menjadi Option supaya ? bisa dipakai di situ.

Alat ini mengerti empat perintah. Setiap perintah adalah satu varian dari sebuah enum, dan beberapa varian membawa data: Add butuh teks, sedangkan Done dan Remove butuh nomor tugas.

#[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("tolong beri nomor tugas"))?;
arg.parse().map_err(|_| format!("'{arg}' bukan nomor tugas"))
}
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("cara pakai: todo add <teks tugas>"))
} 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!("perintah tidak dikenal '{other}'")),
}
}
fn main() {
let examples = [
vec!["add", "Beli", "susu"],
vec!["list"],
vec![],
vec!["done", "2"],
vec!["done", "dua"],
vec!["remove"],
vec!["menari"],
];
for words in examples {
let args: Vec<String> = words.iter().map(|w| w.to_string()).collect();
println!("{:?} -> {:?}", words, parse_command(&args));
}
}

Output:

["add", "Beli", "susu"] -> Ok(Add("Beli susu"))
["list"] -> Ok(List)
[] -> Ok(List)
["done", "2"] -> Ok(Done(2))
["done", "dua"] -> Err("'dua' bukan nomor tugas")
["remove"] -> Err("tolong beri nomor tugas")
["menari"] -> Err("perintah tidak dikenal 'menari'")
▶ Coba sendiri

Di program aslinya, args datang dari command line. Seperti yang kamu lihat di halaman Argumen Command-Line, item pertama dari std::env::args() adalah nama program itu sendiri, jadi kita lewati:

let args: Vec<String> = std::env::args().skip(1).collect();

Kenapa add memakai args[1..].join(" ")? Shell kamu memecah todo add Beli susu menjadi kata-kata terpisah: add, Beli, susu. Menggabungkan semua yang ada setelah add menyatukan kembali teks tugasnya, jadi kamu tidak perlu tanda kutip.

Perhatikan bahwa parse_command tidak pernah crash. Input yang salah menjadi Err dengan pesan yang membantu. Pemanggilnya yang memutuskan mau diapakan.

Semua tugas tinggal di sebuah Vec<Task>. Setiap perintah adalah fungsi kecil yang mengubah vektor itu:

  • Add: cari id terbesar sejauh ini, tambah 1, lalu push tugas baru.
  • Done: cari tugas dengan id itu dan set done = true.
  • Remove: cari posisi tugas itu lalu hapus.
  • List: cetak semua tugas.
#[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!("tidak ada tugas {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!("tidak ada tugas {id}")),
}
}
fn print_tasks(tasks: &[Task]) {
if tasks.is_empty() {
println!("Tidak ada tugas. Selamat menikmati harimu!");
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("Beli susu"));
add_task(&mut tasks, String::from("Telepon nenek"));
add_task(&mut tasks, String::from("Belajar Rust"));
mark_done(&mut tasks, 3).unwrap();
let removed = remove_task(&mut tasks, 1).unwrap();
println!("Dihapus: {}", removed.text);
if let Err(message) = mark_done(&mut tasks, 99) {
println!("Error: {message}");
}
print_tasks(&tasks);
}

Output:

Dihapus: Beli susu
Error: tidak ada tugas 99
[ ] 2: Telepon nenek
[x] 3: Belajar Rust
▶ Coba sendiri

Beberapa pilihan di sini memang disengaja:

  • Id diambil dari id terbesar yang sudah ada, bukan dari tasks.len() + 1. Kalau kamu menghapus tugas 1 lalu menambah tugas baru, memakai panjang vektor akan menghasilkan id kembar.
  • mark_done menerima &mut [Task] (sebuah slice, potongan dari vektor; lihat Slice) karena fungsi ini hanya mengubah isi item. add_task dan remove_task menerima &mut Vec<Task> karena keduanya mengubah panjang vektor.
  • Error-nya berupa Result<_, String>. Error berbentuk String adalah cara paling sederhana yang sudah cukup. Untuk program yang lebih besar, lihat Error Kustom.

iter_mut().find(...) memberimu referensi mutable ke tugas yang cocok, itulah sebabnya task.done = true mengubah tugas yang ada di dalam vektor. Lihat Iterator untuk penjelasan lebih lanjut tentang find dan position.

Sekarang tugas-tugas harus tetap ada setelah program selesai. Kita menyimpannya dengan std::fs::write dan memuatnya dengan std::fs::read_to_string. Keduanya bisa gagal, jadi keduanya mengembalikan Result, dan operator ? meneruskan error apa pun ke pemanggil:

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,
// Saat program pertama kali dijalankan, file-nya belum ada.
// Itu bukan error: artinya cuma "belum ada tugas".
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)
}

Kasus NotFound lebih penting daripada kelihatannya. Tanpa itu, todo add yang paling pertama akan gagal dengan “No such file or directory” (pesan dari sistem operasi yang artinya “file atau direktori tidak ditemukan”), karena kamu belum pernah menyimpan apa pun. Semua error lainnya (seperti “permission denied”, akses ditolak) tetap diteruskan ke atas.

filter_map(Task::from_line) membaca setiap baris dan hanya menyimpan baris yang berhasil di-parse. Perlu diingat, cara ini diam-diam melewati baris yang rusak. Ramah kalau seseorang mengedit file secara manual dan salah ketik, tapi artinya kesalahan bisa tidak ketahuan. Salah satu ide pengembangan di bawah adalah memberi peringatan untuk baris seperti itu.

Lihat Baca-Tulis File untuk penjelasan lebih lanjut tentang membaca dan menulis file.

Ini semuanya digabung jadi satu. Taruh di src/main.rs dan jalankan dengan cargo run -- add Beli susu, cargo run -- list, dan seterusnya.

Logika intinya ada di run_command, yang bekerja dengan Vec<Task> dan tidak tahu apa-apa soal file. main hanya memuat file, memanggil run_command, lalu menyimpan. Memisahkan keduanya membuat logikanya mudah diuji, dan itulah yang memungkinkan kita membuat versi Playground di langkah berikutnya.

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("tolong beri nomor tugas"))?;
arg.parse().map_err(|_| format!("'{arg}' bukan nomor tugas"))
}
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("cara pakai: todo add <teks tugas>"))
} 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!("perintah tidak dikenal '{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!("tidak ada tugas {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!("tidak ada tugas {id}")),
}
}
fn print_tasks(tasks: &[Task]) {
if tasks.is_empty() {
println!("Tidak ada tugas. Selamat menikmati harimu!");
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!("Tugas {id} ditambahkan.");
}
Command::List => print_tasks(tasks),
Command::Done(id) => {
mark_done(tasks, id)?;
println!("Tugas {id} ditandai selesai.");
}
Command::Remove(id) => {
let task = remove_task(tasks, id)?;
println!("Tugas {id} dihapus: {}", 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!("Error: {e}");
process::exit(1);
}
}

Sedikit catatan tentang run() -> Result<(), Box<dyn Error>>. Fungsi-fungsi kita mengembalikan dua tipe error yang berbeda: io::Error dari fungsi file dan String dari fungsi perintah. Box<dyn Error> adalah “kotak” yang bisa menampung tipe error apa saja, jadi ? bisa dipakai untuk keduanya. Lalu main mencetak error dengan eprintln! (yang menulis ke output error) dan keluar dengan kode 1, tanda yang umum dipakai bahwa ada yang salah.

Langkah 6: Versi yang bisa dijalankan di Playground

Section titled “Langkah 6: Versi yang bisa dijalankan di Playground”

Playground tidak bisa mengirim argumen command-line, dan kita tidak mau bergantung pada file. Jadi versi ini memakai run_command yang sama, tapi memberinya daftar perintah yang sudah disiapkan, lalu mencetak isi tasks.txt di akhir:

#[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("tolong beri nomor tugas"))?;
arg.parse().map_err(|_| format!("'{arg}' bukan nomor tugas"))
}
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("cara pakai: todo add <teks tugas>"))
} 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!("perintah tidak dikenal '{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!("tidak ada tugas {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!("tidak ada tugas {id}")),
}
}
fn print_tasks(tasks: &[Task]) {
if tasks.is_empty() {
println!("Tidak ada tugas. Selamat menikmati harimu!");
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!("Tugas {id} ditambahkan.");
}
Command::List => print_tasks(tasks),
Command::Done(id) => {
mark_done(tasks, id)?;
println!("Tugas {id} ditandai selesai.");
}
Command::Remove(id) => {
let task = remove_task(tasks, id)?;
println!("Tugas {id} dihapus: {}", task.text);
}
}
Ok(())
}
fn main() {
// Setiap baris adalah apa yang kamu ketik setelah `todo`.
let script = [
"list",
"add Beli susu",
"add Telepon nenek",
"add Belajar Rust",
"done 3",
"remove 1",
"done 9",
"lompat",
"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!("Error: {message}");
}
}
println!();
println!("Isi tasks.txt nantinya:");
for task in &tasks {
println!("{}", task.to_line());
}
}

Output:

$ todo list
Tidak ada tugas. Selamat menikmati harimu!
$ todo add Beli susu
Tugas 1 ditambahkan.
$ todo add Telepon nenek
Tugas 2 ditambahkan.
$ todo add Belajar Rust
Tugas 3 ditambahkan.
$ todo done 3
Tugas 3 ditandai selesai.
$ todo remove 1
Tugas 1 dihapus: Beli susu
$ todo done 9
Error: tidak ada tugas 9
$ todo lompat
Error: perintah tidak dikenal 'lompat'
$ todo list
[ ] 2: Telepon nenek
[x] 3: Belajar Rust
Isi tasks.txt nantinya:
2|0|Telepon nenek
3|1|Belajar Rust
▶ Coba sendiri

Ubah daftar perintahnya lalu jalankan lagi. Coba tambahkan tugas setelah menghapus satu tugas, lalu pastikan id barunya tidak kembar.

Format id|done|text kita mudah dibaca, tapi kamu harus menulis to_line dan from_line sendiri. Saat tugasmu makin lengkap (tenggat waktu, prioritas, tag), kode buatan tangan itu ikut membesar.

Di proyek sungguhan, biasanya pekerjaan ini diserahkan ke crate serde (bahasa Inggris). Tambahkan dengan:

Terminal window
cargo add serde --features derive
cargo add serde_json

Lalu satu baris derive sudah memberimu fitur simpan dan muat secara gratis:

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("Beli susu") },
Task { id: 2, done: true, text: String::from("Telepon nenek") },
];
let json = serde_json::to_string(&tasks).unwrap();
println!("{json}");
let loaded: Vec<Task> = serde_json::from_str(&json).unwrap();
println!("Berhasil memuat kembali {} tugas", loaded.len());
}

Output:

[{"id":1,"done":false,"text":"Beli susu"},{"id":2,"done":true,"text":"Telepon nenek"}]
Berhasil memuat kembali 2 tugas
▶ Coba sendiri

String JSON itu tinggal kamu tulis ke tasks.json dengan fs::write, persis seperti sebelumnya. Halaman JSON dengan serde membahasnya secara detail.

Memakai ? di main yang tidak mengembalikan apa-apa. Godaannya adalah menulis seperti ini:

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 `()`

Compiler bilang operator ? hanya bisa dipakai di fungsi yang mengembalikan Result atau Option. ? artinya “kalau ini error, kembalikan error itu dari fungsi yang sedang berjalan”. main biasa mengembalikan (), jadi tidak ada tempat untuk mengirim error-nya. Pilihannya: beri main tipe kembalian seperti Result<(), Box<dyn Error>>, atau lakukan seperti program kita: taruh pekerjaannya di fungsi run() yang mengembalikan Result, lalu tangani error-nya sekali saja di main.

Menganggap file yang belum ada sebagai kegagalan. Kalau load_tasks hanya melakukan fs::read_to_string(path)?, program akan gagal saat pertama kali dijalankan, sebelum file-nya ada. Periksa io::ErrorKind::NotFound dan mulai dengan daftar kosong.

Memakai panjang daftar sebagai id berikutnya. Dengan tugas 1, 2, 3, menghapus tugas 1 menyisakan dua tugas. len() + 1 akan menghasilkan 3 lagi, dan sekarang ada dua tugas dengan id yang sama. Pakai id terbesar ditambah satu.

  • Batalkan “done”. Tambahkan perintah undone <id> yang mengembalikan done menjadi false.
  • Edit tugas. edit 2 Telepon nenek nanti malam mengganti teks tugas 2.
  • Bersihkan tugas yang sudah selesai. clear menghapus semua tugas yang sudah selesai. Lihat Vec::retain (bahasa Inggris).
  • Beri peringatan untuk baris yang rusak. Daripada diam-diam melewati baris yang rusak, cetak peringatan beserta nomor barisnya.
  • Perintah help yang mencetak semua perintah dan cara memakainya.
  • Tulis test. parse_command dan run_command tidak menyentuh file, jadi mudah diuji.
  • Pakai parser argumen sungguhan. Crate clap (bahasa Inggris), yang disebut di halaman Argumen Command-Line, memberimu --help dan subcommand secara gratis.
  • Struct Task menyimpan datanya. to_line dan from_line mengubahnya menjadi teks dan sebaliknya.
  • Enum Command menggambarkan apa yang diminta pengguna. parse_command mengubah argumen menjadi Command, atau Err dengan pesan yang membantu.
  • Sebuah Vec<Task> menyimpan daftarnya. Fungsi-fungsi kecil menambah, mencari, mengubah, dan menghapus tugas.
  • fs::read_to_string dan fs::write mengembalikan Result, dan ? meneruskan error ke atas. File yang belum ada saat pertama kali dijalankan dianggap “belum ada tugas”.
  • Memisahkan logika (run_command) dari urusan file dan argumen membuatnya mudah diuji dan bisa dijalankan di Playground.

Kuis singkat

1. Kenapa from_line memakai splitn(3, '|') dan bukan split('|')?

2. Apa yang dilakukan load_tasks kalau tasks.txt belum ada?

3. Kenapa kamu tidak bisa memakai ? langsung di dalam fn main() tanpa tipe kembalian?

4. Crate apa yang biasanya dipakai untuk upgrade menyimpan struct sebagai JSON?

Skor: 0 / 4