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(&self, library_name: &str) -> Result<(String, String)> {
102        // Always use MCP tools/call format for now
103        let request = JsonRpcRequest {
104            jsonrpc: "2.0".to_string(),
105            method: "tools/call".to_string(),
106            params: json!({
107                "name": "resolve-library-id",
108                "arguments": {
109                    "libraryName": library_name
110                }
111            }),
112            id: 1,
113        };
114
115        let response = self.send_request(request).await?;
116
117        if let Some(error) = response.error {
118            anyhow::bail!("API error: {} (code: {})", error.message, error.code);
119        }
120
121        let result = response.result.context("No result in response")?;
122
123        // Extract the library ID from the response text
124        let content = result
125            .get("content")
126            .and_then(|c| c.as_array())
127            .and_then(|arr| arr.first())
128            .and_then(|item| item.get("text"))
129            .and_then(|text| text.as_str())
130            .context("Failed to extract content from response")?;
131
132        // Parse the response following Context7's selection criteria:
133        // 1. First result is pre-ranked by Context7 (prioritize it)
134        // 2. For ties, prefer exact name matches
135        // 3. Secondary: higher snippet count, trust score 7-10
136        let lines: Vec<&str> = content.lines().collect();
137        let mut libraries = Vec::new();
138
139        // Parse all libraries from response
140        let mut current_lib: Option<(String, String, f64, u32)> = None; // (id, title, trust_score, snippets)
141
142        for line in &lines {
143            // Look for library title (first line of each library block)
144            if let Some(stripped) = line.strip_prefix("- Title: ") {
145                let title = stripped.trim().to_string();
146                current_lib = Some((String::new(), title, 0.0, 0));
147            }
148            // Look for library ID
149            else if line.contains("Context7-compatible library ID:") {
150                if let Some((_, title, trust, snippets)) = current_lib.as_mut() {
151                    if let Some(start) = line.find('/') {
152                        let id_part = &line[start..];
153                        let end = id_part.find(char::is_whitespace).unwrap_or(id_part.len());
154                        *title = title.clone(); // Keep title
155                        libraries.push((
156                            id_part[..end].trim().to_string(),
157                            title.clone(),
158                            *trust,
159                            *snippets,
160                        ));
161                    }
162                }
163            }
164            // Look for code snippets count
165            else if line.contains("Code Snippets:") {
166                if let Some((_, _, _, snippets)) = current_lib.as_mut() {
167                    if let Some(count_str) = line.split("Code Snippets:").nth(1) {
168                        if let Ok(count) = count_str.trim().parse::<u32>() {
169                            *snippets = count;
170                        }
171                    }
172                }
173            }
174            // Look for trust score
175            else if line.contains("Trust Score:") {
176                if let Some((_, _, trust, _)) = current_lib.as_mut() {
177                    if let Some(score_str) = line.split("Trust Score:").nth(1) {
178                        if let Ok(score) = score_str.trim().parse::<f64>() {
179                            *trust = score;
180                        }
181                    }
182                }
183            }
184        }
185
186        log::debug!(
187            "Found {} library candidates for '{}'",
188            libraries.len(),
189            library_name
190        );
191        for (i, (id, title, trust, snippets)) in libraries.iter().enumerate() {
192            log::debug!(
193                "  {}: {} ({}) - Trust: {}, Snippets: {}",
194                i + 1,
195                title,
196                id,
197                trust,
198                snippets
199            );
200        }
201
202        // Apply Context7 selection criteria to find the best match
203        let selected_library = libraries.iter().enumerate().max_by_key(
204            |(index, (_id, title, trust_score, snippet_count))| {
205                let mut score = 0;
206
207                // 1. First result gets highest priority (Context7 pre-ranks)
208                score += (1000 - index) * 100;
209
210                // 2. Exact name match gets bonus
211                if title.to_lowercase() == library_name.to_lowercase() {
212                    score += 500;
213                }
214
215                // 3. Partial name match gets smaller bonus
216                if title.to_lowercase().contains(&library_name.to_lowercase()) {
217                    score += 200;
218                }
219
220                // 4. Trust score 7-10 gets bonus
221                if *trust_score >= 7.0 {
222                    score += (*trust_score * 10.0) as usize;
223                }
224
225                // 5. Higher snippet count indicates better documentation
226                score += (*snippet_count as usize).min(100);
227
228                log::debug!(
229                    "Library '{}' score: {} (index: {}, trust: {}, snippets: {})",
230                    title,
231                    score,
232                    index,
233                    trust_score,
234                    snippet_count
235                );
236
237                score
238            },
239        );
240
241        if let Some((index, (library_id, title, trust_score, snippet_count))) = selected_library {
242            log::debug!(
243                "Selected library: '{}' ({}), Trust: {}, Snippets: {}, Position: {}",
244                title,
245                library_id,
246                trust_score,
247                snippet_count,
248                index + 1
249            );
250            Ok((library_id.clone(), title.clone()))
251        } else {
252            // Extract available library names for suggestions
253            let available_libraries: Vec<String> = lines
254                .iter()
255                .filter_map(|line| {
256                    if line.contains("- Title: ") {
257                        Some(line.replace("- Title: ", "").trim().to_string())
258                    } else {
259                        None
260                    }
261                })
262                .collect();
263
264            if !available_libraries.is_empty() {
265                let suggestions =
266                    crate::search::fuzzy_find_libraries(library_name, &available_libraries);
267                if !suggestions.is_empty() {
268                    let suggestion_text: Vec<String> =
269                        suggestions.iter().map(|(name, _)| name.clone()).collect();
270                    anyhow::bail!(
271                        "Library '{}' not found. Did you mean one of: {}?",
272                        library_name,
273                        suggestion_text.join(", ")
274                    );
275                }
276            }
277
278            anyhow::bail!(
279                "No library ID found in response for '{}': {}",
280                library_name,
281                content
282            );
283        }
284    }
285
286    pub async fn get_documentation(&self, library_id: &str, topic: Option<&str>) -> Result<String> {
287        let mut params = json!({
288            "context7CompatibleLibraryID": library_id
289        });
290
291        if let Some(topic_str) = topic {
292            params["topic"] = json!(topic_str);
293        }
294
295        // Always use MCP tools/call format for now
296        let request = JsonRpcRequest {
297            jsonrpc: "2.0".to_string(),
298            method: "tools/call".to_string(),
299            params: json!({
300                "name": "get-library-docs",
301                "arguments": params
302            }),
303            id: 2,
304        };
305
306        let response = self.send_request(request).await?;
307
308        if let Some(error) = response.error {
309            anyhow::bail!("API error: {} (code: {})", error.message, error.code);
310        }
311
312        let result = response.result.context("No result in response")?;
313
314        // Extract the documentation text from the response
315        let content = result
316            .get("content")
317            .and_then(|c| c.as_array())
318            .and_then(|arr| arr.first())
319            .and_then(|item| item.get("text"))
320            .and_then(|text| text.as_str())
321            .context("Failed to extract documentation from response")?;
322
323        Ok(content.to_string())
324    }
325
326    async fn send_request(&self, request: JsonRpcRequest) -> Result<JsonRpcResponse> {
327        let base_url = self.get_base_url();
328        let mut req = self
329            .client
330            .post(base_url)
331            .header("Accept", "application/json, text/event-stream")
332            .header("Content-Type", "application/json")
333            .json(&request);
334
335        if let Some(key) = &self.api_key {
336            req = req.header("CONTEXT7_API_KEY", key);
337        }
338
339        let response = req
340            .send()
341            .await
342            .context("Failed to send request to Context7")?;
343
344        if !response.status().is_success() {
345            let status = response.status();
346            let error_text = response
347                .text()
348                .await
349                .unwrap_or_else(|_| "Unknown error".to_string());
350            anyhow::bail!("HTTP {} error: {}", status, error_text);
351        }
352
353        let content_type = response
354            .headers()
355            .get("content-type")
356            .and_then(|v| v.to_str().ok())
357            .unwrap_or("");
358
359        // Handle different response types
360        if content_type.contains("text/event-stream") {
361            // Handle SSE response
362            let text = response.text().await?;
363            log::debug!("SSE Response: {}", text);
364
365            // Parse SSE to get the JSON data
366            if let Some(json_line) = text.lines().find(|line| line.starts_with("data: ")) {
367                let json_data = &json_line[6..]; // Remove "data: " prefix
368                serde_json::from_str(json_data).context("Failed to parse SSE JSON data")
369            } else {
370                anyhow::bail!("No JSON data found in SSE response");
371            }
372        } else {
373            // Regular JSON response
374            response
375                .json::<JsonRpcResponse>()
376                .await
377                .context("Failed to parse JSON-RPC response")
378        }
379    }
380}