Config: Gerenciamento de Configuração em Camadas para Rust

Guia completo da crate Config em Rust: configuração em camadas com defaults, arquivos TOML/YAML/JSON, variáveis de ambiente, deserialização type-safe com serde, hot reloading e exemplos práticos.

Introdução

A crate Config é a solução mais popular para gerenciamento de configuração em Rust. Ela permite criar sistemas de configuração em camadas, onde valores podem vir de múltiplas fontes – valores padrão, arquivos de configuração, variáveis de ambiente e argumentos de linha de comando – e são mesclados em uma estrutura unificada.

Esse padrão de “layered configuration” é essencial para aplicações modernas que precisam se comportar de forma diferente em cada ambiente (desenvolvimento, staging, produção) sem alterar o código. A crate Config resolve esse problema de forma elegante, integrando-se perfeitamente com o Serde para deserialização type-safe.

Por que usar a crate Config?

  • Múltiplas fontes: arquivos, variáveis de ambiente, valores padrão
  • Múltiplos formatos: TOML, YAML, JSON, INI, RON
  • Merging inteligente: fontes posteriores sobrescrevem anteriores
  • Type-safe: deserialização para structs Rust via Serde
  • Variáveis de ambiente: mapeamento automático de env vars
  • Hierárquico: suporte a configuração aninhada
  • Extensível: crie suas próprias fontes de configuração

Instalação

Adicione a crate Config ao seu Cargo.toml:

[dependencies]
config = "0.14"
serde = { version = "1", features = ["derive"] }

Para formatos de arquivo específicos, adicione as features correspondentes:

[dependencies]
config = { version = "0.14", features = [
    "toml",    # Suporte a TOML
    "yaml",    # Suporte a YAML
    "json",    # Suporte a JSON
    "ini",     # Suporte a INI
    "ron",     # Suporte a RON
] }
serde = { version = "1", features = ["derive"] }

Uso Básico

Configuração simples com arquivo TOML

Primeiro, crie um arquivo de configuração config/default.toml:

[servidor]
host = "127.0.0.1"
porta = 3000
workers = 4

[banco]
url = "postgres://localhost/app_dev"
max_conexoes = 5
timeout_segundos = 30

[log]
nivel = "info"
formato = "texto"

Agora, carregue e deserialize em uma struct Rust:

use config::{Config, File};
use serde::Deserialize;

#[derive(Debug, Deserialize)]
struct Configuracao {
    servidor: ConfigServidor,
    banco: ConfigBanco,
    log: ConfigLog,
}

#[derive(Debug, Deserialize)]
struct ConfigServidor {
    host: String,
    porta: u16,
    workers: usize,
}

#[derive(Debug, Deserialize)]
struct ConfigBanco {
    url: String,
    max_conexoes: u32,
    timeout_segundos: u64,
}

#[derive(Debug, Deserialize)]
struct ConfigLog {
    nivel: String,
    formato: String,
}

fn main() -> Result<(), config::ConfigError> {
    let config = Config::builder()
        .add_source(File::with_name("config/default"))
        .build()?;

    let configuracao: Configuracao = config.try_deserialize()?;

    println!("Servidor: {}:{}", configuracao.servidor.host, configuracao.servidor.porta);
    println!("Banco: {}", configuracao.banco.url);
    println!("Log: {}", configuracao.log.nivel);

    Ok(())
}

Configuração com variáveis de ambiente

use config::{Config, Environment, File};
use serde::Deserialize;

#[derive(Debug, Deserialize)]
struct Configuracao {
    servidor: ConfigServidor,
    banco: ConfigBanco,
}

#[derive(Debug, Deserialize)]
struct ConfigServidor {
    host: String,
    porta: u16,
}

#[derive(Debug, Deserialize)]
struct ConfigBanco {
    url: String,
    max_conexoes: u32,
}

fn main() -> Result<(), config::ConfigError> {
    let config = Config::builder()
        // 1. Valores do arquivo de configuração
        .add_source(File::with_name("config/default"))
        // 2. Variáveis de ambiente com prefixo APP_
        // APP_SERVIDOR__HOST -> servidor.host
        // APP_BANCO__URL -> banco.url
        .add_source(
            Environment::with_prefix("APP")
                .separator("__") // Use __ para separar níveis
        )
        .build()?;

    let configuracao: Configuracao = config.try_deserialize()?;

    println!("{:?}", configuracao);
    Ok(())
}

