use anyhow::{bail, ensure, Context, Result};
use chrono::Utc;
use clap::{Parser, Subcommand};
use colored::Colorize;
use directories::ProjectDirs;
use rand::seq::SliceRandom;
use reqwest::StatusCode;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use tokio::time::{sleep, Duration};
use tracing::{error, info, warn};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
#[derive(Debug, Parser)]
#[command(name = "context7", about = "Cliente CLI para a API Context7")]
struct Cli {
#[command(subcommand)]
comando: Comando,
#[arg(long, global = true)]
json: bool,
}
#[derive(Debug, Subcommand)]
enum Comando {
#[command(alias = "lib", alias = "search")]
Library {
nome: String,
query: Option<String>,
},
#[command(alias = "doc", alias = "context")]
Docs {
library_id: String,
#[arg(long)]
query: Option<String>,
#[arg(long, conflicts_with = "json")]
text: bool,
},
#[command(alias = "key")]
Keys {
#[command(subcommand)]
operacao: OperacaoKeys,
},
}
#[derive(Debug, Subcommand)]
enum OperacaoKeys {
Add {
chave: String,
},
List,
Remove {
indice: usize,
},
Clear {
#[arg(long)]
yes: bool,
},
Path,
Import {
arquivo: std::path::PathBuf,
},
Export,
}
#[derive(Debug, Deserialize, Serialize)]
struct LibrarySearchResult {
id: String,
title: String,
description: Option<String>,
trust_score: Option<f64>,
}
#[derive(Debug, Deserialize, Serialize)]
struct DocumentationSnippet {
content: String,
#[serde(rename = "type")]
tipo: Option<String>,
source_urls: Option<Vec<String>>,
}
#[derive(Debug, Deserialize)]
struct RespostaListaBibliotecas {
results: Vec<LibrarySearchResult>,
}
#[derive(Debug, Deserialize, Serialize)]
struct RespostaDocumentacao {
#[allow(dead_code)]
id: Option<String>,
snippets: Option<Vec<DocumentationSnippet>>,
content: Option<String>,
}
#[derive(Debug, thiserror::Error)]
enum ErroContext7 {
#[error("Nenhuma chave de API válida disponível após {tentativas} tentativas")]
RetryEsgotado { tentativas: u32 },
#[error("Todas as chaves de API falharam por autenticação")]
SemChavesApi,
#[error("Resposta inválida da API: status {status}")]
RespostaInvalida { status: u16 },
#[error("API retornou erro 400: {mensagem}")]
ApiRetornou400 { mensagem: String },
}
#[derive(Debug, Serialize, Deserialize, Clone)]
struct ChaveArmazenada {
value: String,
added_at: String,
}
#[derive(Debug, Serialize, Deserialize, Default)]
struct ConfigArquivo {
schema_version: u32,
#[serde(default)]
keys: Vec<ChaveArmazenada>,
}
fn aplicar_permissoes_600(caminho: &std::path::Path) -> Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(caminho)
.with_context(|| format!("Falha ao ler metadados de: {}", caminho.display()))?
.permissions();
perms.set_mode(0o600);
std::fs::set_permissions(caminho, perms)
.with_context(|| format!("Falha ao definir permissões em: {}", caminho.display()))?;
}
#[cfg(not(unix))]
let _ = caminho;
Ok(())
}
fn descobrir_caminho_config() -> Option<PathBuf> {
ProjectDirs::from("", "", "context7").map(|dirs| dirs.config_dir().join("config.toml"))
}
fn descobrir_caminho_logs_xdg() -> Option<PathBuf> {
ProjectDirs::from("", "", "context7").map(|dirs| {
#[cfg(target_os = "linux")]
{
dirs.state_dir()
.unwrap_or_else(|| dirs.data_local_dir())
.to_path_buf()
}
#[cfg(not(target_os = "linux"))]
{
dirs.data_local_dir().to_path_buf()
}
})
}
fn ler_env_var_chave() -> Option<Vec<String>> {
std::env::var("CONTEXT7_API_KEYS")
.ok()
.map(|valor| {
valor
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
})
.filter(|v| !v.is_empty())
}
fn ler_config_xdg() -> Result<Option<Vec<String>>> {
let caminho = match descobrir_caminho_config() {
Some(p) => p,
None => return Ok(None),
};
if !caminho.exists() {
return Ok(None);
}
let conteudo = std::fs::read_to_string(&caminho)
.with_context(|| format!("Falha ao ler configuração XDG em: {}", caminho.display()))?;
let config: ConfigArquivo = toml::from_str(&conteudo)
.with_context(|| format!("TOML inválido em: {}", caminho.display()))?;
let chaves: Vec<String> = config
.keys
.into_iter()
.map(|c| c.value)
.filter(|v| !v.is_empty())
.collect();
if chaves.is_empty() {
Ok(None)
} else {
Ok(Some(chaves))
}
}
fn ler_env_cwd() -> Option<Vec<String>> {
let caminho = std::env::current_dir().ok().map(|d| d.join(".env"))?;
if !caminho.exists() {
return None;
}
std::fs::read_to_string(&caminho)
.ok()
.and_then(|conteudo| extrair_chaves_env(&conteudo).ok())
}
fn ler_env_compile_time() -> Option<Vec<String>> {
option_env!("CONTEXT7_API_KEYS").map(|valor| {
valor
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
})
}
fn carregar_chaves_api() -> Result<Vec<String>> {
if let Some(chaves) = ler_env_var_chave() {
info!("Chaves carregadas via variável de ambiente CONTEXT7_API_KEYS");
return Ok(chaves);
}
match ler_config_xdg() {
Ok(Some(chaves)) => {
info!("Chaves carregadas via configuração XDG");
return Ok(chaves);
}
Ok(None) => {}
Err(e) => {
warn!("Falha ao ler configuração XDG (continuando): {}", e);
}
}
if let Some(chaves) = ler_env_cwd() {
info!(
"Iniciando context7 com {} chaves de API disponíveis",
chaves.len()
);
return Ok(chaves);
}
if let Some(chaves) = ler_env_compile_time() {
info!("Chaves carregadas via compile-time CONTEXT7_API_KEYS");
return Ok(chaves);
}
bail!("Nenhuma chave de API encontrada. Configure CONTEXT7_API_KEYS, ~/.config/context7/config.toml ou um arquivo .env com CONTEXT7_API=<chave>")
}
fn escrever_config_xdg(nova_chave: &str) -> Result<PathBuf> {
let caminho = descobrir_caminho_config()
.context("Sistema não suporta diretórios XDG — impossível salvar configuração")?;
if let Some(pai) = caminho.parent() {
std::fs::create_dir_all(pai)
.with_context(|| format!("Falha ao criar diretório: {}", pai.display()))?;
}
let mut config = if caminho.exists() {
let conteudo = std::fs::read_to_string(&caminho)
.with_context(|| format!("Falha ao ler config existente: {}", caminho.display()))?;
toml::from_str::<ConfigArquivo>(&conteudo)
.with_context(|| format!("TOML inválido em: {}", caminho.display()))?
} else {
ConfigArquivo {
schema_version: 1,
keys: Vec::new(),
}
};
let ja_existe = config.keys.iter().any(|c| c.value == nova_chave);
if !ja_existe {
config.keys.push(ChaveArmazenada {
value: nova_chave.to_string(),
added_at: Utc::now().to_rfc3339(),
});
}
let toml_str =
toml::to_string_pretty(&config).context("Falha ao serializar configuração para TOML")?;
std::fs::write(&caminho, &toml_str)
.with_context(|| format!("Falha ao escrever config em: {}", caminho.display()))?;
aplicar_permissoes_600(&caminho)?;
Ok(caminho)
}
fn ler_config_xdg_raw() -> Result<Option<ConfigArquivo>> {
let caminho = match descobrir_caminho_config() {
Some(p) => p,
None => return Ok(None),
};
if !caminho.exists() {
return Ok(None);
}
let conteudo = std::fs::read_to_string(&caminho)
.with_context(|| format!("Falha ao ler configuração XDG em: {}", caminho.display()))?;
let config: ConfigArquivo = toml::from_str(&conteudo)
.with_context(|| format!("TOML inválido em: {}", caminho.display()))?;
Ok(Some(config))
}
fn escrever_config_arquivo(config: &ConfigArquivo) -> Result<PathBuf> {
let caminho = descobrir_caminho_config()
.context("Sistema não suporta diretórios XDG — impossível salvar configuração")?;
if let Some(pai) = caminho.parent() {
std::fs::create_dir_all(pai)
.with_context(|| format!("Falha ao criar diretório: {}", pai.display()))?;
}
let toml_str =
toml::to_string_pretty(config).context("Falha ao serializar configuração para TOML")?;
std::fs::write(&caminho, &toml_str)
.with_context(|| format!("Falha ao escrever config em: {}", caminho.display()))?;
aplicar_permissoes_600(&caminho)?;
Ok(caminho)
}
fn mascarar_chave(chave: &str) -> String {
let n_chars = chave.chars().count();
let inicio = 12;
let fim = 4;
if n_chars <= inicio + fim {
return "***".to_string();
}
let prefixo: String = chave.chars().take(inicio).collect();
let sufixo: String = chave
.chars()
.rev()
.take(fim)
.collect::<String>()
.chars()
.rev()
.collect();
format!("{}...{}", prefixo, sufixo)
}
fn confirmar_clear() -> Result<bool> {
use std::io::Write;
print!("Tem certeza que deseja remover TODAS as chaves? [s/N] ");
std::io::stdout()
.flush()
.context("Falha ao limpar buffer de saída")?;
let mut entrada = String::new();
std::io::stdin()
.read_line(&mut entrada)
.context("Falha ao ler confirmação do usuário")?;
Ok(matches!(
entrada.trim().to_lowercase().as_str(),
"s" | "sim"
))
}
fn cmd_keys_add(chave: &str) -> Result<()> {
let caminho = escrever_config_xdg(chave)?;
println!(
"Chave adicionada com sucesso em: {}",
caminho.display().to_string().green()
);
Ok(())
}
fn listar_chaves_mascaradas(chaves: &[ChaveArmazenada]) {
println!(
"{}",
format!("{} chave(s) armazenada(s):", chaves.len())
.green()
.bold()
);
println!("{}", "─".repeat(60).dimmed());
for (i, chave) in chaves.iter().enumerate() {
println!(
" {} {} {}",
format!("[{}]", i + 1).cyan(),
mascarar_chave(&chave.value).bold(),
format!("(adicionada: {})", chave.added_at).dimmed()
);
}
}
fn cmd_keys_list() -> Result<()> {
let exibir_vazia = || {
println!("{}", "Nenhuma chave armazenada.".yellow());
println!(
"Use {} para adicionar uma chave.",
"context7 keys add <CHAVE>".cyan()
);
};
match ler_config_xdg_raw()? {
None => exibir_vazia(),
Some(config) if config.keys.is_empty() => exibir_vazia(),
Some(config) => listar_chaves_mascaradas(&config.keys),
}
Ok(())
}
fn cmd_keys_remove(indice: usize) -> Result<()> {
let mut config = match ler_config_xdg_raw()? {
None => {
println!("{}", "Nenhuma chave armazenada para remover.".yellow());
return Ok(());
}
Some(c) if c.keys.is_empty() => {
println!("{}", "Nenhuma chave armazenada para remover.".yellow());
return Ok(());
}
Some(c) => c,
};
if indice == 0 || indice > config.keys.len() {
println!(
"{}",
format!(
"Índice {} inválido. Use um número entre 1 e {}.",
indice,
config.keys.len()
)
.red()
);
return Ok(());
}
let removida = config.keys.remove(indice - 1);
escrever_config_arquivo(&config)?;
println!(
"Chave {} removida com sucesso.",
mascarar_chave(&removida.value).bold()
);
Ok(())
}
fn cmd_keys_clear(sim: bool) -> Result<()> {
if !sim && !confirmar_clear()? {
println!("{}", "Operação cancelada.".yellow());
return Ok(());
}
let config = ConfigArquivo {
schema_version: 1,
keys: Vec::new(),
};
escrever_config_arquivo(&config)?;
println!("{}", "Todas as chaves foram removidas.".green());
Ok(())
}
fn cmd_keys_path() -> Result<()> {
match descobrir_caminho_config() {
Some(caminho) => println!("{}", caminho.display()),
None => {
println!("{}", "Sistema não suporta diretórios XDG.".red());
}
}
Ok(())
}
fn cmd_keys_import(arquivo: &std::path::Path) -> Result<()> {
let conteudo = std::fs::read_to_string(arquivo)
.with_context(|| format!("Falha ao ler arquivo: {}", arquivo.display()))?;
let chaves = extrair_chaves_env(&conteudo).with_context(|| {
format!(
"Nenhuma chave CONTEXT7_API= encontrada em: {}",
arquivo.display()
)
})?;
let total = chaves.len();
let mut importadas = 0usize;
for chave in &chaves {
escrever_config_xdg(chave)?;
importadas += 1;
}
println!(
"{}",
format!(
"{}/{} chave(s) importada(s) com sucesso.",
importadas, total
)
.green()
);
Ok(())
}
fn cmd_keys_export() -> Result<()> {
match ler_config_xdg_raw()? {
None => {}
Some(config) if config.keys.is_empty() => {}
Some(config) => {
for chave in &config.keys {
println!("CONTEXT7_API={}", chave.value);
}
}
}
Ok(())
}
struct GuardaLog(#[allow(dead_code)] tracing_appender::non_blocking::WorkerGuard);
#[allow(dead_code)]
fn descobrir_raiz_projeto() -> PathBuf {
const RAIZ_COMPILE_TIME: &str = env!("CARGO_MANIFEST_DIR");
let raiz_compile = PathBuf::from(RAIZ_COMPILE_TIME);
if raiz_compile.join("Cargo.toml").exists() {
return raiz_compile;
}
if let Ok(exe) = std::env::current_exe() {
let mut dir = exe.parent().map(|p| p.to_path_buf());
while let Some(d) = dir.clone() {
if d.join("Cargo.toml").exists() {
return d;
}
dir = d.parent().map(|p| p.to_path_buf());
}
}
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
}
pub fn extrair_chaves_env(conteudo: &str) -> Result<Vec<String>> {
let chaves: Vec<String> = conteudo
.lines()
.filter_map(|linha| {
let linha_sem_comentario = linha.split('#').next().unwrap_or("").trim();
linha_sem_comentario
.strip_prefix("CONTEXT7_API=")
.map(|valor| {
valor
.trim()
.trim_matches('"')
.trim_matches('\'')
.to_string()
})
.filter(|v| !v.is_empty())
})
.collect();
ensure!(!chaves.is_empty(), ErroContext7::SemChavesApi);
Ok(chaves)
}
fn inicializar_logging() -> Result<GuardaLog> {
const NOME_BINARIO: &str = env!("CARGO_BIN_NAME");
let pasta_logs = descobrir_caminho_logs_xdg().unwrap_or_else(|| {
let raiz_compile = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
if raiz_compile.join("Cargo.toml").exists() {
raiz_compile.join("logs")
} else {
PathBuf::from("logs")
}
});
let caminho_log = pasta_logs.join(format!("{}.log", NOME_BINARIO));
if caminho_log.exists() {
std::fs::remove_file(&caminho_log)
.with_context(|| format!("Falha ao deletar log anterior: {}", caminho_log.display()))?;
}
std::fs::create_dir_all(&pasta_logs)
.with_context(|| format!("Falha ao criar pasta de logs: {}", pasta_logs.display()))?;
let appender_arquivo =
tracing_appender::rolling::never(&pasta_logs, format!("{}.log", NOME_BINARIO));
let (escritor_nao_bloqueante, guard) = tracing_appender::non_blocking(appender_arquivo);
let filtro = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new(format!("{}=info", NOME_BINARIO)));
let camada_terminal = tracing_subscriber::fmt::layer()
.with_ansi(true)
.with_target(false)
.with_writer(std::io::stderr);
let camada_arquivo = tracing_subscriber::fmt::layer()
.with_ansi(false)
.with_target(true)
.with_writer(escritor_nao_bloqueante);
tracing_subscriber::registry()
.with(filtro)
.with(camada_terminal)
.with(camada_arquivo)
.init();
Ok(GuardaLog(guard))
}
fn criar_cliente_http() -> Result<reqwest::Client> {
let cliente = reqwest::Client::builder()
.use_rustls_tls()
.timeout(Duration::from_secs(30))
.user_agent("context7-cli/0.1.0")
.pool_max_idle_per_host(4)
.build()
.context("Falha ao criar cliente HTTP")?;
Ok(cliente)
}
async fn executar_com_retry<F, Fut, T>(chaves: &[String], operacao: F) -> Result<T>
where
F: Fn(String) -> Fut,
Fut: std::future::Future<Output = Result<T, ErroContext7>>,
{
let max_tentativas = 3usize.min(chaves.len());
let mut chaves_embaralhadas = chaves.to_vec();
let mut rng = rand::thread_rng();
chaves_embaralhadas.shuffle(&mut rng);
let atrasos_ms = [500u64, 1000, 2000];
let mut chaves_falhas_auth = 0usize;
for (tentativa, chave) in chaves_embaralhadas
.into_iter()
.take(max_tentativas)
.enumerate()
{
info!("Tentativa {}/{}", tentativa + 1, max_tentativas);
match operacao(chave).await {
Ok(resultado) => return Ok(resultado),
Err(ErroContext7::ApiRetornou400 { mensagem }) => {
bail!(ErroContext7::ApiRetornou400 { mensagem });
}
Err(ErroContext7::SemChavesApi) => {
chaves_falhas_auth += 1;
warn!("Chave de API inválida (401/403), tentando próxima...");
}
Err(e) => {
warn!("Falha na tentativa {}: {}", tentativa + 1, e);
if tentativa + 1 < max_tentativas && tentativa < atrasos_ms.len() {
let atraso = Duration::from_millis(atrasos_ms[tentativa]);
info!(
"Aguardando {}ms antes de tentar novamente...",
atraso.as_millis()
);
sleep(atraso).await;
}
}
}
}
if chaves_falhas_auth >= max_tentativas {
bail!(ErroContext7::SemChavesApi);
}
bail!(ErroContext7::RetryEsgotado {
tentativas: max_tentativas as u32,
});
}
const BASE_URL: &str = "https://context7.com/api";
async fn buscar_biblioteca(
cliente: &reqwest::Client,
chave: &str,
nome: &str,
query_contexto: &str,
) -> Result<RespostaListaBibliotecas, ErroContext7> {
let url = format!("{}/v1/search", BASE_URL);
let resposta = cliente
.get(&url)
.bearer_auth(chave)
.query(&[("libraryName", nome), ("query", query_contexto)])
.send()
.await
.map_err(|e| {
error!("Erro de rede ao buscar biblioteca: {}", e);
ErroContext7::RespostaInvalida { status: 0 }
})?;
tratar_status_resposta(resposta).await
}
async fn buscar_documentacao(
cliente: &reqwest::Client,
chave: &str,
library_id: &str,
query: Option<&str>,
texto_plano: bool,
) -> Result<RespostaDocumentacao, ErroContext7> {
let id_normalizado = library_id.trim_start_matches('/');
let url = format!("{}/v1/{}", BASE_URL, id_normalizado);
let tipo = if texto_plano { "txt" } else { "json" };
let mut construtor = cliente
.get(&url)
.bearer_auth(chave)
.query(&[("type", tipo)]);
if let Some(q) = query {
construtor = construtor.query(&[("query", q)]);
}
let resposta = construtor.send().await.map_err(|e| {
error!("Erro de rede ao buscar documentação: {}", e);
ErroContext7::RespostaInvalida { status: 0 }
})?;
tratar_status_resposta(resposta).await
}
async fn tratar_status_resposta<T: for<'de> Deserialize<'de>>(
resposta: reqwest::Response,
) -> Result<T, ErroContext7> {
let status = resposta.status();
match status {
s if s.is_success() => resposta.json::<T>().await.map_err(|e| {
error!("Falha ao desserializar resposta JSON: {}", e);
ErroContext7::RespostaInvalida {
status: status.as_u16(),
}
}),
StatusCode::BAD_REQUEST => {
let mensagem = resposta
.text()
.await
.unwrap_or_else(|_| "Sem detalhes".to_string());
Err(ErroContext7::ApiRetornou400 { mensagem })
}
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => Err(ErroContext7::SemChavesApi),
StatusCode::TOO_MANY_REQUESTS => {
warn!("Rate limit atingido (429), aguardando retry...");
Err(ErroContext7::RespostaInvalida {
status: status.as_u16(),
})
}
s if s.is_server_error() => {
warn!(
"Erro do servidor ({}), tentando novamente...",
status.as_u16()
);
Err(ErroContext7::RespostaInvalida {
status: status.as_u16(),
})
}
_ => Err(ErroContext7::RespostaInvalida {
status: status.as_u16(),
}),
}
}
fn exibir_bibliotecas_formatado(resultados: &[LibrarySearchResult]) {
if resultados.is_empty() {
println!("{}", "Nenhuma biblioteca encontrada.".yellow());
return;
}
println!("{}", "Bibliotecas encontradas:".green().bold());
println!("{}", "─".repeat(60).dimmed());
for (i, lib) in resultados.iter().enumerate() {
let numero = format!("{}.", i + 1);
println!("{} {} {}", numero.cyan(), lib.id.bold(), lib.title.dimmed());
if let Some(desc) = &lib.description {
println!(" {}", desc.italic());
}
if let Some(score) = lib.trust_score {
println!(" {} {:.1}", "Confiança:".dimmed(), score);
}
println!();
}
}
fn exibir_documentacao_formatada(doc: &RespostaDocumentacao) {
if let Some(snippets) = &doc.snippets {
if snippets.is_empty() {
println!("{}", "Nenhuma documentação encontrada.".yellow());
return;
}
println!("{}", "Documentação:".green().bold());
println!("{}", "─".repeat(60).dimmed());
for snippet in snippets {
println!("{}", snippet.content);
if let Some(urls) = &snippet.source_urls {
println!("\n{}", "Fontes:".dimmed());
for url in urls {
println!(" {}", url.blue().bold());
}
}
println!();
}
} else if let Some(conteudo) = &doc.content {
println!("{}", conteudo);
} else {
println!("{}", "Sem conteúdo disponível.".yellow());
}
}
fn executar_keys(operacao: OperacaoKeys) -> Result<()> {
match operacao {
OperacaoKeys::Add { chave } => cmd_keys_add(&chave),
OperacaoKeys::List => cmd_keys_list(),
OperacaoKeys::Remove { indice } => cmd_keys_remove(indice),
OperacaoKeys::Clear { yes } => cmd_keys_clear(yes),
OperacaoKeys::Path => cmd_keys_path(),
OperacaoKeys::Import { arquivo } => cmd_keys_import(&arquivo),
OperacaoKeys::Export => cmd_keys_export(),
}
}
async fn executar_library(
nome: String,
query: Option<String>,
json: bool,
chaves: &[String],
cliente: &reqwest::Client,
) -> Result<()> {
info!("Buscando biblioteca: {}", nome);
let query_contexto = query.as_deref().unwrap_or(&nome).to_string();
let cliente_arc = std::sync::Arc::new(cliente.clone());
let nome_clone = nome.clone();
let query_clone = query_contexto.clone();
let resultado = executar_com_retry(chaves, move |chave| {
let c = std::sync::Arc::clone(&cliente_arc);
let n = nome_clone.clone();
let q = query_clone.clone();
async move { buscar_biblioteca(&c, &chave, &n, &q).await }
})
.await
.with_context(|| format!("Falha ao buscar biblioteca '{}' ", nome))?;
if json {
println!(
"{}",
serde_json::to_string_pretty(&resultado.results)
.context("Falha ao serializar resultados para JSON")?
);
} else {
exibir_bibliotecas_formatado(&resultado.results);
}
Ok(())
}
async fn executar_docs(
library_id: String,
query: Option<String>,
text: bool,
json: bool,
chaves: &[String],
cliente: &reqwest::Client,
) -> Result<()> {
info!("Buscando documentação para: {}", library_id);
let cliente_arc = std::sync::Arc::new(cliente.clone());
let id_clone = library_id.clone();
let query_clone = query.clone();
let resultado = executar_com_retry(chaves, move |chave| {
let c = std::sync::Arc::clone(&cliente_arc);
let id = id_clone.clone();
let q = query_clone.clone();
async move { buscar_documentacao(&c, &chave, &id, q.as_deref(), text).await }
})
.await
.with_context(|| format!("Falha ao buscar documentação para '{}' ", library_id))?;
if json {
println!(
"{}",
serde_json::to_string_pretty(&resultado)
.context("Falha ao serializar documentação para JSON")?
);
} else if text {
if let Some(conteudo) = &resultado.content {
println!("{}", conteudo);
} else if let Some(snippets) = &resultado.snippets {
for s in snippets {
println!("{}", s.content);
}
}
} else {
exibir_documentacao_formatada(&resultado);
}
Ok(())
}
#[tokio::main(flavor = "multi_thread", worker_threads = 4)]
async fn main() -> Result<()> {
let _guarda_log = inicializar_logging()?;
let args = Cli::parse();
match args.comando {
Comando::Keys { operacao } => executar_keys(operacao),
Comando::Library { nome, query } => {
let chaves = carregar_chaves_api()?;
let cliente = criar_cliente_http()?;
info!(
"Iniciando context7 com {} chaves de API disponíveis",
chaves.len()
);
executar_library(nome, query, args.json, &chaves, &cliente).await
}
Comando::Docs {
library_id,
query,
text,
} => {
let chaves = carregar_chaves_api()?;
let cliente = criar_cliente_http()?;
info!(
"Iniciando context7 com {} chaves de API disponíveis",
chaves.len()
);
executar_docs(library_id, query, text, args.json, &chaves, &cliente).await
}
}
}
#[cfg(test)]
mod testes {
use super::*;
#[test]
fn testa_parsing_env_com_multiplas_chaves_iguais() {
let mut conteudo = String::new();
for i in 0..17 {
conteudo.push_str(&format!("CONTEXT7_API=ctx7sk-chave-{:02}\n", i));
}
let chaves = extrair_chaves_env(&conteudo).expect("Deve extrair 17 chaves sem erro");
assert_eq!(chaves.len(), 17, "Deve retornar exatamente 17 chaves");
for (i, chave) in chaves.iter().enumerate() {
assert_eq!(
chave,
&format!("ctx7sk-chave-{:02}", i),
"Chave {} deve ter o valor correto",
i
);
}
}
#[test]
fn testa_parsing_env_ignora_comentarios_e_linhas_vazias() {
let conteudo = "# Este é um comentário\n\
CONTEXT7_API=ctx7sk-chave-valida-01\n\
\n\
# Outro comentário\n\
CONTEXT7_API=ctx7sk-chave-valida-02\n\
\n";
let chaves = extrair_chaves_env(conteudo).expect("Deve extrair chaves sem erro");
assert_eq!(chaves.len(), 2, "Deve ignorar comentários e linhas vazias");
assert_eq!(chaves[0], "ctx7sk-chave-valida-01");
assert_eq!(chaves[1], "ctx7sk-chave-valida-02");
}
#[test]
fn testa_parsing_env_remove_aspas_duplas() {
let conteudo = "CONTEXT7_API=\"ctx7sk-abc-com-aspas\"\n";
let chaves = extrair_chaves_env(conteudo).expect("Deve extrair chave sem erro");
assert_eq!(chaves.len(), 1);
assert_eq!(
chaves[0], "ctx7sk-abc-com-aspas",
"Deve remover aspas duplas"
);
}
#[test]
fn testa_parsing_env_remove_aspas_simples() {
let conteudo = "CONTEXT7_API='ctx7sk-abc-aspas-simples'\n";
let chaves = extrair_chaves_env(conteudo).expect("Deve extrair chave sem erro");
assert_eq!(chaves.len(), 1);
assert_eq!(
chaves[0], "ctx7sk-abc-aspas-simples",
"Deve remover aspas simples"
);
}
#[test]
fn testa_parsing_env_erro_quando_nenhuma_chave() {
let conteudo = "# Apenas comentários\n\
OUTRA_VAR=valor\n\
\n";
let resultado = extrair_chaves_env(conteudo);
assert!(
resultado.is_err(),
"Deve retornar Err quando não há chaves CONTEXT7_API"
);
let mensagem_erro = resultado.unwrap_err().to_string();
assert!(
mensagem_erro.contains("chaves") || mensagem_erro.contains("CONTEXT7_API"),
"Mensagem de erro deve mencionar CONTEXT7_API ou chaves, obteve: {}",
mensagem_erro
);
}
#[test]
fn testa_parsing_env_ignora_chaves_vazias() {
let conteudo = "CONTEXT7_API=\n\
CONTEXT7_API=ctx7sk-valida\n";
let chaves = extrair_chaves_env(conteudo).expect("Deve extrair chave sem erro");
assert_eq!(
chaves.len(),
1,
"Deve ignorar entradas CONTEXT7_API sem valor"
);
assert_eq!(chaves[0], "ctx7sk-valida");
}
#[test]
fn testa_parsing_env_ignora_comentario_inline() {
let conteudo = "CONTEXT7_API=ctx7sk-valida # comentário aqui\n";
let chaves = extrair_chaves_env(conteudo).expect("Deve extrair chave sem erro");
assert_eq!(chaves.len(), 1);
assert_eq!(chaves[0], "ctx7sk-valida");
}
#[test]
fn testa_shuffle_chaves_preserva_todos_os_elementos() {
let chaves_originais: Vec<String> =
(0..10).map(|i| format!("ctx7sk-chave-{:02}", i)).collect();
let mut chaves_copia = chaves_originais.clone();
let mut rng = rand::thread_rng();
chaves_copia.shuffle(&mut rng);
assert_eq!(
chaves_copia.len(),
chaves_originais.len(),
"Shuffle deve preservar todos os elementos"
);
let mut ordenadas_original = chaves_originais.clone();
let mut ordenadas_copia = chaves_copia.clone();
ordenadas_original.sort();
ordenadas_copia.sort();
assert_eq!(
ordenadas_original, ordenadas_copia,
"Shuffle deve conter os mesmos elementos, apenas em ordem diferente"
);
}
#[test]
fn testa_descoberta_raiz_cargo_manifest_dir() {
let raiz = descobrir_raiz_projeto();
let cargo_toml = raiz.join("Cargo.toml");
assert!(
cargo_toml.exists(),
"A raiz do projeto deve conter Cargo.toml, mas não encontrou em: {}",
raiz.display()
);
}
#[test]
fn testa_deserializacao_library_search_result() {
let json = r#"{
"id": "/facebook/react",
"title": "React",
"description": "A JavaScript library for building user interfaces",
"trust_score": 95.0
}"#;
let resultado: LibrarySearchResult =
serde_json::from_str(json).expect("Deve deserializar LibrarySearchResult");
assert_eq!(resultado.id, "/facebook/react");
assert_eq!(resultado.title, "React");
assert_eq!(
resultado.description.as_deref(),
Some("A JavaScript library for building user interfaces")
);
assert!((resultado.trust_score.unwrap() - 95.0).abs() < f64::EPSILON);
}
#[test]
fn testa_deserializacao_library_search_result_tolerante_campos_faltando() {
let json = r#"{
"id": "/minimal/lib",
"title": "MinimalLib"
}"#;
let resultado: LibrarySearchResult =
serde_json::from_str(json).expect("Deve deserializar mesmo com campos ausentes");
assert_eq!(resultado.id, "/minimal/lib");
assert_eq!(resultado.title, "MinimalLib");
assert!(resultado.description.is_none(), "description deve ser None");
assert!(resultado.trust_score.is_none(), "trust_score deve ser None");
}
#[test]
fn testa_deserializacao_documentation_snippet() {
let json = r#"{
"content": "The Effect Hook lets you perform side effects in function components.",
"type": "text",
"source_urls": ["https://react.dev/reference/react/useEffect"]
}"#;
let trecho: DocumentationSnippet =
serde_json::from_str(json).expect("Deve deserializar DocumentationSnippet");
assert!(trecho.content.contains("side effects"));
assert_eq!(trecho.tipo.as_deref(), Some("text"));
let urls = trecho.source_urls.as_ref().expect("Deve ter source_urls");
assert_eq!(urls[0], "https://react.dev/reference/react/useEffect");
}
#[test]
fn testa_deserializacao_documentation_snippet_sem_campos_opcionais() {
let json = r#"{
"content": "Conteúdo mínimo do trecho."
}"#;
let trecho: DocumentationSnippet =
serde_json::from_str(json).expect("Deve deserializar sem campos opcionais");
assert_eq!(trecho.content, "Conteúdo mínimo do trecho.");
assert!(trecho.tipo.is_none());
assert!(trecho.source_urls.is_none());
}
#[test]
fn testa_erro_context7_mensagem_sem_chaves_api() {
let erro = ErroContext7::SemChavesApi;
let mensagem = erro.to_string();
assert!(
!mensagem.is_empty(),
"Mensagem de SemChavesApi não deve ser vazia"
);
assert!(
mensagem.to_lowercase().contains("chave")
|| mensagem.to_lowercase().contains("api")
|| mensagem.to_lowercase().contains("auth"),
"Mensagem deve mencionar chaves/api/auth, obteve: {}",
mensagem
);
}
#[test]
fn testa_erro_context7_mensagem_retry_esgotado() {
let erro = ErroContext7::RetryEsgotado { tentativas: 3 };
let mensagem = erro.to_string();
assert!(
mensagem.contains('3'),
"Mensagem deve conter o número de tentativas (3), obteve: {}",
mensagem
);
}
#[test]
fn testa_erro_context7_mensagem_resposta_invalida() {
let erro = ErroContext7::RespostaInvalida { status: 500 };
let mensagem = erro.to_string();
assert!(
mensagem.contains("500"),
"Mensagem deve conter o código de status, obteve: {}",
mensagem
);
}
#[test]
fn testa_erro_context7_mensagem_api_retornou_400() {
let erro = ErroContext7::ApiRetornou400 {
mensagem: "Parâmetro inválido".to_string(),
};
let mensagem = erro.to_string();
assert!(
mensagem.contains("Parâmetro inválido"),
"Mensagem deve conter o texto do erro, obteve: {}",
mensagem
);
}
#[tokio::test]
async fn testa_buscar_biblioteca_com_mock_servidor_retorna_200() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let servidor_mock = MockServer::start().await;
let resposta_json = serde_json::json!({
"results": [
{
"id": "/axum-rs/axum",
"title": "axum",
"description": "Framework web para Rust",
"trust_score": 90.0
}
]
});
Mock::given(method("GET"))
.and(path("/api/v1/search"))
.respond_with(ResponseTemplate::new(200).set_body_json(&resposta_json))
.mount(&servidor_mock)
.await;
let cliente = reqwest::Client::new();
let url = format!("{}/api/v1/search", servidor_mock.uri());
let resposta = cliente
.get(&url)
.bearer_auth("ctx7sk-teste-mock")
.query(&[("libraryName", "axum"), ("query", "axum")])
.send()
.await
.expect("Deve conectar ao mock server");
assert!(resposta.status().is_success(), "Status deve ser 200");
let dados: RespostaListaBibliotecas = resposta
.json()
.await
.expect("Deve deserializar resposta do mock");
assert_eq!(dados.results.len(), 1);
assert_eq!(dados.results[0].id, "/axum-rs/axum");
assert_eq!(dados.results[0].title, "axum");
}
#[tokio::test]
async fn testa_buscar_documentacao_com_mock_servidor_retorna_200() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let servidor_mock = MockServer::start().await;
let resposta_json = serde_json::json!({
"id": "/axum-rs/axum",
"snippets": [
{
"content": "O Router do Axum permite definir rotas HTTP de forma declarativa.",
"type": "text",
"source_urls": ["https://docs.rs/axum/latest/axum/struct.Router.html"]
}
]
});
Mock::given(method("GET"))
.and(path("/api/v1/axum-rs/axum"))
.respond_with(ResponseTemplate::new(200).set_body_json(&resposta_json))
.mount(&servidor_mock)
.await;
let cliente = reqwest::Client::new();
let url = format!("{}/api/v1/axum-rs/axum", servidor_mock.uri());
let resposta = cliente
.get(&url)
.bearer_auth("ctx7sk-teste-docs-mock")
.query(&[("type", "json"), ("query", "como criar router")])
.send()
.await
.expect("Deve conectar ao mock server");
assert!(resposta.status().is_success());
let dados: RespostaDocumentacao = resposta
.json()
.await
.expect("Deve deserializar resposta do mock");
let trechos = dados.snippets.as_ref().expect("Deve ter snippets");
assert_eq!(trechos.len(), 1);
assert!(trechos[0].content.contains("Router do Axum"));
}
fn ler_config_toml_do_caminho(caminho: &std::path::Path) -> Result<ConfigArquivo> {
let conteudo = std::fs::read_to_string(caminho)
.with_context(|| format!("Falha ao ler: {}", caminho.display()))?;
toml::from_str(&conteudo)
.with_context(|| format!("TOML inválido em: {}", caminho.display()))
}
#[test]
#[serial_test::serial]
fn testa_ler_env_var_chave_retorna_some_quando_setada() {
unsafe {
std::env::set_var("CONTEXT7_API_KEYS", "ctx7sk-chave-teste-01");
}
let resultado = ler_env_var_chave();
unsafe {
std::env::remove_var("CONTEXT7_API_KEYS");
}
let chaves = resultado.expect("Deve retornar Some com chave válida");
assert_eq!(chaves.len(), 1, "Deve retornar exatamente 1 chave");
assert_eq!(chaves[0], "ctx7sk-chave-teste-01");
}
#[test]
#[serial_test::serial]
fn testa_ler_env_var_chave_aceita_multiplas_separadas_por_virgula() {
unsafe {
std::env::set_var(
"CONTEXT7_API_KEYS",
"ctx7sk-chave-a, ctx7sk-chave-b , ctx7sk-chave-c",
);
}
let resultado = ler_env_var_chave();
unsafe {
std::env::remove_var("CONTEXT7_API_KEYS");
}
let chaves = resultado.expect("Deve retornar Some com múltiplas chaves");
assert_eq!(chaves.len(), 3, "Deve retornar 3 chaves");
assert_eq!(chaves[0], "ctx7sk-chave-a");
assert_eq!(chaves[1], "ctx7sk-chave-b");
assert_eq!(chaves[2], "ctx7sk-chave-c");
}
#[test]
#[serial_test::serial]
fn testa_ler_env_var_chave_retorna_none_quando_vazia() {
unsafe {
std::env::set_var("CONTEXT7_API_KEYS", "");
}
let resultado = ler_env_var_chave();
unsafe {
std::env::remove_var("CONTEXT7_API_KEYS");
}
assert!(
resultado.is_none(),
"Deve retornar None quando env var está vazia"
);
}
#[test]
#[serial_test::serial]
fn testa_ler_env_var_chave_retorna_none_quando_apenas_whitespace() {
unsafe {
std::env::set_var("CONTEXT7_API_KEYS", " , , ");
}
let resultado = ler_env_var_chave();
unsafe {
std::env::remove_var("CONTEXT7_API_KEYS");
}
assert!(
resultado.is_none(),
"Deve retornar None quando env var contém apenas whitespace/vírgulas"
);
}
#[test]
#[serial_test::serial]
fn testa_ler_env_var_chave_retorna_none_quando_ausente() {
unsafe {
std::env::remove_var("CONTEXT7_API_KEYS");
}
let resultado = ler_env_var_chave();
assert!(
resultado.is_none(),
"Deve retornar None quando env var não existe"
);
}
#[test]
#[serial_test::serial]
fn testa_ler_config_xdg_arquivo_inexistente_retorna_none() {
let dir_temp = tempfile::TempDir::new().expect("Deve criar diretório temporário");
unsafe {
std::env::set_var("XDG_CONFIG_HOME", dir_temp.path());
}
let resultado = ler_config_xdg();
unsafe {
std::env::remove_var("XDG_CONFIG_HOME");
}
let valor = resultado.expect("Deve retornar Ok quando arquivo não existe");
assert!(
valor.is_none(),
"Deve retornar None quando config.toml não existe"
);
}
#[test]
#[serial_test::serial]
fn testa_ler_config_xdg_le_toml_valido_com_multiplas_chaves() {
let dir_temp = tempfile::TempDir::new().expect("Deve criar diretório temporário");
let dir_context7 = dir_temp.path().join("context7");
std::fs::create_dir_all(&dir_context7).expect("Deve criar diretório context7");
let toml_conteudo = r#"schema_version = 1
[[keys]]
value = "ctx7sk-chave-xdg-01"
added_at = "2026-01-01T00:00:00+00:00"
[[keys]]
value = "ctx7sk-chave-xdg-02"
added_at = "2026-01-02T00:00:00+00:00"
"#;
std::fs::write(dir_context7.join("config.toml"), toml_conteudo)
.expect("Deve escrever config.toml");
unsafe {
std::env::set_var("XDG_CONFIG_HOME", dir_temp.path());
}
let resultado = ler_config_xdg();
unsafe {
std::env::remove_var("XDG_CONFIG_HOME");
}
let chaves = resultado
.expect("Deve retornar Ok")
.expect("Deve retornar Some com chaves");
assert_eq!(chaves.len(), 2, "Deve retornar 2 chaves");
assert_eq!(chaves[0], "ctx7sk-chave-xdg-01");
assert_eq!(chaves[1], "ctx7sk-chave-xdg-02");
}
#[test]
#[serial_test::serial]
fn testa_ler_config_xdg_retorna_err_quando_toml_invalido() {
let dir_temp = tempfile::TempDir::new().expect("Deve criar diretório temporário");
let dir_context7 = dir_temp.path().join("context7");
std::fs::create_dir_all(&dir_context7).expect("Deve criar diretório context7");
std::fs::write(
dir_context7.join("config.toml"),
"schema_version = INVALIDO\n[[[malformado",
)
.expect("Deve escrever TOML inválido");
unsafe {
std::env::set_var("XDG_CONFIG_HOME", dir_temp.path());
}
let resultado = ler_config_xdg();
unsafe {
std::env::remove_var("XDG_CONFIG_HOME");
}
assert!(
resultado.is_err(),
"Deve retornar Err quando TOML está malformado"
);
}
#[test]
#[serial_test::serial]
fn testa_ler_config_xdg_preserva_ordem_das_chaves() {
let dir_temp = tempfile::TempDir::new().expect("Deve criar diretório temporário");
let dir_context7 = dir_temp.path().join("context7");
std::fs::create_dir_all(&dir_context7).expect("Deve criar diretório context7");
let toml_conteudo = r#"schema_version = 1
[[keys]]
value = "ctx7sk-primeira"
added_at = "2026-01-01T00:00:00+00:00"
[[keys]]
value = "ctx7sk-segunda"
added_at = "2026-01-02T00:00:00+00:00"
[[keys]]
value = "ctx7sk-terceira"
added_at = "2026-01-03T00:00:00+00:00"
"#;
std::fs::write(dir_context7.join("config.toml"), toml_conteudo)
.expect("Deve escrever config.toml");
unsafe {
std::env::set_var("XDG_CONFIG_HOME", dir_temp.path());
}
let resultado = ler_config_xdg();
unsafe {
std::env::remove_var("XDG_CONFIG_HOME");
}
let chaves = resultado
.expect("Deve retornar Ok")
.expect("Deve retornar Some");
assert_eq!(
chaves[0], "ctx7sk-primeira",
"Primeira chave deve ser preservada"
);
assert_eq!(
chaves[1], "ctx7sk-segunda",
"Segunda chave deve ser preservada"
);
assert_eq!(
chaves[2], "ctx7sk-terceira",
"Terceira chave deve ser preservada"
);
}
#[test]
#[serial_test::serial]
fn testa_escrever_config_xdg_roundtrip_serializa_e_deserializa() {
let dir_temp = tempfile::TempDir::new().expect("Deve criar diretório temporário");
unsafe {
std::env::set_var("XDG_CONFIG_HOME", dir_temp.path());
}
let caminho =
escrever_config_xdg("ctx7sk-roundtrip-01").expect("Deve escrever config sem erro");
let config_lido = ler_config_toml_do_caminho(&caminho)
.expect("Deve ler TOML escrito por escrever_config_xdg");
unsafe {
std::env::remove_var("XDG_CONFIG_HOME");
}
assert_eq!(config_lido.schema_version, 1, "schema_version deve ser 1");
assert_eq!(config_lido.keys.len(), 1, "Deve conter 1 chave");
assert_eq!(
config_lido.keys[0].value, "ctx7sk-roundtrip-01",
"Valor da chave deve ser preservado"
);
assert!(
!config_lido.keys[0].added_at.is_empty(),
"added_at não deve ser vazio"
);
}
#[test]
#[serial_test::serial]
fn testa_escrever_config_xdg_cria_diretorios_pai_se_nao_existirem() {
let dir_temp = tempfile::TempDir::new().expect("Deve criar diretório temporário");
let xdg_novo = dir_temp.path().join("xdg_inexistente");
unsafe {
std::env::set_var("XDG_CONFIG_HOME", &xdg_novo);
}
let resultado = escrever_config_xdg("ctx7sk-mkdir-teste");
unsafe {
std::env::remove_var("XDG_CONFIG_HOME");
}
let caminho = resultado.expect("Deve criar diretório pai e escrever config");
assert!(
caminho.exists(),
"Arquivo de config deve existir após escrita"
);
}
#[test]
#[serial_test::serial]
fn testa_escrever_config_xdg_nao_duplica_chave_ja_existente() {
let dir_temp = tempfile::TempDir::new().expect("Deve criar diretório temporário");
unsafe {
std::env::set_var("XDG_CONFIG_HOME", dir_temp.path());
}
escrever_config_xdg("ctx7sk-unica").expect("Primeira escrita deve funcionar");
escrever_config_xdg("ctx7sk-unica").expect("Segunda escrita não deve falhar");
let caminho = descobrir_caminho_config().expect("Deve ter caminho XDG");
let config = ler_config_toml_do_caminho(&caminho).expect("Deve ler config");
unsafe {
std::env::remove_var("XDG_CONFIG_HOME");
}
assert_eq!(
config.keys.len(),
1,
"Não deve duplicar chave já existente — deve ter apenas 1"
);
}
#[test]
#[serial_test::serial]
fn testa_escrever_config_xdg_acumula_chaves_distintas() {
let dir_temp = tempfile::TempDir::new().expect("Deve criar diretório temporário");
unsafe {
std::env::set_var("XDG_CONFIG_HOME", dir_temp.path());
}
escrever_config_xdg("ctx7sk-chave-a").expect("Primeira escrita deve funcionar");
escrever_config_xdg("ctx7sk-chave-b").expect("Segunda escrita deve funcionar");
escrever_config_xdg("ctx7sk-chave-c").expect("Terceira escrita deve funcionar");
let caminho = descobrir_caminho_config().expect("Deve ter caminho XDG");
let config = ler_config_toml_do_caminho(&caminho).expect("Deve ler config");
unsafe {
std::env::remove_var("XDG_CONFIG_HOME");
}
assert_eq!(config.keys.len(), 3, "Deve acumular 3 chaves distintas");
let valores: Vec<&str> = config.keys.iter().map(|c| c.value.as_str()).collect();
assert!(valores.contains(&"ctx7sk-chave-a"));
assert!(valores.contains(&"ctx7sk-chave-b"));
assert!(valores.contains(&"ctx7sk-chave-c"));
}
#[test]
#[cfg(unix)]
#[serial_test::serial]
fn testa_escrever_config_xdg_aplica_permissoes_600_em_unix() {
use std::os::unix::fs::PermissionsExt;
let dir_temp = tempfile::TempDir::new().expect("Deve criar diretório temporário");
unsafe {
std::env::set_var("XDG_CONFIG_HOME", dir_temp.path());
}
let caminho =
escrever_config_xdg("ctx7sk-perm-600").expect("Deve escrever config sem erro");
unsafe {
std::env::remove_var("XDG_CONFIG_HOME");
}
let metadados = std::fs::metadata(&caminho).expect("Deve obter metadados do arquivo");
let modo = metadados.permissions().mode() & 0o777;
assert_eq!(
modo, 0o600,
"Permissões devem ser 600 (leitura/escrita apenas pelo dono), obteve: {:o}",
modo
);
}
#[test]
fn testa_config_arquivo_roundtrip_serde_preserva_todos_campos() {
let config_original = ConfigArquivo {
schema_version: 1,
keys: vec![
ChaveArmazenada {
value: "ctx7sk-serde-01".to_string(),
added_at: "2026-01-01T12:00:00+00:00".to_string(),
},
ChaveArmazenada {
value: "ctx7sk-serde-02".to_string(),
added_at: "2026-01-02T12:00:00+00:00".to_string(),
},
],
};
let toml_str = toml::to_string_pretty(&config_original)
.expect("Deve serializar ConfigArquivo para TOML");
let config_deserializado: ConfigArquivo =
toml::from_str(&toml_str).expect("Deve deserializar TOML de volta para ConfigArquivo");
assert_eq!(
config_deserializado.schema_version, config_original.schema_version,
"schema_version deve ser preservado no roundtrip"
);
assert_eq!(
config_deserializado.keys.len(),
config_original.keys.len(),
"Número de chaves deve ser preservado"
);
assert_eq!(
config_deserializado.keys[0].value, config_original.keys[0].value,
"Valor da primeira chave deve ser preservado"
);
assert_eq!(
config_deserializado.keys[0].added_at, config_original.keys[0].added_at,
"added_at da primeira chave deve ser preservado"
);
}
#[test]
fn testa_config_arquivo_schema_version_sempre_presente_na_serializacao() {
let config = ConfigArquivo {
schema_version: 1,
keys: Vec::new(),
};
let toml_str = toml::to_string_pretty(&config).expect("Deve serializar para TOML");
assert!(
toml_str.contains("schema_version"),
"schema_version deve estar presente na serialização TOML"
);
assert!(
toml_str.contains('1'),
"Valor 1 de schema_version deve estar presente"
);
}
#[test]
fn testa_config_arquivo_keys_vazio_aceito_na_deserializacao() {
let toml_str = "schema_version = 1\n";
let config: ConfigArquivo =
toml::from_str(toml_str).expect("Deve deserializar com keys ausente (default vazio)");
assert_eq!(config.schema_version, 1);
assert!(
config.keys.is_empty(),
"keys deve ser vazio quando não presente no TOML"
);
}
#[test]
fn testa_chave_armazenada_preserva_added_at_como_string_utc() {
let timestamp = "2026-04-08T20:00:00+00:00";
let chave = ChaveArmazenada {
value: "ctx7sk-timestamp".to_string(),
added_at: timestamp.to_string(),
};
let toml_str = toml::to_string_pretty(&chave).expect("Deve serializar ChaveArmazenada");
let chave_de_volta: ChaveArmazenada =
toml::from_str(&toml_str).expect("Deve deserializar ChaveArmazenada");
assert_eq!(
chave_de_volta.added_at, timestamp,
"Timestamp added_at deve ser preservado exatamente"
);
}
#[test]
#[serial_test::serial]
fn testa_carregar_chaves_api_env_var_tem_prioridade_sobre_xdg() {
let dir_temp = tempfile::TempDir::new().expect("Deve criar diretório temporário");
let dir_context7 = dir_temp.path().join("context7");
std::fs::create_dir_all(&dir_context7).expect("Deve criar diretório context7");
let toml_xdg = r#"schema_version = 1
[[keys]]
value = "ctx7sk-xdg-deve-ser-ignorada"
added_at = "2026-01-01T00:00:00+00:00"
"#;
std::fs::write(dir_context7.join("config.toml"), toml_xdg)
.expect("Deve escrever config XDG");
unsafe {
std::env::set_var("CONTEXT7_API_KEYS", "ctx7sk-env-var-prioritaria");
std::env::set_var("XDG_CONFIG_HOME", dir_temp.path());
}
let resultado = carregar_chaves_api();
unsafe {
std::env::remove_var("CONTEXT7_API_KEYS");
std::env::remove_var("XDG_CONFIG_HOME");
}
let chaves = resultado.expect("Deve carregar chaves via env var");
assert_eq!(chaves.len(), 1);
assert_eq!(
chaves[0], "ctx7sk-env-var-prioritaria",
"Env var deve ter prioridade sobre XDG"
);
}
#[test]
#[serial_test::serial]
fn testa_carregar_chaves_api_xdg_usado_quando_env_var_ausente() {
let dir_temp = tempfile::TempDir::new().expect("Deve criar diretório temporário");
let dir_context7 = dir_temp.path().join("context7");
std::fs::create_dir_all(&dir_context7).expect("Deve criar diretório context7");
let toml_xdg = r#"schema_version = 1
[[keys]]
value = "ctx7sk-via-xdg"
added_at = "2026-01-01T00:00:00+00:00"
"#;
std::fs::write(dir_context7.join("config.toml"), toml_xdg)
.expect("Deve escrever config XDG");
unsafe {
std::env::remove_var("CONTEXT7_API_KEYS");
std::env::set_var("XDG_CONFIG_HOME", dir_temp.path());
}
let resultado = carregar_chaves_api();
unsafe {
std::env::remove_var("XDG_CONFIG_HOME");
}
let chaves = resultado.expect("Deve carregar chaves via XDG");
assert_eq!(chaves.len(), 1);
assert_eq!(chaves[0], "ctx7sk-via-xdg");
}
#[test]
#[serial_test::serial]
fn testa_carregar_chaves_api_retorna_err_quando_nada_disponivel() {
let dir_temp = tempfile::TempDir::new().expect("Deve criar diretório temporário");
let dir_xdg_vazio = dir_temp.path().join("xdg_vazio");
std::fs::create_dir_all(&dir_xdg_vazio).expect("Deve criar diretório XDG vazio");
let dir_sem_env = dir_temp.path().join("sem_env");
std::fs::create_dir_all(&dir_sem_env).expect("Deve criar diretório sem .env");
unsafe {
std::env::remove_var("CONTEXT7_API_KEYS");
std::env::set_var("XDG_CONFIG_HOME", &dir_xdg_vazio);
}
let cwd_original = std::env::current_dir().expect("Deve obter CWD atual");
std::env::set_current_dir(&dir_sem_env).expect("Deve mudar CWD");
let resultado = carregar_chaves_api();
std::env::set_current_dir(&cwd_original).expect("Deve restaurar CWD");
unsafe {
std::env::remove_var("XDG_CONFIG_HOME");
}
assert!(
resultado.is_err(),
"Deve retornar Err quando nenhuma camada fornecer chaves"
);
let mensagem = resultado.unwrap_err().to_string();
assert!(
mensagem.to_lowercase().contains("chave")
|| mensagem.to_lowercase().contains("api")
|| mensagem.to_lowercase().contains("config"),
"Mensagem de erro deve orientar o usuário, obteve: {}",
mensagem
);
}
#[test]
#[serial_test::serial]
fn testa_ler_env_cwd_le_env_com_multiplas_chaves_context7_api() {
let dir_temp = tempfile::TempDir::new().expect("Deve criar diretório temporário");
let conteudo_env = "CONTEXT7_API=ctx7sk-cwd-01\nCONTEXT7_API=ctx7sk-cwd-02\n";
std::fs::write(dir_temp.path().join(".env"), conteudo_env)
.expect("Deve escrever .env temporário");
let cwd_original = std::env::current_dir().expect("Deve obter CWD");
std::env::set_current_dir(dir_temp.path()).expect("Deve mudar CWD para temp");
let resultado = ler_env_cwd();
std::env::set_current_dir(&cwd_original).expect("Deve restaurar CWD");
let chaves = resultado.expect("Deve retornar Some com chaves do .env CWD");
assert_eq!(chaves.len(), 2, "Deve ler 2 chaves do .env");
assert_eq!(chaves[0], "ctx7sk-cwd-01");
assert_eq!(chaves[1], "ctx7sk-cwd-02");
}
#[test]
#[serial_test::serial]
fn testa_ler_env_cwd_retorna_none_quando_env_ausente() {
let dir_temp = tempfile::TempDir::new().expect("Deve criar diretório temporário");
let cwd_original = std::env::current_dir().expect("Deve obter CWD");
std::env::set_current_dir(dir_temp.path()).expect("Deve mudar CWD para temp sem .env");
let resultado = ler_env_cwd();
std::env::set_current_dir(&cwd_original).expect("Deve restaurar CWD");
assert!(
resultado.is_none(),
"Deve retornar None quando não há .env no CWD"
);
}
#[test]
fn testa_descobrir_caminho_logs_xdg_retorna_algum_caminho_valido() {
let resultado = descobrir_caminho_logs_xdg();
if let Some(caminho) = resultado {
let caminho_str = caminho.to_string_lossy();
assert!(
caminho_str.contains("context7"),
"Caminho de logs XDG deve conter 'context7', obteve: {}",
caminho_str
);
}
}
#[test]
#[serial_test::serial]
fn testa_carregar_chaves_api_env_cwd_usado_quando_env_var_e_xdg_ausentes() {
let dir_temp = tempfile::TempDir::new().expect("Deve criar diretório temporário");
let dir_xdg_sem_config = dir_temp.path().join("xdg_sem_config");
std::fs::create_dir_all(&dir_xdg_sem_config).expect("Deve criar diretório XDG vazio");
let dir_cwd = dir_temp.path().join("cwd_com_env");
std::fs::create_dir_all(&dir_cwd).expect("Deve criar CWD temporário");
std::fs::write(dir_cwd.join(".env"), "CONTEXT7_API=ctx7sk-cwd-camada-3\n")
.expect("Deve escrever .env no CWD");
unsafe {
std::env::remove_var("CONTEXT7_API_KEYS");
std::env::set_var("XDG_CONFIG_HOME", &dir_xdg_sem_config);
}
let cwd_original = std::env::current_dir().expect("Deve obter CWD");
std::env::set_current_dir(&dir_cwd).expect("Deve mudar CWD");
let resultado = carregar_chaves_api();
std::env::set_current_dir(&cwd_original).expect("Deve restaurar CWD");
unsafe {
std::env::remove_var("XDG_CONFIG_HOME");
}
let chaves = resultado.expect("Deve carregar chaves via .env CWD");
assert_eq!(chaves.len(), 1);
assert_eq!(chaves[0], "ctx7sk-cwd-camada-3");
}
#[test]
#[serial_test::serial]
fn testa_ler_config_xdg_keys_vazio_retorna_none() {
let dir_temp = tempfile::TempDir::new().expect("Deve criar diretório temporário");
let dir_context7 = dir_temp.path().join("context7");
std::fs::create_dir_all(&dir_context7).expect("Deve criar diretório context7");
let toml_sem_chaves = "schema_version = 1\n";
std::fs::write(dir_context7.join("config.toml"), toml_sem_chaves)
.expect("Deve escrever config.toml sem keys");
unsafe {
std::env::set_var("XDG_CONFIG_HOME", dir_temp.path());
}
let resultado = ler_config_xdg();
unsafe {
std::env::remove_var("XDG_CONFIG_HOME");
}
let valor = resultado.expect("Deve retornar Ok");
assert!(
valor.is_none(),
"Deve retornar None quando config.toml existe mas keys está vazio"
);
}
#[test]
#[serial_test::serial]
fn testa_carregar_chaves_api_faz_fallback_quando_xdg_invalido() {
let dir_temp = tempfile::TempDir::new().expect("Deve criar diretório temporário");
let dir_context7 = dir_temp.path().join("context7");
std::fs::create_dir_all(&dir_context7).expect("Deve criar diretório context7");
std::fs::write(dir_context7.join("config.toml"), "[[[invalido")
.expect("Deve escrever TOML inválido");
let dir_cwd = dir_temp.path().join("cwd_fallback");
std::fs::create_dir_all(&dir_cwd).expect("Deve criar CWD com .env");
std::fs::write(dir_cwd.join(".env"), "CONTEXT7_API=ctx7sk-fallback-cwd\n")
.expect("Deve escrever .env no CWD");
unsafe {
std::env::remove_var("CONTEXT7_API_KEYS");
std::env::set_var("XDG_CONFIG_HOME", dir_temp.path());
}
let cwd_original = std::env::current_dir().expect("Deve obter CWD");
std::env::set_current_dir(&dir_cwd).expect("Deve mudar CWD");
let resultado = carregar_chaves_api();
std::env::set_current_dir(&cwd_original).expect("Deve restaurar CWD");
unsafe {
std::env::remove_var("XDG_CONFIG_HOME");
}
let chaves = resultado.expect("Deve carregar chaves via fallback .env CWD");
assert_eq!(chaves.len(), 1);
assert_eq!(chaves[0], "ctx7sk-fallback-cwd");
}
#[test]
fn testa_mascarar_chave_com_valor_longo_exibe_prefixo_e_sufixo() {
let chave = "ctx7sk-abc123-def456-ghi789";
assert_eq!(chave.len(), 27, "Pré-condição: chave deve ter 27 chars");
let mascarada = mascarar_chave(chave);
assert!(
mascarada.starts_with("ctx7sk-abc12"),
"Deve iniciar com os primeiros 12 chars, obteve: {}",
mascarada
);
assert!(
mascarada.ends_with("i789"),
"Deve terminar com os últimos 4 chars, obteve: {}",
mascarada
);
assert!(
mascarada.contains("..."),
"Deve conter '...' entre prefixo e sufixo, obteve: {}",
mascarada
);
}
#[test]
fn testa_mascarar_chave_curta_retorna_asteriscos() {
let chave_exatamente_16 = "ctx7sk-abcdef012";
assert_eq!(
chave_exatamente_16.len(),
16,
"Pré-condição: chave deve ter 16 chars"
);
let mascarada = mascarar_chave(chave_exatamente_16);
assert_eq!(
mascarada, "***",
"Chave de 16 chars deve retornar '***' (len <= inicio+fim), obteve: {}",
mascarada
);
}
#[test]
fn testa_mascarar_chave_vazia_retorna_asteriscos() {
let mascarada = mascarar_chave("");
assert_eq!(
mascarada, "***",
"Chave vazia deve retornar '***', obteve: {}",
mascarada
);
}
#[test]
fn testa_mascarar_chave_de_exatamente_17_chars_mascara_corretamente() {
let chave = "ctx7sk-abcdef0123"; assert_eq!(chave.len(), 17, "Pré-condição: chave deve ter 17 chars");
let mascarada = mascarar_chave(chave);
assert!(
mascarada.contains("..."),
"Chave de 17 chars deve ser mascarada, obteve: {}",
mascarada
);
assert_eq!(
&mascarada[..12],
&chave[..12],
"Prefixo de 12 chars deve ser preservado"
);
assert!(
mascarada.ends_with(&chave[chave.len() - 4..]),
"Sufixo de 4 chars deve ser preservado"
);
}
#[test]
#[serial_test::serial]
fn testa_cmd_keys_add_cria_config_quando_nao_existe() {
let dir_temp = tempfile::TempDir::new().expect("Deve criar diretório temporário");
unsafe {
std::env::set_var("XDG_CONFIG_HOME", dir_temp.path());
}
let resultado = cmd_keys_add("ctx7sk-nova-chave-add-test");
unsafe {
std::env::remove_var("XDG_CONFIG_HOME");
}
resultado.expect("cmd_keys_add deve funcionar em config vazio");
let caminho = dir_temp.path().join("context7").join("config.toml");
assert!(
caminho.exists(),
"config.toml deve existir após cmd_keys_add"
);
let config = ler_config_toml_do_caminho(&caminho).expect("Deve ler config criado");
assert_eq!(config.keys.len(), 1, "Config deve ter 1 chave");
assert_eq!(config.keys[0].value, "ctx7sk-nova-chave-add-test");
}
#[test]
#[serial_test::serial]
fn testa_cmd_keys_add_acumula_em_config_existente() {
let dir_temp = tempfile::TempDir::new().expect("Deve criar diretório temporário");
unsafe {
std::env::set_var("XDG_CONFIG_HOME", dir_temp.path());
}
cmd_keys_add("ctx7sk-chave-um").expect("Primeira adição deve funcionar");
cmd_keys_add("ctx7sk-chave-dois").expect("Segunda adição deve funcionar");
let caminho = descobrir_caminho_config().expect("Deve ter caminho XDG");
let config = ler_config_toml_do_caminho(&caminho).expect("Deve ler config");
unsafe {
std::env::remove_var("XDG_CONFIG_HOME");
}
assert_eq!(config.keys.len(), 2, "Deve acumular 2 chaves");
assert_eq!(config.keys[0].value, "ctx7sk-chave-um");
assert_eq!(config.keys[1].value, "ctx7sk-chave-dois");
}
#[test]
#[serial_test::serial]
fn testa_cmd_keys_add_nao_duplica_chave_existente() {
let dir_temp = tempfile::TempDir::new().expect("Deve criar diretório temporário");
unsafe {
std::env::set_var("XDG_CONFIG_HOME", dir_temp.path());
}
cmd_keys_add("ctx7sk-unica-dedup").expect("Primeira adição deve funcionar");
cmd_keys_add("ctx7sk-unica-dedup").expect("Segunda adição da mesma chave não deve falhar");
let caminho = descobrir_caminho_config().expect("Deve ter caminho XDG");
let config = ler_config_toml_do_caminho(&caminho).expect("Deve ler config");
unsafe {
std::env::remove_var("XDG_CONFIG_HOME");
}
assert_eq!(
config.keys.len(),
1,
"Não deve duplicar chave já existente — deve ter apenas 1"
);
}
#[test]
#[cfg(unix)]
#[serial_test::serial]
fn testa_cmd_keys_add_aplica_permissoes_600_em_unix() {
use std::os::unix::fs::PermissionsExt;
let dir_temp = tempfile::TempDir::new().expect("Deve criar diretório temporário");
unsafe {
std::env::set_var("XDG_CONFIG_HOME", dir_temp.path());
}
cmd_keys_add("ctx7sk-perm-600-keys-add").expect("Deve adicionar chave sem erro");
let caminho = descobrir_caminho_config().expect("Deve ter caminho XDG");
unsafe {
std::env::remove_var("XDG_CONFIG_HOME");
}
let metadados = std::fs::metadata(&caminho).expect("Deve obter metadados");
let modo = metadados.permissions().mode() & 0o777;
assert_eq!(
modo, 0o600,
"Permissões devem ser 600 após cmd_keys_add, obteve: {:o}",
modo
);
}
#[test]
#[serial_test::serial]
fn testa_cmd_keys_remove_indice_1_de_config_com_3_chaves() {
let dir_temp = tempfile::TempDir::new().expect("Deve criar diretório temporário");
unsafe {
std::env::set_var("XDG_CONFIG_HOME", dir_temp.path());
}
escrever_config_xdg("ctx7sk-rem-alpha").expect("Deve escrever chave 1");
escrever_config_xdg("ctx7sk-rem-beta").expect("Deve escrever chave 2");
escrever_config_xdg("ctx7sk-rem-gamma").expect("Deve escrever chave 3");
cmd_keys_remove(1).expect("Remove índice 1 deve funcionar");
let caminho = descobrir_caminho_config().expect("Deve ter caminho XDG");
let config = ler_config_toml_do_caminho(&caminho).expect("Deve ler config");
unsafe {
std::env::remove_var("XDG_CONFIG_HOME");
}
assert_eq!(config.keys.len(), 2, "Devem sobrar 2 chaves após remoção");
assert_eq!(
config.keys[0].value, "ctx7sk-rem-beta",
"Primeira chave restante deve ser beta"
);
assert_eq!(
config.keys[1].value, "ctx7sk-rem-gamma",
"Segunda chave restante deve ser gamma"
);
}
#[test]
#[serial_test::serial]
fn testa_cmd_keys_remove_indice_2_de_config_com_3_chaves() {
let dir_temp = tempfile::TempDir::new().expect("Deve criar diretório temporário");
unsafe {
std::env::set_var("XDG_CONFIG_HOME", dir_temp.path());
}
escrever_config_xdg("ctx7sk-mid-alpha").expect("Deve escrever chave 1");
escrever_config_xdg("ctx7sk-mid-beta").expect("Deve escrever chave 2");
escrever_config_xdg("ctx7sk-mid-gamma").expect("Deve escrever chave 3");
cmd_keys_remove(2).expect("Remove índice 2 deve funcionar");
let caminho = descobrir_caminho_config().expect("Deve ter caminho XDG");
let config = ler_config_toml_do_caminho(&caminho).expect("Deve ler config");
unsafe {
std::env::remove_var("XDG_CONFIG_HOME");
}
assert_eq!(
config.keys.len(),
2,
"Devem sobrar 2 chaves após remoção da do meio"
);
assert_eq!(config.keys[0].value, "ctx7sk-mid-alpha");
assert_eq!(config.keys[1].value, "ctx7sk-mid-gamma");
}
#[test]
#[serial_test::serial]
fn testa_cmd_keys_remove_indice_zero_retorna_ok_com_mensagem() {
let dir_temp = tempfile::TempDir::new().expect("Deve criar diretório temporário");
unsafe {
std::env::set_var("XDG_CONFIG_HOME", dir_temp.path());
}
escrever_config_xdg("ctx7sk-idx-zero-test").expect("Deve escrever chave");
let resultado = cmd_keys_remove(0);
unsafe {
std::env::remove_var("XDG_CONFIG_HOME");
}
assert!(
resultado.is_ok(),
"Índice 0 inválido deve retornar Ok (não Err), obteve: {:?}",
resultado
);
}
#[test]
#[serial_test::serial]
fn testa_cmd_keys_remove_indice_maior_que_len_retorna_ok_com_mensagem() {
let dir_temp = tempfile::TempDir::new().expect("Deve criar diretório temporário");
unsafe {
std::env::set_var("XDG_CONFIG_HOME", dir_temp.path());
}
escrever_config_xdg("ctx7sk-overflow-test").expect("Deve escrever chave");
let resultado = cmd_keys_remove(99);
unsafe {
std::env::remove_var("XDG_CONFIG_HOME");
}
assert!(
resultado.is_ok(),
"Índice fora do range deve retornar Ok (não Err), obteve: {:?}",
resultado
);
}
#[test]
#[serial_test::serial]
fn testa_cmd_keys_remove_em_config_vazio_retorna_ok() {
let dir_temp = tempfile::TempDir::new().expect("Deve criar diretório temporário");
unsafe {
std::env::set_var("XDG_CONFIG_HOME", dir_temp.path());
}
let resultado = cmd_keys_remove(1);
unsafe {
std::env::remove_var("XDG_CONFIG_HOME");
}
assert!(
resultado.is_ok(),
"Remover de config vazio deve retornar Ok, obteve: {:?}",
resultado
);
}
#[test]
#[serial_test::serial]
fn testa_cmd_keys_clear_com_yes_true_limpa_todas_as_chaves() {
let dir_temp = tempfile::TempDir::new().expect("Deve criar diretório temporário");
unsafe {
std::env::set_var("XDG_CONFIG_HOME", dir_temp.path());
}
escrever_config_xdg("ctx7sk-clear-alpha").expect("Deve escrever chave 1");
escrever_config_xdg("ctx7sk-clear-beta").expect("Deve escrever chave 2");
let caminho = descobrir_caminho_config().expect("Deve ter caminho XDG");
let antes = ler_config_toml_do_caminho(&caminho).expect("Deve ler config antes");
assert_eq!(antes.keys.len(), 2, "Pré-condição: 2 chaves antes do clear");
cmd_keys_clear(true).expect("clear com yes=true deve funcionar");
let depois = ler_config_toml_do_caminho(&caminho).expect("Deve ler config depois");
unsafe {
std::env::remove_var("XDG_CONFIG_HOME");
}
assert!(
depois.keys.is_empty(),
"Após clear com yes=true, chaves devem estar vazias"
);
}
#[test]
#[serial_test::serial]
fn testa_cmd_keys_clear_com_yes_true_em_config_inexistente_funciona() {
let dir_temp = tempfile::TempDir::new().expect("Deve criar diretório temporário");
unsafe {
std::env::set_var("XDG_CONFIG_HOME", dir_temp.path());
}
let resultado = cmd_keys_clear(true);
unsafe {
std::env::remove_var("XDG_CONFIG_HOME");
}
assert!(
resultado.is_ok(),
"clear em config inexistente deve retornar Ok (idempotente), obteve: {:?}",
resultado
);
}
#[test]
#[serial_test::serial]
fn testa_cmd_keys_path_retorna_ok_e_caminho_contem_context7() {
let dir_temp = tempfile::TempDir::new().expect("Deve criar diretório temporário");
unsafe {
std::env::set_var("XDG_CONFIG_HOME", dir_temp.path());
}
let resultado = cmd_keys_path();
unsafe {
std::env::remove_var("XDG_CONFIG_HOME");
}
resultado.expect("cmd_keys_path deve retornar Ok");
}
#[test]
#[serial_test::serial]
fn testa_descobrir_caminho_config_xdg_config_home_termina_com_config_toml() {
let dir_temp = tempfile::TempDir::new().expect("Deve criar diretório temporário");
unsafe {
std::env::set_var("XDG_CONFIG_HOME", dir_temp.path());
}
let caminho = descobrir_caminho_config();
unsafe {
std::env::remove_var("XDG_CONFIG_HOME");
}
let caminho = caminho.expect("Deve retornar caminho XDG válido");
assert!(
caminho.to_string_lossy().ends_with("config.toml"),
"Caminho deve terminar com config.toml, obteve: {}",
caminho.display()
);
assert!(
caminho.to_string_lossy().contains("context7"),
"Caminho deve conter 'context7', obteve: {}",
caminho.display()
);
}
#[test]
#[serial_test::serial]
fn testa_cmd_keys_import_env_valido_com_multiplas_chaves() {
let dir_temp = tempfile::TempDir::new().expect("Deve criar diretório temporário");
let arquivo_env = dir_temp.path().join("chaves.env");
std::fs::write(
&arquivo_env,
"CONTEXT7_API=ctx7sk-import-alpha\nCONTEXT7_API=ctx7sk-import-beta\n",
)
.expect("Deve escrever .env de teste");
unsafe {
std::env::set_var("XDG_CONFIG_HOME", dir_temp.path());
}
let resultado = cmd_keys_import(&arquivo_env);
let caminho = descobrir_caminho_config().expect("Deve ter caminho XDG");
let config = ler_config_toml_do_caminho(&caminho).expect("Deve ler config após import");
unsafe {
std::env::remove_var("XDG_CONFIG_HOME");
}
resultado.expect("import de .env válido deve funcionar");
assert_eq!(config.keys.len(), 2, "Deve ter importado 2 chaves");
let valores: Vec<&str> = config.keys.iter().map(|c| c.value.as_str()).collect();
assert!(
valores.contains(&"ctx7sk-import-alpha"),
"Deve conter ctx7sk-import-alpha"
);
assert!(
valores.contains(&"ctx7sk-import-beta"),
"Deve conter ctx7sk-import-beta"
);
}
#[test]
#[serial_test::serial]
fn testa_cmd_keys_import_env_sem_chaves_retorna_err() {
let dir_temp = tempfile::TempDir::new().expect("Deve criar diretório temporário");
let arquivo_env = dir_temp.path().join("vazio.env");
std::fs::write(&arquivo_env, "# apenas comentario\nOUTRA_VAR=valor\n")
.expect("Deve escrever .env sem chaves");
unsafe {
std::env::set_var("XDG_CONFIG_HOME", dir_temp.path());
}
let resultado = cmd_keys_import(&arquivo_env);
unsafe {
std::env::remove_var("XDG_CONFIG_HOME");
}
assert!(
resultado.is_err(),
"Import de .env sem chaves CONTEXT7_API deve retornar Err"
);
}
#[test]
#[serial_test::serial]
fn testa_cmd_keys_import_arquivo_inexistente_retorna_err() {
let dir_temp = tempfile::TempDir::new().expect("Deve criar diretório temporário");
let arquivo_inexistente = dir_temp.path().join("nao_existe.env");
unsafe {
std::env::set_var("XDG_CONFIG_HOME", dir_temp.path());
}
let resultado = cmd_keys_import(&arquivo_inexistente);
unsafe {
std::env::remove_var("XDG_CONFIG_HOME");
}
assert!(
resultado.is_err(),
"Import de arquivo inexistente deve retornar Err"
);
}
#[test]
#[serial_test::serial]
fn testa_cmd_keys_import_roundtrip_add_depois_list() {
let dir_temp = tempfile::TempDir::new().expect("Deve criar diretório temporário");
let arquivo_env = dir_temp.path().join("roundtrip.env");
std::fs::write(
&arquivo_env,
"CONTEXT7_API=ctx7sk-rtrip-01\nCONTEXT7_API=ctx7sk-rtrip-02\n",
)
.expect("Deve escrever .env de roundtrip");
unsafe {
std::env::set_var("XDG_CONFIG_HOME", dir_temp.path());
}
cmd_keys_import(&arquivo_env).expect("Import deve funcionar");
let config = ler_config_xdg_raw()
.expect("Deve retornar Ok")
.expect("Deve retornar Some após import");
unsafe {
std::env::remove_var("XDG_CONFIG_HOME");
}
assert_eq!(
config.keys.len(),
2,
"Roundtrip: deve ter 2 chaves após import"
);
assert_eq!(config.keys[0].value, "ctx7sk-rtrip-01");
assert_eq!(config.keys[1].value, "ctx7sk-rtrip-02");
}
#[test]
#[serial_test::serial]
fn testa_cmd_keys_export_em_config_vazio_retorna_ok() {
let dir_temp = tempfile::TempDir::new().expect("Deve criar diretório temporário");
unsafe {
std::env::set_var("XDG_CONFIG_HOME", dir_temp.path());
}
let resultado = cmd_keys_export();
unsafe {
std::env::remove_var("XDG_CONFIG_HOME");
}
assert!(
resultado.is_ok(),
"Export de config vazio deve retornar Ok, obteve: {:?}",
resultado
);
}
#[test]
#[serial_test::serial]
fn testa_cmd_keys_export_retorna_ok_com_chaves_existentes() {
let dir_temp = tempfile::TempDir::new().expect("Deve criar diretório temporário");
unsafe {
std::env::set_var("XDG_CONFIG_HOME", dir_temp.path());
}
escrever_config_xdg("ctx7sk-export-um").expect("Deve escrever chave 1");
escrever_config_xdg("ctx7sk-export-dois").expect("Deve escrever chave 2");
let resultado = cmd_keys_export();
unsafe {
std::env::remove_var("XDG_CONFIG_HOME");
}
assert!(
resultado.is_ok(),
"Export com chaves existentes deve retornar Ok, obteve: {:?}",
resultado
);
}
#[test]
#[serial_test::serial]
fn testa_ler_config_xdg_raw_retorna_none_sem_arquivo() {
let dir_temp = tempfile::TempDir::new().expect("Deve criar diretório temporário");
unsafe {
std::env::set_var("XDG_CONFIG_HOME", dir_temp.path());
}
let resultado = ler_config_xdg_raw();
unsafe {
std::env::remove_var("XDG_CONFIG_HOME");
}
let valor = resultado.expect("Deve retornar Ok");
assert!(
valor.is_none(),
"Deve retornar None quando config.toml não existe"
);
}
#[test]
#[serial_test::serial]
fn testa_ler_config_xdg_raw_retorna_config_com_chaves() {
let dir_temp = tempfile::TempDir::new().expect("Deve criar diretório temporário");
let dir_context7 = dir_temp.path().join("context7");
std::fs::create_dir_all(&dir_context7).expect("Deve criar diretório context7");
let toml = r#"schema_version = 1
[[keys]]
value = "ctx7sk-raw-01"
added_at = "2026-04-08T00:00:00+00:00"
[[keys]]
value = "ctx7sk-raw-02"
added_at = "2026-04-08T00:01:00+00:00"
"#;
std::fs::write(dir_context7.join("config.toml"), toml).expect("Deve escrever config.toml");
unsafe {
std::env::set_var("XDG_CONFIG_HOME", dir_temp.path());
}
let resultado = ler_config_xdg_raw();
unsafe {
std::env::remove_var("XDG_CONFIG_HOME");
}
let config = resultado
.expect("Deve retornar Ok")
.expect("Deve retornar Some com config");
assert_eq!(config.keys.len(), 2);
assert_eq!(config.keys[0].value, "ctx7sk-raw-01");
assert_eq!(config.keys[1].value, "ctx7sk-raw-02");
}
#[test]
#[serial_test::serial]
fn testa_fluxo_completo_add_list_remove_clear() {
let dir_temp = tempfile::TempDir::new().expect("Deve criar diretório temporário");
unsafe {
std::env::set_var("XDG_CONFIG_HOME", dir_temp.path());
}
cmd_keys_add("ctx7sk-fluxo-01").expect("Add 1 deve funcionar");
cmd_keys_add("ctx7sk-fluxo-02").expect("Add 2 deve funcionar");
cmd_keys_add("ctx7sk-fluxo-03").expect("Add 3 deve funcionar");
let config_antes = ler_config_xdg_raw()
.expect("Ok")
.expect("Some com 3 chaves");
assert_eq!(config_antes.keys.len(), 3, "Deve ter 3 chaves após 3 adds");
cmd_keys_remove(2).expect("Remove índice 2 deve funcionar");
let config_pos_remove = ler_config_xdg_raw()
.expect("Ok")
.expect("Some com 2 chaves");
assert_eq!(
config_pos_remove.keys.len(),
2,
"Deve ter 2 chaves após remove"
);
assert_eq!(config_pos_remove.keys[0].value, "ctx7sk-fluxo-01");
assert_eq!(config_pos_remove.keys[1].value, "ctx7sk-fluxo-03");
cmd_keys_clear(true).expect("Clear com yes=true deve funcionar");
let caminho = descobrir_caminho_config().expect("Deve ter caminho");
let config_final = ler_config_toml_do_caminho(&caminho).expect("Deve ler config final");
unsafe {
std::env::remove_var("XDG_CONFIG_HOME");
}
assert!(
config_final.keys.is_empty(),
"Após clear, chaves devem estar vazias"
);
}
}