Zum Inhalt springen

Projekt: To-do-Liste in Rust

In diesem Projekt baust du eine kleine To-do-Liste, die in deinem Terminal lebt. Du tippst todo add Milch kaufen, und sie merkt es sich. Du schließt das Terminal, kommst morgen wieder, tippst todo list – und deine Aufgaben sind noch da.

Das ist ein tolles zweites Projekt, weil es sich wie ein echtes Werkzeug anfühlt. Du brauchst dafür Structs, Enums, Vektoren, Kommandozeilenargumente, Dateien lesen und schreiben und Result mit ?. Wir bauen jedes Teil einzeln, testen es im Playground und setzen am Ende alles zusammen.

So sieht es aus, wenn du das fertige Werkzeug benutzt:

Terminal window
$ cargo run -- add Milch kaufen
Aufgabe 1 hinzugefügt.
$ cargo run -- add Oma anrufen
Aufgabe 2 hinzugefügt.
$ cargo run -- done 1
Aufgabe 1 als erledigt markiert.
$ cargo run -- list
[x] 1: Milch kaufen
[ ] 2: Oma anrufen
$ cargo run -- remove 1
Aufgabe 1 entfernt: Milch kaufen

Alles nach -- geht an dein Programm, nicht an Cargo. Die Befehlsnamen (add, list, done und remove) lassen wir auf Englisch, wie bei den meisten Kommandozeilen-Tools. Wenn du lieber neu oder liste tippen willst, änderst du sie einfach in parse_command (Schritt 2).

Die Aufgaben werden in einer Datei namens tasks.txt gespeichert, eine Aufgabe pro Zeile:

2|0|Oma anrufen

Jede Zeile hat die Form id|done|text. Dabei steht done auf 1, wenn die Aufgabe erledigt ist, und auf 0, wenn nicht.

Leg ein neues Projekt an:

Terminal window
cargo new todo
cd todo

Jede Aufgabe hat eine Nummer (damit du sie ansprechen kannst), eine Markierung für „erledigt“ und einen Text. Ein Struct passt dafür perfekt. Wir geben ihm außerdem zwei Methoden: Die eine macht aus einer Aufgabe eine Textzeile für die Datei, die andere liest eine Aufgabe aus so einer Zeile wieder ein.

#[derive(Debug)]
struct Task {
id: u32,
done: bool,
text: String,
}
impl Task {
// Macht aus einer Aufgabe eine Zeile, z. B. "1|0|Milch kaufen".
fn to_line(&self) -> String {
let done = if self.done { 1 } else { 0 };
format!("{}|{}|{}", self.id, done, self.text)
}
// Liest eine Aufgabe aus einer Zeile. Gibt None zurück, wenn die Zeile kaputt ist.
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("Milch kaufen") };
let line = task.to_line();
println!("{line}");
println!("{:?}", Task::from_line("2|1|Oma anrufen"));
println!("{:?}", Task::from_line("3|0|Über A|B-Tests lesen"));
println!("{:?}", Task::from_line("diese Zeile ist kaputt"));
}

Ausgabe:

1|0|Milch kaufen
Some(Task { id: 2, done: true, text: "Oma anrufen" })
Some(Task { id: 3, done: false, text: "Über A|B-Tests lesen" })
None
▶ Selbst ausprobieren

Zwei Details lohnen einen genaueren Blick:

  • splitn(3, '|') teilt in höchstens 3 Stücke. Die ersten beiden | trennen die Felder, und alles danach ist der Text – auch wenn darin selbst ein | vorkommt. Mit einem einfachen split('|') würde die Aufgabe „Über A|B-Tests lesen“ die Hälfte ihres Textes verlieren.
  • from_line gibt eine Option zurück und verwendet bei jedem Schritt ?. Fehlt ein Teil oder ist die Id keine Zahl, gibt die ganze Funktion None zurück. Das .ok() macht aus dem Result von parse() eine Option, damit ? damit arbeiten kann.

Das Werkzeug versteht vier Befehle. Jeder ist eine Variante eines Enums, und manche Varianten tragen Daten mit sich: Add braucht den Text, Done und Remove brauchen eine Aufgabennummer.

#[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("bitte gib eine Aufgabennummer an"))?;
arg.parse().map_err(|_| format!("'{arg}' ist keine Aufgabennummer"))
}
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("Verwendung: todo add <Aufgabentext>"))
} 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!("unbekannter Befehl '{other}'")),
}
}
fn main() {
let examples = [
vec!["add", "Milch", "kaufen"],
vec!["list"],
vec![],
vec!["done", "2"],
vec!["done", "zwei"],
vec!["remove"],
vec!["tanzen"],
];
for words in examples {
let args: Vec<String> = words.iter().map(|w| w.to_string()).collect();
println!("{:?} -> {:?}", words, parse_command(&args));
}
}

