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(format!("context7-cli/{}", env!("CARGO_PKG_VERSION")))
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::BibliotecaNaoEncontrada { library_id }) => {
155 bail!(ErroContext7::BibliotecaNaoEncontrada { library_id });
157 }
158
159 Err(ErroContext7::SemChavesApi) => {
160 chaves_falhas_auth += 1;
161 warn!("Chave de API inválida (401/403), tentando próxima...");
162 }
163
164 Err(ErroContext7::RespostaInvalida { status: 200 }) => {
165 bail!(ErroContext7::RespostaInvalida { status: 200 });
168 }
169
170 Err(e) => {
171 warn!("Falha na tentativa {}: {}", tentativa + 1, e);
172
173 if tentativa + 1 < max_tentativas && tentativa < atrasos_ms.len() {
175 let atraso = Duration::from_millis(atrasos_ms[tentativa]);
176 info!(
177 "Aguardando {}ms antes de tentar novamente...",
178 atraso.as_millis()
179 );
180 sleep(atraso).await;
181 }
182 }
183 }
184 }
185
186 if chaves_falhas_auth >= max_tentativas {
187 bail!(ErroContext7::SemChavesApi);
188 }
189
190 bail!(ErroContext7::RetryEsgotado {
191 tentativas: max_tentativas as u32,
192 });
193}
194
195pub async fn buscar_biblioteca(
201 cliente: &reqwest::Client,
202 chave: &str,
203 nome: &str,
204 query_contexto: &str,
205) -> Result<RespostaListaBibliotecas, ErroContext7> {
206 let url = format!("{}/v1/search", BASE_URL);
207
208 let resposta = cliente
209 .get(&url)
210 .bearer_auth(chave)
211 .query(&[("libraryName", nome), ("query", query_contexto)])
212 .send()
213 .await
214 .map_err(|e| {
215 error!("Erro de rede ao buscar biblioteca: {}", e);
216 ErroContext7::RespostaInvalida { status: 0 }
217 })?;
218
219 tratar_status_resposta(resposta).await
220}
221
222pub async fn buscar_documentacao(
227 cliente: &reqwest::Client,
228 chave: &str,
229 library_id: &str,
230 query: Option<&str>,
231) -> Result<RespostaDocumentacao, ErroContext7> {
232 let id_normalizado = library_id.trim_start_matches('/');
234 let url = format!("{}/v1/{}", BASE_URL, id_normalizado);
235
236 let mut construtor = cliente
237 .get(&url)
238 .bearer_auth(chave)
239 .query(&[("type", "json")]);
240
241 if let Some(q) = query {
242 construtor = construtor.query(&[("query", q)]);
243 }
244
245 let resposta = construtor.send().await.map_err(|e| {
246 error!("Erro de rede ao buscar documentação: {}", e);
247 ErroContext7::RespostaInvalida { status: 0 }
248 })?;
249
250 tratar_status_resposta(resposta).await.map_err(|e| match e {
251 ErroContext7::RespostaInvalida { status: 404 } => ErroContext7::BibliotecaNaoEncontrada {
252 library_id: library_id.to_string(),
253 },
254 outro => outro,
255 })
256}
257
258pub async fn buscar_documentacao_texto(
263 cliente: &reqwest::Client,
264 chave: &str,
265 library_id: &str,
266 query: Option<&str>,
267) -> Result<String, ErroContext7> {
268 let id_normalizado = library_id.trim_start_matches('/');
269 let url = format!("{}/v1/{}", BASE_URL, id_normalizado);
270
271 let mut construtor = cliente
272 .get(&url)
273 .bearer_auth(chave)
274 .query(&[("type", "txt")]);
275
276 if let Some(q) = query {
277 construtor = construtor.query(&[("query", q)]);
278 }
279
280 let resposta = construtor.send().await.map_err(|e| {
281 error!("Erro de rede ao buscar documentação: {}", e);
282 ErroContext7::RespostaInvalida { status: 0 }
283 })?;
284
285 let status = resposta.status();
286
287 if !status.is_success() {
288 match status {
289 StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => {
290 return Err(ErroContext7::SemChavesApi);
291 }
292 StatusCode::BAD_REQUEST => {
293 let mensagem = resposta
294 .text()
295 .await
296 .unwrap_or_else(|_| "Sem detalhes".to_string());
297 return Err(ErroContext7::ApiRetornou400 { mensagem });
298 }
299 StatusCode::NOT_FOUND => {
300 return Err(ErroContext7::BibliotecaNaoEncontrada {
301 library_id: library_id.to_string(),
302 });
303 }
304 _ => {
305 return Err(ErroContext7::RespostaInvalida {
306 status: status.as_u16(),
307 });
308 }
309 }
310 }
311
312 resposta
313 .text()
314 .await
315 .map_err(|_| ErroContext7::RespostaInvalida {
316 status: status.as_u16(),
317 })
318}
319
320async fn tratar_status_resposta<T: for<'de> Deserialize<'de>>(
322 resposta: reqwest::Response,
323) -> Result<T, ErroContext7> {
324 let status = resposta.status();
325
326 match status {
327 s if s.is_success() => resposta.json::<T>().await.map_err(|e| {
328 error!("Falha ao desserializar resposta JSON: {}", e);
329 ErroContext7::RespostaInvalida {
330 status: status.as_u16(),
331 }
332 }),
333
334 StatusCode::BAD_REQUEST => {
335 let mensagem = resposta
336 .text()
337 .await
338 .unwrap_or_else(|_| "Sem detalhes".to_string());
339 Err(ErroContext7::ApiRetornou400 { mensagem })
340 }
341
342 StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => Err(ErroContext7::SemChavesApi),
343
344 StatusCode::TOO_MANY_REQUESTS => {
345 warn!("Rate limit atingido (429), aguardando retry...");
346 Err(ErroContext7::RespostaInvalida {
347 status: status.as_u16(),
348 })
349 }
350
351 s if s.is_server_error() => {
352 warn!(
353 "Erro do servidor ({}), tentando novamente...",
354 status.as_u16()
355 );
356 Err(ErroContext7::RespostaInvalida {
357 status: status.as_u16(),
358 })
359 }
360
361 _ => Err(ErroContext7::RespostaInvalida {
362 status: status.as_u16(),
363 }),
364 }
365}
366
367#[cfg(test)]
370mod testes {
371 use super::*;
372
373 #[test]
376 fn testa_deserializacao_library_search_result() {
377 let json = r#"{
378 "id": "/facebook/react",
379 "title": "React",
380 "description": "A JavaScript library for building user interfaces",
381 "trustScore": 95.0
382 }"#;
383
384 let resultado: LibrarySearchResult =
385 serde_json::from_str(json).expect("Deve deserializar LibrarySearchResult");
386
387 assert_eq!(resultado.id, "/facebook/react");
388 assert_eq!(resultado.title, "React");
389 assert_eq!(
390 resultado.description.as_deref(),
391 Some("A JavaScript library for building user interfaces")
392 );
393 assert!((resultado.trust_score.unwrap() - 95.0).abs() < f64::EPSILON);
394 }
395
396 #[test]
397 fn testa_deserializacao_library_search_result_tolerante_campos_faltando() {
398 let json = r#"{
399 "id": "/minimal/lib",
400 "title": "MinimalLib"
401 }"#;
402
403 let resultado: LibrarySearchResult =
404 serde_json::from_str(json).expect("Deve deserializar mesmo com campos ausentes");
405
406 assert_eq!(resultado.id, "/minimal/lib");
407 assert_eq!(resultado.title, "MinimalLib");
408 assert!(resultado.description.is_none(), "description deve ser None");
409 assert!(resultado.trust_score.is_none(), "trust_score deve ser None");
410 }
411
412 #[test]
413 fn testa_deserializacao_library_search_result_com_campos_opcionais() {
414 let json = r#"{
415 "id": "/facebook/react",
416 "title": "React",
417 "trustScore": 95.0,
418 "stars": 228000,
419 "totalSnippets": 1500,
420 "totalTokens": 250000,
421 "verified": true,
422 "branch": "main",
423 "state": "active"
424 }"#;
425
426 let resultado: LibrarySearchResult =
427 serde_json::from_str(json).expect("Deve deserializar com campos opcionais");
428
429 assert_eq!(resultado.stars, Some(228_000i64));
430 assert_eq!(resultado.total_snippets, Some(1_500));
431 assert_eq!(resultado.total_tokens, Some(250_000));
432 assert_eq!(resultado.verified, Some(true));
433 assert_eq!(resultado.branch.as_deref(), Some("main"));
434 assert_eq!(resultado.state.as_deref(), Some("active"));
435 }
436
437 #[test]
438 fn testa_deserializacao_documentation_snippet() {
439 let json = r#"{
440 "pageTitle": "React Hooks API",
441 "codeTitle": "useEffect example",
442 "codeDescription": "The Effect Hook lets you perform side effects.",
443 "codeLanguage": "javascript",
444 "codeTokens": 68,
445 "codeId": "https://github.com/facebook/react/blob/main/packages/react/src/ReactHooks.js",
446 "codeList": [
447 {"language": "javascript", "code": "useEffect(() => { /* effect */ }, []);"}
448 ],
449 "relevance": 0.032,
450 "model": "gemini-2.5-flash"
451 }"#;
452
453 let trecho: DocumentationSnippet =
454 serde_json::from_str(json).expect("Deve deserializar DocumentationSnippet");
455
456 assert_eq!(trecho.page_title.as_deref(), Some("React Hooks API"));
457 assert_eq!(trecho.code_title.as_deref(), Some("useEffect example"));
458 assert_eq!(trecho.code_language.as_deref(), Some("javascript"));
459 assert_eq!(trecho.code_tokens, Some(68));
460 let lista = trecho.code_list.as_ref().expect("Deve ter code_list");
461 assert_eq!(lista.len(), 1);
462 assert_eq!(lista[0].language, "javascript");
463 assert!((trecho.relevance.unwrap() - 0.032).abs() < f64::EPSILON);
464 }
465
466 #[test]
467 fn testa_deserializacao_documentation_snippet_sem_campos_opcionais() {
468 let json = r#"{}"#;
469
470 let trecho: DocumentationSnippet =
471 serde_json::from_str(json).expect("Deve deserializar snippet completamente vazio");
472
473 assert!(trecho.page_title.is_none());
474 assert!(trecho.code_title.is_none());
475 assert!(trecho.code_list.is_none());
476 }
477
478 #[test]
479 fn testa_deserializacao_code_block() {
480 let json = r#"{"language": "rust", "code": "fn main() {}"}"#;
481
482 let bloco: CodeBlock = serde_json::from_str(json).expect("Deve deserializar CodeBlock");
483
484 assert_eq!(bloco.language, "rust");
485 assert_eq!(bloco.code, "fn main() {}");
486 }
487
488 #[tokio::test]
491 async fn testa_buscar_biblioteca_com_mock_servidor_retorna_200() {
492 use wiremock::matchers::{method, path};
493 use wiremock::{Mock, MockServer, ResponseTemplate};
494
495 let servidor_mock = MockServer::start().await;
496
497 let resposta_json = serde_json::json!({
498 "results": [
499 {
500 "id": "/axum-rs/axum",
501 "title": "axum",
502 "description": "Framework web para Rust",
503 "trustScore": 90.0
504 }
505 ]
506 });
507
508 Mock::given(method("GET"))
509 .and(path("/api/v1/search"))
510 .respond_with(ResponseTemplate::new(200).set_body_json(&resposta_json))
511 .mount(&servidor_mock)
512 .await;
513
514 let cliente = reqwest::Client::new();
515 let url = format!("{}/api/v1/search", servidor_mock.uri());
516
517 let resposta = cliente
518 .get(&url)
519 .bearer_auth("ctx7sk-teste-mock")
520 .query(&[("libraryName", "axum"), ("query", "axum")])
521 .send()
522 .await
523 .expect("Deve conectar ao mock server");
524
525 assert!(resposta.status().is_success(), "Status deve ser 200");
526
527 let dados: RespostaListaBibliotecas = resposta
528 .json()
529 .await
530 .expect("Deve deserializar resposta do mock");
531
532 assert_eq!(dados.results.len(), 1);
533 assert_eq!(dados.results[0].id, "/axum-rs/axum");
534 assert_eq!(dados.results[0].title, "axum");
535 }
536
537 #[tokio::test]
538 async fn testa_buscar_documentacao_com_mock_servidor_retorna_200() {
539 use wiremock::matchers::{method, path};
540 use wiremock::{Mock, MockServer, ResponseTemplate};
541
542 let servidor_mock = MockServer::start().await;
543
544 let resposta_json = serde_json::json!({
545 "snippets": [
546 {
547 "pageTitle": "axum::Router",
548 "codeTitle": "Basic Router setup",
549 "codeDescription": "O Router do Axum permite definir rotas HTTP de forma declarativa.",
550 "codeLanguage": "rust",
551 "codeList": [
552 {"language": "rust", "code": "let app = Router::new().route(\"/\", get(handler));"}
553 ]
554 }
555 ]
556 });
557
558 Mock::given(method("GET"))
559 .and(path("/api/v1/axum-rs/axum"))
560 .respond_with(ResponseTemplate::new(200).set_body_json(&resposta_json))
561 .mount(&servidor_mock)
562 .await;
563
564 let cliente = reqwest::Client::new();
565 let url = format!("{}/api/v1/axum-rs/axum", servidor_mock.uri());
566
567 let resposta = cliente
568 .get(&url)
569 .bearer_auth("ctx7sk-teste-docs-mock")
570 .query(&[("type", "json"), ("query", "como criar router")])
571 .send()
572 .await
573 .expect("Deve conectar ao mock server");
574
575 assert!(resposta.status().is_success());
576
577 let dados: RespostaDocumentacao = resposta
578 .json()
579 .await
580 .expect("Deve deserializar resposta do mock");
581
582 let trechos = dados.snippets.as_ref().expect("Deve ter snippets");
583 assert_eq!(trechos.len(), 1);
584 let lista = trechos[0].code_list.as_ref().expect("Deve ter code_list");
585 assert!(lista[0].code.contains("Router::new"));
586 }
587
588 #[test]
591 fn testa_shuffle_chaves_preserva_todos_os_elementos() {
592 let chaves_originais: Vec<String> =
593 (0..10).map(|i| format!("ctx7sk-chave-{:02}", i)).collect();
594
595 let mut chaves_copia = chaves_originais.clone();
596 let mut rng = rand::thread_rng();
597 chaves_copia.shuffle(&mut rng);
598
599 assert_eq!(
600 chaves_copia.len(),
601 chaves_originais.len(),
602 "Shuffle deve preservar todos os elementos"
603 );
604
605 let mut ordenadas_original = chaves_originais.clone();
606 let mut ordenadas_copia = chaves_copia.clone();
607 ordenadas_original.sort();
608 ordenadas_copia.sort();
609 assert_eq!(
610 ordenadas_original, ordenadas_copia,
611 "Shuffle deve conter os mesmos elementos, apenas em ordem diferente"
612 );
613 }
614
615 #[test]
616 fn testa_max_tentativas_limitado_a_5() {
617 let muitas_chaves: Vec<String> =
619 (0..10).map(|i| format!("ctx7sk-chave-{:02}", i)).collect();
620 let max = muitas_chaves.len().min(5);
621 assert_eq!(
622 max, 5,
623 "Max tentativas deve ser limitado a 5 mesmo com 10 chaves"
624 );
625
626 let poucas_chaves: Vec<String> = vec!["ctx7sk-a".to_string(), "ctx7sk-b".to_string()];
627 let max2 = poucas_chaves.len().min(5);
628 assert_eq!(max2, 2, "Com 2 chaves, max deve ser 2");
629 }
630}