Salta ai contenuti

Progetto: lista di cose da fare (CLI) in Rust

In questo progetto costruisci una piccola lista di cose da fare che vive nel tuo terminale. Scrivi todo add Comprare il latte, e lei se lo ricorda. Chiudi il terminale, torni domani, scrivi todo list, e le tue attività sono ancora lì.

È un ottimo secondo progetto perché sembra uno strumento vero. Usa struct, enum, vettori, argomenti da riga di comando, I/O su file e Result con ?. Costruiamo ogni pezzo da solo, lo proviamo nel Playground, e poi mettiamo tutto insieme.

Ecco come si usa lo strumento finito:

Terminal window
$ cargo run -- add Comprare il latte
Aggiunta l'attività 1.
$ cargo run -- add Chiamare la nonna
Aggiunta l'attività 2.
$ cargo run -- done 1
Attività 1 segnata come completata.
$ cargo run -- list
[x] 1: Comprare il latte
[ ] 2: Chiamare la nonna
$ cargo run -- remove 1
Rimossa l'attività 1: Comprare il latte

Tutto quello che sta dopo -- viene passato al tuo programma, non a Cargo. I comandi (add, list, done, remove) restano in inglese, come nella maggior parte degli strumenti da riga di comando. Le attività vengono salvate in un file chiamato tasks.txt, un’attività per riga:

2|0|Chiamare la nonna

Ogni riga è id|done|testo, dove done vale 1 per «completata» e 0 per «da fare».

Crea un nuovo progetto con:

Terminal window
cargo new todo
cd todo

Ogni attività ha un numero (così puoi indicarla), un flag «completata» e un testo. Una struct è la scelta naturale. Le diamo anche due metodi: uno per trasformare un’attività in una riga di testo per il file, e uno per rileggerla da una riga.

#[derive(Debug)]
struct Task {
id: u32,
done: bool,
text: String,
}
impl Task {
// Trasforma un'attività in una riga, come "1|0|Comprare il latte".
fn to_line(&self) -> String {
let done = if self.done { 1 } else { 0 };
format!("{}|{}|{}", self.id, done, self.text)
}
// Rilegge un'attività da una riga. Restituisce None se la riga è rovinata.
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("Comprare il latte") };
let line = task.to_line();
println!("{line}");
println!("{:?}", Task::from_line("2|1|Chiamare la nonna"));
println!("{:?}", Task::from_line("3|0|Studiare gli A|B test"));
println!("{:?}", Task::from_line("questa riga è rovinata"));
}

Output:

1|0|Comprare il latte
Some(Task { id: 2, done: true, text: "Chiamare la nonna" })
Some(Task { id: 3, done: false, text: "Studiare gli A|B test" })
None
▶ Provalo tu

Due dettagli meritano di andare piano:

  • splitn(3, '|') divide in al massimo 3 pezzi. I primi due caratteri | separano i campi, e tutto il resto è il testo, anche se il testo stesso contiene un |. Con un semplice split('|'), l’attività «Studiare gli A|B test» perderebbe metà del testo.
  • from_line restituisce un Option, e usa ? a ogni passo. Se manca un pezzo o l’id non è un numero, l’intera funzione restituisce None. Il .ok() trasforma il Result di parse() in un Option, così ? può funzionare.

Lo strumento capisce quattro comandi. Ognuno è una variante di un enum, e alcune varianti portano dei dati: Add ha bisogno del testo, Done e Remove di un numero di attività.

#[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("indica il numero di un'attività"))?;
arg.parse().map_err(|_| format!("'{arg}' non è un numero di attività"))
}
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("uso: todo add <testo dell'attività>"))
} 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!("comando sconosciuto '{other}'")),
}
}
fn main() {
let examples = [
vec!["add", "Comprare", "il", "latte"],
vec!["list"],
vec![],
vec!["done", "2"],
vec!["done", "due"],
vec!["remove"],
vec!["balla"],
];
for words in examples {
let args: Vec<String> = words.iter().map(|w| w.to_string()).collect();
println!("{:?} -> {:?}", words, parse_command(&args));
}
}