Ausgabe:

["add", "Milch", "kaufen"] -> Ok(Add("Milch kaufen"))
["list"] -> Ok(List)
[] -> Ok(List)
["done", "2"] -> Ok(Done(2))
["done", "zwei"] -> Err("'zwei' ist keine Aufgabennummer")
["remove"] -> Err("bitte gib eine Aufgabennummer an")
["tanzen"] -> Err("unbekannter Befehl 'tanzen'")
▶ Selbst ausprobieren

Im echten Programm kommt args von der Kommandozeile. Wie du auf der Seite Kommandozeilenargumente gesehen hast, ist das erste Element von std::env::args() der Name des Programms selbst. Deshalb überspringen wir es:

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

Warum verwendet add den Ausdruck args[1..].join(" ")? Deine Shell zerlegt todo add Milch kaufen in einzelne Wörter: add, Milch, kaufen. Wenn wir alles nach add wieder zusammenfügen, ist der Aufgabentext wieder komplett – du brauchst also keine Anführungszeichen.

Beachte: parse_command stürzt nie ab. Falsche Eingaben werden zu einem Err mit einer hilfreichen Meldung. Was damit passiert, entscheidet der Aufrufer.

Alle Aufgaben liegen in einem Vec<Task>. Jeder Befehl ist eine kleine Funktion, die den Vektor verändert:

  • Add: die größte bisherige Id suchen, 1 dazuzählen und eine neue Aufgabe anhängen.
  • Done: die Aufgabe mit dieser Id suchen und done = true setzen.
  • Remove: die Position der Aufgabe suchen und sie entfernen.
  • List: alle Aufgaben ausgeben.
#[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!("es gibt keine Aufgabe {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!("es gibt keine Aufgabe {id}")),
}
}
fn print_tasks(tasks: &[Task]) {
if tasks.is_empty() {
println!("Nichts zu tun. Genieß deinen Tag!");
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("Milch kaufen"));
add_task(&mut tasks, String::from("Oma anrufen"));
add_task(&mut tasks, String::from("Rust lernen"));
mark_done(&mut tasks, 3).unwrap();
let removed = remove_task(&mut tasks, 1).unwrap();
println!("Entfernt: {}", removed.text);
if let Err(message) = mark_done(&mut tasks, 99) {
println!("Fehler: {message}");
}
print_tasks(&tasks);
}

Ausgabe:

Entfernt: Milch kaufen
Fehler: es gibt keine Aufgabe 99
[ ] 2: Oma anrufen
[x] 3: Rust lernen
▶ Selbst ausprobieren

Ein paar Entscheidungen sind hier ganz bewusst getroffen:

  • Die Id kommt von der größten vorhandenen Id, nicht von tasks.len() + 1. Wenn du Aufgabe 1 entfernst und dann eine neue hinzufügst, bekämst du mit der Länge eine doppelte Id.
  • mark_done nimmt &mut [Task] (einen Slice), weil es nur Elemente verändert. add_task und remove_task nehmen &mut Vec<Task>, weil sie die Länge ändern.
  • Fehler sind Result<_, String>. Ein String als Fehler ist das Einfachste, was funktioniert. Für größere Programme schau dir Eigene Fehlertypen an.

iter_mut().find(...) liefert dir eine veränderbare Referenz auf die passende Aufgabe. Deshalb ändert task.done = true die Aufgabe direkt im Vektor. Mehr zu find und position findest du bei den Iteratoren.

Jetzt sollen die Aufgaben das Programmende überleben. Wir speichern sie mit std::fs::write und laden sie mit std::fs::read_to_string. Beides kann schiefgehen, also geben beide ein Result zurück. Der ?-Operator reicht jeden Fehler an den Aufrufer weiter:

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,
// Beim ersten Start gibt es noch keine Datei.
// Das ist kein Fehler: Es heißt nur "keine Aufgaben".
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)
}

Der Fall NotFound ist wichtiger, als er aussieht. Ohne ihn würde schon das allererste todo add mit „No such file or directory“ scheitern, weil du ja noch nichts gespeichert hast. Diese Meldung kommt vom Betriebssystem und erscheint deshalb meist auf Englisch. Alle anderen Fehler (zum Beispiel „permission denied“, also fehlende Rechte) werden weiterhin nach oben gereicht.

filter_map(Task::from_line) liest jede Zeile und behält nur die, die sich einlesen lassen. Sei dir bewusst, dass kaputte Zeilen dabei still übersprungen werden. Das ist freundlich, wenn jemand die Datei von Hand bearbeitet und sich vertippt. Es heißt aber auch, dass ein Fehler unbemerkt bleiben kann. Eine der Erweiterungsideen unten ist, stattdessen eine Warnung auszugeben.

