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.
Cosa costruirai
Sezione intitolata “Cosa costruirai”Ecco come si usa lo strumento finito:
$ cargo run -- add Comprare il latteAggiunta l'attività 1.$ cargo run -- add Chiamare la nonnaAggiunta l'attività 2.$ cargo run -- done 1Attività 1 segnata come completata.$ cargo run -- list[x] 1: Comprare il latte[ ] 2: Chiamare la nonna$ cargo run -- remove 1Rimossa l'attività 1: Comprare il latteTutto 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 nonnaOgni riga è id|done|testo, dove done vale 1 per «completata» e 0 per «da fare».
Crea un nuovo progetto con:
cargo new todocd todoPasso 1: la struct Task
Sezione intitolata “Passo 1: la struct Task”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 latteSome(Task { id: 2, done: true, text: "Chiamare la nonna" })Some(Task { id: 3, done: false, text: "Studiare gli A|B test" })NoneDue 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 semplicesplit('|'), l’attività «Studiare gli A|B test» perderebbe metà del testo.from_linerestituisce unOption, e usa?a ogni passo. Se manca un pezzo o l’id non è un numero, l’intera funzione restituisceNone. Il.ok()trasforma ilResultdiparse()in unOption, così?può funzionare.
Passo 2: l’enum Command
Sezione intitolata “Passo 2: l’enum Command”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'")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.
Passo 3: lavorare con la lista delle attività
Sezione intitolata “Passo 3: lavorare con la lista delle attività”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
pushdi 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 latteErrore: non esiste l'attività 99[ ] 2: Chiamare la nonna[x] 3: Imparare RustAlcune 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_doneprende&mut [Task](una slice) perché modifica solo gli elementi.add_taskeremove_taskprendono&mut Vec<Task>perché cambiano la lunghezza.- Gli errori sono
Result<_, String>. Un erroreStringè 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.
Passo 4: salvare e caricare il file
Sezione intitolata “Passo 4: salvare e caricare il file”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.
Passo 5: il programma completo
Sezione intitolata “Passo 5: il programma completo”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.
Passo 6: una versione da eseguire nel Playground
Sezione intitolata “Passo 6: una versione da eseguire nel Playground”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 listNiente da fare. Goditi la giornata!$ todo add Comprare il latteAggiunta l'attività 1.$ todo add Chiamare la nonnaAggiunta l'attività 2.$ todo add Imparare RustAggiunta l'attività 3.$ todo done 3Attività 3 segnata come completata.$ todo remove 1Rimossa l'attività 1: Comprare il latte$ todo done 9Errore: non esiste l'attività 9$ todo saltaErrore: comando sconosciuto 'salta'$ todo list[ ] 2: Chiamare la nonna[x] 3: Imparare Rust
tasks.txt conterrebbe:2|0|Chiamare la nonna3|1|Imparare RustCambia 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.
Miglioramento: salvare in JSON con serde
Sezione intitolata “Miglioramento: salvare in JSON con serde”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:
cargo add serde --features derivecargo add serde_jsonPoi 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àScriveresti quella stringa JSON in tasks.json con fs::write, esattamente come prima. La pagina JSON con Serde spiega tutto nel dettaglio.
Errori comuni
Sezione intitolata “Errori comuni”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.
Idee per ampliarlo
Sezione intitolata “Idee per ampliarlo”- Annulla «completata». Aggiungi un comando
undone <id>che rimettedoneafalse. - Modifica un’attività.
edit 2 Chiamare la nonna staserasostituisce il testo dell’attività 2. - Elimina le attività completate.
clearrimuove tutte le attività completate. Dai un’occhiata aVec::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
helpche stampa tutti i comandi e come usarli. - Scrivi dei test.
parse_commanderun_commandnon 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à--helpe i sottocomandi gratis.
Riepilogo
Sezione intitolata “Riepilogo”- Una struct
Taskcontiene i dati.to_lineefrom_linela trasformano in testo e viceversa. - Un enum
Commanddescrive cosa ha chiesto l’utente.parse_commandtrasforma gli argomenti in unCommand, oppure in unErrcon un messaggio utile. - Un
Vec<Task>contiene la lista. Piccole funzioni aggiungono, trovano, modificano e rimuovono le attività. fs::read_to_stringefs::writerestituisconoResult, 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
Sezione intitolata “Verifica veloce”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
Per approfondire
Sezione intitolata “Per approfondire”- An I/O Project: Building a Command Line Program — The Rust Book (in inglese): un progetto CLI guidato più grande, con ottimi consigli su come dividere
mainin pezzi più piccoli e testabili. std::fs— documentazione di std (in inglese):read_to_string,writee le altre funzioni sui file dietro il codice di salvataggio e caricamento.std::env::args— documentazione di std (in inglese): come arrivano gli argomenti da riga di comando, e perché il primo è il nome del programma.str::splitn— documentazione di std (in inglese): dividere una riga in al massimo N pezzi. È il trucco che tiene intero il testo di un’attività che contiene|.serde_json— docs.rs (in inglese): il crate JSON della sezione sul miglioramento, con esempi dito_stringefrom_str. La guida di serde (in inglese) spiega gli attributi derive.