1use serde::{Deserialize, Serialize};
9
10use crate::scoring::QualityIssue;
11
12#[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#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct AnalyzeRequest {
35 pub content: String,
37 pub document_type: String,
39 pub context: Option<String>,
41 pub task: String,
43}
44
45#[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#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct Recommendation {
56 pub title: String,
57 pub description: String,
58 pub priority: String,
60 pub category: String,
62}
63
64#[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#[derive(Debug, Clone, Deserialize)]
75struct LicenseVerifyResponse {
76 valid: bool,
77 #[serde(default)]
78 #[allow(dead_code)]
79 message: Option<String>,
80}
81
82pub struct CloudClient {
84 base_url: String,
85 license_key: Option<String>,
86 device_id: String,
87 http: reqwest::Client,
88}
89
90impl CloudClient {
91 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 #[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 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 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 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}