Skip to main content

kardo_core/pro/
cloud.rs

1//! Cloud API client for Kardo proxy server.
2//!
3//! Communicates with `api.kardo.app` for:
4//! - License verification
5//! - AI-powered document analysis (via Claude)
6//! - Fix suggestions
7
8use serde::{Deserialize, Serialize};
9
10use crate::scoring::QualityIssue;
11
12/// Typed errors for cloud API operations.
13#[derive(Debug, thiserror::Error)]
14pub enum CloudError {
15    #[error("HTTP error: {0}")]
16    Http(#[from] reqwest::Error),
17    #[error("API error: {0}")]
18    Api(String),
19    #[error("Authentication failed: {0}")]
20    Auth(String),
21}
22
23impl From<CloudError> for String {
24    fn from(e: CloudError) -> Self {
25        e.to_string()
26    }
27}
28
29const DEFAULT_BASE_URL: &str = "https://api.kardo.app";
30const CLIENT_VERSION: &str = env!("CARGO_PKG_VERSION");
31
32/// Request body for AI analysis.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct AnalyzeRequest {
35    /// Anonymized content to analyze.
36    pub content: String,
37    /// Type of document (e.g. "readme", "claude_md", "prd").
38    pub document_type: String,
39    /// Additional context for the AI.
40    pub context: Option<String>,
41    /// Task type: "recommend", "fix", or "classify".
42    pub task: String,
43}
44
45/// Response from AI analysis.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct AnalyzeResponse {
48    pub recommendations: Vec<Recommendation>,
49    pub fix_suggestions: Vec<FixSuggestion>,
50    pub model_used: String,
51}
52
53/// A recommendation from the AI analysis.
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct Recommendation {
56    pub title: String,
57    pub description: String,
58    /// Priority: "high", "medium", or "low".
59    pub priority: String,
60    /// Category: "structure", "content", "completeness", etc.
61    pub category: String,
62}
63
64/// A fix suggestion for a specific quality issue.
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct FixSuggestion {
67    pub issue_id: String,
68    pub suggestion: String,
69    pub confidence: f64,
70    pub code_snippet: Option<String>,
71}
72
73/// License verification response from the server.
74#[derive(Debug, Clone, Deserialize)]
75struct LicenseVerifyResponse {
76    valid: bool,
77    #[serde(default)]
78    #[allow(dead_code)]
79    message: Option<String>,
80}
81
82/// Cloud API client.
83pub struct CloudClient {
84    base_url: String,
85    license_key: Option<String>,
86    device_id: String,
87    http: reqwest::Client,
88}
89
90impl CloudClient {
91    /// Create a new cloud client.
92    ///
93    /// `base_url` defaults to `https://api.kardo.app` if empty.
94    pub fn new(base_url: &str, license_key: Option<String>) -> Self {
95        let url = if base_url.is_empty() {
96            DEFAULT_BASE_URL.to_string()
97        } else {
98            base_url.to_string()
99        };
100
101        let device_id = crate::pro::ProManager::device_fingerprint();
102
103        let http = reqwest::Client::builder()
104            .timeout(std::time::Duration::from_secs(30))
105            .build()
106            .unwrap_or_default();
107
108        Self {
109            base_url: url,
110            license_key,
111            device_id,
112            http,
113        }
114    }
115
116    /// Create a cloud client with a specific device ID (useful for testing).
117    #[cfg(test)]
118    pub fn with_device_id(base_url: &str, license_key: Option<String>, device_id: String) -> Self {
119        let url = if base_url.is_empty() {
120            DEFAULT_BASE_URL.to_string()
121        } else {
122            base_url.to_string()
123        };
124
125        let http = reqwest::Client::builder()
126            .timeout(std::time::Duration::from_secs(30))
127            .build()
128            .unwrap_or_default();
129
130        Self {
131            base_url: url,
132            license_key,
133            device_id,
134            http,
135        }
136    }
137
138    /// Verify the license key with the proxy server.
139    ///
140    /// Returns `Ok(true)` if valid, `Ok(false)` if invalid,
141    /// or `Err` if the server is unreachable.
142    pub async fn verify_license(&self) -> Result<bool, CloudError> {
143        let key = self
144            .license_key
145            .as_ref()
146            .ok_or_else(|| CloudError::Auth("No license key configured".to_string()))?;
147
148        let url = format!("{}/api/verify-license", self.base_url);
149
150        #[derive(Serialize)]
151        struct VerifyRequest<'a> {
152            license_key: &'a str,
153            device_id: &'a str,
154            client_version: &'a str,
155        }
156
157        let resp = self
158            .http
159            .post(&url)
160            .json(&VerifyRequest {
161                license_key: key,
162                device_id: &self.device_id,
163                client_version: CLIENT_VERSION,
164            })
165            .send()
166            .await?;
167
168        if !resp.status().is_success() {
169            return Err(CloudError::Api(format!(
170                "License verification failed: server returned {}",
171                resp.status()
172            )));
173        }
174
175        let body: LicenseVerifyResponse = resp
176            .json()
177            .await
178            .map_err(|e| CloudError::Api(format!("Failed to parse verification response: {}", e)))?;
179
180        Ok(body.valid)
181    }
182
183    /// Send anonymized content for AI analysis.
184    ///
185    /// The content should already be anonymized before calling this method.
186    pub async fn analyze(&self, request: AnalyzeRequest) -> Result<AnalyzeResponse, CloudError> {
187        let key = self
188            .license_key
189            .as_ref()
190            .ok_or_else(|| CloudError::Auth("No license key configured".to_string()))?;
191
192        let url = format!("{}/api/analyze", self.base_url);
193
194        #[derive(Serialize)]
195        struct ApiAnalyzeRequest<'a> {
196            content: &'a str,
197            document_type: &'a str,
198            context: Option<&'a str>,
199            task: &'a str,
200            device_id: &'a str,
201            client_version: &'a str,
202        }
203
204        let resp = self
205            .http
206            .post(&url)
207            .header("Authorization", format!("Bearer {}", key))
208            .json(&ApiAnalyzeRequest {
209                content: &request.content,
210                document_type: &request.document_type,
211                context: request.context.as_deref(),
212                task: &request.task,
213                device_id: &self.device_id,
214                client_version: CLIENT_VERSION,
215            })
216            .send()
217            .await?;
218
219        if !resp.status().is_success() {
220            let status = resp.status();
221            let body = resp.text().await.unwrap_or_default();
222            return Err(CloudError::Api(format!(
223                "Analysis request failed (HTTP {}): {}",
224                status, body
225            )));
226        }
227
228        resp.json::<AnalyzeResponse>()
229            .await
230            .map_err(|e| CloudError::Api(format!("Failed to parse analysis response: {}", e)))
231    }
232
233    /// Get AI fix suggestions for specific quality issues.
234    ///
235    /// Uses the `/api/analyze` endpoint with `task="fix"`.
236    pub async fn get_fix_suggestions(
237        &self,
238        issues: Vec<QualityIssue>,
239        context: &str,
240    ) -> Result<Vec<FixSuggestion>, CloudError> {
241        let key = self
242            .license_key
243            .as_ref()
244            .ok_or_else(|| CloudError::Auth("No license key configured".to_string()))?;
245
246        let url = format!("{}/api/analyze", self.base_url);
247
248        #[derive(Serialize)]
249        struct FixRequest<'a> {
250            issues: &'a [QualityIssue],
251            context: &'a str,
252            task: &'a str,
253            device_id: &'a str,
254            client_version: &'a str,
255        }
256
257        let resp = self
258            .http
259            .post(&url)
260            .header("Authorization", format!("Bearer {}", key))
261            .json(&FixRequest {
262                issues: &issues,
263                context,
264                task: "fix",
265                device_id: &self.device_id,
266                client_version: CLIENT_VERSION,
267            })
268            .send()
269            .await?;
270
271        if !resp.status().is_success() {
272            let status = resp.status();
273            let body = resp.text().await.unwrap_or_default();
274            return Err(CloudError::Api(format!(
275                "Fix suggestions request failed (HTTP {}): {}",
276                status, body
277            )));
278        }
279
280        #[derive(Deserialize)]
281        struct FixResponse {
282            suggestions: Vec<FixSuggestion>,
283        }
284
285        let body: FixResponse = resp
286            .json()
287            .await
288            .map_err(|e| CloudError::Api(format!("Failed to parse fix suggestions response: {}", e)))?;
289
290        Ok(body.suggestions)
291    }
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    #[test]
299    fn test_cloud_client_default_url() {
300        let client = CloudClient::with_device_id("", None, "test-device".into());
301        assert_eq!(client.base_url, DEFAULT_BASE_URL);
302    }
303
304    #[test]
305    fn test_cloud_client_custom_url() {
306        let client =
307            CloudClient::with_device_id("https://custom.api.com", None, "test-device".into());
308        assert_eq!(client.base_url, "https://custom.api.com");
309    }
310
311    #[test]
312    fn test_analyze_request_serialization() {
313        let req = AnalyzeRequest {
314            content: "# README\nThis is a test".to_string(),
315            document_type: "readme".to_string(),
316            context: Some("Project analysis".to_string()),
317            task: "recommend".to_string(),
318        };
319        let json = serde_json::to_string(&req).unwrap();
320        assert!(json.contains("readme"));
321        assert!(json.contains("Project analysis"));
322        assert!(json.contains("recommend"));
323    }
324
325    #[test]
326    fn test_analyze_response_deserialization() {
327        let json = serde_json::json!({
328            "recommendations": [{
329                "title": "Add installation section",
330                "description": "Your README lacks installation instructions",
331                "priority": "high",
332                "category": "completeness"
333            }],
334            "fix_suggestions": [{
335                "issue_id": "readme-001",
336                "suggestion": "Add a ## Installation section",
337                "confidence": 0.92,
338                "code_snippet": "## Installation\n\n```bash\nnpm install\n```"
339            }],
340            "model_used": "claude-3-5-sonnet"
341        });
342
343        let resp: AnalyzeResponse = serde_json::from_value(json).unwrap();
344        assert_eq!(resp.recommendations.len(), 1);
345        assert_eq!(resp.recommendations[0].priority, "high");
346        assert_eq!(resp.fix_suggestions.len(), 1);
347        assert!((resp.fix_suggestions[0].confidence - 0.92).abs() < f64::EPSILON);
348        assert_eq!(resp.model_used, "claude-3-5-sonnet");
349    }
350
351    #[test]
352    fn test_fix_suggestion_deserialization() {
353        let json = r#"{
354            "issue_id": "stale-001",
355            "suggestion": "Update the changelog",
356            "confidence": 0.85,
357            "code_snippet": null
358        }"#;
359        let fix: FixSuggestion = serde_json::from_str(json).unwrap();
360        assert_eq!(fix.issue_id, "stale-001");
361        assert!(fix.code_snippet.is_none());
362    }
363
364    #[tokio::test]
365    async fn test_verify_license_no_key() {
366        let client =
367            CloudClient::with_device_id("https://localhost:9999", None, "test-device".into());
368        let result = client.verify_license().await;
369        assert!(result.is_err());
370        let err = result.unwrap_err();
371        assert!(
372            matches!(err, CloudError::Auth(_)),
373            "Expected Auth error, got: {err}"
374        );
375        assert!(err.to_string().contains("No license key"));
376    }
377
378    #[tokio::test]
379    async fn test_verify_license_connection_error() {
380        let client = CloudClient::with_device_id(
381            "https://localhost:9999",
382            Some("VV-TEST-1234-5678-ABCD".to_string()),
383            "test-device".into(),
384        );
385        let result = client.verify_license().await;
386        assert!(result.is_err());
387        assert!(matches!(result.unwrap_err(), CloudError::Http(_)));
388    }
389
390    #[tokio::test]
391    async fn test_analyze_connection_error() {
392        let client = CloudClient::with_device_id(
393            "https://localhost:9999",
394            Some("VV-TEST-1234-5678-ABCD".to_string()),
395            "test-device".into(),
396        );
397        let result = client
398            .analyze(AnalyzeRequest {
399                content: "test".to_string(),
400                document_type: "readme".to_string(),
401                context: None,
402                task: "recommend".to_string(),
403            })
404            .await;
405        assert!(result.is_err());
406        assert!(matches!(result.unwrap_err(), CloudError::Http(_)));
407    }
408
409    #[tokio::test]
410    async fn test_get_fix_suggestions_connection_error() {
411        let client = CloudClient::with_device_id(
412            "https://localhost:9999",
413            Some("VV-TEST-1234-5678-ABCD".to_string()),
414            "test-device".into(),
415        );
416        let result = client.get_fix_suggestions(vec![], "context").await;
417        assert!(result.is_err());
418    }
419}