Gerenciador de Tarefas CLI

Construa um gerenciador de tarefas completo para o terminal em Rust com persistência em JSON, filtros e saída colorida.

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

  1. Tags e categorias: Adicione suporte a tags (ex: --tag trabalho --tag pessoal) para organizar tarefas e permitir filtragem por tag.

  2. Data de vencimento: Implemente um campo de prazo (--vencimento 2026-03-15) e destaque em vermelho as tarefas vencidas ao listar.

  3. Busca por texto: Adicione um subcomando buscar que encontre tarefas pela descrição usando correspondência parcial ou regex.

  4. Exportação para Markdown: Crie um subcomando exportar que gere um arquivo Markdown formatado com todas as tarefas, ideal para compartilhar relatórios.

  5. 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