← Volver al listado de tecnologías

Proyecto Final: CLI en Zig

Por: Artiko
zigproyectoclipractica

Proyecto Final: CLI de Gestión de Tareas

Descripción

Construiremos ztask, una herramienta CLI para gestionar tareas que demuestra:

Estructura del Proyecto

ztask/
├── build.zig
├── src/
│   ├── main.zig
│   ├── task.zig
│   ├── storage.zig
│   ├── cli.zig
│   └── lib.zig
└── tests/
    └── test_task.zig

Modelo de Datos (task.zig)

const std = @import("std");

pub const Priority = enum {
    low,
    medium,
    high,

    pub fn toString(self: Priority) []const u8 {
        return switch (self) {
            .low => "baja",
            .medium => "media",
            .high => "alta",
        };
    }

    pub fn fromString(s: []const u8) ?Priority {
        if (std.mem.eql(u8, s, "low") or std.mem.eql(u8, s, "baja")) return .low;
        if (std.mem.eql(u8, s, "medium") or std.mem.eql(u8, s, "media")) return .medium;
        if (std.mem.eql(u8, s, "high") or std.mem.eql(u8, s, "alta")) return .high;
        return null;
    }
};

pub const Task = struct {
    id: u32,
    titulo: []const u8,
    descripcion: ?[]const u8,
    prioridad: Priority,
    completada: bool,
    creada: i64,

    pub fn crear(allocator: std.mem.Allocator, id: u32, titulo: []const u8) !Task {
        const titulo_copia = try allocator.dupe(u8, titulo);
        return Task{
            .id = id,
            .titulo = titulo_copia,
            .descripcion = null,
            .prioridad = .medium,
            .completada = false,
            .creada = std.time.timestamp(),
        };
    }

    pub fn deinit(self: *Task, allocator: std.mem.Allocator) void {
        allocator.free(self.titulo);
        if (self.descripcion) |desc| {
            allocator.free(desc);
        }
    }

    pub fn completar(self: *Task) void {
        self.completada = true;
    }

    pub fn setPrioridad(self: *Task, prioridad: Priority) void {
        self.prioridad = prioridad;
    }
};

pub const TaskList = struct {
    tasks: std.ArrayList(Task),
    next_id: u32,
    allocator: std.mem.Allocator,

    pub fn init(allocator: std.mem.Allocator) TaskList {
        return .{
            .tasks = std.ArrayList(Task).init(allocator),
            .next_id = 1,
            .allocator = allocator,
        };
    }

    pub fn deinit(self: *TaskList) void {
        for (self.tasks.items) |*task| {
            task.deinit(self.allocator);
        }
        self.tasks.deinit();
    }

    pub fn agregar(self: *TaskList, titulo: []const u8) !*Task {
        const task = try Task.crear(self.allocator, self.next_id, titulo);
        try self.tasks.append(task);
        self.next_id += 1;
        return &self.tasks.items[self.tasks.items.len - 1];
    }

    pub fn buscar(self: *TaskList, id: u32) ?*Task {
        for (self.tasks.items) |*task| {
            if (task.id == id) return task;
        }
        return null;
    }

    pub fn eliminar(self: *TaskList, id: u32) bool {
        for (self.tasks.items, 0..) |*task, i| {
            if (task.id == id) {
                task.deinit(self.allocator);
                _ = self.tasks.orderedRemove(i);
                return true;
            }
        }
        return false;
    }

    pub fn listar(self: *TaskList, solo_pendientes: bool) []Task {
        if (!solo_pendientes) return self.tasks.items;

        // Filtrar pendientes requeriría allocator, simplificamos
        return self.tasks.items;
    }
};

Almacenamiento (storage.zig)

const std = @import("std");
const Task = @import("task.zig").Task;
const TaskList = @import("task.zig").TaskList;
const Priority = @import("task.zig").Priority;

pub const StorageError = error{
    FileNotFound,
    ParseError,
    WriteError,
};