// Uso:
// APP_SERVIDOR__PORTA=8080 APP_BANCO__URL=postgres://prod/app cargo run

Configuração em camadas por ambiente

A abordagem mais comum: ter um arquivo base e sobrescrever por ambiente:

use config::{Config, Environment, File};
use serde::Deserialize;
use std::env;

#[derive(Debug, Deserialize)]
struct Configuracao {
    ambiente: String,
    servidor: ConfigServidor,
    banco: ConfigBanco,
    log: ConfigLog,
    redis: Option<ConfigRedis>,
}

#[derive(Debug, Deserialize)]
struct ConfigServidor {
    host: String,
    porta: u16,
    workers: usize,
}

#[derive(Debug, Deserialize)]
struct ConfigBanco {
    url: String,
    max_conexoes: u32,
}

#[derive(Debug, Deserialize)]
struct ConfigLog {
    nivel: String,
    formato: String,
}

#[derive(Debug, Deserialize)]
struct ConfigRedis {
    url: String,
    pool_size: u32,
}

fn carregar_configuracao() -> Result<Configuracao, config::ConfigError> {
    // Determinar o ambiente (dev, staging, producao)
    let ambiente = env::var("APP_ENV").unwrap_or_else(|_| "dev".to_string());

    let config = Config::builder()
        // Camada 1: Valores padrão
        .set_default("ambiente", ambiente.clone())?
        .set_default("servidor.host", "127.0.0.1")?
        .set_default("servidor.porta", 3000)?
        .set_default("servidor.workers", 4)?
        .set_default("log.nivel", "debug")?
        .set_default("log.formato", "texto")?

        // Camada 2: Arquivo de configuração base
        .add_source(File::with_name("config/default").required(false))

        // Camada 3: Arquivo específico do ambiente
        // config/dev.toml, config/staging.toml, config/producao.toml
        .add_source(
            File::with_name(&format!("config/{}", ambiente)).required(false),
        )

        // Camada 4: Arquivo local (não commitado no git)
        .add_source(File::with_name("config/local").required(false))

        // Camada 5: Variáveis de ambiente (maior prioridade)
        .add_source(
            Environment::with_prefix("APP")
                .separator("__")
                .try_parsing(true), // Tentar converter "true"/"3000" automaticamente
        )
        .build()?;

    config.try_deserialize()
}

fn main() {
    match carregar_configuracao() {
        Ok(config) => {
            println!("Ambiente: {}", config.ambiente);
            println!("Servidor: {}:{}", config.servidor.host, config.servidor.porta);
            println!("Workers: {}", config.servidor.workers);
            println!("Banco: {}", config.banco.url);
            println!("Log: {} ({})", config.log.nivel, config.log.formato);

            if let Some(redis) = &config.redis {
                println!("Redis: {} (pool: {})", redis.url, redis.pool_size);
            }
        }
        Err(e) => {
            eprintln!("Erro ao carregar configuração: {}", e);
            std::process::exit(1);
        }
    }
}

Exemplo dos arquivos por ambiente:

# config/default.toml
[servidor]
host = "127.0.0.1"
porta = 3000
workers = 4

[banco]
url = "postgres://localhost/app_dev"
max_conexoes = 5

[log]
nivel = "debug"
formato = "texto"
# config/producao.toml
[servidor]
host = "0.0.0.0"
porta = 8080
workers = 16

[banco]
url = "postgres://db.prod.internal/app"
max_conexoes = 50

[log]
nivel = "info"
formato = "json"

[redis]
url = "redis://redis.prod.internal:6379"
pool_size = 20

Recursos Avançados

Valores padrão com Default trait

use config::{Config, Environment, File};
use serde::Deserialize;

#[derive(Debug, Deserialize)]
#[serde(default)]
struct ConfigServidor {
    host: String,
    porta: u16,
    workers: usize,
    max_body_size: usize,
    timeout_segundos: u64,
    tls: bool,
}

