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:
zigdeployprecisa 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:
- Flags de CLI: intenção explícita da execução atual.
- Variáveis de ambiente: boas para CI, containers e segredos.
- Arquivo local do projeto: configuração versionável ou por repositório.
- Arquivo do usuário em XDG config: preferência pessoal persistente.
- Defaults compilados: valores seguros para começar.
Em forma de tabela:
| Fonte | Exemplo | Deve guardar segredo? | Quando usar |
|---|---|---|---|
| Flag | --timeout 10s | Não | Ajuste pontual |
| Env var | ZIGTOOL_TOKEN | Sim, com cuidado | CI, sessão local, secret manager |
| Projeto | .zigtool/config.ini | Não | Convenções do repo |
| Usuário | ~/.config/zigtool/config.ini | Evite | Preferências pessoais |
| Default | timeout 30s | Não | Experiê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:
- o valor tem formato aceitável?
- o valor é seguro para esta ação?
- 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 explainou telemetria; - mostre apenas
set/not setou 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
.envcomo 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;
--helpmostra flags e env vars importantes;config explainou 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.