pub fn guardar(list: *TaskList, path: []const u8) !void {
    const file = try std.fs.cwd().createFile(path, .{});
    defer file.close();

    var writer = file.writer();

    try writer.writeAll("[\n");

    for (list.tasks.items, 0..) |task, i| {
        try writer.print(
            \\  {{
            \\    "id": {d},
            \\    "titulo": "{s}",
            \\    "prioridad": "{s}",
            \\    "completada": {s}
            \\  }}
        , .{
            task.id,
            task.titulo,
            task.prioridad.toString(),
            if (task.completada) "true" else "false",
        });

        if (i < list.tasks.items.len - 1) {
            try writer.writeAll(",\n");
        } else {
            try writer.writeAll("\n");
        }
    }

    try writer.writeAll("]\n");
}

pub fn cargar(allocator: std.mem.Allocator, path: []const u8) !TaskList {
    const file = std.fs.cwd().openFile(path, .{}) catch |err| {
        if (err == error.FileNotFound) {
            return TaskList.init(allocator);
        }
        return err;
    };
    defer file.close();

    var list = TaskList.init(allocator);

    // Parser JSON simplificado
    const contenido = try file.readToEndAlloc(allocator, 1024 * 1024);
    defer allocator.free(contenido);

    // Implementación simplificada - en producción usar std.json
    _ = contenido;

    return list;
}

pub fn obtenerRutaDefault(allocator: std.mem.Allocator) ![]const u8 {
    if (std.posix.getenv("HOME")) |home| {
        return try std.fmt.allocPrint(allocator, "{s}/.ztasks.json", .{home});
    }
    return try allocator.dupe(u8, ".ztasks.json");
}

CLI Parser (cli.zig)

const std = @import("std");

pub const Comando = union(enum) {
    agregar: []const u8,
    listar: struct { pendientes: bool },
    completar: u32,
    eliminar: u32,
    ayuda,
    version,
};

pub const ParseError = error{
    ComandoDesconocido,
    ArgumentoFaltante,
    IdInvalido,
};

pub fn parsear(args: []const []const u8) ParseError!Comando {
    if (args.len < 2) return .ayuda;

    const comando = args[1];

    if (std.mem.eql(u8, comando, "add") or std.mem.eql(u8, comando, "agregar")) {
        if (args.len < 3) return ParseError.ArgumentoFaltante;
        return .{ .agregar = args[2] };
    }

    if (std.mem.eql(u8, comando, "list") or std.mem.eql(u8, comando, "listar")) {
        const pendientes = args.len > 2 and
            (std.mem.eql(u8, args[2], "--pending") or
            std.mem.eql(u8, args[2], "-p"));
        return .{ .listar = .{ .pendientes = pendientes } };
    }

    if (std.mem.eql(u8, comando, "done") or std.mem.eql(u8, comando, "completar")) {
        if (args.len < 3) return ParseError.ArgumentoFaltante;
        const id = std.fmt.parseInt(u32, args[2], 10) catch {
            return ParseError.IdInvalido;
        };
        return .{ .completar = id };
    }

    if (std.mem.eql(u8, comando, "rm") or std.mem.eql(u8, comando, "eliminar")) {
        if (args.len < 3) return ParseError.ArgumentoFaltante;
        const id = std.fmt.parseInt(u32, args[2], 10) catch {
            return ParseError.IdInvalido;
        };
        return .{ .eliminar = id };
    }

    if (std.mem.eql(u8, comando, "help") or std.mem.eql(u8, comando, "--help")) {
        return .ayuda;
    }

    if (std.mem.eql(u8, comando, "version") or std.mem.eql(u8, comando, "--version")) {
        return .version;
    }

    return ParseError.ComandoDesconocido;
}

pub fn mostrarAyuda() void {
    const ayuda =
        \\ztask - Gestor de tareas en Zig
        \\
        \\USO:
        \\  ztask <comando> [opciones]
        \\
        \\COMANDOS:
        \\  add <titulo>     Agregar nueva tarea
        \\  list [-p]        Listar tareas (-p: solo pendientes)
        \\  done <id>        Marcar tarea como completada
        \\  rm <id>          Eliminar tarea
        \\  help             Mostrar esta ayuda
        \\  version          Mostrar versión
        \\
        \\EJEMPLOS:
        \\  ztask add "Comprar leche"
        \\  ztask list
        \\  ztask done 1
        \\
    ;
    std.debug.print("{s}", .{ayuda});
}