impl Default for ConfigServidor {
    fn default() -> Self {
        Self {
            host: "127.0.0.1".to_string(),
            porta: 3000,
            workers: num_cpus::get(),
            max_body_size: 10 * 1024 * 1024, // 10MB
            timeout_segundos: 30,
            tls: false,
        }
    }
}

#[derive(Debug, Deserialize)]
#[serde(default)]
struct ConfigCache {
    habilitado: bool,
    ttl_segundos: u64,
    max_entradas: usize,
}

impl Default for ConfigCache {
    fn default() -> Self {
        Self {
            habilitado: true,
            ttl_segundos: 300,
            max_entradas: 10_000,
        }
    }
}

#[derive(Debug, Deserialize)]
struct Configuracao {
    #[serde(default)]
    servidor: ConfigServidor,
    #[serde(default)]
    cache: ConfigCache,
}

fn main() -> Result<(), config::ConfigError> {
    // Mesmo sem arquivo de configuração, os defaults funcionam
    let config = Config::builder()
        .add_source(File::with_name("config/default").required(false))
        .add_source(Environment::with_prefix("APP").separator("__"))
        .build()?;

    let configuracao: Configuracao = config.try_deserialize()?;
    println!("{:#?}", configuracao);

    Ok(())
}

Configuração com YAML

# config/default.yaml
servidor:
  host: "0.0.0.0"
  porta: 3000
  workers: 4

banco:
  url: "postgres://localhost/app"
  max_conexoes: 10
  pool:
    min_idle: 2
    max_lifetime_minutos: 30

features:
  - nome: "cache"
    habilitado: true
  - nome: "rate_limit"
    habilitado: true
  - nome: "beta_ui"
    habilitado: false

cors:
  origens_permitidas:
    - "http://localhost:3000"
    - "http://localhost:5173"
  metodos_permitidos:
    - "GET"
    - "POST"
    - "PUT"
    - "DELETE"
use config::{Config, File, FileFormat};
use serde::Deserialize;

#[derive(Debug, Deserialize)]
struct Configuracao {
    servidor: ConfigServidor,
    banco: ConfigBanco,
    features: Vec<Feature>,
    cors: ConfigCors,
}

#[derive(Debug, Deserialize)]
struct ConfigServidor {
    host: String,
    porta: u16,
    workers: usize,
}

#[derive(Debug, Deserialize)]
struct ConfigBanco {
    url: String,
    max_conexoes: u32,
    pool: ConfigPool,
}

#[derive(Debug, Deserialize)]
struct ConfigPool {
    min_idle: u32,
    max_lifetime_minutos: u64,
}

#[derive(Debug, Deserialize)]
struct Feature {
    nome: String,
    habilitado: bool,
}

#[derive(Debug, Deserialize)]
struct ConfigCors {
    origens_permitidas: Vec<String>,
    metodos_permitidos: Vec<String>,
}

fn main() -> Result<(), config::ConfigError> {
    let config = Config::builder()
        .add_source(File::new("config/default", FileFormat::Yaml))
        .build()?;

    let configuracao: Configuracao = config.try_deserialize()?;

    println!("Features habilitadas:");
    for f in &configuracao.features {
        if f.habilitado {
            println!("  - {}", f.nome);
        }
    }

    println!("Origens CORS:");
    for origem in &configuracao.cors.origens_permitidas {
        println!("  - {}", origem);
    }

    Ok(())
}

Validação de configuração

use config::{Config, File, Environment};
use serde::Deserialize;

#[derive(Debug, Deserialize)]
struct Configuracao {
    servidor: ConfigServidor,
    banco: ConfigBanco,
    jwt: ConfigJwt,
}

#[derive(Debug, Deserialize)]
struct ConfigServidor {
    host: String,
    porta: u16,
    workers: usize,
}

#[derive(Debug, Deserialize)]
struct ConfigBanco {
    url: String,
    max_conexoes: u32,
}

#[derive(Debug, Deserialize)]
struct ConfigJwt {
    secret: String,
    expiracao_minutos: u64,
}