Output:

["add", "Comprare", "il", "latte"] -> Ok(Add("Comprare il latte"))
["list"] -> Ok(List)
[] -> Ok(List)
["done", "2"] -> Ok(Done(2))
["done", "due"] -> Err("'due' non è un numero di attività")
["remove"] -> Err("indica il numero di un'attività")
["balla"] -> Err("comando sconosciuto 'balla'")
▶ Provalo tu

Nel programma vero, args arriva dalla riga di comando. Come hai visto nella pagina Argomenti da riga di comando, il primo elemento di std::env::args() è il nome del programma stesso, quindi lo saltiamo:

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

Perché add usa args[1..].join(" ")? La shell divide todo add Comprare il latte in parole separate: add, Comprare, il, latte. Unire tutto quello che viene dopo add rimette insieme il testo dell’attività, così non servono le virgolette.

Nota che parse_command non va mai in crash. Un input sbagliato diventa un Err con un messaggio utile. Chi la chiama decide cosa farne.

Tutte le attività vivono in un Vec<Task>. Ogni comando è una piccola funzione che modifica il vettore:

  • Add: trova l’id più grande finora, aggiungi 1 e fai push di una nuova attività.
  • Done: trova l’attività con quell’id e imposta done = true.
  • Remove: trova la posizione dell’attività e rimuovila.
  • List: stampa tutte le attività.
#[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!("non esiste l'attività {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!("non esiste l'attività {id}")),
}
}
fn print_tasks(tasks: &[Task]) {
if tasks.is_empty() {
println!("Niente da fare. Goditi la giornata!");
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("Comprare il latte"));
add_task(&mut tasks, String::from("Chiamare la nonna"));
add_task(&mut tasks, String::from("Imparare Rust"));
mark_done(&mut tasks, 3).unwrap();
let removed = remove_task(&mut tasks, 1).unwrap();
println!("Rimossa: {}", removed.text);
if let Err(message) = mark_done(&mut tasks, 99) {
println!("Errore: {message}");
}
print_tasks(&tasks);
}

Output:

Rimossa: Comprare il latte
Errore: non esiste l'attività 99
[ ] 2: Chiamare la nonna
[x] 3: Imparare Rust
▶ Provalo tu

Alcune scelte qui sono intenzionali:

  • Gli id partono dall’id più grande esistente, non da tasks.len() + 1. Se rimuovi l’attività 1 e poi ne aggiungi una nuova, usare la lunghezza ti darebbe un id duplicato.
  • mark_done prende &mut [Task] (una slice) perché modifica solo gli elementi. add_task e remove_task prendono &mut Vec<Task> perché cambiano la lunghezza.
  • Gli errori sono Result<_, String>. Un errore String è la cosa più semplice che funziona. Per programmi più grandi, guarda Errori personalizzati.

iter_mut().find(...) ti dà un riferimento mutabile all’attività trovata, ed è per questo che task.done = true modifica l’attività dentro il vettore. Guarda Iteratori per saperne di più su find e position.

Ora le attività devono sopravvivere alla fine del programma. Le salviamo con std::fs::write e le carichiamo con std::fs::read_to_string. Entrambe possono fallire, quindi entrambe restituiscono Result, e l’operatore ? passa ogni errore a chi ha chiamato:

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,
// La prima volta che esegui il programma, il file non c'è ancora.
// Non è un errore: significa solo «nessuna attività».
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)
}

Il caso NotFound conta più di quanto sembri. Senza di lui, il primissimo todo add fallirebbe con «No such file or directory» (file o cartella inesistente), perché non hai ancora salvato niente. Tutti gli altri errori (come «permission denied», permesso negato) vengono comunque passati su.

filter_map(Task::from_line) legge ogni riga e tiene solo quelle che si riescono a interpretare. Attenzione: così le righe rovinate vengono saltate in silenzio. È comodo se qualcuno modifica il file a mano e fa un errore di battitura, ma significa anche che un errore potrebbe passare inosservato. Più sotto trovi l’idea di avvisare invece di saltarle.

