use anyhow::{bail, Context, Result};
use rand::seq::SliceRandom;
use reqwest::StatusCode;
use serde::{Deserialize, Serialize};
use tokio::time::{sleep, Duration};
use tracing::{error, info, warn};
use crate::errors::ErroContext7;
use crate::i18n::{t, Mensagem};
const BASE_URL: &str = "https://context7.com/api";
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LibrarySearchResult {
pub id: String,
pub title: String,
pub description: Option<String>,
pub trust_score: Option<f64>,
pub stars: Option<i64>,
pub total_snippets: Option<u64>,
pub total_tokens: Option<u64>,
pub verified: Option<bool>,
pub branch: Option<String>,
pub state: Option<String>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct CodeBlock {
pub language: String,
pub code: String,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct DocumentationSnippet {
pub page_title: Option<String>,
pub code_title: Option<String>,
pub code_description: Option<String>,
pub code_language: Option<String>,
pub code_tokens: Option<u64>,
pub code_id: Option<String>,
pub code_list: Option<Vec<CodeBlock>>,
pub relevance: Option<f64>,
pub model: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct RespostaListaBibliotecas {
pub results: Vec<LibrarySearchResult>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct RespostaDocumentacao {
pub snippets: Option<Vec<DocumentationSnippet>>,
}
pub 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.2.1")
.pool_max_idle_per_host(4)
.build()
.with_context(|| t(Mensagem::FalhaCriarClienteHttp))?;
Ok(cliente)
}
pub 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 = chaves.len().min(5);
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(ErroContext7::RespostaInvalida { status: 200 }) => {
bail!(ErroContext7::RespostaInvalida { status: 200 });
}
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,
});
}
pub 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
}
pub async fn buscar_documentacao(
cliente: &reqwest::Client,
chave: &str,
library_id: &str,
query: Option<&str>,
) -> Result<RespostaDocumentacao, ErroContext7> {
let id_normalizado = library_id.trim_start_matches('/');
let url = format!("{}/v1/{}", BASE_URL, id_normalizado);
let mut construtor = cliente
.get(&url)
.bearer_auth(chave)
.query(&[("type", "json")]);
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
}
pub async fn buscar_documentacao_texto(
cliente: &reqwest::Client,
chave: &str,
library_id: &str,
query: Option<&str>,
) -> Result<String, ErroContext7> {
let id_normalizado = library_id.trim_start_matches('/');
let url = format!("{}/v1/{}", BASE_URL, id_normalizado);
let mut construtor = cliente
.get(&url)
.bearer_auth(chave)
.query(&[("type", "txt")]);
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 }
})?;
let status = resposta.status();
if !status.is_success() {
match status {
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => {
return Err(ErroContext7::SemChavesApi);
}
StatusCode::BAD_REQUEST => {
let mensagem = resposta
.text()
.await
.unwrap_or_else(|_| "Sem detalhes".to_string());
return Err(ErroContext7::ApiRetornou400 { mensagem });
}
_ => {
return Err(ErroContext7::RespostaInvalida {
status: status.as_u16(),
});
}
}
}
resposta
.text()
.await
.map_err(|_| ErroContext7::RespostaInvalida {
status: status.as_u16(),
})
}
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(),
}),
}
}
#[cfg(test)]
mod testes {
use super::*;
#[test]
fn testa_deserializacao_library_search_result() {
let json = r#"{
"id": "/facebook/react",
"title": "React",
"description": "A JavaScript library for building user interfaces",
"trustScore": 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_library_search_result_com_campos_opcionais() {
let json = r#"{
"id": "/facebook/react",
"title": "React",
"trustScore": 95.0,
"stars": 228000,
"totalSnippets": 1500,
"totalTokens": 250000,
"verified": true,
"branch": "main",
"state": "active"
}"#;
let resultado: LibrarySearchResult =
serde_json::from_str(json).expect("Deve deserializar com campos opcionais");
assert_eq!(resultado.stars, Some(228_000i64));
assert_eq!(resultado.total_snippets, Some(1_500));
assert_eq!(resultado.total_tokens, Some(250_000));
assert_eq!(resultado.verified, Some(true));
assert_eq!(resultado.branch.as_deref(), Some("main"));
assert_eq!(resultado.state.as_deref(), Some("active"));
}
#[test]
fn testa_deserializacao_documentation_snippet() {
let json = r#"{
"pageTitle": "React Hooks API",
"codeTitle": "useEffect example",
"codeDescription": "The Effect Hook lets you perform side effects.",
"codeLanguage": "javascript",
"codeTokens": 68,
"codeId": "https://github.com/facebook/react/blob/main/packages/react/src/ReactHooks.js",
"codeList": [
{"language": "javascript", "code": "useEffect(() => { /* effect */ }, []);"}
],
"relevance": 0.032,
"model": "gemini-2.5-flash"
}"#;
let trecho: DocumentationSnippet =
serde_json::from_str(json).expect("Deve deserializar DocumentationSnippet");
assert_eq!(trecho.page_title.as_deref(), Some("React Hooks API"));
assert_eq!(trecho.code_title.as_deref(), Some("useEffect example"));
assert_eq!(trecho.code_language.as_deref(), Some("javascript"));
assert_eq!(trecho.code_tokens, Some(68));
let lista = trecho.code_list.as_ref().expect("Deve ter code_list");
assert_eq!(lista.len(), 1);
assert_eq!(lista[0].language, "javascript");
assert!((trecho.relevance.unwrap() - 0.032).abs() < f64::EPSILON);
}
#[test]
fn testa_deserializacao_documentation_snippet_sem_campos_opcionais() {
let json = r#"{}"#;
let trecho: DocumentationSnippet =
serde_json::from_str(json).expect("Deve deserializar snippet completamente vazio");
assert!(trecho.page_title.is_none());
assert!(trecho.code_title.is_none());
assert!(trecho.code_list.is_none());
}
#[test]
fn testa_deserializacao_code_block() {
let json = r#"{"language": "rust", "code": "fn main() {}"}"#;
let bloco: CodeBlock = serde_json::from_str(json).expect("Deve deserializar CodeBlock");
assert_eq!(bloco.language, "rust");
assert_eq!(bloco.code, "fn main() {}");
}
#[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",
"trustScore": 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!({
"snippets": [
{
"pageTitle": "axum::Router",
"codeTitle": "Basic Router setup",
"codeDescription": "O Router do Axum permite definir rotas HTTP de forma declarativa.",
"codeLanguage": "rust",
"codeList": [
{"language": "rust", "code": "let app = Router::new().route(\"/\", get(handler));"}
]
}
]
});
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);
let lista = trechos[0].code_list.as_ref().expect("Deve ter code_list");
assert!(lista[0].code.contains("Router::new"));
}
#[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_max_tentativas_limitado_a_5() {
let muitas_chaves: Vec<String> =
(0..10).map(|i| format!("ctx7sk-chave-{:02}", i)).collect();
let max = muitas_chaves.len().min(5);
assert_eq!(
max, 5,
"Max tentativas deve ser limitado a 5 mesmo com 10 chaves"
);
let poucas_chaves: Vec<String> = vec!["ctx7sk-a".to_string(), "ctx7sk-b".to_string()];
let max2 = poucas_chaves.len().min(5);
assert_eq!(max2, 2, "Com 2 chaves, max deve ser 2");
}
}