Pular para o conteúdo

Projeto: Lista de tarefas em Rust

Neste projeto você vai criar uma pequena lista de tarefas que mora no seu terminal. Você digita todo add Comprar leite e ela guarda. Fecha o terminal, volta amanhã, digita todo list, e suas tarefas continuam lá.

É um ótimo segundo projeto, porque parece uma ferramenta de verdade. Ele usa structs (estruturas), enums (enumerações), vetores, argumentos de linha de comando, leitura e escrita de arquivos e Result com ?. Vamos construir cada parte separadamente, testar no Playground e, no fim, juntar tudo.

Veja como fica o uso da ferramenta pronta:

Terminal window
$ cargo run -- add Comprar leite
Tarefa 1 adicionada.
$ cargo run -- add Ligar para a vovó
Tarefa 2 adicionada.
$ cargo run -- done 1
Tarefa 1 marcada como concluída.
$ cargo run -- list
[x] 1: Comprar leite
[ ] 2: Ligar para a vovó
$ cargo run -- remove 1
Tarefa 1 removida: Comprar leite

Tudo o que vem depois de -- é passado para o seu programa, e não para o Cargo. Os nomes dos comandos (add, list, done e remove) ficam em inglês, como na maioria das ferramentas de terminal. Se você preferir adicionar ou listar, é só trocá-los em parse_command (Passo 2).

As tarefas ficam salvas em um arquivo chamado tasks.txt, uma tarefa por linha:

2|0|Ligar para a vovó

Cada linha tem o formato id|done|text, em que done vale 1 para tarefa concluída e 0 para tarefa pendente.

Crie um projeto novo com:

Terminal window
cargo new todo
cd todo

Toda tarefa tem um número (para você poder se referir a ela), uma marcação de “concluída” e um texto. Um struct encaixa perfeitamente. Também damos a ele dois métodos: um que transforma a tarefa em uma linha de texto para o arquivo, e outro que lê a tarefa de volta a partir de uma linha.

#[derive(Debug)]
struct Task {
id: u32,
done: bool,
text: String,
}
impl Task {
// Transforma a tarefa em uma linha, como "1|0|Comprar leite".
fn to_line(&self) -> String {
let done = if self.done { 1 } else { 0 };
format!("{}|{}|{}", self.id, done, self.text)
}
// Lê a tarefa de volta a partir de uma linha. Retorna None se a linha estiver quebrada.
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("Comprar leite") };
let line = task.to_line();
println!("{line}");
println!("{:?}", Task::from_line("2|1|Ligar para a vovó"));
println!("{:?}", Task::from_line("3|0|Ler sobre testes A|B"));
println!("{:?}", Task::from_line("esta linha está quebrada"));
}

Saída:

1|0|Comprar leite
Some(Task { id: 2, done: true, text: "Ligar para a vovó" })
Some(Task { id: 3, done: false, text: "Ler sobre testes A|B" })
None
▶ Experimente você mesmo

Vale a pena olhar com calma para dois detalhes:

  • splitn(3, '|') divide o texto em no máximo 3 pedaços. Os dois primeiros | separam os campos, e tudo o que vem depois é o texto, mesmo que ele tenha um |. Com um split('|') simples, a tarefa “Ler sobre testes A|B” perderia metade do texto.
  • from_line retorna um Option e usa ? em cada passo. Se faltar algum pedaço ou se o id não for um número, a função inteira retorna None. O .ok() transforma o Result de parse() em um Option, para que o ? funcione com ele.

A ferramenta entende quatro comandos. Cada um é uma variante de um enum, e algumas variantes carregam dados: Add precisa do texto, e Done e Remove precisam do número da tarefa.

#[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("informe o número da tarefa"))?;
arg.parse().map_err(|_| format!("'{arg}' não é um número de tarefa"))
}
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 <texto da tarefa>"))
} 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 desconhecido '{other}'")),
}
}
fn main() {
let examples = [
vec!["add", "Comprar", "leite"],
vec!["list"],
vec![],
vec!["done", "2"],
vec!["done", "dois"],
vec!["remove"],
vec!["dançar"],
];
for words in examples {
let args: Vec<String> = words.iter().map(|w| w.to_string()).collect();
println!("{:?} -> {:?}", words, parse_command(&args));
}
}

