Skip to main content

manx_cli/
client.rs

1use anyhow::{Context, Result};
2use reqwest::Client;
3use serde::{Deserialize, Serialize};
4use serde_json::json;
5use std::time::Duration;
6
7const CONTEXT7_MCP_URL: &str = "https://mcp.context7.com/mcp";
8const REQUEST_TIMEOUT: u64 = 30;
9
10#[derive(Debug, Clone)]
11pub struct Context7Client {
12    client: Client,
13    api_key: Option<String>,
14}
15
16#[derive(Debug, Serialize)]
17struct JsonRpcRequest {
18    jsonrpc: String,
19    method: String,
20    params: serde_json::Value,
21    id: u64,
22}
23
24#[derive(Debug, Deserialize)]
25struct JsonRpcResponse {
26    #[allow(dead_code)]
27    jsonrpc: String,
28    #[allow(dead_code)]
29    id: u64,
30    #[serde(skip_serializing_if = "Option::is_none")]
31    result: Option<serde_json::Value>,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    error: Option<JsonRpcError>,
34}
35
36#[derive(Debug, Deserialize)]
37struct JsonRpcError {
38    code: i32,
39    message: String,
40    #[allow(dead_code)]
41    #[serde(skip_serializing_if = "Option::is_none")]
42    data: Option<serde_json::Value>,
43}
44
45#[derive(Debug, Deserialize, Serialize)]
46pub struct LibraryInfo {
47    pub id: String,
48    pub name: String,
49    pub version: Option<String>,
50    pub description: Option<String>,
51}
52
53#[derive(Debug, Deserialize, Serialize)]
54pub struct Documentation {
55    pub library: LibraryInfo,
56    pub sections: Vec<DocSection>,
57}
58
59#[derive(Debug, Deserialize, Serialize)]
60pub struct DocSection {
61    pub id: String,
62    pub title: String,
63    pub content: String,
64    pub code_examples: Vec<CodeExample>,
65    pub url: Option<String>,
66}
67
68#[derive(Debug, Deserialize, Serialize)]
69pub struct CodeExample {
70    pub language: String,
71    pub code: String,
72    pub description: Option<String>,
73}
74
75#[derive(Debug, Clone, Deserialize, Serialize)]
76pub struct SearchResult {
77    pub id: String,
78    pub library: String,
79    pub title: String,
80    pub excerpt: String,
81    pub url: Option<String>,
82    pub relevance_score: f32,
83}
84
85impl Context7Client {
86    pub fn new(api_key: Option<String>) -> Result<Self> {
87        let client = Client::builder()
88            .timeout(Duration::from_secs(REQUEST_TIMEOUT))
89            .user_agent(format!("manx/{}", env!("CARGO_PKG_VERSION")))
90            .build()
91            .context("Failed to create HTTP client")?;
92
93        Ok(Self { client, api_key })
94    }
95
96    fn get_base_url(&self) -> &str {
97        // For now, always use MCP URL until we confirm the correct API endpoint
98        CONTEXT7_MCP_URL
99    }
100
101    pub async fn resolve_library(
102        &self,
103        library_name: &str,
104        query: &str,
105    ) -> Result<(String, String)> {
106        // Always use MCP tools/call format for now
107        let request = JsonRpcRequest {
108            jsonrpc: "2.0".to_string(),
109            method: "tools/call".to_string(),
110            params: json!({
111                "name": "resolve-library-id",
112                "arguments": {
113                    "libraryName": library_name,
114                    "query": query
115                }
116            }),
117            id: 1,
118        };
119
120        let response = self.send_request(request).await?;
121
122        if let Some(error) = response.error {
123            anyhow::bail!("API error: {} (code: {})", error.message, error.code);
124        }
125
126        let result = response.result.context("No result in response")?;
127
128        // Extract the library ID from the response text
129        let content = result
130            .get("content")
131            .and_then(|c| c.as_array())
132            .and_then(|arr| arr.first())
133            .and_then(|item| item.get("text"))
134            .and_then(|text| text.as_str())
135            .context("Failed to extract content from response")?;
136
137        // Parse the response following Context7's selection criteria:
138        // 1. First result is pre-ranked by Context7 (prioritize it)
139        // 2. For ties, prefer exact name matches
140        // 3. Secondary: higher snippet count, benchmark score
141        let lines: Vec<&str> = content.lines().collect();
142        let mut libraries = Vec::new();
143
144        // Parse all libraries from response - collect all fields before pushing
145        let mut current_id = String::new();
146        let mut current_title = String::new();
147        let mut current_score: f64 = 0.0;
148        let mut current_snippets: u32 = 0;
149        let mut has_current = false;
150
151        for line in &lines {
152            // Look for library title (start of a new library block)
153            if let Some(stripped) = line.strip_prefix("- Title: ") {
154                // Push previous library if we have one
155                if has_current && !current_id.is_empty() {
156                    libraries.push((
157                        current_id.clone(),
158                        current_title.clone(),
159                        current_score,
160                        current_snippets,
161                    ));
162                }
163                current_title = stripped.trim().to_string();
164                current_id = String::new();
165                current_score = 0.0;
166                current_snippets = 0;
167                has_current = true;
168            }
169            // Look for library ID
170            else if has_current && line.contains("Context7-compatible library ID:") {
171                if let Some(start) = line.find('/') {
172                    let id_part = &line[start..];
173                    let end = id_part.find(char::is_whitespace).unwrap_or(id_part.len());
174                    current_id = id_part[..end].trim().to_string();
175                }
176            }
177            // Look for code snippets count
178            else if has_current && line.contains("Code Snippets:") {
179                if let Some(count_str) = line.split("Code Snippets:").nth(1) {
180                    if let Ok(count) = count_str.trim().parse::<u32>() {
181                        current_snippets = count;
182                    }
183                }
184            }
185            // Look for benchmark score (replaces old Trust Score)
186            else if has_current && line.contains("Benchmark Score:") {
187                if let Some(score_str) = line.split("Benchmark Score:").nth(1) {
188                    if let Ok(score) = score_str.trim().parse::<f64>() {
189                        current_score = score;
190                    }
191                }
192            }
193        }
194        // Push the last library
195        if has_current && !current_id.is_empty() {
196            libraries.push((current_id, current_title, current_score, current_snippets));
197        }
198
199        log::debug!(
200            "Found {} library candidates for '{}'",
201            libraries.len(),
202            library_name
203        );
204        for (i, (id, title, benchmark, snippets)) in libraries.iter().enumerate() {
205            log::debug!(
206                "  {}: {} ({}) - Benchmark: {}, Snippets: {}",
207                i + 1,
208                title,
209                id,
210                benchmark,
211                snippets
212            );
213        }
214
215        // Apply Context7 selection criteria to find the best match
216        let selected_library = libraries.iter().enumerate().max_by_key(
217            |(index, (_id, title, benchmark_score, snippet_count))| {
218                let mut score = 0;
219
220                // 1. First result gets highest priority (Context7 pre-ranks)
221                score += (1000 - index) * 100;
222
223                // 2. Exact name match gets bonus
224                if title.to_lowercase() == library_name.to_lowercase() {
225                    score += 500;
226                }
227
228                // 3. Partial name match gets smaller bonus
229                if title.to_lowercase().contains(&library_name.to_lowercase()) {
230                    score += 200;
231                }
232
233                // 4. Higher benchmark score (0-100) gets bonus
234                score += *benchmark_score as usize;
235
236                // 5. Higher snippet count indicates better documentation
237                score += (*snippet_count as usize).min(100);
238
239                log::debug!(
240                    "Library '{}' score: {} (index: {}, benchmark: {}, snippets: {})",
241                    title,
242                    score,
243                    index,
244                    benchmark_score,
245                    snippet_count
246                );
247
248                score
249            },
250        );
251
252        if let Some((index, (library_id, title, benchmark_score, snippet_count))) = selected_library
253        {
254            log::debug!(
255                "Selected library: '{}' ({}), Benchmark: {}, Snippets: {}, Position: {}",
256                title,
257                library_id,
258                benchmark_score,
259                snippet_count,
260                index + 1
261            );
262            Ok((library_id.clone(), title.clone()))
263        } else {
264            // Extract available library names for suggestions
265            let available_libraries: Vec<String> = lines
266                .iter()
267                .filter_map(|line| {
268                    if line.contains("- Title: ") {
269                        Some(line.replace("- Title: ", "").trim().to_string())
270                    } else {
271                        None
272                    }
273                })
274                .collect();
275
276            if !available_libraries.is_empty() {
277                let suggestions =
278                    crate::search::fuzzy_find_libraries(library_name, &available_libraries);
279                if !suggestions.is_empty() {
280                    let suggestion_text: Vec<String> =
281                        suggestions.iter().map(|(name, _)| name.clone()).collect();
282                    anyhow::bail!(
283                        "Library '{}' not found. Did you mean one of: {}?",
284                        library_name,
285                        suggestion_text.join(", ")
286                    );
287                }
288            }
289
290            anyhow::bail!(
291                "No library ID found in response for '{}': {}",
292                library_name,
293                content
294            );
295        }
296    }
297
298    pub async fn get_documentation(&self, library_id: &str, topic: Option<&str>) -> Result<String> {
299        let mut params = json!({
300            "libraryId": library_id
301        });
302
303        params["query"] = json!(topic.unwrap_or_default());
304
305        // Always use MCP tools/call format for now
306        let request = JsonRpcRequest {
307            jsonrpc: "2.0".to_string(),
308            method: "tools/call".to_string(),
309            params: json!({
310                "name": "query-docs",
311                "arguments": params
312            }),
313            id: 2,
314        };
315
316        let response = self.send_request(request).await?;
317
318        if let Some(error) = response.error {
319            anyhow::bail!("API error: {} (code: {})", error.message, error.code);
320        }
321
322        let result = response.result.context("No result in response")?;
323
324        // Extract the documentation text from the response
325        let content = result
326            .get("content")
327            .and_then(|c| c.as_array())
328            .and_then(|arr| arr.first())
329            .and_then(|item| item.get("text"))
330            .and_then(|text| text.as_str())
331            .context("Failed to extract documentation from response")?;
332
333        log::debug!("{:?}", content);
334        Ok(content.to_string())
335    }
336
337    async fn send_request(&self, request: JsonRpcRequest) -> Result<JsonRpcResponse> {
338        let base_url = self.get_base_url();
339        let mut req = self
340            .client
341            .post(base_url)
342            .header("Accept", "application/json, text/event-stream")
343            .header("Content-Type", "application/json")
344            .json(&request);
345
346        if let Some(key) = &self.api_key {
347            req = req.header("CONTEXT7_API_KEY", key);
348        }
349
350        let response = req
351            .send()
352            .await
353            .context("Failed to send request to Context7")?;
354
355        if !response.status().is_success() {
356            let status = response.status();
357            let error_text = response
358                .text()
359                .await
360                .unwrap_or_else(|_| "Unknown error".to_string());
361            anyhow::bail!("HTTP {} error: {}", status, error_text);
362        }
363
364        let content_type = response
365            .headers()
366            .get("content-type")
367            .and_then(|v| v.to_str().ok())
368            .unwrap_or("");
369
370        // Handle different response types
371        if content_type.contains("text/event-stream") {
372            // Handle SSE response
373            let text = response.text().await?;
374            log::debug!("SSE Response: {}", text);
375
376            // Parse SSE to get the JSON data
377            if let Some(json_line) = text.lines().find(|line| line.starts_with("data: ")) {
378                let json_data = &json_line[6..]; // Remove "data: " prefix
379                serde_json::from_str(json_data).context("Failed to parse SSE JSON data")
380            } else {
381                anyhow::bail!("No JSON data found in SSE response");
382            }
383        } else {
384            // Regular JSON response
385            response
386                .json::<JsonRpcResponse>()
387                .await
388                .context("Failed to parse JSON-RPC response")
389        }
390    }
391}