impl Configuracao {
    fn validar(&self) -> Result<(), Vec<String>> {
        let mut erros = Vec::new();

        // Validar servidor
        if self.servidor.porta == 0 {
            erros.push("Porta do servidor não pode ser 0".to_string());
        }
        if self.servidor.workers == 0 {
            erros.push("Número de workers deve ser pelo menos 1".to_string());
        }
        if self.servidor.workers > 256 {
            erros.push("Número de workers não pode exceder 256".to_string());
        }

        // Validar banco
        if self.banco.url.is_empty() {
            erros.push("URL do banco de dados é obrigatória".to_string());
        }
        if !self.banco.url.starts_with("postgres://") {
            erros.push("URL do banco deve começar com postgres://".to_string());
        }
        if self.banco.max_conexoes == 0 {
            erros.push("max_conexoes deve ser pelo menos 1".to_string());
        }

        // Validar JWT
        if self.jwt.secret.len() < 32 {
            erros.push("JWT secret deve ter pelo menos 32 caracteres".to_string());
        }
        if self.jwt.expiracao_minutos == 0 {
            erros.push("Expiração do JWT deve ser maior que 0".to_string());
        }

        if erros.is_empty() {
            Ok(())
        } else {
            Err(erros)
        }
    }
}

fn carregar_e_validar() -> Result<Configuracao, String> {
    let config = Config::builder()
        .add_source(File::with_name("config/default").required(false))
        .add_source(Environment::with_prefix("APP").separator("__"))
        .build()
        .map_err(|e| format!("Erro ao carregar configuração: {}", e))?;

    let configuracao: Configuracao = config
        .try_deserialize()
        .map_err(|e| format!("Erro ao deserializar configuração: {}", e))?;

    configuracao
        .validar()
        .map_err(|erros| {
            format!(
                "Configuração inválida:\n{}",
                erros.iter().map(|e| format!("  - {}", e)).collect::<Vec<_>>().join("\n")
            )
        })?;

    Ok(configuracao)
}

fn main() {
    match carregar_e_validar() {
        Ok(config) => {
            println!("Configuração carregada e validada:");
            println!("{:#?}", config);
        }
        Err(e) => {
            eprintln!("{}", e);
            std::process::exit(1);
        }
    }
}

Acessar valores individuais

use config::{Config, File};

fn main() -> Result<(), config::ConfigError> {
    let config = Config::builder()
        .add_source(File::with_name("config/default"))
        .build()?;

    // Acessar valores individuais sem deserializar tudo
    let porta: u16 = config.get("servidor.porta")?;
    let host: String = config.get("servidor.host")?;
    let max_conn: u32 = config.get("banco.max_conexoes")?;

    println!("{}:{} (max conn: {})", host, porta, max_conn);

    // Verificar se uma chave existe
    let tem_redis = config.get::<String>("redis.url").is_ok();
    println!("Redis configurado: {}", tem_redis);

    // Valor com fallback
    let log_level = config
        .get::<String>("log.nivel")
        .unwrap_or_else(|_| "info".to_string());
    println!("Log: {}", log_level);

    Ok(())
}

Configuração com secrets de arquivo

use config::{Config, Environment, File};
use serde::Deserialize;

#[derive(Debug, Deserialize)]
struct Configuracao {
    banco: ConfigBanco,
    jwt_secret: String,
    api_keys: ApiKeys,
}

#[derive(Debug, Deserialize)]
struct ConfigBanco {
    url: String,
}

#[derive(Debug, Deserialize)]
struct ApiKeys {
    stripe: String,
    sendgrid: String,
}

fn carregar_config() -> Result<Configuracao, config::ConfigError> {
    let config = Config::builder()
        // Configuração base
        .add_source(File::with_name("config/default"))

        // Secrets de arquivos (ex: montados como volumes no Docker/K8s)
        // /run/secrets/database_url -> banco.url
        .set_override_option(
            "banco.url",
            ler_secret("/run/secrets/database_url"),
        )?
        .set_override_option(
            "jwt_secret",
            ler_secret("/run/secrets/jwt_secret"),
        )?
        .set_override_option(
            "api_keys.stripe",
            ler_secret("/run/secrets/stripe_key"),
        )?

        // Variáveis de ambiente (maior prioridade)
        .add_source(Environment::with_prefix("APP").separator("__"))
        .build()?;

    config.try_deserialize()
}