Saída:

["add", "Comprar", "leite"] -> Ok(Add("Comprar leite"))
["list"] -> Ok(List)
[] -> Ok(List)
["done", "2"] -> Ok(Done(2))
["done", "dois"] -> Err("'dois' não é um número de tarefa")
["remove"] -> Err("informe o número da tarefa")
["dançar"] -> Err("comando desconhecido 'dançar'")
▶ Experimente você mesmo

No programa de verdade, args vem da linha de comando. Como você viu na página Argumentos de linha de comando, o primeiro item de std::env::args() é o nome do próprio programa, então pulamos esse item:

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

Por que o add usa args[1..].join(" ")? O seu shell divide todo add Comprar leite em palavras separadas: add, Comprar, leite. Juntar tudo o que vem depois de add remonta o texto da tarefa, então você não precisa de aspas.

Repare que parse_command nunca quebra o programa. Uma entrada inválida vira um Err com uma mensagem útil, e quem chamou a função decide o que fazer com ela.

Todas as tarefas ficam em um Vec<Task>. Cada comando é uma pequena função que altera o vetor:

  • Add: encontra o maior id até agora, soma 1 e adiciona (push) uma tarefa nova.
  • Done: encontra a tarefa com aquele id e define done = true.
  • Remove: encontra a posição da tarefa e a remove.
  • List: mostra todas as tarefas.
#[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!("a tarefa {id} não existe")),
}
}
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!("a tarefa {id} não existe")),
}
}
fn print_tasks(tasks: &[Task]) {
if tasks.is_empty() {
println!("Nada para fazer. Aproveite o seu dia!");
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("Comprar leite"));
add_task(&mut tasks, String::from("Ligar para a vovó"));
add_task(&mut tasks, String::from("Aprender Rust"));
mark_done(&mut tasks, 3).unwrap();
let removed = remove_task(&mut tasks, 1).unwrap();
println!("Removida: {}", removed.text);
if let Err(message) = mark_done(&mut tasks, 99) {
println!("Erro: {message}");
}
print_tasks(&tasks);
}

Saída:

Removida: Comprar leite
Erro: a tarefa 99 não existe
[ ] 2: Ligar para a vovó
[x] 3: Aprender Rust
▶ Experimente você mesmo

Algumas escolhas aqui são de propósito:

  • Os ids vêm do maior id existente, e não de tasks.len() + 1. Se você remover a tarefa 1 e depois adicionar outra, usar o tamanho da lista daria um id repetido.
  • mark_done recebe &mut [Task] (um slice, ou fatia), porque só altera os itens. Já add_task e remove_task recebem &mut Vec<Task>, porque mudam o tamanho do vetor.
  • Os erros são Result<_, String>. Um erro do tipo String é a solução mais simples que funciona. Para programas maiores, veja Erros personalizados.

iter_mut().find(...) dá a você uma referência mutável para a tarefa encontrada. É por isso que task.done = true altera a tarefa dentro do vetor. Veja Iteradores para saber mais sobre find e position.

Agora as tarefas precisam sobreviver depois que o programa termina. Nós as salvamos com std::fs::write e carregamos com std::fs::read_to_string. As duas funções podem falhar, então as duas retornam Result, e o operador ? repassa qualquer erro para quem chamou:

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,
// Na primeira vez que você roda o programa, o arquivo ainda não existe.
// Isso não é um erro: só quer dizer "nenhuma tarefa".
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)
}

O caso NotFound é mais importante do que parece. Sem ele, o primeiríssimo todo add falharia com “No such file or directory” (“arquivo ou diretório não encontrado”, mensagem do sistema em inglês), porque você ainda não salvou nada. Todos os outros erros (como “permission denied”, permissão negada) continuam sendo repassados.

filter_map(Task::from_line) lê cada linha e fica só com as que conseguem ser interpretadas. Atenção: isso pula as linhas quebradas sem avisar. É gentil se alguém editar o arquivo à mão e errar a digitação, mas também significa que um erro pode passar despercebido. Uma das ideias de extensão lá embaixo é mostrar um aviso em vez disso.

