Crie uma CLI de lista de tarefas

RustBeginner
Pratique Agora

Introdução

Uma aplicação útil de linha de comando conecta várias partes: argumentos tipados, dados do domínio, armazenamento persistente, saída clara, erros recuperáveis e verificações automatizadas. Criar tudo isso a partir de um arquivo vazio esconderia o design sob uma grande quantidade de código digitado.

Neste laboratório, a configuração inicial fornece o esqueleto completo de uma lista de tarefas. Você lerá o mapa de módulos e, em seguida, concluirá uma parte por vez: decodificar e salvar o armazenamento, adicionar tarefas, listar tarefas, marcar tarefas como concluídas e gerar uma compilação de release testada. Você nunca precisará colar um arquivo-fonte inteiro.

Leia o mapa do projeto e a interface de comandos

Nesta etapa, você se familiarizará com o projeto preparado e relacionará os arquivos ao fluxo de dados antes de editar o código.

Entre no projeto e liste os arquivos de código-fonte:

cd /home/labex/project/tasker
ls src
lib.rs  main.rs  model.rs  store.rs

Cada arquivo tem uma responsabilidade principal:

  • main.rs controla a fronteira do processo: analisa os argumentos, imprime mensagens de sucesso e informa falhas;
  • lib.rs contém as operações de tarefas chamadas pela CLI e pelos testes;
  • model.rs define uma Task e a converte de e para uma linha de armazenamento;
  • store.rs lê e grava a coleção de tarefas.

Essa separação impede que a análise de argumentos, as operações do domínio e os detalhes do arquivo se transformem em uma única função grande. A configuração inicial já escreveu as declarações dos módulos e o analisador mais longo do modelo; você concluirá apenas as partes marcadas nas operações.

Inspecione o ponto de entrada da CLI:

nano src/main.rs

#[command(subcommand)] informa ao clap que a próxima palavra do comando seleciona uma variante do enum Commands. A opção --file está marcada como global = true, portanto os usuários podem colocá-la antes ou depois de um subcomando. Seu valor PathBuf padrão é tasks.db. Pressione Ctrl+X sem alterar o arquivo.

Exiba a ajuda de nível superior gerada:

cargo run --quiet -- --help

A ajuda lista add, list e done. Solicite a ajuda específica de add:

cargo run --quiet -- add --help

O <TITLE> obrigatório vem do campo title: String da variante Add. A interface já existe; nas etapas seguintes, você fará com que a operação de biblioteca de cada comando funcione.

Conclua a fronteira de armazenamento

Nesta etapa, você concluirá as duas pequenas transformações que conectam os valores das tarefas a um arquivo de texto local.

Abra o módulo de armazenamento preparado:

nano src/store.rs

O formato do arquivo usa uma tarefa por linha, com três campos separados por tabulações:

id<TAB>status<TAB>title

model.rs já fornece Task::encode e Task::decode. O módulo de armazenamento precisa apenas aplicar esses auxiliares à coleção completa.

Substitua o TODO de carregamento e as duas linhas abaixo dele por:

    let tasks = contents
        .lines()
        .filter(|line| !line.is_empty())
        .map(Task::decode)
        .collect::<Result<Vec<_>, _>>()?;
    Ok(tasks)

O iterador transforma cada linha não vazia em um Result<Task, String>. Coletar em Result<Vec<_>, _> interrompe a operação na primeira linha inválida ou retorna todas as tarefas decodificadas. O ponto de interrogação propaga esse erro a partir de load.

Em save, substitua o TODO e as três linhas finais por:

    fs::write(path, contents)
        .map_err(|error| format!("could not write {}: {error}", path.display()))

fs::write cria ou substitui o arquivo de armazenamento. map_err adiciona o caminho que falhou, preservando um Result recuperável.

Salve e saia do nano. Execute apenas o teste específico de armazenamento:

cargo test store::tests::saves_and_loads_tasks

Um único teste aprovado comprova que uma coleção de tarefas pode atravessar a fronteira do arquivo e retornar como valores Rust iguais.

Adicione e persista novas tarefas

Nesta etapa, você implementará a operação de biblioteca por trás do subcomando add.

Abra o ponto de entrada da biblioteca:

nano src/lib.rs

Substitua o TODO de add_task e o corpo de placeholder por:

    let mut tasks = store::load(path)?;
    let next_id = tasks.iter().map(|task| task.id).max().unwrap_or(0) + 1;
    let task = Task::new(next_id, title);
    tasks.push(task.clone());
    store::save(path, &tasks)?;
    Ok(task)

A operação primeiro carrega o estado atual. max().unwrap_or(0) + 1 produz o ID 1 para um arquivo vazio e, caso contrário, um valor maior em um que o maior ID existente. A tarefa é clonada uma vez porque uma cópia pertencente ao vetor, enquanto a cópia retornada permite que a CLI descreva o que foi adicionado.