Guarda I/O su file per saperne di più su lettura e scrittura dei file.

Ecco tutto insieme. Mettilo in src/main.rs ed eseguilo con cargo run -- add Comprare il latte, cargo run -- list e così via.

La logica principale sta in run_command, che lavora su un Vec<Task> e non sa niente di file. main si limita a caricare il file, chiamare run_command e salvare. Tenere separate queste parti rende la logica facile da testare, ed è quello che ci permette di costruire una versione per il Playground nel prossimo passo.

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("indica il numero di un'attività"))?;
arg.parse().map_err(|_| format!("'{arg}' non è un numero di attività"))
}
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("uso: todo add <testo dell'attività>"))
} 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!("comando sconosciuto '{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!("non esiste l'attività {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!("non esiste l'attività {id}")),
}
}
fn print_tasks(tasks: &[Task]) {
if tasks.is_empty() {
println!("Niente da fare. Goditi la giornata!");
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!("Aggiunta l'attività {id}.");
}
Command::List => print_tasks(tasks),
Command::Done(id) => {
mark_done(tasks, id)?;
println!("Attività {id} segnata come completata.");
}
Command::Remove(id) => {
let task = remove_task(tasks, id)?;
println!("Rimossa l'attività {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!("Errore: {e}");
process::exit(1);
}
}

Una nota su run() -> Result<(), Box<dyn Error>>. Le nostre funzioni restituiscono due tipi di errore diversi: io::Error dalle funzioni sui file e String dalle funzioni sui comandi. Box<dyn Error> è una scatola che può contenere qualsiasi tipo di errore, quindi ? funziona con entrambi. main poi stampa l’errore con eprintln! (che scrive sull’output degli errori) ed esce con il codice 1, il segnale abituale che qualcosa è andato storto.

Il Playground non può passare argomenti da riga di comando, e non vogliamo che dipenda da file. Quindi questa versione tiene lo stesso run_command, ma gli passa una lista di comandi già scritta e alla fine stampa cosa conterrebbe 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("indica il numero di un'attività"))?;
arg.parse().map_err(|_| format!("'{arg}' non è un numero di attività"))
}
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("uso: todo add <testo dell'attività>"))
} 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!("comando sconosciuto '{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!("non esiste l'attività {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!("non esiste l'attività {id}")),
}
}
fn print_tasks(tasks: &[Task]) {
if tasks.is_empty() {
println!("Niente da fare. Goditi la giornata!");
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!("Aggiunta l'attività {id}.");
}
Command::List => print_tasks(tasks),
Command::Done(id) => {
mark_done(tasks, id)?;
println!("Attività {id} segnata come completata.");
}
Command::Remove(id) => {
let task = remove_task(tasks, id)?;
println!("Rimossa l'attività {id}: {}", task.text);
}
}
Ok(())
}
fn main() {
// Ogni riga è quello che scriveresti dopo `todo`.
let script = [
"list",
"add Comprare il latte",
"add Chiamare la nonna",
"add Imparare Rust",
"done 3",
"remove 1",
"done 9",
"salta",
"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!("Errore: {message}");
}
}
println!();
println!("tasks.txt conterrebbe:");
for task in &tasks {
println!("{}", task.to_line());
}
}

Output:

$ todo list
Niente da fare. Goditi la giornata!
$ todo add Comprare il latte
Aggiunta l'attività 1.
$ todo add Chiamare la nonna
Aggiunta l'attività 2.
$ todo add Imparare Rust
Aggiunta l'attività 3.
$ todo done 3
Attività 3 segnata come completata.
$ todo remove 1
Rimossa l'attività 1: Comprare il latte
$ todo done 9
Errore: non esiste l'attività 9
$ todo salta
Errore: comando sconosciuto 'salta'
$ todo list
[ ] 2: Chiamare la nonna
[x] 3: Imparare Rust
tasks.txt conterrebbe:
2|0|Chiamare la nonna
3|1|Imparare Rust
▶ Provalo tu

