Configuração para CLI em Zig: XDG, Env Vars e Arquivos Locais

Uma CLI útil quase sempre começa simples: alguns argumentos posicionais, uma flag --verbose e talvez um caminho de saída. Depois aparecem tokens de API, formato de relatório, URL do servidor, cache local, timeout, perfil de ambiente e preferências que ninguém quer digitar toda vez. Se essa configuração cresce sem regra clara, a ferramenta fica imprevisível: um valor vem da flag, outro do .env, outro de um arquivo escondido, e ninguém sabe qual venceu.

Zig combina bem com CLIs porque força decisões explícitas. A mesma disciplina vale para configuração. Em vez de depender de um framework que mistura tudo, desenhe uma camada pequena que leia fontes conhecidas, valide o resultado e entregue uma struct final para o resto do programa.

Este guia mostra uma abordagem prática para configuração de CLI em Zig usando flags, variáveis de ambiente, diretórios XDG, arquivos locais e defaults seguros. Ele complementa o artigo de criar CLI profissional em Zig, a receita de argumentos de linha de comando, a receita de variáveis de ambiente, o guia de ferramentas internas em Zig e o material de SQLite para ferramentas locais.

O problema que a configuração resolve

Uma CLI usada por uma pessoa pode sobreviver com flags longas. Uma CLI usada por uma equipe precisa de memória operacional. Exemplos:

  • zigdeploy precisa saber o endpoint padrão, timeout e diretório de artefatos;
  • um analisador de logs precisa lembrar filtros comuns e formato de saída;
  • uma ferramenta de build precisa descobrir cache, profile e target;
  • um cliente interno precisa ler token de API sem gravar segredo no repositório;
  • um agente local precisa diferenciar configuração global, projeto atual e execução pontual.

A regra de ouro é separar configuração persistente de intenção do comando atual. Um arquivo pode dizer que o endpoint padrão é produção, mas --endpoint staging no comando atual deve vencer. Um token pode vir de variável de ambiente, mas nunca deve aparecer em logs ou em --help.

Precedência recomendada

Defina a ordem antes de escrever o parser. Uma precedência comum e fácil de explicar:

  1. Flags de CLI: intenção explícita da execução atual.
  2. Variáveis de ambiente: boas para CI, containers e segredos.
  3. Arquivo local do projeto: configuração versionável ou por repositório.
  4. Arquivo do usuário em XDG config: preferência pessoal persistente.
  5. Defaults compilados: valores seguros para começar.

Em forma de tabela:

FonteExemploDeve guardar segredo?Quando usar
Flag--timeout 10sNãoAjuste pontual
Env varZIGTOOL_TOKENSim, com cuidadoCI, sessão local, secret manager
Projeto.zigtool/config.iniNãoConvenções do repo
Usuário~/.config/zigtool/config.iniEvitePreferências pessoais
Defaulttimeout 30sNãoExperiência inicial

A vantagem de explicitar isso no código e na documentação é que suporte fica simples: rode zigtool config explain ou imprima uma visão sem segredos indicando de onde cada valor veio.

Modele a configuração final como struct

O resto da aplicação não deve saber se o valor veio de flag, env ou arquivo. Ele deve receber uma struct validada:

const std = @import("std");

const OutputFormat = enum { text, json, markdown };

const Config = struct {
    endpoint: []const u8,
    token: ?[]const u8,
    cache_dir: []const u8,
    timeout_ms: u32,
    output: OutputFormat,
    verbose: bool,
};

Essa struct representa a verdade final. Se endpoint está vazio, se timeout_ms é zero ou se output tem valor inválido, o erro deve acontecer antes de rodar qualquer ação. Uma CLI previsível falha cedo.

Para debug, você pode manter metadados separados:

const Source = enum { flag, env, project_file, user_file, default };

const FieldSource = struct {
    endpoint: Source = .default,
    token: Source = .default,
    cache_dir: Source = .default,
    timeout_ms: Source = .default,
    output: Source = .default,
    verbose: Source = .default,
};

Não misture FieldSource com a lógica de negócio. Ele existe para config explain, testes e mensagens de diagnóstico.

Comece pelos defaults seguros

Defaults não devem ser convenientes ao ponto de perigosos. Para uma ferramenta que fala com API, talvez o default seja endpoint de leitura, timeout moderado e saída humana. Para uma ferramenta que altera estado, exija flag explícita.

fn defaultConfig(allocator: std.mem.Allocator) !Config {
    const cache_dir = try std.fs.getAppDataDir(allocator, "zigtool");

    return .{
        .endpoint = "https://api.exemplo.local",
        .token = null,
        .cache_dir = cache_dir,
        .timeout_ms = 30_000,
        .output = .text,
        .verbose = false,
    };
}