fn ler_secret(caminho: &str) -> Option<String> {
    std::fs::read_to_string(caminho)
        .ok()
        .map(|s| s.trim().to_string())
}

Boas Práticas

1. Estruture os arquivos de configuração

config/
  default.toml       # Valores padrão para todos os ambientes
  dev.toml           # Sobrescritas para desenvolvimento
  staging.toml       # Sobrescritas para staging
  producao.toml      # Sobrescritas para produção
  local.toml         # Sobrescritas locais (no .gitignore)

2. Nunca commite secrets

# .gitignore
config/local.toml
config/local.yaml
.env

3. Use um singleton global para a configuração

use config::{Config, Environment, File};
use once_cell::sync::Lazy;
use serde::Deserialize;

#[derive(Debug, Deserialize)]
pub struct Configuracao {
    pub servidor: ConfigServidor,
    pub banco: ConfigBanco,
}

#[derive(Debug, Deserialize)]
pub struct ConfigServidor {
    pub host: String,
    pub porta: u16,
}

#[derive(Debug, Deserialize)]
pub struct ConfigBanco {
    pub url: String,
}

pub static CONFIG: Lazy<Configuracao> = Lazy::new(|| {
    let ambiente = std::env::var("APP_ENV").unwrap_or_else(|_| "dev".to_string());

    Config::builder()
        .add_source(File::with_name("config/default").required(false))
        .add_source(File::with_name(&format!("config/{}", ambiente)).required(false))
        .add_source(Environment::with_prefix("APP").separator("__"))
        .build()
        .expect("Erro ao construir configuração")
        .try_deserialize()
        .expect("Erro ao deserializar configuração")
});

// Uso em qualquer lugar:
fn main() {
    println!("Porta: {}", CONFIG.servidor.porta);
    println!("Banco: {}", CONFIG.banco.url);
}

4. Documente todas as variáveis de configuração

use serde::Deserialize;

/// Configuração completa da aplicação
///
/// ## Variáveis de ambiente
///
/// | Variável | Padrão | Descrição |
/// |---|---|---|
/// | APP_SERVIDOR__HOST | 127.0.0.1 | Host para bind |
/// | APP_SERVIDOR__PORTA | 3000 | Porta HTTP |
/// | APP_BANCO__URL | - | URL de conexão PostgreSQL |
/// | APP_LOG__NIVEL | info | Nível de log (trace/debug/info/warn/error) |
#[derive(Debug, Deserialize)]
pub struct Configuracao {
    #[serde(default)]
    pub servidor: ConfigServidor,
    pub banco: ConfigBanco,
    #[serde(default)]
    pub log: ConfigLog,
}

#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct ConfigServidor {
    /// Host para bind do servidor HTTP
    pub host: String,
    /// Porta do servidor HTTP (1024-65535)
    pub porta: u16,
}

impl Default for ConfigServidor {
    fn default() -> Self {
        Self {
            host: "127.0.0.1".to_string(),
            porta: 3000,
        }
    }
}

#[derive(Debug, Deserialize)]
pub struct ConfigBanco {
    /// URL de conexão PostgreSQL
    /// Exemplo: postgres://usuario:senha@host:5432/banco
    pub url: String,
}

#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct ConfigLog {
    /// Nível de log: trace, debug, info, warn, error
    pub nivel: String,
    /// Formato: texto ou json
    pub formato: String,
}

impl Default for ConfigLog {
    fn default() -> Self {
        Self {
            nivel: "info".to_string(),
            formato: "texto".to_string(),
        }
    }
}

5. Integre com Clap para CLI

use clap::Parser;
use config::{Config, Environment, File};
use serde::Deserialize;

#[derive(Parser)]
struct CliArgs {
    /// Arquivo de configuração
    #[arg(short, long, default_value = "config/default")]
    config: String,