Main (main.zig)

const std = @import("std");
const Task = @import("task.zig").Task;
const TaskList = @import("task.zig").TaskList;
const cli = @import("cli.zig");
const storage = @import("storage.zig");

const VERSION = "1.0.0";

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    const args = try std.process.argsAlloc(allocator);
    defer std.process.argsFree(allocator, args);

    const comando = cli.parsear(args) catch |err| {
        switch (err) {
            cli.ParseError.ComandoDesconocido => {
                std.debug.print("Error: Comando desconocido\n", .{});
            },
            cli.ParseError.ArgumentoFaltante => {
                std.debug.print("Error: Argumento faltante\n", .{});
            },
            cli.ParseError.IdInvalido => {
                std.debug.print("Error: ID inválido\n", .{});
            },
        }
        cli.mostrarAyuda();
        return;
    };

    const ruta = try storage.obtenerRutaDefault(allocator);
    defer allocator.free(ruta);

    var lista = try storage.cargar(allocator, ruta);
    defer lista.deinit();

    switch (comando) {
        .agregar => |titulo| {
            _ = try lista.agregar(titulo);
            try storage.guardar(&lista, ruta);
            std.debug.print("Tarea agregada\n", .{});
        },
        .listar => |opts| {
            const tareas = lista.listar(opts.pendientes);
            if (tareas.len == 0) {
                std.debug.print("No hay tareas\n", .{});
                return;
            }
            std.debug.print("\n{s:<4} {s:<30} {s:<10} {s}\n", .{
                "ID", "TITULO", "PRIORIDAD", "ESTADO",
            });
            std.debug.print("{s}\n", .{"-" ** 60});
            for (tareas) |task| {
                const estado = if (task.completada) "[x]" else "[ ]";
                std.debug.print("{d:<4} {s:<30} {s:<10} {s}\n", .{
                    task.id,
                    task.titulo,
                    task.prioridad.toString(),
                    estado,
                });
            }
        },
        .completar => |id| {
            if (lista.buscar(id)) |task| {
                task.completar();
                try storage.guardar(&lista, ruta);
                std.debug.print("Tarea {d} completada\n", .{id});
            } else {
                std.debug.print("Tarea {d} no encontrada\n", .{id});
            }
        },
        .eliminar => |id| {
            if (lista.eliminar(id)) {
                try storage.guardar(&lista, ruta);
                std.debug.print("Tarea {d} eliminada\n", .{id});
            } else {
                std.debug.print("Tarea {d} no encontrada\n", .{id});
            }
        },
        .ayuda => cli.mostrarAyuda(),
        .version => std.debug.print("ztask v{s}\n", .{VERSION}),
    }
}

Tests (tests/test_task.zig)

const std = @import("std");
const testing = std.testing;
const Task = @import("../src/task.zig").Task;
const TaskList = @import("../src/task.zig").TaskList;
const Priority = @import("../src/task.zig").Priority;
const cli = @import("../src/cli.zig");

test "crear tarea" {
    var task = try Task.crear(testing.allocator, 1, "Test task");
    defer task.deinit(testing.allocator);

    try testing.expectEqual(@as(u32, 1), task.id);
    try testing.expectEqualStrings("Test task", task.titulo);
    try testing.expect(!task.completada);
}

test "completar tarea" {
    var task = try Task.crear(testing.allocator, 1, "Test");
    defer task.deinit(testing.allocator);

    try testing.expect(!task.completada);
    task.completar();
    try testing.expect(task.completada);
}

