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;
13use crate::i18n::{t, Mensagem};
14
15const BASE_URL: &str = "https://context7.com/api";
18
19#[derive(Debug, Deserialize, Serialize)]
23#[serde(rename_all = "camelCase")]
24pub struct LibrarySearchResult {
25 pub id: String,
27 pub title: String,
29 pub description: Option<String>,
31 pub trust_score: Option<f64>,
33 pub stars: Option<i64>,
35 pub total_snippets: Option<u64>,
37 pub total_tokens: Option<u64>,
39 pub verified: Option<bool>,
41 pub branch: Option<String>,
43 pub state: Option<String>,
45}
46
47#[derive(Debug, Deserialize, Serialize, Clone)]
49pub struct CodeBlock {
50 pub language: String,
52 pub code: String,
54}
55
56#[derive(Debug, Deserialize, Serialize, Clone)]
58#[serde(rename_all = "camelCase")]
59pub struct DocumentationSnippet {
60 pub page_title: Option<String>,
62 pub code_title: Option<String>,
64 pub code_description: Option<String>,
66 pub code_language: Option<String>,
68 pub code_tokens: Option<u64>,
70 pub code_id: Option<String>,
72 pub code_list: Option<Vec<CodeBlock>>,
74 pub relevance: Option<f64>,
76 pub model: Option<String>,
78}
79
80#[derive(Debug, Deserialize)]
82pub struct RespostaListaBibliotecas {
83 pub results: Vec<LibrarySearchResult>,
85}
86
87#[derive(Debug, Deserialize, Serialize)]
89pub struct RespostaDocumentacao {
90 pub snippets: Option<Vec<DocumentationSnippet>>,
92}
93
94pub fn criar_cliente_http() -> Result<reqwest::Client> {
100 let cliente = reqwest::Client::builder()
101 .use_rustls_tls()
102 .timeout(Duration::from_secs(30))
103 .user_agent("context7-cli/0.2.1")
104 .pool_max_idle_per_host(4)
105 .build()
106 .with_context(|| t(Mensagem::FalhaCriarClienteHttp))?;
107
108 Ok(cliente)
109}
110
111pub async fn executar_com_retry<F, Fut, T>(chaves: &[String], operacao: F) -> Result<T>
125where
126 F: Fn(String) -> Fut,
127 Fut: std::future::Future<Output = Result<T, ErroContext7>>,
128{
129 let max_tentativas = chaves.len().min(5);
130
131 let mut chaves_embaralhadas = chaves.to_vec();
133 let mut rng = rand::thread_rng();
134 chaves_embaralhadas.shuffle(&mut rng);
135
136 let atrasos_ms = [500u64, 1000, 2000];
137 let mut chaves_falhas_auth = 0usize;
138
139 for (tentativa, chave) in chaves_embaralhadas
140 .into_iter()
141 .take(max_tentativas)
142 .enumerate()
143 {
144 info!("Tentativa {}/{}", tentativa + 1, max_tentativas);
145
146 match operacao(chave).await {
147 Ok(resultado) => return Ok(resultado),
148
149 Err(ErroContext7::ApiRetornou400 { mensagem }) => {
150 bail!(ErroContext7::ApiRetornou400 { mensagem });
152 }
153
154 Err(ErroContext7::SemChavesApi) => {
155 chaves_falhas_auth += 1;
156 warn!("Chave de API inválida (401/403), tentando próxima...");
157 }
158
159 Err(ErroContext7::RespostaInvalida { status: 200 }) => {
160 bail!(ErroContext7::RespostaInvalida { status: 200 });
163 }
164
165 Err(e) => {
166 warn!("Falha na tentativa {}: {}", tentativa + 1, e);
167
168 if tentativa + 1 < max_tentativas && tentativa < atrasos_ms.len() {
170 let atraso = Duration::from_millis(atrasos_ms[tentativa]);
171 info!(
172 "Aguardando {}ms antes de tentar novamente...",
173 atraso.as_millis()
174 );
175 sleep(atraso).await;
176 }
177 }
178 }
179 }
180
181 if chaves_falhas_auth >= max_tentativas {
182 bail!(ErroContext7::SemChavesApi);
183 }
184
185 bail!(ErroContext7::RetryEsgotado {
186 tentativas: max_tentativas as u32,
187 });
188}
189
190pub async fn buscar_biblioteca(
196 cliente: &reqwest::Client,
197 chave: &str,
198 nome: &str,
199 query_contexto: &str,
200) -> Result<RespostaListaBibliotecas, ErroContext7> {
201 let url = format!("{}/v1/search", BASE_URL);
202
203 let resposta = cliente
204 .get(&url)
205 .bearer_auth(chave)
206 .query(&[("libraryName", nome), ("query", query_contexto)])
207 .send()
208 .await
209 .map_err(|e| {
210 error!("Erro de rede ao buscar biblioteca: {}", e);
211 ErroContext7::RespostaInvalida { status: 0 }
212 })?;
213
214 tratar_status_resposta(resposta).await
215}
216
217pub async fn buscar_documentacao(
222 cliente: &reqwest::Client,
223 chave: &str,
224 library_id: &str,
225 query: Option<&str>,
226) -> Result<RespostaDocumentacao, ErroContext7> {
227 let id_normalizado = library_id.trim_start_matches('/');
229 let url = format!("{}/v1/{}", BASE_URL, id_normalizado);
230
231 let mut construtor = cliente
232 .get(&url)
233 .bearer_auth(chave)
234 .query(&[("type", "json")]);
235
236 if let Some(q) = query {
237 construtor = construtor.query(&[("query", q)]);
238 }
239
240 let resposta = construtor.send().await.map_err(|e| {
241 error!("Erro de rede ao buscar documentação: {}", e);
242 ErroContext7::RespostaInvalida { status: 0 }
243 })?;
244
245 tratar_status_resposta(resposta).await
246}
247
248pub async fn buscar_documentacao_texto(
253 cliente: &reqwest::Client,
254 chave: &str,
255 library_id: &str,
256 query: Option<&str>,
257) -> Result<String, ErroContext7> {
258 let id_normalizado = library_id.trim_start_matches('/');
259 let url = format!("{}/v1/{}", BASE_URL, id_normalizado);
260
261 let mut construtor = cliente
262 .get(&url)
263 .bearer_auth(chave)
264 .query(&[("type", "txt")]);
265
266 if let Some(q) = query {
267 construtor = construtor.query(&[("query", q)]);
268 }
269
270 let resposta = construtor.send().await.map_err(|e| {
271 error!("Erro de rede ao buscar documentação: {}", e);
272 ErroContext7::RespostaInvalida { status: 0 }
273 })?;
274
275 let status = resposta.status();
276
277 if !status.is_success() {
278 match status {
279 StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => {
280 return Err(ErroContext7::SemChavesApi);
281 }
282 StatusCode::BAD_REQUEST => {
283 let mensagem = resposta
284 .text()
285 .await
286 .unwrap_or_else(|_| "Sem detalhes".to_string());
287 return Err(ErroContext7::ApiRetornou400 { mensagem });
288 }
289 _ => {
290 return Err(ErroContext7::RespostaInvalida {
291 status: status.as_u16(),
292 });
293 }
294 }
295 }
296
297 resposta
298 .text()
299 .await
300 .map_err(|_| ErroContext7::RespostaInvalida {
301 status: status.as_u16(),
302 })
303}
304
305async fn tratar_status_resposta<T: for<'de> Deserialize<'de>>(
307 resposta: reqwest::Response,
308) -> Result<T, ErroContext7> {
309 let status = resposta.status();
310
311 match status {
312 s if s.is_success() => resposta.json::<T>().await.map_err(|e| {
313 error!("Falha ao desserializar resposta JSON: {}", e);
314 ErroContext7::RespostaInvalida {
315 status: status.as_u16(),
316 }
317 }),
318
319 StatusCode::BAD_REQUEST => {
320 let mensagem = resposta
321 .text()
322 .await
323 .unwrap_or_else(|_| "Sem detalhes".to_string());
324 Err(ErroContext7::ApiRetornou400 { mensagem })
325 }
326
327 StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => Err(ErroContext7::SemChavesApi),
328
329 StatusCode::TOO_MANY_REQUESTS => {
330 warn!("Rate limit atingido (429), aguardando retry...");
331 Err(ErroContext7::RespostaInvalida {
332 status: status.as_u16(),
333 })
334 }
335
336 s if s.is_server_error() => {
337 warn!(
338 "Erro do servidor ({}), tentando novamente...",
339 status.as_u16()
340 );
341 Err(ErroContext7::RespostaInvalida {
342 status: status.as_u16(),
343 })
344 }
345
346 _ => Err(ErroContext7::RespostaInvalida {
347 status: status.as_u16(),
348 }),
349 }
350}
351
352#[cfg(test)]
355mod testes {
356 use super::*;
357
358 #[test]
361 fn testa_deserializacao_library_search_result() {
362 let json = r#"{
363 "id": "/facebook/react",
364 "title": "React",
365 "description": "A JavaScript library for building user interfaces",
366 "trustScore": 95.0
367 }"#;
368
369 let resultado: LibrarySearchResult =
370 serde_json::from_str(json).expect("Deve deserializar LibrarySearchResult");
371
372 assert_eq!(resultado.id, "/facebook/react");
373 assert_eq!(resultado.title, "React");
374 assert_eq!(
375 resultado.description.as_deref(),
376 Some("A JavaScript library for building user interfaces")
377 );
378 assert!((resultado.trust_score.unwrap() - 95.0).abs() < f64::EPSILON);
379 }
380
381 #[test]
382 fn testa_deserializacao_library_search_result_tolerante_campos_faltando() {
383 let json = r#"{
384 "id": "/minimal/lib",
385 "title": "MinimalLib"
386 }"#;
387
388 let resultado: LibrarySearchResult =
389 serde_json::from_str(json).expect("Deve deserializar mesmo com campos ausentes");
390
391 assert_eq!(resultado.id, "/minimal/lib");
392 assert_eq!(resultado.title, "MinimalLib");
393 assert!(resultado.description.is_none(), "description deve ser None");
394 assert!(resultado.trust_score.is_none(), "trust_score deve ser None");
395 }
396
397 #[test]
398 fn testa_deserializacao_library_search_result_com_campos_opcionais() {
399 let json = r#"{
400 "id": "/facebook/react",
401 "title": "React",
402 "trustScore": 95.0,
403 "stars": 228000,
404 "totalSnippets": 1500,
405 "totalTokens": 250000,
406 "verified": true,
407 "branch": "main",
408 "state": "active"
409 }"#;
410
411 let resultado: LibrarySearchResult =
412 serde_json::from_str(json).expect("Deve deserializar com campos opcionais");
413
414 assert_eq!(resultado.stars, Some(228_000i64));
415 assert_eq!(resultado.total_snippets, Some(1_500));
416 assert_eq!(resultado.total_tokens, Some(250_000));
417 assert_eq!(resultado.verified, Some(true));
418 assert_eq!(resultado.branch.as_deref(), Some("main"));
419 assert_eq!(resultado.state.as_deref(), Some("active"));
420 }
421
422 #[test]
423 fn testa_deserializacao_documentation_snippet() {
424 let json = r#"{
425 "pageTitle": "React Hooks API",
426 "codeTitle": "useEffect example",
427 "codeDescription": "The Effect Hook lets you perform side effects.",
428 "codeLanguage": "javascript",
429 "codeTokens": 68,
430 "codeId": "https://github.com/facebook/react/blob/main/packages/react/src/ReactHooks.js",
431 "codeList": [
432 {"language": "javascript", "code": "useEffect(() => { /* effect */ }, []);"}
433 ],
434 "relevance": 0.032,
435 "model": "gemini-2.5-flash"
436 }"#;
437
438 let trecho: DocumentationSnippet =
439 serde_json::from_str(json).expect("Deve deserializar DocumentationSnippet");
440
441 assert_eq!(trecho.page_title.as_deref(), Some("React Hooks API"));
442 assert_eq!(trecho.code_title.as_deref(), Some("useEffect example"));
443 assert_eq!(trecho.code_language.as_deref(), Some("javascript"));
444 assert_eq!(trecho.code_tokens, Some(68));
445 let lista = trecho.code_list.as_ref().expect("Deve ter code_list");
446 assert_eq!(lista.len(), 1);
447 assert_eq!(lista[0].language, "javascript");
448 assert!((trecho.relevance.unwrap() - 0.032).abs() < f64::EPSILON);
449 }
450
451 #[test]
452 fn testa_deserializacao_documentation_snippet_sem_campos_opcionais() {
453 let json = r#"{}"#;
454
455 let trecho: DocumentationSnippet =
456 serde_json::from_str(json).expect("Deve deserializar snippet completamente vazio");
457
458 assert!(trecho.page_title.is_none());
459 assert!(trecho.code_title.is_none());
460 assert!(trecho.code_list.is_none());
461 }
462
463 #[test]
464 fn testa_deserializacao_code_block() {
465 let json = r#"{"language": "rust", "code": "fn main() {}"}"#;
466
467 let bloco: CodeBlock = serde_json::from_str(json).expect("Deve deserializar CodeBlock");
468
469 assert_eq!(bloco.language, "rust");
470 assert_eq!(bloco.code, "fn main() {}");
471 }
472
473 #[tokio::test]
476 async fn testa_buscar_biblioteca_com_mock_servidor_retorna_200() {
477 use wiremock::matchers::{method, path};
478 use wiremock::{Mock, MockServer, ResponseTemplate};
479
480 let servidor_mock = MockServer::start().await;
481
482 let resposta_json = serde_json::json!({
483 "results": [
484 {
485 "id": "/axum-rs/axum",
486 "title": "axum",
487 "description": "Framework web para Rust",
488 "trustScore": 90.0
489 }
490 ]
491 });
492
493 Mock::given(method("GET"))
494 .and(path("/api/v1/search"))
495 .respond_with(ResponseTemplate::new(200).set_body_json(&resposta_json))
496 .mount(&servidor_mock)
497 .await;
498
499 let cliente = reqwest::Client::new();
500 let url = format!("{}/api/v1/search", servidor_mock.uri());
501
502 let resposta = cliente
503 .get(&url)
504 .bearer_auth("ctx7sk-teste-mock")
505 .query(&[("libraryName", "axum"), ("query", "axum")])
506 .send()
507 .await
508 .expect("Deve conectar ao mock server");
509
510 assert!(resposta.status().is_success(), "Status deve ser 200");
511
512 let dados: RespostaListaBibliotecas = resposta
513 .json()
514 .await
515 .expect("Deve deserializar resposta do mock");
516
517 assert_eq!(dados.results.len(), 1);
518 assert_eq!(dados.results[0].id, "/axum-rs/axum");
519 assert_eq!(dados.results[0].title, "axum");
520 }
521
522 #[tokio::test]
523 async fn testa_buscar_documentacao_com_mock_servidor_retorna_200() {
524 use wiremock::matchers::{method, path};
525 use wiremock::{Mock, MockServer, ResponseTemplate};
526
527 let servidor_mock = MockServer::start().await;
528
529 let resposta_json = serde_json::json!({
530 "snippets": [
531 {
532 "pageTitle": "axum::Router",
533 "codeTitle": "Basic Router setup",
534 "codeDescription": "O Router do Axum permite definir rotas HTTP de forma declarativa.",
535 "codeLanguage": "rust",
536 "codeList": [
537 {"language": "rust", "code": "let app = Router::new().route(\"/\", get(handler));"}
538 ]
539 }
540 ]
541 });
542
543 Mock::given(method("GET"))
544 .and(path("/api/v1/axum-rs/axum"))
545 .respond_with(ResponseTemplate::new(200).set_body_json(&resposta_json))
546 .mount(&servidor_mock)
547 .await;
548
549 let cliente = reqwest::Client::new();
550 let url = format!("{}/api/v1/axum-rs/axum", servidor_mock.uri());
551
552 let resposta = cliente
553 .get(&url)
554 .bearer_auth("ctx7sk-teste-docs-mock")
555 .query(&[("type", "json"), ("query", "como criar router")])
556 .send()
557 .await
558 .expect("Deve conectar ao mock server");
559
560 assert!(resposta.status().is_success());
561
562 let dados: RespostaDocumentacao = resposta
563 .json()
564 .await
565 .expect("Deve deserializar resposta do mock");
566
567 let trechos = dados.snippets.as_ref().expect("Deve ter snippets");
568 assert_eq!(trechos.len(), 1);
569 let lista = trechos[0].code_list.as_ref().expect("Deve ter code_list");
570 assert!(lista[0].code.contains("Router::new"));
571 }
572
573 #[test]
576 fn testa_shuffle_chaves_preserva_todos_os_elementos() {
577 let chaves_originais: Vec<String> =
578 (0..10).map(|i| format!("ctx7sk-chave-{:02}", i)).collect();
579
580 let mut chaves_copia = chaves_originais.clone();
581 let mut rng = rand::thread_rng();
582 chaves_copia.shuffle(&mut rng);
583
584 assert_eq!(
585 chaves_copia.len(),
586 chaves_originais.len(),
587 "Shuffle deve preservar todos os elementos"
588 );
589
590 let mut ordenadas_original = chaves_originais.clone();
591 let mut ordenadas_copia = chaves_copia.clone();
592 ordenadas_original.sort();
593 ordenadas_copia.sort();
594 assert_eq!(
595 ordenadas_original, ordenadas_copia,
596 "Shuffle deve conter os mesmos elementos, apenas em ordem diferente"
597 );
598 }
599
600 #[test]
601 fn testa_max_tentativas_limitado_a_5() {
602 let muitas_chaves: Vec<String> =
604 (0..10).map(|i| format!("ctx7sk-chave-{:02}", i)).collect();
605 let max = muitas_chaves.len().min(5);
606 assert_eq!(
607 max, 5,
608 "Max tentativas deve ser limitado a 5 mesmo com 10 chaves"
609 );
610
611 let poucas_chaves: Vec<String> = vec!["ctx7sk-a".to_string(), "ctx7sk-b".to_string()];
612 let max2 = poucas_chaves.len().min(5);
613 assert_eq!(max2, 2, "Com 2 chaves, max deve ser 2");
614 }
615}