1use anyhow::{bail, Context, Result};
6use rand::seq::SliceRandom;
7use reqwest::StatusCode;
8use serde::{Deserialize, Serialize};
9use tokio::time::{sleep, Duration};
10use tracing::{error, info, warn};
11
12use crate::errors::ErroContext7;
13
14const BASE_URL: &str = "https://context7.com/api";
17
18#[derive(Debug, Deserialize, Serialize)]
22pub struct LibrarySearchResult {
23 pub id: String,
25 pub title: String,
27 pub description: Option<String>,
29 pub trust_score: Option<f64>,
31}
32
33#[derive(Debug, Deserialize, Serialize)]
35pub struct DocumentationSnippet {
36 pub content: String,
38 #[serde(rename = "type")]
40 pub tipo: Option<String>,
41 pub source_urls: Option<Vec<String>>,
43}
44
45#[derive(Debug, Deserialize)]
47pub struct RespostaListaBibliotecas {
48 pub results: Vec<LibrarySearchResult>,
50}
51
52#[derive(Debug, Deserialize, Serialize)]
54pub struct RespostaDocumentacao {
55 #[allow(dead_code)]
57 pub id: Option<String>,
58 pub snippets: Option<Vec<DocumentationSnippet>>,
60 pub content: Option<String>,
62}
63
64pub fn criar_cliente_http() -> Result<reqwest::Client> {
70 let cliente = reqwest::Client::builder()
71 .use_rustls_tls()
72 .timeout(Duration::from_secs(30))
73 .user_agent("context7-cli/0.2.0")
74 .pool_max_idle_per_host(4)
75 .build()
76 .context("Falha ao criar cliente HTTP")?;
77
78 Ok(cliente)
79}
80
81pub async fn executar_com_retry<F, Fut, T>(chaves: &[String], operacao: F) -> Result<T>
92where
93 F: Fn(String) -> Fut,
94 Fut: std::future::Future<Output = Result<T, ErroContext7>>,
95{
96 let max_tentativas = 3usize.min(chaves.len());
97
98 let mut chaves_embaralhadas = chaves.to_vec();
100 let mut rng = rand::thread_rng();
101 chaves_embaralhadas.shuffle(&mut rng);
102
103 let atrasos_ms = [500u64, 1000, 2000];
104 let mut chaves_falhas_auth = 0usize;
105
106 for (tentativa, chave) in chaves_embaralhadas
107 .into_iter()
108 .take(max_tentativas)
109 .enumerate()
110 {
111 info!("Tentativa {}/{}", tentativa + 1, max_tentativas);
112
113 match operacao(chave).await {
114 Ok(resultado) => return Ok(resultado),
115
116 Err(ErroContext7::ApiRetornou400 { mensagem }) => {
117 bail!(ErroContext7::ApiRetornou400 { mensagem });
119 }
120
121 Err(ErroContext7::SemChavesApi) => {
122 chaves_falhas_auth += 1;
123 warn!("Chave de API inválida (401/403), tentando próxima...");
124 }
125
126 Err(e) => {
127 warn!("Falha na tentativa {}: {}", tentativa + 1, e);
128
129 if tentativa + 1 < max_tentativas && tentativa < atrasos_ms.len() {
131 let atraso = Duration::from_millis(atrasos_ms[tentativa]);
132 info!(
133 "Aguardando {}ms antes de tentar novamente...",
134 atraso.as_millis()
135 );
136 sleep(atraso).await;
137 }
138 }
139 }
140 }
141
142 if chaves_falhas_auth >= max_tentativas {
143 bail!(ErroContext7::SemChavesApi);
144 }
145
146 bail!(ErroContext7::RetryEsgotado {
147 tentativas: max_tentativas as u32,
148 });
149}
150
151pub async fn buscar_biblioteca(
157 cliente: &reqwest::Client,
158 chave: &str,
159 nome: &str,
160 query_contexto: &str,
161) -> Result<RespostaListaBibliotecas, ErroContext7> {
162 let url = format!("{}/v1/search", BASE_URL);
163
164 let resposta = cliente
165 .get(&url)
166 .bearer_auth(chave)
167 .query(&[("libraryName", nome), ("query", query_contexto)])
168 .send()
169 .await
170 .map_err(|e| {
171 error!("Erro de rede ao buscar biblioteca: {}", e);
172 ErroContext7::RespostaInvalida { status: 0 }
173 })?;
174
175 tratar_status_resposta(resposta).await
176}
177
178pub async fn buscar_documentacao(
183 cliente: &reqwest::Client,
184 chave: &str,
185 library_id: &str,
186 query: Option<&str>,
187 texto_plano: bool,
188) -> Result<RespostaDocumentacao, ErroContext7> {
189 let id_normalizado = library_id.trim_start_matches('/');
191 let url = format!("{}/v1/{}", BASE_URL, id_normalizado);
192
193 let tipo = if texto_plano { "txt" } else { "json" };
194
195 let mut construtor = cliente
196 .get(&url)
197 .bearer_auth(chave)
198 .query(&[("type", tipo)]);
199
200 if let Some(q) = query {
201 construtor = construtor.query(&[("query", q)]);
202 }
203
204 let resposta = construtor.send().await.map_err(|e| {
205 error!("Erro de rede ao buscar documentação: {}", e);
206 ErroContext7::RespostaInvalida { status: 0 }
207 })?;
208
209 tratar_status_resposta(resposta).await
210}
211
212async fn tratar_status_resposta<T: for<'de> Deserialize<'de>>(
214 resposta: reqwest::Response,
215) -> Result<T, ErroContext7> {
216 let status = resposta.status();
217
218 match status {
219 s if s.is_success() => resposta.json::<T>().await.map_err(|e| {
220 error!("Falha ao desserializar resposta JSON: {}", e);
221 ErroContext7::RespostaInvalida {
222 status: status.as_u16(),
223 }
224 }),
225
226 StatusCode::BAD_REQUEST => {
227 let mensagem = resposta
228 .text()
229 .await
230 .unwrap_or_else(|_| "Sem detalhes".to_string());
231 Err(ErroContext7::ApiRetornou400 { mensagem })
232 }
233
234 StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => Err(ErroContext7::SemChavesApi),
235
236 StatusCode::TOO_MANY_REQUESTS => {
237 warn!("Rate limit atingido (429), aguardando retry...");
238 Err(ErroContext7::RespostaInvalida {
239 status: status.as_u16(),
240 })
241 }
242
243 s if s.is_server_error() => {
244 warn!(
245 "Erro do servidor ({}), tentando novamente...",
246 status.as_u16()
247 );
248 Err(ErroContext7::RespostaInvalida {
249 status: status.as_u16(),
250 })
251 }
252
253 _ => Err(ErroContext7::RespostaInvalida {
254 status: status.as_u16(),
255 }),
256 }
257}
258
259#[cfg(test)]
262mod testes {
263 use super::*;
264
265 #[test]
268 fn testa_deserializacao_library_search_result() {
269 let json = r#"{
270 "id": "/facebook/react",
271 "title": "React",
272 "description": "A JavaScript library for building user interfaces",
273 "trust_score": 95.0
274 }"#;
275
276 let resultado: LibrarySearchResult =
277 serde_json::from_str(json).expect("Deve deserializar LibrarySearchResult");
278
279 assert_eq!(resultado.id, "/facebook/react");
280 assert_eq!(resultado.title, "React");
281 assert_eq!(
282 resultado.description.as_deref(),
283 Some("A JavaScript library for building user interfaces")
284 );
285 assert!((resultado.trust_score.unwrap() - 95.0).abs() < f64::EPSILON);
286 }
287
288 #[test]
289 fn testa_deserializacao_library_search_result_tolerante_campos_faltando() {
290 let json = r#"{
291 "id": "/minimal/lib",
292 "title": "MinimalLib"
293 }"#;
294
295 let resultado: LibrarySearchResult =
296 serde_json::from_str(json).expect("Deve deserializar mesmo com campos ausentes");
297
298 assert_eq!(resultado.id, "/minimal/lib");
299 assert_eq!(resultado.title, "MinimalLib");
300 assert!(resultado.description.is_none(), "description deve ser None");
301 assert!(resultado.trust_score.is_none(), "trust_score deve ser None");
302 }
303
304 #[test]
305 fn testa_deserializacao_documentation_snippet() {
306 let json = r#"{
307 "content": "The Effect Hook lets you perform side effects in function components.",
308 "type": "text",
309 "source_urls": ["https://react.dev/reference/react/useEffect"]
310 }"#;
311
312 let trecho: DocumentationSnippet =
313 serde_json::from_str(json).expect("Deve deserializar DocumentationSnippet");
314
315 assert!(trecho.content.contains("side effects"));
316 assert_eq!(trecho.tipo.as_deref(), Some("text"));
317 let urls = trecho.source_urls.as_ref().expect("Deve ter source_urls");
318 assert_eq!(urls[0], "https://react.dev/reference/react/useEffect");
319 }
320
321 #[test]
322 fn testa_deserializacao_documentation_snippet_sem_campos_opcionais() {
323 let json = r#"{
324 "content": "Conteúdo mínimo do trecho."
325 }"#;
326
327 let trecho: DocumentationSnippet =
328 serde_json::from_str(json).expect("Deve deserializar sem campos opcionais");
329
330 assert_eq!(trecho.content, "Conteúdo mínimo do trecho.");
331 assert!(trecho.tipo.is_none());
332 assert!(trecho.source_urls.is_none());
333 }
334
335 #[tokio::test]
338 async fn testa_buscar_biblioteca_com_mock_servidor_retorna_200() {
339 use wiremock::matchers::{method, path};
340 use wiremock::{Mock, MockServer, ResponseTemplate};
341
342 let servidor_mock = MockServer::start().await;
343
344 let resposta_json = serde_json::json!({
345 "results": [
346 {
347 "id": "/axum-rs/axum",
348 "title": "axum",
349 "description": "Framework web para Rust",
350 "trust_score": 90.0
351 }
352 ]
353 });
354
355 Mock::given(method("GET"))
356 .and(path("/api/v1/search"))
357 .respond_with(ResponseTemplate::new(200).set_body_json(&resposta_json))
358 .mount(&servidor_mock)
359 .await;
360
361 let cliente = reqwest::Client::new();
362 let url = format!("{}/api/v1/search", servidor_mock.uri());
363
364 let resposta = cliente
365 .get(&url)
366 .bearer_auth("ctx7sk-teste-mock")
367 .query(&[("libraryName", "axum"), ("query", "axum")])
368 .send()
369 .await
370 .expect("Deve conectar ao mock server");
371
372 assert!(resposta.status().is_success(), "Status deve ser 200");
373
374 let dados: RespostaListaBibliotecas = resposta
375 .json()
376 .await
377 .expect("Deve deserializar resposta do mock");
378
379 assert_eq!(dados.results.len(), 1);
380 assert_eq!(dados.results[0].id, "/axum-rs/axum");
381 assert_eq!(dados.results[0].title, "axum");
382 }
383
384 #[tokio::test]
385 async fn testa_buscar_documentacao_com_mock_servidor_retorna_200() {
386 use wiremock::matchers::{method, path};
387 use wiremock::{Mock, MockServer, ResponseTemplate};
388
389 let servidor_mock = MockServer::start().await;
390
391 let resposta_json = serde_json::json!({
392 "id": "/axum-rs/axum",
393 "snippets": [
394 {
395 "content": "O Router do Axum permite definir rotas HTTP de forma declarativa.",
396 "type": "text",
397 "source_urls": ["https://docs.rs/axum/latest/axum/struct.Router.html"]
398 }
399 ]
400 });
401
402 Mock::given(method("GET"))
403 .and(path("/api/v1/axum-rs/axum"))
404 .respond_with(ResponseTemplate::new(200).set_body_json(&resposta_json))
405 .mount(&servidor_mock)
406 .await;
407
408 let cliente = reqwest::Client::new();
409 let url = format!("{}/api/v1/axum-rs/axum", servidor_mock.uri());
410
411 let resposta = cliente
412 .get(&url)
413 .bearer_auth("ctx7sk-teste-docs-mock")
414 .query(&[("type", "json"), ("query", "como criar router")])
415 .send()
416 .await
417 .expect("Deve conectar ao mock server");
418
419 assert!(resposta.status().is_success());
420
421 let dados: RespostaDocumentacao = resposta
422 .json()
423 .await
424 .expect("Deve deserializar resposta do mock");
425
426 let trechos = dados.snippets.as_ref().expect("Deve ter snippets");
427 assert_eq!(trechos.len(), 1);
428 assert!(trechos[0].content.contains("Router do Axum"));
429 }
430
431 #[test]
434 fn testa_shuffle_chaves_preserva_todos_os_elementos() {
435 let chaves_originais: Vec<String> =
436 (0..10).map(|i| format!("ctx7sk-chave-{:02}", i)).collect();
437
438 let mut chaves_copia = chaves_originais.clone();
439 let mut rng = rand::thread_rng();
440 chaves_copia.shuffle(&mut rng);
441
442 assert_eq!(
443 chaves_copia.len(),
444 chaves_originais.len(),
445 "Shuffle deve preservar todos os elementos"
446 );
447
448 let mut ordenadas_original = chaves_originais.clone();
449 let mut ordenadas_copia = chaves_copia.clone();
450 ordenadas_original.sort();
451 ordenadas_copia.sort();
452 assert_eq!(
453 ordenadas_original, ordenadas_copia,
454 "Shuffle deve conter os mesmos elementos, apenas em ordem diferente"
455 );
456 }
457}