Todo List CLI em Zig — Tutorial Passo a Passo

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

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 ArrayList e allocators
  • Persistência de dados em arquivos de texto
  • Tagged unions (union(enum)) para representar comandos
  • Padrão errdefer para segurança de memória
  • GeneralPurposeAllocator para detecção de vazamentos

Próximos Passos

Continue aprendendo Zig

Explore mais tutoriais e artigos em português para dominar a linguagem Zig.