Skip to main content

lean_ctx/
cloud_client.rs

1use std::path::PathBuf;
2
3fn config_dir() -> PathBuf {
4    let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
5    home.join(".lean-ctx").join("cloud")
6}
7
8fn credentials_path() -> PathBuf {
9    config_dir().join("credentials.json")
10}
11
12pub fn api_url() -> String {
13    std::env::var("LEAN_CTX_API_URL").unwrap_or_else(|_| "https://api.leanctx.com".to_string())
14}
15
16#[derive(serde::Serialize, serde::Deserialize)]
17struct Credentials {
18    api_key: String,
19    user_id: String,
20    email: String,
21}
22
23pub fn save_credentials(api_key: &str, user_id: &str, email: &str) -> std::io::Result<()> {
24    let dir = config_dir();
25    std::fs::create_dir_all(&dir)?;
26    let creds = Credentials {
27        api_key: api_key.to_string(),
28        user_id: user_id.to_string(),
29        email: email.to_string(),
30    };
31    let json = serde_json::to_string_pretty(&creds).map_err(std::io::Error::other)?;
32    std::fs::write(credentials_path(), json)
33}
34
35pub fn load_api_key() -> Option<String> {
36    let data = std::fs::read_to_string(credentials_path()).ok()?;
37    let creds: Credentials = serde_json::from_str(&data).ok()?;
38    Some(creds.api_key)
39}
40
41pub fn is_logged_in() -> bool {
42    load_api_key().is_some()
43}
44
45pub struct RegisterResult {
46    pub api_key: String,
47    pub user_id: String,
48    pub email_verified: bool,
49    pub verification_sent: bool,
50}
51
52pub fn register(email: &str, password: Option<&str>) -> Result<RegisterResult, String> {
53    let url = format!("{}/api/auth/register", api_url());
54    let mut body = serde_json::json!({ "email": email });
55    if let Some(pw) = password {
56        body["password"] = serde_json::Value::String(pw.to_string());
57    }
58
59    let resp = ureq::post(&url)
60        .header("Content-Type", "application/json")
61        .send(serde_json::to_vec(&body).unwrap().as_slice())
62        .map_err(|e| format!("Request failed: {e}"))?;
63
64    let resp_body = resp
65        .into_body()
66        .read_to_string()
67        .map_err(|e| format!("Failed to read response: {e}"))?;
68
69    let json: serde_json::Value =
70        serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
71
72    Ok(RegisterResult {
73        api_key: json["api_key"]
74            .as_str()
75            .ok_or("Missing api_key in response")?
76            .to_string(),
77        user_id: json["user_id"]
78            .as_str()
79            .ok_or("Missing user_id in response")?
80            .to_string(),
81        email_verified: json["email_verified"].as_bool().unwrap_or(false),
82        verification_sent: json["verification_sent"].as_bool().unwrap_or(false),
83    })
84}
85
86pub fn forgot_password(email: &str) -> Result<String, String> {
87    let url = format!("{}/api/auth/forgot-password", api_url());
88    let body = serde_json::json!({ "email": email });
89
90    let resp = ureq::post(&url)
91        .header("Content-Type", "application/json")
92        .send(serde_json::to_vec(&body).unwrap().as_slice())
93        .map_err(|e| format!("Request failed: {e}"))?;
94
95    let resp_body = resp
96        .into_body()
97        .read_to_string()
98        .map_err(|e| format!("Failed to read response: {e}"))?;
99
100    let json: serde_json::Value =
101        serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
102
103    Ok(json["message"]
104        .as_str()
105        .unwrap_or("If an account exists, a reset email has been sent.")
106        .to_string())
107}
108
109pub fn login(email: &str, password: &str) -> Result<RegisterResult, String> {
110    let url = format!("{}/api/auth/login", api_url());
111    let body = serde_json::json!({ "email": email, "password": password });
112
113    let resp = ureq::post(&url)
114        .header("Content-Type", "application/json")
115        .send(serde_json::to_vec(&body).unwrap().as_slice())
116        .map_err(|e| {
117            let msg = e.to_string();
118            if msg.contains("401") {
119                "Invalid email or password".to_string()
120            } else {
121                format!("Request failed: {e}")
122            }
123        })?;
124
125    let resp_body = resp
126        .into_body()
127        .read_to_string()
128        .map_err(|e| format!("Failed to read response: {e}"))?;
129
130    let json: serde_json::Value =
131        serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
132
133    Ok(RegisterResult {
134        api_key: json["api_key"]
135            .as_str()
136            .ok_or("Missing api_key in response")?
137            .to_string(),
138        user_id: json["user_id"]
139            .as_str()
140            .ok_or("Missing user_id in response")?
141            .to_string(),
142        email_verified: json["email_verified"].as_bool().unwrap_or(false),
143        verification_sent: false,
144    })
145}
146
147pub fn sync_stats(stats: &[serde_json::Value]) -> Result<String, String> {
148    let api_key = load_api_key().ok_or("Not logged in. Run: lean-ctx login")?;
149    let url = format!("{}/api/stats", api_url());
150
151    let body = serde_json::json!({ "stats": stats });
152
153    let resp = ureq::post(&url)
154        .header("Authorization", &format!("Bearer {api_key}"))
155        .header("Content-Type", "application/json")
156        .send(serde_json::to_vec(&body).unwrap().as_slice())
157        .map_err(|e| format!("Sync failed: {e}"))?;
158
159    let resp_body = resp
160        .into_body()
161        .read_to_string()
162        .map_err(|e| format!("Failed to read response: {e}"))?;
163
164    let json: serde_json::Value =
165        serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
166
167    Ok(json["message"].as_str().unwrap_or("Synced").to_string())
168}
169
170pub fn contribute(entries: &[serde_json::Value]) -> Result<String, String> {
171    let url = format!("{}/api/contribute", api_url());
172
173    let body = serde_json::json!({ "entries": entries });
174
175    let resp = ureq::post(&url)
176        .header("Content-Type", "application/json")
177        .send(serde_json::to_vec(&body).unwrap().as_slice())
178        .map_err(|e| format!("Contribute failed: {e}"))?;
179
180    let resp_body = resp
181        .into_body()
182        .read_to_string()
183        .map_err(|e| format!("Failed to read response: {e}"))?;
184
185    let json: serde_json::Value =
186        serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
187
188    Ok(json["message"]
189        .as_str()
190        .unwrap_or("Contributed")
191        .to_string())
192}
193
194pub fn push_knowledge(entries: &[serde_json::Value]) -> Result<String, String> {
195    let api_key = load_api_key().ok_or("Not logged in. Run: lean-ctx login")?;
196    let url = format!("{}/api/sync/knowledge", api_url());
197
198    let body = serde_json::json!({ "entries": entries });
199
200    let resp = ureq::post(&url)
201        .header("Authorization", &format!("Bearer {api_key}"))
202        .header("Content-Type", "application/json")
203        .send(serde_json::to_vec(&body).unwrap().as_slice())
204        .map_err(|e| format!("Push failed: {e}"))?;
205
206    let resp_body = resp
207        .into_body()
208        .read_to_string()
209        .map_err(|e| format!("Failed to read response: {e}"))?;
210
211    let json: serde_json::Value =
212        serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
213
214    Ok(format!(
215        "{} entries synced",
216        json["synced"].as_i64().unwrap_or(0)
217    ))
218}
219
220pub fn pull_cloud_models() -> Result<serde_json::Value, String> {
221    let api_key = load_api_key().ok_or("Not logged in. Run: lean-ctx login <email>")?;
222    let url = format!("{}/api/cloud/models", api_url());
223
224    let resp = ureq::get(&url)
225        .header("Authorization", &format!("Bearer {api_key}"))
226        .call()
227        .map_err(|e| {
228            let msg = e.to_string();
229            if msg.contains("403") {
230                "This feature is not available for your account.".to_string()
231            } else {
232                format!("Connection failed. Check your internet connection. ({e})")
233            }
234        })?;
235
236    let resp_body = resp
237        .into_body()
238        .read_to_string()
239        .map_err(|e| format!("Failed to read response: {e}"))?;
240
241    serde_json::from_str(&resp_body).map_err(|e| format!("Invalid response: {e}"))
242}
243
244pub fn save_cloud_models(data: &serde_json::Value) -> std::io::Result<()> {
245    let dir = config_dir();
246    std::fs::create_dir_all(&dir)?;
247    let json = serde_json::to_string_pretty(data).map_err(std::io::Error::other)?;
248    std::fs::write(dir.join("cloud_models.json"), json)
249}
250
251pub fn load_cloud_models() -> Option<serde_json::Value> {
252    let path = config_dir().join("cloud_models.json");
253    let data = std::fs::read_to_string(path).ok()?;
254    serde_json::from_str(&data).ok()
255}
256
257pub fn is_cloud_user() -> bool {
258    let path = config_dir().join("plan.txt");
259    std::fs::read_to_string(path)
260        .map(|p| matches!(p.trim(), "cloud" | "pro"))
261        .unwrap_or(false)
262}
263
264pub fn save_plan(plan: &str) -> std::io::Result<()> {
265    let dir = config_dir();
266    std::fs::create_dir_all(&dir)?;
267    std::fs::write(dir.join("plan.txt"), plan)
268}
269
270pub fn fetch_plan() -> Result<String, String> {
271    let api_key = load_api_key().ok_or("Not logged in")?;
272    let url = format!("{}/api/auth/me", api_url());
273
274    let resp = ureq::get(&url)
275        .header("Authorization", &format!("Bearer {api_key}"))
276        .call()
277        .map_err(|e| format!("Failed to check plan: {e}"))?;
278
279    let resp_body = resp
280        .into_body()
281        .read_to_string()
282        .map_err(|e| format!("Failed to read response: {e}"))?;
283
284    let json: serde_json::Value =
285        serde_json::from_str(&resp_body).map_err(|e| format!("Invalid response: {e}"))?;
286
287    Ok(json["plan"].as_str().unwrap_or("free").to_string())
288}
289
290pub fn push_commands(entries: &[serde_json::Value]) -> Result<String, String> {
291    let api_key = load_api_key().ok_or("Not logged in. Run: lean-ctx login")?;
292    let url = format!("{}/api/sync/commands", api_url());
293    let body = serde_json::json!({ "commands": entries });
294    let resp = ureq::post(&url)
295        .header("Authorization", &format!("Bearer {api_key}"))
296        .header("Content-Type", "application/json")
297        .send(serde_json::to_vec(&body).unwrap().as_slice())
298        .map_err(|e| format!("Push failed: {e}"))?;
299    let resp_body = resp
300        .into_body()
301        .read_to_string()
302        .map_err(|e| format!("Failed to read response: {e}"))?;
303    let json: serde_json::Value =
304        serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
305    Ok(format!(
306        "{} commands synced",
307        json["synced"].as_i64().unwrap_or(0)
308    ))
309}
310
311pub fn push_cep(entries: &[serde_json::Value]) -> Result<String, String> {
312    let api_key = load_api_key().ok_or("Not logged in. Run: lean-ctx login")?;
313    let url = format!("{}/api/sync/cep", api_url());
314    let body = serde_json::json!({ "scores": entries });
315    let resp = ureq::post(&url)
316        .header("Authorization", &format!("Bearer {api_key}"))
317        .header("Content-Type", "application/json")
318        .send(serde_json::to_vec(&body).unwrap().as_slice())
319        .map_err(|e| format!("Push failed: {e}"))?;
320    let resp_body = resp
321        .into_body()
322        .read_to_string()
323        .map_err(|e| format!("Failed to read response: {e}"))?;
324    let json: serde_json::Value =
325        serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
326    Ok(format!(
327        "{} sessions synced",
328        json["synced"].as_i64().unwrap_or(0)
329    ))
330}
331
332pub fn push_gain(entries: &[serde_json::Value]) -> Result<String, String> {
333    let api_key = load_api_key().ok_or("Not logged in. Run: lean-ctx login")?;
334    let url = format!("{}/api/sync/gain", api_url());
335    let body = serde_json::json!({ "scores": entries });
336    let resp = ureq::post(&url)
337        .header("Authorization", &format!("Bearer {api_key}"))
338        .header("Content-Type", "application/json")
339        .send(serde_json::to_vec(&body).unwrap().as_slice())
340        .map_err(|e| format!("Push failed: {e}"))?;
341    let resp_body = resp
342        .into_body()
343        .read_to_string()
344        .map_err(|e| format!("Failed to read response: {e}"))?;
345    let json: serde_json::Value =
346        serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
347    Ok(format!(
348        "{} gain scores synced",
349        json["synced"].as_i64().unwrap_or(0)
350    ))
351}
352
353pub fn push_gotchas(entries: &[serde_json::Value]) -> Result<String, String> {
354    let api_key = load_api_key().ok_or("Not logged in. Run: lean-ctx login")?;
355    let url = format!("{}/api/sync/gotchas", api_url());
356    let body = serde_json::json!({ "gotchas": entries });
357    let resp = ureq::post(&url)
358        .header("Authorization", &format!("Bearer {api_key}"))
359        .header("Content-Type", "application/json")
360        .send(serde_json::to_vec(&body).unwrap().as_slice())
361        .map_err(|e| format!("Push failed: {e}"))?;
362    let resp_body = resp
363        .into_body()
364        .read_to_string()
365        .map_err(|e| format!("Failed to read response: {e}"))?;
366    let json: serde_json::Value =
367        serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
368    Ok(format!(
369        "{} gotchas synced",
370        json["synced"].as_i64().unwrap_or(0)
371    ))
372}
373
374pub fn push_buddy(data: &serde_json::Value) -> Result<String, String> {
375    let api_key = load_api_key().ok_or("Not logged in. Run: lean-ctx login")?;
376    let url = format!("{}/api/sync/buddy", api_url());
377    let resp = ureq::post(&url)
378        .header("Authorization", &format!("Bearer {api_key}"))
379        .header("Content-Type", "application/json")
380        .send(serde_json::to_vec(data).unwrap().as_slice())
381        .map_err(|e| format!("Push failed: {e}"))?;
382    let resp_body = resp
383        .into_body()
384        .read_to_string()
385        .map_err(|e| format!("Failed to read response: {e}"))?;
386    let _json: serde_json::Value =
387        serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
388    Ok("Buddy synced".to_string())
389}
390
391pub fn push_feedback(entries: &[serde_json::Value]) -> Result<String, String> {
392    let api_key = load_api_key().ok_or("Not logged in. Run: lean-ctx login")?;
393    let url = format!("{}/api/sync/feedback", api_url());
394    let resp = ureq::post(&url)
395        .header("Authorization", &format!("Bearer {api_key}"))
396        .header("Content-Type", "application/json")
397        .send(serde_json::to_vec(entries).unwrap().as_slice())
398        .map_err(|e| format!("Push failed: {e}"))?;
399    let resp_body = resp
400        .into_body()
401        .read_to_string()
402        .map_err(|e| format!("Failed to read response: {e}"))?;
403    let json: serde_json::Value =
404        serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
405    Ok(format!(
406        "{} thresholds synced",
407        json["synced"].as_i64().unwrap_or(0)
408    ))
409}
410
411pub fn pull_knowledge() -> Result<Vec<serde_json::Value>, String> {
412    let api_key = load_api_key().ok_or("Not logged in. Run: lean-ctx login")?;
413    let url = format!("{}/api/sync/knowledge", api_url());
414
415    let resp = ureq::get(&url)
416        .header("Authorization", &format!("Bearer {api_key}"))
417        .call()
418        .map_err(|e| format!("Pull failed: {e}"))?;
419
420    let resp_body = resp
421        .into_body()
422        .read_to_string()
423        .map_err(|e| format!("Failed to read response: {e}"))?;
424
425    let entries: Vec<serde_json::Value> =
426        serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
427
428    Ok(entries)
429}