Cambia lo script ed eseguilo di nuovo. Prova ad aggiungere un’attività dopo averne rimossa una, e controlla che il nuovo id non sia un duplicato.

Il nostro formato id|done|testo è facile da leggere, ma hai dovuto scrivere a mano to_line e from_line. Man mano che l’attività si arricchisce (scadenze, priorità, etichette), anche quel codice scritto a mano cresce.

Nei progetti veri, di solito si lascia fare questo lavoro ai crate serde (in inglese). Aggiungili con:

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

Poi una sola riga derive ti dà salvataggio e caricamento 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("Comprare il latte") },
Task { id: 2, done: true, text: String::from("Chiamare la nonna") },
];
let json = serde_json::to_string(&tasks).unwrap();
println!("{json}");
let loaded: Vec<Task> = serde_json::from_str(&json).unwrap();
println!("Ricaricate {} attività", loaded.len());
}

Output:

[{"id":1,"done":false,"text":"Comprare il latte"},{"id":2,"done":true,"text":"Chiamare la nonna"}]
Ricaricate 2 attività
▶ Provalo tu

Scriveresti quella stringa JSON in tasks.json con fs::write, esattamente come prima. La pagina JSON con Serde spiega tutto nel dettaglio.

Usare ? in un main che non restituisce niente. Viene la tentazione di scrivere così:

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

Il compilatore dice che l’operatore ? si può usare solo in una funzione che restituisce Result o Option. ? significa «se questo è un errore, restituiscilo dalla funzione corrente». Un main semplice restituisce (), quindi l’errore non ha dove andare. Dai a main un tipo di ritorno come Result<(), Box<dyn Error>>, oppure fai come il nostro programma: metti il lavoro in una funzione run() che restituisce Result, e gestisci l’errore una volta sola in main.

Trattare un file mancante come un fallimento. Se load_tasks facesse solo fs::read_to_string(path)?, il programma fallirebbe alla primissima esecuzione, prima che esista qualsiasi file. Controlla io::ErrorKind::NotFound e parti invece con una lista vuota.

Usare la lunghezza della lista come prossimo id. Con le attività 1, 2, 3, rimuovere l’attività 1 ne lascia due. len() + 1 darebbe di nuovo 3, e ora due attività avrebbero lo stesso id. Usa l’id più grande più uno.

  • Annulla «completata». Aggiungi un comando undone <id> che rimette done a false.
  • Modifica un’attività. edit 2 Chiamare la nonna stasera sostituisce il testo dell’attività 2.
  • Elimina le attività completate. clear rimuove tutte le attività completate. Dai un’occhiata a Vec::retain (in inglese).
  • Avvisa per le righe rovinate. Invece di saltare in silenzio le righe sbagliate, stampa un avviso con il numero di riga.
  • Un comando help che stampa tutti i comandi e come usarli.
  • Scrivi dei test. parse_command e run_command non toccano i file, quindi sono facili da testare.
  • Usa un vero parser di argomenti. Il crate clap (in inglese), citato nella pagina Argomenti da riga di comando, ti dà --help e i sottocomandi gratis.
  • Una struct Task contiene i dati. to_line e from_line la trasformano in testo e viceversa.
  • Un enum Command descrive cosa ha chiesto l’utente. parse_command trasforma gli argomenti in un Command, oppure in un Err con un messaggio utile.
  • Un Vec<Task> contiene la lista. Piccole funzioni aggiungono, trovano, modificano e rimuovono le attività.
  • fs::read_to_string e fs::write restituiscono Result, e ? passa gli errori verso l’alto. Un file mancante alla prima esecuzione viene trattato come «nessuna attività».
  • Tenere la logica (run_command) separata da file e argomenti la rende facile da testare e da eseguire nel Playground.

Verifica veloce

1. Perché from_line usa splitn(3, '|') invece di split('|')?

2. Cosa fa load_tasks quando tasks.txt non esiste ancora?

3. Perché non puoi usare ? direttamente dentro fn main() senza tipo di ritorno?

4. Qual è il crate che si usa di solito per salvare le struct in JSON?

Punteggio: 0 / 4