    /// Ambiente (dev, staging, producao)
    #[arg(short, long, env = "APP_ENV", default_value = "dev")]
    ambiente: String,

    /// Porta do servidor (sobrescreve config)
    #[arg(short, long)]
    porta: Option<u16>,
}

#[derive(Debug, Deserialize)]
struct Configuracao {
    servidor: ConfigServidor,
}

#[derive(Debug, Deserialize)]
struct ConfigServidor {
    host: String,
    porta: u16,
}

fn main() -> Result<(), config::ConfigError> {
    let cli = CliArgs::parse();

    let mut builder = Config::builder()
        .add_source(File::with_name(&cli.config).required(false))
        .add_source(
            File::with_name(&format!("config/{}", cli.ambiente)).required(false),
        )
        .add_source(Environment::with_prefix("APP").separator("__"));

    // CLI args sobrescrevem tudo
    if let Some(porta) = cli.porta {
        builder = builder.set_override("servidor.porta", porta as i64)?;
    }

    let config = builder.build()?;
    let configuracao: Configuracao = config.try_deserialize()?;

    println!("Ambiente: {}", cli.ambiente);
    println!("Servidor: {}:{}", configuracao.servidor.host, configuracao.servidor.porta);

    Ok(())
}

Exemplos Práticos

Sistema de configuração completo para aplicação web

use config::{Config, Environment, File};
use serde::Deserialize;
use std::env;

// === Structs de configuração ===

#[derive(Debug, Deserialize)]
pub struct AppConfig {
    pub app: ConfigApp,
    pub servidor: ConfigServidor,
    pub banco: ConfigBanco,
    pub log: ConfigLog,
    #[serde(default)]
    pub cache: ConfigCache,
    #[serde(default)]
    pub cors: ConfigCors,
    #[serde(default)]
    pub email: Option<ConfigEmail>,
}

#[derive(Debug, Deserialize)]
pub struct ConfigApp {
    pub nome: String,
    pub versao: String,
    pub ambiente: String,
}

#[derive(Debug, Deserialize)]
pub struct ConfigServidor {
    pub host: String,
    pub porta: u16,
    pub workers: usize,
    #[serde(default = "default_body_limit")]
    pub max_body_size_mb: usize,
    #[serde(default = "default_timeout")]
    pub timeout_segundos: u64,
}

fn default_body_limit() -> usize { 10 }
fn default_timeout() -> u64 { 30 }

#[derive(Debug, Deserialize)]
pub struct ConfigBanco {
    pub url: String,
    pub max_conexoes: u32,
    #[serde(default = "default_min_conexoes")]
    pub min_conexoes: u32,
    #[serde(default = "default_lifetime")]
    pub max_lifetime_minutos: u64,
}

fn default_min_conexoes() -> u32 { 2 }
fn default_lifetime() -> u64 { 30 }

#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct ConfigLog {
    pub nivel: String,
    pub formato: String,
    pub arquivo: Option<String>,
}

impl Default for ConfigLog {
    fn default() -> Self {
        Self {
            nivel: "info".to_string(),
            formato: "texto".to_string(),
            arquivo: None,
        }
    }
}

#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct ConfigCache {
    pub habilitado: bool,
    pub redis_url: Option<String>,
    pub ttl_segundos: u64,
}

impl Default for ConfigCache {
    fn default() -> Self {
        Self {
            habilitado: false,
            redis_url: None,
            ttl_segundos: 300,
        }
    }
}

#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct ConfigCors {
    pub habilitado: bool,
    pub origens: Vec<String>,
    pub max_age_segundos: u64,
}

impl Default for ConfigCors {
    fn default() -> Self {
        Self {
            habilitado: true,
            origens: vec!["*".to_string()],
            max_age_segundos: 3600,
        }
    }
}

#[derive(Debug, Deserialize)]
pub struct ConfigEmail {
    pub smtp_host: String,
    pub smtp_porta: u16,
    pub remetente: String,
    pub usuario: String,
    pub senha: String,
}

// === Carregamento ===