Mehr zum Lesen und Schreiben von Dateien findest du unter Dateien lesen und schreiben.

Hier ist alles zusammen. Pack es in src/main.rs und starte es mit cargo run -- add Milch kaufen, cargo run -- list und so weiter.

Die eigentliche Logik steckt in run_command. Diese Funktion arbeitet mit einem Vec<Task> und weiß nichts von Dateien. main lädt nur die Datei, ruft run_command auf und speichert. Diese Trennung macht die Logik leicht testbar – und genau dadurch können wir im nächsten Schritt eine Version für den Playground bauen.

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("bitte gib eine Aufgabennummer an"))?;
arg.parse().map_err(|_| format!("'{arg}' ist keine Aufgabennummer"))
}
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("Verwendung: todo add <Aufgabentext>"))
} 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!("unbekannter Befehl '{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!("es gibt keine Aufgabe {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!("es gibt keine Aufgabe {id}")),
}
}
fn print_tasks(tasks: &[Task]) {
if tasks.is_empty() {
println!("Nichts zu tun. Genieß deinen Tag!");
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!("Aufgabe {id} hinzugefügt.");
}
Command::List => print_tasks(tasks),
Command::Done(id) => {
mark_done(tasks, id)?;
println!("Aufgabe {id} als erledigt markiert.");
}
Command::Remove(id) => {
let task = remove_task(tasks, id)?;
println!("Aufgabe {id} entfernt: {}", 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!("Fehler: {e}");
process::exit(1);
}
}

Ein Wort zu run() -> Result<(), Box<dyn Error>>: Unsere Funktionen geben zwei verschiedene Fehlertypen zurück – io::Error bei den Dateifunktionen und String bei den Befehlsfunktionen. Box<dyn Error> ist eine Box, die jeden Fehlertyp aufnehmen kann. So funktioniert ? mit beiden. main gibt den Fehler dann mit eprintln! aus (das schreibt in die Fehlerausgabe) und beendet das Programm mit dem Code 1. Das ist das übliche Signal dafür, dass etwas schiefgegangen ist.

Der Playground kann keine Kommandozeilenargumente übergeben, und wir wollen auch nicht von Dateien abhängen. Deshalb nutzt diese Version dasselbe run_command, füttert es aber mit einer festen Liste von Befehlen. Am Ende gibt sie aus, was in tasks.txt stehen würde:

#[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("bitte gib eine Aufgabennummer an"))?;
arg.parse().map_err(|_| format!("'{arg}' ist keine Aufgabennummer"))
}
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("Verwendung: todo add <Aufgabentext>"))
} 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!("unbekannter Befehl '{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!("es gibt keine Aufgabe {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!("es gibt keine Aufgabe {id}")),
}
}
fn print_tasks(tasks: &[Task]) {
if tasks.is_empty() {
println!("Nichts zu tun. Genieß deinen Tag!");
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!("Aufgabe {id} hinzugefügt.");
}
Command::List => print_tasks(tasks),
Command::Done(id) => {
mark_done(tasks, id)?;
println!("Aufgabe {id} als erledigt markiert.");
}
Command::Remove(id) => {
let task = remove_task(tasks, id)?;
println!("Aufgabe {id} entfernt: {}", task.text);
}
}
Ok(())
}
fn main() {
// Jede Zeile ist das, was du nach `todo` tippen würdest.
let script = [
"list",
"add Milch kaufen",
"add Oma anrufen",
"add Rust lernen",
"done 3",
"remove 1",
"done 9",
"springen",
"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!("Fehler: {message}");
}
}
println!();
println!("In tasks.txt würde stehen:");
for task in &tasks {
println!("{}", task.to_line());
}
}

Ausgabe:

$ todo list
Nichts zu tun. Genieß deinen Tag!
$ todo add Milch kaufen
Aufgabe 1 hinzugefügt.
$ todo add Oma anrufen
Aufgabe 2 hinzugefügt.
$ todo add Rust lernen
Aufgabe 3 hinzugefügt.
$ todo done 3
Aufgabe 3 als erledigt markiert.
$ todo remove 1
Aufgabe 1 entfernt: Milch kaufen
$ todo done 9
Fehler: es gibt keine Aufgabe 9
$ todo springen
Fehler: unbekannter Befehl 'springen'
$ todo list
[ ] 2: Oma anrufen
[x] 3: Rust lernen
In tasks.txt würde stehen:
2|0|Oma anrufen
3|1|Rust lernen
▶ Selbst ausprobieren

Ändere das Skript und führ es noch einmal aus. Füge zum Beispiel eine Aufgabe hinzu, nachdem du eine entfernt hast, und prüfe, dass die neue Id kein Duplikat ist.