Veja a página Arquivos em Rust para saber mais sobre como ler e escrever arquivos.

Aqui está tudo junto. Coloque em src/main.rs e rode com cargo run -- add Comprar leite, cargo run -- list e assim por diante.

A lógica principal fica em run_command, que trabalha com um Vec<Task> e não sabe nada sobre arquivos. A main só carrega o arquivo, chama run_command e salva. Manter essas partes separadas deixa a lógica fácil de testar, e é isso que nos permite montar uma versão para o Playground no próximo 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("informe o número da tarefa"))?;
arg.parse().map_err(|_| format!("'{arg}' não é um número de tarefa"))
}
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 <texto da tarefa>"))
} 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 desconhecido '{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!("a tarefa {id} não existe")),
}
}
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!("a tarefa {id} não existe")),
}
}
fn print_tasks(tasks: &[Task]) {
if tasks.is_empty() {
println!("Nada para fazer. Aproveite o seu dia!");
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!("Tarefa {id} adicionada.");
}
Command::List => print_tasks(tasks),
Command::Done(id) => {
mark_done(tasks, id)?;
println!("Tarefa {id} marcada como concluída.");
}
Command::Remove(id) => {
let task = remove_task(tasks, id)?;
println!("Tarefa {id} removida: {}", 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!("Erro: {e}");
process::exit(1);
}
}

Uma observação sobre run() -> Result<(), Box<dyn Error>>. Nossas funções retornam dois tipos de erro diferentes: io::Error nas funções de arquivo e String nas funções de comando. Box<dyn Error> é uma caixa que pode guardar qualquer tipo de erro, então o ? funciona com os dois. Depois, a main mostra o erro com eprintln! (que escreve na saída de erros) e encerra com o código 1, o sinal habitual de que algo deu errado.

O Playground não consegue passar argumentos de linha de comando, e não queremos depender de arquivos. Por isso, esta versão mantém o mesmo run_command, mas o alimenta com uma lista de comandos pré-definida e, no fim, mostra o que o tasks.txt conteria:

#[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("informe o número da tarefa"))?;
arg.parse().map_err(|_| format!("'{arg}' não é um número de tarefa"))
}
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 <texto da tarefa>"))
} 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 desconhecido '{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!("a tarefa {id} não existe")),
}
}
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!("a tarefa {id} não existe")),
}
}
fn print_tasks(tasks: &[Task]) {
if tasks.is_empty() {
println!("Nada para fazer. Aproveite o seu dia!");
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!("Tarefa {id} adicionada.");
}
Command::List => print_tasks(tasks),
Command::Done(id) => {
mark_done(tasks, id)?;
println!("Tarefa {id} marcada como concluída.");
}
Command::Remove(id) => {
let task = remove_task(tasks, id)?;
println!("Tarefa {id} removida: {}", task.text);
}
}
Ok(())
}
fn main() {
// Cada linha é o que você digitaria depois de `todo`.
let script = [
"list",
"add Comprar leite",
"add Ligar para a vovó",
"add Aprender Rust",
"done 3",
"remove 1",
"done 9",
"pular",
"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!("Erro: {message}");
}
}
println!();
println!("O tasks.txt ficaria assim:");
for task in &tasks {
println!("{}", task.to_line());
}
}

Saída:

$ todo list
Nada para fazer. Aproveite o seu dia!
$ todo add Comprar leite
Tarefa 1 adicionada.
$ todo add Ligar para a vovó
Tarefa 2 adicionada.
$ todo add Aprender Rust
Tarefa 3 adicionada.
$ todo done 3
Tarefa 3 marcada como concluída.
$ todo remove 1
Tarefa 1 removida: Comprar leite
$ todo done 9
Erro: a tarefa 9 não existe
$ todo pular
Erro: comando desconhecido 'pular'
$ todo list
[ ] 2: Ligar para a vovó
[x] 3: Aprender Rust
O tasks.txt ficaria assim:
2|0|Ligar para a vovó
3|1|Aprender Rust
▶ Experimente você mesmo

