Neste projeto, vamos construir um gerenciador de tarefas (to-do list) completo para a linha de comando. Aplicativos de gerenciamento de tarefas são um clássico do aprendizado de programação porque cobrem operações CRUD (criar, listar, atualizar, deletar), persistência de dados e uma interface com o usuário. Em Rust, esse projeto é uma excelente oportunidade para praticar serialização com serde, manipulação de arquivos e o uso elegante de enums para comandos.
Ao final deste walkthrough, você terá uma ferramenta prática que pode usar no seu dia a dia para organizar tarefas diretamente do terminal.
O Que Vamos Construir
Nosso gerenciador tarefas terá os seguintes recursos:
- Adicionar tarefas com descrição e prioridade
- Listar todas as tarefas com status colorido
- Marcar tarefas como concluídas
- Remover tarefas por ID
- Filtrar por status (pendente/concluída)
- Persistência automática em arquivo JSON
- Exibição formatada com cores no terminal
Estrutura do Projeto
tarefas/
├── Cargo.toml
└── src/
├── main.rs
├── cli.rs
├── tarefa.rs
└── armazenamento.rs
Configurando o Projeto
cargo new tarefas
cd tarefas
Configure o Cargo.toml:
[package]
name = "tarefas"
version = "0.1.0"
edition = "2021"
[dependencies]
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
colored = "2"
chrono = { version = "0.4", features = ["serde"] }
Usamos serde e serde_json para serializar as tarefas em JSON, chrono para registrar datas e colored para a saída no terminal.
Passo 1: Modelando a Tarefa
O módulo tarefa.rs define a estrutura de dados central do nosso aplicativo.
// src/tarefa.rs
use chrono::{DateTime, Local};
use serde::{Deserialize, Serialize};
use std::fmt;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum Prioridade {
Baixa,
Media,
Alta,
}
impl fmt::Display for Prioridade {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Prioridade::Baixa => write!(f, "Baixa"),
Prioridade::Media => write!(f, "Média"),
Prioridade::Alta => write!(f, "Alta"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Tarefa {
pub id: u32,
pub descricao: String,
pub concluida: bool,
pub prioridade: Prioridade,
pub criada_em: DateTime<Local>,
pub concluida_em: Option<DateTime<Local>>,
}
impl Tarefa {
pub fn nova(id: u32, descricao: String, prioridade: Prioridade) -> Self {
Self {
id,
descricao,
concluida: false,
prioridade,
criada_em: Local::now(),
concluida_em: None,
}
}
pub fn concluir(&mut self) {
self.concluida = true;
self.concluida_em = Some(Local::now());
}
}
Usamos #[derive(Serialize, Deserialize)] para que o serde possa converter automaticamente nossas structs de e para JSON. O campo concluida_em é Option<DateTime<Local>> — None enquanto a tarefa está pendente e Some(data) quando concluída.
Passo 2: Persistência com JSON
O módulo armazenamento.rs cuida de ler e escrever as tarefas no disco.
// src/armazenamento.rs
use crate::tarefa::Tarefa;
use std::fs;
use std::io;
use std::path::PathBuf;
/// Retorna o caminho do arquivo de dados
fn caminho_arquivo() -> PathBuf {
let mut caminho = dirs_or_default();
caminho.push("tarefas.json");
caminho
}
/// Tenta usar o diretório home; caso contrário, usa o diretório atual
fn dirs_or_default() -> PathBuf {
std::env::var("HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("."))
}
/// Carrega as tarefas do arquivo JSON
pub fn carregar() -> io::Result<Vec<Tarefa>> {
let caminho = caminho_arquivo();
if !caminho.exists() {
return Ok(Vec::new());
}
let conteudo = fs::read_to_string(&caminho)?;
if conteudo.trim().is_empty() {
return Ok(Vec::new());
}
let tarefas: Vec<Tarefa> = serde_json::from_str(&conteudo)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
Ok(tarefas)
}
/// Salva as tarefas no arquivo JSON
pub fn salvar(tarefas: &[Tarefa]) -> io::Result<()> {
let caminho = caminho_arquivo();
let json = serde_json::to_string_pretty(tarefas)
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
fs::write(&caminho, json)?;
Ok(())
}
/// Gera o próximo ID disponível
pub fn proximo_id(tarefas: &[Tarefa]) -> u32 {
tarefas.iter().map(|t| t.id).max().unwrap_or(0) + 1
}
A função carregar lê o arquivo JSON e desserializa para um Vec<Tarefa>. Se o arquivo não existir, retornamos um vetor vazio. A função salvar serializa as tarefas com to_string_pretty para manter o arquivo legível.
Passo 3: Interface de Linha de Comando
O módulo cli.rs define os subcomandos do aplicativo.
// src/cli.rs
use clap::{Parser, Subcommand, ValueEnum};
#[derive(Parser, Debug)]
#[command(name = "tarefas")]
#[command(about = "Gerenciador de tarefas para o terminal")]
pub struct Cli {
#[command(subcommand)]
pub comando: Comando,
}
#[derive(Subcommand, Debug)]
pub enum Comando {
/// Adiciona uma nova tarefa
Adicionar {
/// Descrição da tarefa
descricao: String,
/// Prioridade: baixa, media, alta
#[arg(short, long, default_value = "media")]
prioridade: NivelPrioridade,
},
/// Lista as tarefas
Listar {
/// Filtrar por status: todas, pendentes, concluidas
#[arg(short, long, default_value = "todas")]
filtro: FiltroStatus,
},
/// Marca uma tarefa como concluída
Concluir {
/// ID da tarefa
id: u32,
},
/// Remove uma tarefa
Remover {
/// ID da tarefa
id: u32,
},
/// Limpa todas as tarefas concluídas
Limpar,
}
#[derive(Debug, Clone, ValueEnum)]
pub enum NivelPrioridade {
Baixa,
Media,
Alta,
}
#[derive(Debug, Clone, ValueEnum)]
pub enum FiltroStatus {
Todas,
Pendentes,
Concluidas,
}
Usamos o recurso de subcomandos do clap — cada ação (adicionar, listar, concluir, remover, limpar) é um subcomando com seus próprios argumentos.
Passo 4: Juntando Tudo no main.rs
// src/main.rs
mod armazenamento;
mod cli;
mod tarefa;
use clap::Parser;
use cli::{Cli, Comando, FiltroStatus, NivelPrioridade};
use colored::*;
use tarefa::{Prioridade, Tarefa};
fn main() {
let cli = Cli::parse();
let mut tarefas = match armazenamento::carregar() {
Ok(t) => t,
Err(e) => {
eprintln!("{} Erro ao carregar tarefas: {}", "ERRO:".red().bold(), e);
std::process::exit(1);
}
};
match cli.comando {
Comando::Adicionar {
descricao,
prioridade,
} => {
let nivel = match prioridade {
NivelPrioridade::Baixa => Prioridade::Baixa,
NivelPrioridade::Media => Prioridade::Media,
NivelPrioridade::Alta => Prioridade::Alta,
};
let id = armazenamento::proximo_id(&tarefas);
let nova = Tarefa::nova(id, descricao.clone(), nivel);
tarefas.push(nova);
println!(
"{} Tarefa #{} adicionada: {}",
"OK".green().bold(),
id,
descricao
);
}
Comando::Listar { filtro } => {
let filtradas: Vec<&Tarefa> = match filtro {
FiltroStatus::Todas => tarefas.iter().collect(),
FiltroStatus::Pendentes => tarefas.iter().filter(|t| !t.concluida).collect(),
FiltroStatus::Concluidas => tarefas.iter().filter(|t| t.concluida).collect(),
};
if filtradas.is_empty() {
println!("{}", "Nenhuma tarefa encontrada.".yellow());
return;
}
println!(
"{:<5} {:<10} {:<10} {}",
"ID".bold(),
"Status".bold(),
"Prior.".bold(),
"Descrição".bold()
);
println!("{}", "-".repeat(60));
for tarefa in &filtradas {
let status = if tarefa.concluida {
"Concluída".green().to_string()
} else {
"Pendente".yellow().to_string()
};
let prioridade = match tarefa.prioridade {
Prioridade::Alta => "Alta".red().to_string(),
Prioridade::Media => "Média".yellow().to_string(),
Prioridade::Baixa => "Baixa".blue().to_string(),
};
println!(
"{:<5} {:<10} {:<10} {}",
tarefa.id, status, prioridade, tarefa.descricao
);
}
println!(
"\n{} tarefa(s) no total.",
filtradas.len().to_string().bold()
);
}
Comando::Concluir { id } => {
if let Some(tarefa) = tarefas.iter_mut().find(|t| t.id == id) {
if tarefa.concluida {
println!("{} Tarefa #{} já está concluída.", "AVISO:".yellow(), id);
return;
}
tarefa.concluir();
println!(
"{} Tarefa #{} marcada como concluída!",
"OK".green().bold(),
id
);
} else {
eprintln!("{} Tarefa #{} não encontrada.", "ERRO:".red().bold(), id);
std::process::exit(1);
}
}
Comando::Remover { id } => {
let tamanho_antes = tarefas.len();
tarefas.retain(|t| t.id != id);
if tarefas.len() < tamanho_antes {
println!("{} Tarefa #{} removida.", "OK".green().bold(), id);
} else {
eprintln!("{} Tarefa #{} não encontrada.", "ERRO:".red().bold(), id);
std::process::exit(1);
}
}
Comando::Limpar => {
let quantidade = tarefas.iter().filter(|t| t.concluida).count();
tarefas.retain(|t| !t.concluida);
println!(
"{} {} tarefa(s) concluída(s) removida(s).",
"OK".green().bold(),
quantidade
);
}
}
if let Err(e) = armazenamento::salvar(&tarefas) {
eprintln!("{} Erro ao salvar tarefas: {}", "ERRO:".red().bold(), e);
std::process::exit(1);
}
}
O main.rs segue um padrão simples: carregar dados, processar o comando e salvar dados. Cada comando faz a operação correspondente no vetor de tarefas e exibe uma mensagem de confirmação.
Como Executar
cargo build --release
Exemplos de uso:
# Adicionar tarefas
./target/release/tarefas adicionar "Estudar ownership em Rust" --prioridade alta
# OK Tarefa #1 adicionada: Estudar ownership em Rust
./target/release/tarefas adicionar "Configurar CI/CD" --prioridade media
# OK Tarefa #2 adicionada: Configurar CI/CD
./target/release/tarefas adicionar "Ler documentação do serde"
# OK Tarefa #3 adicionada: Ler documentação do serde
# Listar todas
./target/release/tarefas listar
# ID Status Prior. Descrição
# ------------------------------------------------------------
# 1 Pendente Alta Estudar ownership em Rust
# 2 Pendente Média Configurar CI/CD
# 3 Pendente Média Ler documentação do serde
#
# 3 tarefa(s) no total.
# Concluir uma tarefa
./target/release/tarefas concluir 1
# OK Tarefa #1 marcada como concluída!
# Filtrar pendentes
./target/release/tarefas listar --filtro pendentes
# Remover tarefa
./target/release/tarefas remover 3
# OK Tarefa #3 removida.
# Limpar todas as concluídas
./target/release/tarefas limpar
# OK 1 tarefa(s) concluída(s) removida(s).
Desafios para Expandir
Tags e categorias: Adicione suporte a tags (ex:
--tag trabalho --tag pessoal) para organizar tarefas e permitir filtragem por tag.Data de vencimento: Implemente um campo de prazo (
--vencimento 2026-03-15) e destaque em vermelho as tarefas vencidas ao listar.Busca por texto: Adicione um subcomando
buscarque encontre tarefas pela descrição usando correspondência parcial ou regex.Exportação para Markdown: Crie um subcomando
exportarque gere um arquivo Markdown formatado com todas as tarefas, ideal para compartilhar relatórios.Múltiplas listas: Permita que o usuário crie e alterne entre diferentes listas de tarefas (ex:
tarefas --lista trabalho adicionar "Reunião").
Veja Também
- Trabalhando com Vec — o tipo vetor em Rust
- HashMap — mapas de chave-valor
- Serializar e Desserializar JSON — trabalhando com serde_json
- Lendo Arquivos — padrões de leitura de arquivos
- Escrevendo em Arquivos — persistência de dados em disco