std.fs.getAppDataDir ajuda a respeitar convenções do sistema. Em Linux, isso tende a seguir a família XDG; em outros sistemas, usa o local apropriado para dados de aplicação. Para configuração textual, você pode calcular caminhos de forma parecida ou aceitar --config para apontar um arquivo específico.

Leia variáveis de ambiente com nomes estáveis

Variáveis de ambiente funcionam muito bem para CI e segredos. Use prefixo claro para evitar colisão:

fn applyEnv(config: *Config, sources: *FieldSource) void {
    if (std.process.getEnvVarOwned(std.heap.page_allocator, "ZIGTOOL_ENDPOINT")) |value| {
        config.endpoint = value;
        sources.endpoint = .env;
    } else |_| {}

    if (std.process.getEnvVarOwned(std.heap.page_allocator, "ZIGTOOL_TOKEN")) |value| {
        config.token = value;
        sources.token = .env;
    } else |_| {}

    if (std.process.getEnvVarOwned(std.heap.page_allocator, "ZIGTOOL_OUTPUT")) |value| {
        if (std.mem.eql(u8, value, "json")) {
            config.output = .json;
            sources.output = .env;
        } else if (std.mem.eql(u8, value, "markdown")) {
            config.output = .markdown;
            sources.output = .env;
        } else if (std.mem.eql(u8, value, "text")) {
            config.output = .text;
            sources.output = .env;
        }
    } else |_| {}
}

O exemplo usa page_allocator para focar no desenho. Em uma CLI real, passe o allocator da aplicação e libere as strings alocadas no shutdown. Se a ferramenta roda muitas vezes dentro do mesmo processo, esse cuidado deixa de ser detalhe.

Arquivo de configuração: simples ganha

Para CLIs pequenas, um formato key=value costuma bastar:

endpoint=https://api.exemplo.local
cache_dir=.zigtool/cache
output=json
timeout_ms=15000

Não coloque token no arquivo versionado do projeto. Se precisar documentar, use placeholder:

# Defina ZIGTOOL_TOKEN no ambiente ou no secret manager.
endpoint=https://api.exemplo.local

Um parser mínimo pode ignorar comentários e linhas vazias:

fn applyConfigLine(config: *Config, sources: *FieldSource, line: []const u8, source: Source) !void {
    const trimmed = std.mem.trim(u8, line, " \t\r\n");
    if (trimmed.len == 0 or std.mem.startsWith(u8, trimmed, "#")) return;

    const eq = std.mem.indexOfScalar(u8, trimmed, '=') orelse return error.InvalidConfigLine;
    const key = std.mem.trim(u8, trimmed[0..eq], " \t");
    const value = std.mem.trim(u8, trimmed[eq + 1 ..], " \t");

    if (std.mem.eql(u8, key, "endpoint")) {
        config.endpoint = value;
        sources.endpoint = source;
    } else if (std.mem.eql(u8, key, "cache_dir")) {
        config.cache_dir = value;
        sources.cache_dir = source;
    } else if (std.mem.eql(u8, key, "timeout_ms")) {
        config.timeout_ms = try std.fmt.parseInt(u32, value, 10);
        sources.timeout_ms = source;
    } else if (std.mem.eql(u8, key, "output")) {
        if (std.mem.eql(u8, value, "json")) config.output = .json
        else if (std.mem.eql(u8, value, "markdown")) config.output = .markdown
        else if (std.mem.eql(u8, value, "text")) config.output = .text
        else return error.InvalidOutputFormat;
        sources.output = source;
    } else {
        return error.UnknownConfigKey;
    }
}

Em produção, copie value para memória própria se o buffer da linha será reutilizado. O exemplo mostra a lógica de precedência, mas a vida útil das strings precisa ser tratada com o mesmo rigor que qualquer outro slice em Zig.

Flags vencem tudo

Depois de aplicar defaults, arquivo de usuário, arquivo do projeto e env vars, processe as flags. Isso permite override explícito:

fn applyArgs(config: *Config, sources: *FieldSource, args: []const []const u8) !void {
    var i: usize = 0;
    while (i < args.len) : (i += 1) {
        const arg = args[i];

        if (std.mem.eql(u8, arg, "--endpoint")) {
            i += 1;
            if (i >= args.len) return error.MissingEndpoint;
            config.endpoint = args[i];
            sources.endpoint = .flag;
        } else if (std.mem.eql(u8, arg, "--json")) {
            config.output = .json;
            sources.output = .flag;
        } else if (std.mem.eql(u8, arg, "--timeout-ms")) {
            i += 1;
            if (i >= args.len) return error.MissingTimeout;
            config.timeout_ms = try std.fmt.parseInt(u32, args[i], 10);
            sources.timeout_ms = .flag;
        } else if (std.mem.eql(u8, arg, "--verbose")) {
            config.verbose = true;
            sources.verbose = .flag;
        }
    }
}

