Skip to main content

context7_cli/
api.rs

1/// HTTP client, retry logic, and Context7 API calls.
2///
3/// This module owns the full lifecycle of API interaction:
4/// request building, status handling, and key-rotation retry.
5use 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
15// ─── CONSTANTE ───────────────────────────────────────────────────────────────
16
17const BASE_URL: &str = "https://context7.com/api";
18
19// ─── MODELOS DE RESPOSTA DA API ───────────────────────────────────────────────
20
21/// Represents a single library entry returned by the search endpoint.
22#[derive(Debug, Deserialize, Serialize)]
23#[serde(rename_all = "camelCase")]
24pub struct LibrarySearchResult {
25    /// Unique library identifier (e.g. `/facebook/react`).
26    pub id: String,
27    /// Human-readable library title.
28    pub title: String,
29    /// Optional short description of the library.
30    pub description: Option<String>,
31    /// Relevance/trust score returned by the API, if available.
32    pub trust_score: Option<f64>,
33    /// Number of GitHub stars, if available. The API returns `-1` when unavailable.
34    pub stars: Option<i64>,
35    /// Total number of documentation snippets indexed.
36    pub total_snippets: Option<u64>,
37    /// Total number of tokens indexed.
38    pub total_tokens: Option<u64>,
39    /// Whether the library has been verified by the Context7 team.
40    pub verified: Option<bool>,
41    /// Git branch used for indexing.
42    pub branch: Option<String>,
43    /// Indexing state (e.g. "active", "pending").
44    pub state: Option<String>,
45}
46
47/// A single code block within a documentation snippet.
48#[derive(Debug, Deserialize, Serialize, Clone)]
49pub struct CodeBlock {
50    /// Programming language of the code (e.g. `"rust"`, `"bash"`).
51    pub language: String,
52    /// Source code content.
53    pub code: String,
54}
55
56/// Represents a single documentation excerpt returned by the docs endpoint (JSON mode).
57#[derive(Debug, Deserialize, Serialize, Clone)]
58#[serde(rename_all = "camelCase")]
59pub struct DocumentationSnippet {
60    /// Page title of the source documentation page, if available.
61    pub page_title: Option<String>,
62    /// Title of this specific code snippet, if available.
63    pub code_title: Option<String>,
64    /// Description accompanying the code snippet, if available.
65    pub code_description: Option<String>,
66    /// Primary programming language of the snippet, if available.
67    pub code_language: Option<String>,
68    /// Number of tokens in this snippet, if available.
69    pub code_tokens: Option<u64>,
70    /// Unique identifier or source URL of this snippet, if available.
71    pub code_id: Option<String>,
72    /// List of code blocks contained in this snippet.
73    pub code_list: Option<Vec<CodeBlock>>,
74    /// Relevance score for the query, if available.
75    pub relevance: Option<f64>,
76    /// Model used to generate or rank this snippet, if available.
77    pub model: Option<String>,
78}
79
80/// Top-level response from the library search endpoint (`GET /api/v1/search`).
81#[derive(Debug, Deserialize)]
82pub struct RespostaListaBibliotecas {
83    /// List of matching libraries.
84    pub results: Vec<LibrarySearchResult>,
85}
86
87/// Top-level response from the documentation endpoint (`GET /api/v1/{library_id}`).
88#[derive(Debug, Deserialize, Serialize)]
89pub struct RespostaDocumentacao {
90    /// Structured documentation snippets (JSON mode).
91    pub snippets: Option<Vec<DocumentationSnippet>>,
92}
93
94// ─── CLIENTE HTTP ─────────────────────────────────────────────────────────────
95
96/// Creates a reusable HTTP client with rustls-TLS, 30 s timeout, and HTTP/2.
97///
98/// The client should be created once per invocation and shared via `Arc`.
99pub 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
111// ─── RETRY COM ROTAÇÃO DE CHAVES ──────────────────────────────────────────────
112
113/// Executes an API call with retry and key rotation.
114///
115/// Shuffles a local copy of the provided keys (random draw without replacement)
116/// and retries up to `min(keys.len(), 5)` times with exponential backoff:
117/// 500 ms → 1 s → 2 s.
118///
119/// Short-circuits immediately on parse errors (status 200 but JSON failed) —
120/// retrying with another key would not help in that case.
121///
122/// The closure receives an owned `String` (clone of the key) to satisfy the
123/// `async move` ownership requirement inside the future.
124pub 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    // Shuffles a local copy — avoids modifying the caller's vec
132    let mut chaves_embaralhadas = chaves.to_vec();
133    // Fisher-Yates shuffle com fastrand — substitui rand::SliceRandom
134    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                // 400 is not transient — abort immediately
154                bail!(ErroContext7::ApiRetornou400 { mensagem });
155            }
156
157            Err(ErroContext7::BibliotecaNaoEncontrada { library_id }) => {
158                // Library doesn't exist — no point trying other keys
159                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                // Parse failure on HTTP 200 — schema mismatch, not a key issue
169                // Short-circuit: no point trying other keys
170                bail!(ErroContext7::RespostaInvalida { status: 200 });
171            }
172
173            Err(e) => {
174                warn!("Falha na tentativa {}: {}", tentativa + 1, e);
175
176                // Backoff before next attempt (not on the last one)
177                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
198// ─── CHAMADAS À API ───────────────────────────────────────────────────────────
199
200/// Searches for libraries matching `nome` with optional relevance `query_contexto`.
201///
202/// Returns `Err(ErroContext7)` on HTTP errors to enable retry in `executar_com_retry`.
203pub 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
225/// Fetches documentation for `library_id` with an optional `query` filter (JSON mode).
226///
227/// Always requests `type=json`. Use [`buscar_documentacao_texto`] for plain-text output.
228/// Returns `Err(ErroContext7)` on HTTP errors to enable retry in `executar_com_retry`.
229pub async fn buscar_documentacao(
230    cliente: &reqwest::Client,
231    chave: &str,
232    library_id: &str,
233    query: Option<&str>,
234) -> Result<RespostaDocumentacao, ErroContext7> {
235    // Normalise library_id: strip leading slash if present
236    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
261/// Fetches documentation for `library_id` as raw plain text (markdown).
262///
263/// Uses `type=txt`. Returns the raw response body as a `String`.
264/// Returns `Err(ErroContext7)` on HTTP errors to enable retry in `executar_com_retry`.
265pub 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
323/// Maps HTTP status codes to typed `ErroContext7` variants or deserialises success bodies.
324async 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// ─── TESTES ───────────────────────────────────────────────────────────────────
371
372#[cfg(test)]
373mod testes {
374    use super::*;
375
376    // ── Desserialização de structs ────────────────────────────────────────────
377
378    #[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    // ── Mock HTTP ─────────────────────────────────────────────────────────────
492
493    #[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    // ── Shuffle de chaves ─────────────────────────────────────────────────────
592
593    #[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        // Fisher-Yates shuffle com fastrand — substitui rand::SliceRandom
600        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        // Verifica que chaves.len().min(5) funciona corretamente
624        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}