Mude o script e rode de novo. Experimente adicionar uma tarefa depois de remover outra e confira que o novo id não se repete.

Nosso formato id|done|text é fácil de ler, mas você teve que escrever to_line e from_line à mão. Conforme sua tarefa ganha mais informações (prazos, prioridades, etiquetas), esse código escrito à mão cresce junto.

Em projetos reais, o mais comum é deixar esse trabalho para o serde (em inglês), um conjunto de crates (bibliotecas do Rust). Adicione os crates com:

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

Depois, uma única linha de derive já te dá o salvamento e o carregamento de graça:

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("Comprar leite") },
Task { id: 2, done: true, text: String::from("Ligar para a vovó") },
];
let json = serde_json::to_string(&tasks).unwrap();
println!("{json}");
let loaded: Vec<Task> = serde_json::from_str(&json).unwrap();
println!("{} tarefas carregadas de volta", loaded.len());
}

Saída:

[{"id":1,"done":false,"text":"Comprar leite"},{"id":2,"done":true,"text":"Ligar para a vovó"}]
2 tarefas carregadas de volta
▶ Experimente você mesmo

Você gravaria esse texto JSON em tasks.json com fs::write, exatamente como antes. A página JSON com Serde explica isso em detalhes.

Usar ? em uma main que não retorna nada. É tentador escrever assim:

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

O compilador está dizendo que o operador ? só pode ser usado em uma função que retorna Result ou Option, e que esta main retorna (). O ? quer dizer “se isto for um erro, retorne-o da função atual”. Uma main simples retorna (), então não há para onde mandar o erro. Ou você dá à main um tipo de retorno como Result<(), Box<dyn Error>>, ou faz como o nosso programa: coloca o trabalho em uma função run() que retorna Result e trata o erro uma única vez na main.

Tratar a falta do arquivo como uma falha. Se load_tasks fizesse só fs::read_to_string(path)?, o programa falharia logo na primeira execução, antes de qualquer arquivo existir. Verifique io::ErrorKind::NotFound e comece com uma lista vazia.

Usar o tamanho da lista como próximo id. Com as tarefas 1, 2 e 3, remover a tarefa 1 deixa duas tarefas. len() + 1 daria 3 de novo, e agora duas tarefas teriam o mesmo id. Use o maior id mais um.

  • Desfazer o “done”. Adicione um comando undone <id> que volta done para false.
  • Editar uma tarefa. edit 2 Ligar para a vovó hoje à noite troca o texto da tarefa 2.
  • Limpar as tarefas concluídas. clear remove todas as tarefas que já estão concluídas. Dê uma olhada em Vec::retain (em inglês).
  • Avisar sobre linhas quebradas. Em vez de pular as linhas inválidas em silêncio, mostre um aviso com o número da linha.
  • Um comando help que mostra todos os comandos e como usá-los.
  • Escrever testes. parse_command e run_command não mexem em arquivos, então são fáceis de testar.
  • Usar um parser de argumentos de verdade. O crate clap (em inglês), citado na página Argumentos de linha de comando, te dá --help e subcomandos de graça.
  • Um struct Task guarda os dados. to_line e from_line o transformam em texto e de volta.
  • Um enum Command descreve o que o usuário pediu. parse_command transforma os argumentos em um Command, ou em um Err com uma mensagem útil.
  • Um Vec<Task> guarda a lista. Pequenas funções adicionam, encontram, alteram e removem tarefas.
  • fs::read_to_string e fs::write retornam Result, e o ? repassa os erros. A falta do arquivo na primeira execução é tratada como “nenhuma tarefa”.
  • Manter a lógica (run_command) separada dos arquivos e dos argumentos facilita os testes e permite rodar o programa no Playground.

Teste o que aprendeu

1. Por que from_line usa splitn(3, '|') em vez de split('|')?

2. O que load_tasks faz quando o tasks.txt ainda não existe?

3. Por que você não pode usar ? diretamente dentro de uma fn main() sem tipo de retorno?

4. Qual crate é a melhoria mais comum para salvar structs como JSON?

Pontuação: 0 / 4