Se a CLI já tem muitos subcomandos, separe parsing de comando e parsing de configuração. zigtool deploy --timeout-ms 5000 não deveria aceitar as mesmas flags posicionais que zigtool config set output json sem intenção clara.

Valide antes de executar

A validação deve responder três perguntas:

  1. o valor tem formato aceitável?
  2. o valor é seguro para esta ação?
  3. a combinação de valores faz sentido?
fn validateConfig(config: Config) !void {
    if (!std.mem.startsWith(u8, config.endpoint, "https://")) {
        return error.EndpointMustUseHttps;
    }

    if (config.timeout_ms < 100 or config.timeout_ms > 120_000) {
        return error.InvalidTimeout;
    }

    if (config.cache_dir.len == 0) {
        return error.EmptyCacheDir;
    }
}

Para ações destrutivas, não esconda confirmação dentro de config global. Prefira uma flag explícita no comando atual, como --confirm-delete, porque intenção perigosa não deve ficar gravada em arquivo.

Segredos: leia, use, não exponha

Token de API, senha, chave privada e webhook secreto devem vir de secret manager ou variável de ambiente. Se a CLI precisa persistir credencial local, use o cofre do sistema quando possível. Arquivo em texto puro só deve ser exceção documentada e com permissões restritas.

Cuidados práticos:

  • não imprima token em erro, debug, config explain ou telemetria;
  • mostre apenas set / not set ou os últimos quatro caracteres se for realmente necessário;
  • não aceite segredo por flag quando o shell history é risco;
  • não grave env vars em arquivos de log;
  • trate .env como conveniência local, não como contrato de produção.

Uma função de explicação pode mascarar:

fn printConfigExplain(config: Config, sources: FieldSource, writer: anytype) !void {
    try writer.print("endpoint: {s} ({})\n", .{ config.endpoint, sources.endpoint });
    try writer.print("cache_dir: {s} ({})\n", .{ config.cache_dir, sources.cache_dir });
    try writer.print("timeout_ms: {d} ({})\n", .{ config.timeout_ms, sources.timeout_ms });
    try writer.print("token: {s} ({})\n", .{ if (config.token == null) "not set" else "set", sources.token });
}

Projeto local vs usuário global

Uma boa regra:

  • configuração que todos no projeto compartilham pode ficar em .zigtool/config.ini;
  • preferências pessoais ficam no diretório XDG do usuário;
  • segredos ficam no ambiente ou cofre;
  • overrides pontuais ficam nas flags.

Isso evita commits acidentais de token e reduz conflito de configuração. Se o repositório precisa de exemplo, publique .zigtool/config.example.ini, não o arquivo real.

Para cache e estado, use outro caminho. Configuração responde “como rodar”; cache responde “o que já aconteceu”. Misturar os dois cria arquivos difíceis de revisar e limpar.

Teste a precedência

Configuração merece testes porque bugs nessa camada são silenciosos. Monte testes com entradas pequenas:

test "flag vence env e default" {
    var config = try defaultConfig(std.testing.allocator);
    var sources = FieldSource{};

    config.endpoint = "https://env.exemplo.local";
    sources.endpoint = .env;

    try applyArgs(&config, &sources, &.{ "--endpoint", "https://flag.exemplo.local" });

    try std.testing.expectEqualStrings("https://flag.exemplo.local", config.endpoint);
    try std.testing.expectEqual(Source.flag, sources.endpoint);
}

Também teste erro de timeout inválido, formato de output desconhecido, arquivo com chave errada e ausência de token quando uma ação exige autenticação.

Checklist para uma CLI configurável

Antes de considerar a configuração pronta, confira:

  • existe uma ordem de precedência documentada;
  • --help mostra flags e env vars importantes;
  • config explain ou modo debug mostra fontes sem revelar segredos;
  • valores perigosos não ficam persistidos por acidente;
  • arquivo de projeto não exige caminho absoluto da máquina de uma pessoa;
  • defaults são seguros;
  • validação roda antes de executar efeitos externos;
  • testes cobrem precedência e erros comuns.

Configuração boa é chata. Ela não aparece quando tudo dá certo e ajuda rápido quando algo dá errado. Em Zig, isso combina com a filosofia da linguagem: menos magia, mais contrato explícito. Se você já tem uma CLI básica, a próxima melhoria não é adicionar mais flags aleatórias; é transformar configuração em uma camada pequena, testada e previsível.

Próximos passos

Se a CLI ainda está no começo, leia criar aplicação de linha de comando em Zig e a receita de argumentos CLI. Para ferramentas que precisam persistir histórico ou cache, veja Zig e SQLite para ferramentas locais. Para distribuição, combine isso com GitHub Actions para releases multiplataforma ou GitLab CI para Zig.

Continue aprendendo Zig

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