test "TaskList agregar y buscar" {
    var lista = TaskList.init(testing.allocator);
    defer lista.deinit();

    _ = try lista.agregar("Tarea 1");
    _ = try lista.agregar("Tarea 2");

    try testing.expectEqual(@as(usize, 2), lista.tasks.items.len);

    const encontrada = lista.buscar(1);
    try testing.expect(encontrada != null);
    try testing.expectEqualStrings("Tarea 1", encontrada.?.titulo);
}

test "TaskList eliminar" {
    var lista = TaskList.init(testing.allocator);
    defer lista.deinit();

    _ = try lista.agregar("Tarea 1");
    _ = try lista.agregar("Tarea 2");

    try testing.expect(lista.eliminar(1));
    try testing.expectEqual(@as(usize, 1), lista.tasks.items.len);
    try testing.expect(lista.buscar(1) == null);
}

test "Priority fromString" {
    try testing.expectEqual(Priority.low, Priority.fromString("low").?);
    try testing.expectEqual(Priority.high, Priority.fromString("alta").?);
    try testing.expect(Priority.fromString("invalid") == null);
}

test "CLI parsear add" {
    const args = &[_][]const u8{ "ztask", "add", "Nueva tarea" };
    const cmd = try cli.parsear(args);

    switch (cmd) {
        .agregar => |titulo| {
            try testing.expectEqualStrings("Nueva tarea", titulo);
        },
        else => try testing.expect(false),
    }
}

test "CLI parsear list" {
    const args = &[_][]const u8{ "ztask", "list" };
    const cmd = try cli.parsear(args);

    switch (cmd) {
        .listar => |opts| {
            try testing.expect(!opts.pendientes);
        },
        else => try testing.expect(false),
    }
}

test "CLI parsear done" {
    const args = &[_][]const u8{ "ztask", "done", "5" };
    const cmd = try cli.parsear(args);

    switch (cmd) {
        .completar => |id| {
            try testing.expectEqual(@as(u32, 5), id);
        },
        else => try testing.expect(false),
    }
}

test "CLI comando desconocido" {
    const args = &[_][]const u8{ "ztask", "invalid" };
    const result = cli.parsear(args);
    try testing.expectError(cli.ParseError.ComandoDesconocido, result);
}

Build (build.zig)

const std = @import("std");

pub fn build(b: *std.Build) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});

    const exe = b.addExecutable(.{
        .name = "ztask",
        .root_source_file = b.path("src/main.zig"),
        .target = target,
        .optimize = optimize,
    });

    b.installArtifact(exe);

    const run_cmd = b.addRunArtifact(exe);
    run_cmd.step.dependOn(b.getInstallStep());
    if (b.args) |args| run_cmd.addArgs(args);

    const run_step = b.step("run", "Ejecutar ztask");
    run_step.dependOn(&run_cmd.step);

    // Tests de módulos
    const task_tests = b.addTest(.{
        .root_source_file = b.path("tests/test_task.zig"),
        .target = target,
        .optimize = optimize,
    });

    const test_step = b.step("test", "Ejecutar tests");
    test_step.dependOn(&b.addRunArtifact(task_tests).step);
}

Uso

# Compilar
zig build

# Ejecutar
./zig-out/bin/ztask add "Aprender Zig"
./zig-out/bin/ztask add "Completar tutorial"
./zig-out/bin/ztask list
./zig-out/bin/ztask done 1
./zig-out/bin/ztask rm 2

# Tests
zig build test

Extensiones Sugeridas

  1. Persistencia real: Implementar parser JSON completo
  2. Fechas de vencimiento: Agregar campo de fecha límite
  3. Categorías: Organizar tareas por proyecto
  4. Colores: Colorear salida según prioridad
  5. Exportar: Generar reportes en Markdown

Resumen del Tutorial

Has aprendido:

  1. Sintaxis básica y tipos de Zig
  2. Control de flujo y funciones
  3. Arrays, slices y strings
  4. Structs, enums y unions
  5. Gestión de memoria con allocators
  6. Manejo de errores robusto
  7. Metaprogramación con comptime
  8. Interoperabilidad con C
  9. Concurrencia y threads
  10. Build system completo
  11. Testing en cada paso

¡Felicitaciones por completar el tutorial de Zig!