Salve e saia do nano. Adicione duas tarefas a um arquivo separado para demonstração:

cargo run --quiet -- --file add-demo.db add "Write release notes"
Added 1: Write release notes
cargo run --quiet -- --file add-demo.db add "Tag version"
Added 2: Tag version

O segundo ID comprova que o comando carregou o primeiro registro antes de escolher e salvar o próximo.

Formate a lista de tarefas

Nesta etapa, você converterá as tarefas armazenadas em uma saída de comando estável e legível.

Abra src/lib.rs novamente:

nano src/lib.rs

Substitua o TODO de list_tasks e o corpo de placeholder por:

    let tasks = store::load(path)?;
    Ok(tasks
        .iter()
        .map(|task| {
            let marker = if task.done { "x" } else { " " };
            format!("[{marker}] {}: {}", task.id, task.title)
        })
        .collect())

O marcador é uma visualização compacta do status: [ ] significa aberta e [x] significa concluída. Essa função retorna linhas de exibição em vez de imprimi-las, permitindo que os testes e outros chamadores inspecionem o resultado sem capturar a saída do terminal.

Salve e saia do nano. Reutilize o arquivo criado na etapa anterior:

cargo run --quiet -- --file add-demo.db list
[ ] 1: Write release notes
[ ] 2: Tag version

A biblioteca é responsável por formatar as linhas das tarefas, enquanto main.rs continua responsável apenas por imprimir as linhas retornadas.

Marque uma tarefa como concluída

Nesta etapa, você atualizará uma tarefa preservando o restante da coleção armazenada.

Abra o código-fonte da biblioteca:

nano src/lib.rs

Substitua o TODO de complete_task e o corpo de placeholder por:

    let mut tasks = store::load(path)?;
    let task = tasks
        .iter_mut()
        .find(|task| task.id == id)
        .ok_or_else(|| format!("task {id} was not found"))?;
    task.done = true;
    let completed = task.clone();
    store::save(path, &tasks)?;
    Ok(completed)

iter_mut() fornece referências mutáveis para que o registro correspondente possa ser alterado no próprio lugar. find retorna None quando o ID não existe; ok_or_else converte essa ausência no erro explicativo da função. A tarefa concluída é clonada antes do salvamento porque o empréstimo mutável pertence ao vetor que será salvo.

Salve e saia do nano. Conclua a tarefa 1 no arquivo de demonstração:

cargo run --quiet -- --file add-demo.db done 1
Completed 1: Write release notes

Liste novamente o estado armazenado:

cargo run --quiet -- --file add-demo.db list
[x] 1: Write release notes
[ ] 2: Tag version

Teste um ID inexistente:

cargo run --quiet -- --file add-demo.db done 99

Esse comando deve falhar. A mensagem é enviada para stderr e o processo termina com um código diferente de zero porque main.rs já converte o Err da biblioteca na fronteira do processo.

Teste e faça a compilação de release

Nesta etapa, você aplicará o ciclo de qualidade para entrega e produzirá um executável de release a partir do projeto concluído.

Comece formatando as edições feitas nos módulos de armazenamento e da biblioteca:

cargo fmt

Confirme que o formatador não deixou alterações pendentes:

cargo fmt -- --check

Execute o Clippy em modo rigoroso:

cargo clippy -- -D warnings

Execute o conjunto completo de testes:

cargo test

Dois testes devem passar: o teste específico de ida e volta do armazenamento e o fluxo completo de biblioteca add-list-done. Esses testes usam arquivos temporários, portanto exercitam a persistência real sem depender do banco de dados de demonstração.

Compile o destino de release otimizado:

cargo build --release --locked

--release seleciona o perfil de release otimizado do Cargo, em vez do perfil de desenvolvimento, que é mais rápido para compilar. --locked exige exatamente o grafo de dependências já registrado em Cargo.lock. Execute diretamente o executável resultante:

./target/release/tasker --file release-demo.db add "Publish tasker"
Added 1: Publish tasker
./target/release/tasker --file release-demo.db list
[ ] 1: Publish tasker

O caminho direto comprova que você está executando o artefato compilado, em vez de pedir ao Cargo que o compile e execute para você.

Resumo

Você concluiu uma CLI Rust com vários comandos sem redigitar sua arquitetura. O projeto final analisa subcomandos tipados com clap, persiste os modelos de tarefas por meio de um módulo de armazenamento dedicado, propaga erros com contexto, mantém a saída do processo em main, passa nos testes específicos e de ponta a ponta da biblioteca, atende ao ciclo de qualidade e produz um executável de release bloqueado.