Unser Format id|done|text ist gut lesbar, aber to_line und from_line musstest du von Hand schreiben. Wenn deine Aufgaben mehr Felder bekommen (Fälligkeitsdatum, Priorität, Tags), wächst dieser handgeschriebene Code mit.

In echten Projekten überlässt man diese Arbeit meist den serde-Crates (auf Englisch). Füge sie so hinzu:

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

Dann bekommst du mit einer einzigen derive-Zeile Speichern und Laden geschenkt:

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("Milch kaufen") },
Task { id: 2, done: true, text: String::from("Oma anrufen") },
];
let json = serde_json::to_string(&tasks).unwrap();
println!("{json}");
let loaded: Vec<Task> = serde_json::from_str(&json).unwrap();
println!("{} Aufgaben wieder geladen", loaded.len());
}

Ausgabe:

[{"id":1,"done":false,"text":"Milch kaufen"},{"id":2,"done":true,"text":"Oma anrufen"}]
2 Aufgaben wieder geladen
▶ Selbst ausprobieren

Diesen JSON-String würdest du genau wie vorher mit fs::write in tasks.json schreiben. Die Seite JSON mit Serde erklärt das ausführlich.

? in einem main verwenden, das nichts zurückgibt. Es ist verlockend, so etwas zu schreiben:

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

Der Compiler sagt dir: Den Operator ? darfst du nur in einer Funktion verwenden, die Result oder Option zurückgibt. ? bedeutet „Wenn das hier ein Fehler ist, gib ihn aus der aktuellen Funktion zurück“. Ein einfaches main gibt () zurück, also gibt es keinen Ort, an den der Fehler gehen könnte. Entweder gibst du main einen Rückgabetyp wie Result<(), Box<dyn Error>>, oder du machst es wie unser Programm: Du steckst die Arbeit in eine Funktion run(), die Result zurückgibt, und behandelst den Fehler einmal in main.

Eine fehlende Datei als Fehlschlag behandeln. Würde load_tasks einfach fs::read_to_string(path)? aufrufen, scheiterte das Programm schon beim allerersten Start, wenn noch gar keine Datei existiert. Prüfe auf io::ErrorKind::NotFound und starte in dem Fall mit einer leeren Liste.

Die Länge der Liste als nächste Id verwenden. Bei den Aufgaben 1, 2 und 3 bleiben nach dem Entfernen von Aufgabe 1 noch zwei übrig. len() + 1 ergäbe wieder 3 – und schon teilen sich zwei Aufgaben eine Id. Nimm stattdessen die größte Id plus eins.

  • „Erledigt“ rückgängig machen. Füge einen Befehl undone <id> hinzu, der done wieder auf false setzt.
  • Eine Aufgabe bearbeiten. edit 2 Oma heute Abend anrufen ersetzt den Text von Aufgabe 2.
  • Erledigte Aufgaben aufräumen. clear entfernt alle erledigten Aufgaben. Schau dir dafür Vec::retain an (auf Englisch).
  • Vor kaputten Zeilen warnen. Statt fehlerhafte Zeilen still zu überspringen, gib eine Warnung mit der Zeilennummer aus.
  • Ein Befehl help, der alle Befehle und ihre Verwendung ausgibt.
  • Tests schreiben. parse_command und run_command fassen keine Dateien an, deshalb kannst du sie leicht testen.
  • Einen richtigen Argument-Parser verwenden. Das Crate clap (auf Englisch), das auf der Seite Kommandozeilenargumente erwähnt wird, liefert dir --help und Unterbefehle frei Haus.
  • Ein Struct Task hält die Daten. to_line und from_line machen daraus Text und wieder zurück.
  • Ein Enum Command beschreibt, was der Benutzer will. parse_command macht aus den Argumenten ein Command – oder ein Err mit einer hilfreichen Meldung.
  • Ein Vec<Task> hält die Liste. Kleine Funktionen fügen Aufgaben hinzu, finden, ändern und entfernen sie.
  • fs::read_to_string und fs::write geben ein Result zurück, und ? reicht Fehler weiter. Eine fehlende Datei beim ersten Start wird als „keine Aufgaben“ behandelt.
  • Wenn du die Logik (run_command) von Dateien und Argumenten trennst, kannst du sie leicht testen und im Playground ausführen.

Kurzer Check

1. Warum verwendet from_line den Aufruf splitn(3, '|') statt split('|')?

2. Was macht load_tasks, wenn tasks.txt noch nicht existiert?

3. Warum kannst du ? nicht direkt in einem fn main() ohne Rückgabetyp verwenden?

4. Welches Crate ist das übliche Upgrade, um Structs als JSON zu speichern?

Punkte: 0 / 4