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