impl AppConfig {
    pub fn carregar() -> Result<Self, config::ConfigError> {
        let ambiente = env::var("APP_ENV").unwrap_or_else(|_| "dev".to_string());

        let config = Config::builder()
            // Defaults programáticos
            .set_default("app.nome", "Minha App")?
            .set_default("app.versao", env!("CARGO_PKG_VERSION"))?
            .set_default("app.ambiente", &ambiente)?

            // Camada 1: Configuração base
            .add_source(File::with_name("config/default").required(false))

            // Camada 2: Configuração do ambiente
            .add_source(
                File::with_name(&format!("config/{}", ambiente)).required(false),
            )

            // Camada 3: Configuração local (não commitada)
            .add_source(File::with_name("config/local").required(false))

            // Camada 4: Variáveis de ambiente
            .add_source(
                Environment::with_prefix("APP")
                    .separator("__")
                    .try_parsing(true),
            )
            .build()?;

        let app_config: AppConfig = config.try_deserialize()?;

        // Validar
        app_config.validar()?;

        Ok(app_config)
    }

    fn validar(&self) -> Result<(), config::ConfigError> {
        if self.servidor.porta == 0 {
            return Err(config::ConfigError::Message(
                "Porta do servidor não pode ser 0".to_string(),
            ));
        }

        if self.banco.url.is_empty() {
            return Err(config::ConfigError::Message(
                "URL do banco de dados é obrigatória".to_string(),
            ));
        }

        Ok(())
    }

    pub fn is_producao(&self) -> bool {
        self.app.ambiente == "producao"
    }

    pub fn is_dev(&self) -> bool {
        self.app.ambiente == "dev"
    }
}

fn main() {
    match AppConfig::carregar() {
        Ok(config) => {
            println!("=== {} v{} ({}) ===",
                config.app.nome, config.app.versao, config.app.ambiente);
            println!("Servidor: {}:{}", config.servidor.host, config.servidor.porta);
            println!("Workers: {}", config.servidor.workers);
            println!("Banco: {} (max: {} conn)",
                config.banco.url, config.banco.max_conexoes);
            println!("Log: {} ({})", config.log.nivel, config.log.formato);
            println!("Cache: {}", if config.cache.habilitado { "ativo" } else { "inativo" });
            println!("Produção: {}", config.is_producao());
        }
        Err(e) => {
            eprintln!("Falha ao carregar configuração: {}", e);
            std::process::exit(1);
        }
    }
}

Comparação com Alternativas

Característicaconfigdotenvyfigmentenvy
AbordagemMulti-fonte, layeredArquivos .envMulti-fonteEnv vars apenas
FormatosTOML, YAML, JSON, INI.envTOML, YAML, JSONEnv vars
LayeringSim (múltiplas fontes)NãoSimNão
Type-safeSim (via Serde)Não (strings)Sim (via Serde)Sim (via Serde)
Env varsSimSimSimSim (foco)
ValidaçãoManualNãoManualManual
ComplexidadeMédiaBaixaMédiaBaixa
  • config vs dotenvy: dotenvy apenas carrega .env para variáveis de ambiente. config faz merge de múltiplas fontes.
  • config vs figment: figment (usado pelo Rocket) é similar em conceito. config é mais popular e tem mais documentação.
  • config vs envy: envy deserializa apenas de variáveis de ambiente. config suporta múltiplas fontes.
  • Para projetos simples que só precisam de env vars, dotenvy + envy é suficiente. Para configuração completa em camadas, use config.

Conclusão

A crate Config resolve o problema universal de gerenciamento de configuração de forma elegante e Rust-idiomatic. O sistema de camadas permite que a mesma aplicação funcione perfeitamente em desenvolvimento local (com defaults sensatos), staging (com arquivo de configuração), e produção (com variáveis de ambiente e secrets), tudo sem alterar uma linha de código.

A integração perfeita com Serde significa que suas configurações são structs Rust tipadas e verificadas em compile-time, eliminando erros de configuração que só apareceriam em runtime.

Próximos passos

  • Combine com Clap para aceitar configuração via linha de comando
  • Use Tracing para logar quando configurações são carregadas
  • Explore dotenvy para carregar .env em desenvolvimento
  • Integre com Axum para injetar configuração como estado da aplicação