Todo List CLI em Zig — Tutorial Passo a Passo
Neste tutorial, vamos construir um aplicativo de lista de tarefas para o terminal. Este projeto é uma evolução natural da Calculadora CLI: além de interação com o usuário, vamos trabalhar com leitura/escrita de arquivos, gerenciamento de memória com ArrayList e serialização de dados.
O Que Vamos Construir
Nosso todo list vai:
- Adicionar, listar, completar e remover tarefas
- Persistir tarefas em um arquivo de texto
- Filtrar tarefas por status (pendente/concluída)
- Exibir uma interface limpa com indicadores visuais
- Usar alocação dinâmica de forma idiomática
Por Que Este Projeto?
Um todo list CLI exige que você trabalhe com coleções dinâmicas (não sabemos quantas tarefas o usuário terá), persistência em disco (os dados precisam sobreviver ao fechamento do programa) e manipulação de estado mutável. Em Zig, isso nos apresenta ao ArrayList, ao sistema de allocators e à API de arquivos.
Pré-requisitos
- Zig 0.13+ instalado
- Conclusão do projeto Calculadora CLI (recomendado)
Passo 1: Modelando uma Tarefa
const std = @import("std");
const fs = std.fs;
const mem = std.mem;
const io = std.io;
const Allocator = std.mem.Allocator;
/// Representa uma tarefa individual.
/// Optamos por armazenar o texto como slice alocado porque
/// cada tarefa tem tamanho diferente — um buffer fixo
/// desperdiçaria memória ou limitaria o comprimento do texto.
const Tarefa = struct {
texto: []u8,
concluida: bool,
id: u32,
/// Libera a memória alocada para o texto da tarefa.
pub fn deinit(self: *Tarefa, allocator: Allocator) void {
allocator.free(self.texto);
}
};
Por que []u8 e não [256]u8? Se usássemos um buffer fixo de 256 bytes para cada tarefa, 100 tarefas consumiriam 25KB mesmo que a maioria tivesse apenas 20 caracteres. Com slices alocadas, cada tarefa usa exatamente a memória necessária. O trade-off é que precisamos gerenciar a memória manualmente, mas o sistema de allocators do Zig torna isso seguro e explícito.
Passo 2: Gerenciador de Tarefas
/// Gerencia a coleção de tarefas.
/// Encapsula o ArrayList e a lógica de persistência
/// para manter o main() limpo e focado na interação.
const GerenciadorTarefas = struct {
tarefas: std.ArrayList(Tarefa),
proximo_id: u32,
arquivo_path: []const u8,
allocator: Allocator,
const Self = @This();
pub fn init(allocator: Allocator, arquivo_path: []const u8) Self {
return Self{
.tarefas = std.ArrayList(Tarefa).init(allocator),
.proximo_id = 1,
.arquivo_path = arquivo_path,
.allocator = allocator,
};
}
pub fn deinit(self: *Self) void {
for (self.tarefas.items) |*tarefa| {
tarefa.deinit(self.allocator);
}
self.tarefas.deinit();
}
/// Adiciona uma nova tarefa.
/// Duplicamos o texto para que a tarefa possua sua própria
/// cópia — assim não dependemos do buffer de leitura.
pub fn adicionar(self: *Self, texto: []const u8) !void {
const texto_copia = try self.allocator.dupe(u8, texto);
errdefer self.allocator.free(texto_copia);
try self.tarefas.append(.{
.texto = texto_copia,
.concluida = false,
.id = self.proximo_id,
});
self.proximo_id += 1;
}
/// Marca uma tarefa como concluída pelo seu ID.
pub fn concluir(self: *Self, id: u32) bool {
for (self.tarefas.items) |*tarefa| {
if (tarefa.id == id) {
tarefa.concluida = true;
return true;
}
}
return false;
}
/// Remove uma tarefa pelo seu ID.
pub fn remover(self: *Self, id: u32) bool {
for (self.tarefas.items, 0..) |*tarefa, i| {
if (tarefa.id == id) {
tarefa.deinit(self.allocator);
_ = self.tarefas.orderedRemove(i);
return true;
}
}
return false;
}
/// Conta tarefas pendentes.
pub fn pendentes(self: *const Self) usize {
var count: usize = 0;
for (self.tarefas.items) |tarefa| {
if (!tarefa.concluida) count += 1;
}
return count;
}
/// Lista todas as tarefas formatadas.
pub fn listar(self: *const Self, writer: anytype, filtro: Filtro) !void {
var exibidas: usize = 0;
for (self.tarefas.items) |tarefa| {
const exibir = switch (filtro) {
.todas => true,
.pendentes => !tarefa.concluida,
.concluidas => tarefa.concluida,
};
if (exibir) {
const marcador: []const u8 = if (tarefa.concluida) "[x]" else "[ ]";
try writer.print(" {s} #{d}: {s}\n", .{ marcador, tarefa.id, tarefa.texto });
exibidas += 1;
}
}
if (exibidas == 0) {
try writer.print(" (nenhuma tarefa encontrada)\n", .{});
}
}
/// Salva todas as tarefas em arquivo.
/// Formato: cada linha é "STATUS|TEXTO" onde STATUS é 0 ou 1.
/// Escolhemos este formato simples em vez de JSON porque:
/// 1. É fácil de parsear e debugar manualmente
/// 2. Não requer uma biblioteca de serialização
/// 3. É suficiente para os dados que temos
pub fn salvar(self: *const Self) !void {
const arquivo = try fs.cwd().createFile(self.arquivo_path, .{});
defer arquivo.close();
const writer = arquivo.writer();
for (self.tarefas.items) |tarefa| {
const status: u8 = if (tarefa.concluida) '1' else '0';
try writer.print("{c}|{s}\n", .{ status, tarefa.texto });
}
}
/// Carrega tarefas de um arquivo existente.
pub fn carregar(self: *Self) !void {
const arquivo = fs.cwd().openFile(self.arquivo_path, .{}) catch |err| {
if (err == error.FileNotFound) return; // Arquivo ainda não existe
return err;
};
defer arquivo.close();
const reader = arquivo.reader();
var buf: [1024]u8 = undefined;
while (reader.readUntilDelimiterOrEof(&buf, '\n') catch null) |linha| {
if (linha.len < 3) continue; // Mínimo: "0|x"
if (linha[1] != '|') continue;
const concluida = linha[0] == '1';
const texto = linha[2..];
const texto_copia = try self.allocator.dupe(u8, texto);
errdefer self.allocator.free(texto_copia);
try self.tarefas.append(.{
.texto = texto_copia,
.concluida = concluida,
.id = self.proximo_id,
});
self.proximo_id += 1;
}
}
};
const Filtro = enum {
todas,
pendentes,
concluidas,
};
Por que errdefer? Quando chamamos allocator.dupe e depois tarefas.append, há uma possibilidade de que append falhe (por exemplo, se a realocação do array falhar). Nesse caso, errdefer garante que o texto duplicado será liberado. Sem ele, teríamos um vazamento de memória. Esse padrão é fundamental em Zig — veja mais em gerenciamento de memória.
Passo 3: Parser de Comandos
/// Comandos suportados pela interface.
const Comando = union(enum) {
adicionar: []const u8,
concluir: u32,
remover: u32,
listar: Filtro,
salvar,
ajuda,
sair,
invalido: []const u8,
};
/// Parseia a entrada do usuário em um comando estruturado.
fn parsearComando(entrada: []const u8) Comando {
const limpa = mem.trim(u8, entrada, " \t\r\n");
if (limpa.len == 0) return .{ .listar = .todas };
// Comandos sem argumento
if (mem.eql(u8, limpa, "sair") or mem.eql(u8, limpa, "q")) return .sair;
if (mem.eql(u8, limpa, "ajuda") or mem.eql(u8, limpa, "help")) return .ajuda;
if (mem.eql(u8, limpa, "salvar")) return .salvar;
if (mem.eql(u8, limpa, "listar")) return .{ .listar = .todas };
if (mem.eql(u8, limpa, "pendentes")) return .{ .listar = .pendentes };
if (mem.eql(u8, limpa, "concluidas")) return .{ .listar = .concluidas };
// Comandos com argumento
if (mem.startsWith(u8, limpa, "add ") or mem.startsWith(u8, limpa, "adicionar ")) {
const inicio = if (mem.startsWith(u8, limpa, "add ")) @as(usize, 4) else 11;
const texto = mem.trim(u8, limpa[inicio..], " ");
if (texto.len > 0) return .{ .adicionar = texto };
}
if (mem.startsWith(u8, limpa, "done ") or mem.startsWith(u8, limpa, "concluir ")) {
const inicio = if (mem.startsWith(u8, limpa, "done ")) @as(usize, 5) else 9;
const num_str = mem.trim(u8, limpa[inicio..], " #");
if (std.fmt.parseInt(u32, num_str, 10)) |id| return .{ .concluir = id } else |_| {}
}
if (mem.startsWith(u8, limpa, "rm ") or mem.startsWith(u8, limpa, "remover ")) {
const inicio = if (mem.startsWith(u8, limpa, "rm ")) @as(usize, 3) else 8;
const num_str = mem.trim(u8, limpa[inicio..], " #");
if (std.fmt.parseInt(u32, num_str, 10)) |id| return .{ .remover = id } else |_| {}
}
return .{ .invalido = limpa };
}
Por que union(enum) para Comando? Esta tagged union permite que cada variante carregue dados diferentes: adicionar carrega o texto, concluir carrega o ID, listar carrega o filtro. É o equivalente Zig de um “sum type” — seguro e expressivo.
Passo 4: Função Principal
pub fn main() !void {
// Usamos GeneralPurposeAllocator porque ele detecta
// vazamentos de memória em modo debug — muito útil durante
// o desenvolvimento.
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer {
const check = gpa.deinit();
if (check == .leak) @panic("Vazamento de memória detectado!");
}
const allocator = gpa.allocator();
const stdout = io.getStdOut().writer();
const stdin = io.getStdIn().reader();
var gerenciador = GerenciadorTarefas.init(allocator, "tarefas.txt");
defer gerenciador.deinit();
// Tenta carregar tarefas existentes
gerenciador.carregar() catch |err| {
try stdout.print("Aviso: não foi possível carregar tarefas: {}\n", .{err});
};
try stdout.print(
\\
\\ Todo List Zig v1.0
\\ Digite 'ajuda' para ver os comandos disponíveis.
\\ {d} tarefa(s) carregada(s).
\\
, .{gerenciador.tarefas.items.len});
var buf: [1024]u8 = undefined;
while (true) {
const pend = gerenciador.pendentes();
try stdout.print("\n[{d} pendente(s)] > ", .{pend});
const linha = stdin.readUntilDelimiterOrEof(&buf, '\n') catch {
try stdout.print("Erro ao ler entrada.\n", .{});
continue;
} orelse break;
const cmd = parsearComando(linha);
switch (cmd) {
.adicionar => |texto| {
try gerenciador.adicionar(texto);
try stdout.print(" Tarefa adicionada: {s}\n", .{texto});
},
.concluir => |id| {
if (gerenciador.concluir(id)) {
try stdout.print(" Tarefa #{d} concluída!\n", .{id});
} else {
try stdout.print(" Tarefa #{d} não encontrada.\n", .{id});
}
},
.remover => |id| {
if (gerenciador.remover(id)) {
try stdout.print(" Tarefa #{d} removida.\n", .{id});
} else {
try stdout.print(" Tarefa #{d} não encontrada.\n", .{id});
}
},
.listar => |filtro| {
const titulo: []const u8 = switch (filtro) {
.todas => "--- Todas as Tarefas ---",
.pendentes => "--- Tarefas Pendentes ---",
.concluidas => "--- Tarefas Concluídas ---",
};
try stdout.print("\n{s}\n", .{titulo});
try gerenciador.listar(stdout, filtro);
},
.salvar => {
gerenciador.salvar() catch |err| {
try stdout.print(" Erro ao salvar: {}\n", .{err});
continue;
};
try stdout.print(" Tarefas salvas em '{s}'.\n", .{gerenciador.arquivo_path});
},
.ajuda => {
try stdout.print(
\\
\\ Comandos disponíveis:
\\ add <texto> - Adiciona uma tarefa
\\ done <id> - Marca tarefa como concluída
\\ rm <id> - Remove uma tarefa
\\ listar - Lista todas as tarefas
\\ pendentes - Lista tarefas pendentes
\\ concluidas - Lista tarefas concluídas
\\ salvar - Salva tarefas em arquivo
\\ ajuda - Mostra esta ajuda
\\ sair - Sai do programa
\\
, .{});
},
.sair => {
gerenciador.salvar() catch {};
try stdout.print(" Tarefas salvas. Até logo!\n", .{});
break;
},
.invalido => |texto| {
try stdout.print(" Comando desconhecido: '{s}'. Digite 'ajuda'.\n", .{texto});
},
}
}
}
Passo 5: Testes
test "adicionar e listar tarefas" {
const allocator = std.testing.allocator;
var ger = GerenciadorTarefas.init(allocator, "test_todo.txt");
defer ger.deinit();
try ger.adicionar("Comprar pão");
try ger.adicionar("Estudar Zig");
try std.testing.expectEqual(@as(usize, 2), ger.tarefas.items.len);
try std.testing.expectEqual(@as(usize, 2), ger.pendentes());
}
test "concluir tarefa" {
const allocator = std.testing.allocator;
var ger = GerenciadorTarefas.init(allocator, "test_todo.txt");
defer ger.deinit();
try ger.adicionar("Tarefa teste");
try std.testing.expect(ger.concluir(1));
try std.testing.expectEqual(@as(usize, 0), ger.pendentes());
}
test "remover tarefa" {
const allocator = std.testing.allocator;
var ger = GerenciadorTarefas.init(allocator, "test_todo.txt");
defer ger.deinit();
try ger.adicionar("Tarefa teste");
try std.testing.expect(ger.remover(1));
try std.testing.expectEqual(@as(usize, 0), ger.tarefas.items.len);
}
test "parsear comandos" {
const cmd_add = parsearComando("add Comprar leite");
switch (cmd_add) {
.adicionar => |texto| try std.testing.expectEqualStrings("Comprar leite", texto),
else => return error.TestExpectedEqual,
}
const cmd_sair = parsearComando("sair");
try std.testing.expectEqual(Comando.sair, cmd_sair);
}
Compilando e Executando
zig build
./zig-out/bin/todo-list-cli
Conceitos Aprendidos
- Gerenciamento de memória com
ArrayListe allocators - Persistência de dados em arquivos de texto
- Tagged unions (
union(enum)) para representar comandos - Padrão
errdeferpara segurança de memória GeneralPurposeAllocatorpara detecção de vazamentos
Próximos Passos
- Explore gerenciamento de memória para entender allocators
- Aprenda sobre I/O de arquivos para persistência avançada
- Construa o próximo projeto: Conversor de Temperatura