1use crate::{cloud_client, core};
2
3fn mask_email(email: &str) -> String {
4 match email.split_once('@') {
5 Some((local, domain)) if local.len() > 2 => {
6 format!("{}...@{domain}", &local[..2])
7 }
8 _ => "***".to_string(),
9 }
10}
11
12fn parse_auth_args(args: &[String]) -> (String, Option<String>) {
13 let mut email = String::new();
14 let mut password: Option<String> = None;
15 let mut i = 0;
16 while i < args.len() {
17 match args[i].as_str() {
18 "--password" | "-p" => {
19 i += 1;
20 if i < args.len() {
21 password = Some(args[i].clone());
22 }
23 }
24 _ => {
25 if email.is_empty() {
26 email = args[i].trim().to_lowercase();
27 }
28 }
29 }
30 i += 1;
31 }
32 (email, password)
33}
34
35fn require_email_and_password(args: &[String], usage: &str) -> (String, String) {
36 let (email, password) = parse_auth_args(args);
37
38 if email.is_empty() {
39 eprintln!("Usage: {usage}");
40 std::process::exit(1);
41 }
42 if !email.contains('@') || !email.contains('.') {
43 eprintln!("Invalid email address: {email}");
44 std::process::exit(1);
45 }
46
47 let pw = match password {
48 Some(p) => p,
49 None => match rpassword::prompt_password("Password: ") {
50 Ok(p) => p,
51 Err(e) => {
52 eprintln!("Could not read password: {e}");
53 std::process::exit(1);
54 }
55 },
56 };
57 if pw.len() < 8 {
58 eprintln!("Password must be at least 8 characters.");
59 std::process::exit(1);
60 }
61 (email, pw)
62}
63
64fn save_and_report(r: &cloud_client::RegisterResult, email: &str) {
65 if let Err(e) = cloud_client::save_credentials(&r.api_key, &r.user_id, email) {
66 eprintln!("Warning: Could not save credentials: {e}");
67 eprintln!("Please try again.");
68 return;
69 }
70 if let Ok(plan) = cloud_client::fetch_plan() {
71 let _ = cloud_client::save_plan(&plan);
72 }
73 println!("API key saved to ~/.lean-ctx/cloud/credentials.json");
74 if r.verification_sent {
75 println!("Verification email sent — please check your inbox.");
76 }
77 if !r.email_verified {
78 println!("Note: Your email is not yet verified.");
79 }
80}
81
82pub fn cmd_login(args: &[String]) {
83 let (email, pw) = require_email_and_password(args, "lean-ctx login <email> [--password <pw>]");
84
85 println!("Logging in to LeanCTX Cloud...");
86
87 match cloud_client::login(&email, &pw) {
88 Ok(r) => {
89 save_and_report(&r, &email);
90 println!("Logged in as {}", mask_email(&email));
91 }
92 Err(e) if e.contains("403") => {
93 eprintln!("Please verify your email first. Check your inbox.");
94 std::process::exit(1);
95 }
96 Err(e) if e.contains("Invalid email or password") => {
97 eprintln!("Invalid email or password.");
98 eprintln!("Forgot your password? Run: lean-ctx forgot-password <email>");
99 eprintln!("No account yet? Run: lean-ctx register <email>");
100 std::process::exit(1);
101 }
102 Err(e) => {
103 eprintln!("Login failed: {e}");
104 eprintln!("If you don't have an account yet, run: lean-ctx register <email>");
105 std::process::exit(1);
106 }
107 }
108}
109
110pub fn cmd_forgot_password(args: &[String]) {
111 let (email, _) = parse_auth_args(args);
112
113 if email.is_empty() {
114 eprintln!("Usage: lean-ctx forgot-password <email>");
115 std::process::exit(1);
116 }
117
118 println!("Sending password reset email...");
119
120 match cloud_client::forgot_password(&email) {
121 Ok(_msg) => {
122 println!("Password reset email sent to {}.", mask_email(&email));
123 println!("Check your inbox and follow the reset link.");
124 }
125 Err(e) => {
126 eprintln!("Failed: {e}");
127 std::process::exit(1);
128 }
129 }
130}
131
132pub fn cmd_register(args: &[String]) {
133 let (email, pw) =
134 require_email_and_password(args, "lean-ctx register <email> [--password <pw>]");
135
136 println!("Creating LeanCTX Cloud account...");
137
138 match cloud_client::register(&email, Some(&pw)) {
139 Ok(r) => {
140 save_and_report(&r, &email);
141 println!("Account created for {}", mask_email(&email));
142 }
143 Err(e) if e.contains("409") || e.contains("already exists") => {
144 eprintln!("An account with this email already exists.");
145 eprintln!("Run: lean-ctx login <email>");
146 std::process::exit(1);
147 }
148 Err(e) => {
149 eprintln!("Registration failed: {e}");
150 std::process::exit(1);
151 }
152 }
153}
154
155pub fn cmd_sync() {
156 if !cloud_client::is_logged_in() {
157 eprintln!("Not logged in. Run: lean-ctx login <email>");
158 std::process::exit(1);
159 }
160
161 println!("Syncing stats...");
162 let store = core::stats::load();
163 let entries = build_sync_entries(&store);
164 if entries.is_empty() {
165 println!("No stats to sync yet.");
166 } else {
167 match cloud_client::sync_stats(&entries) {
168 Ok(_) => println!(" Stats: synced"),
169 Err(e) => eprintln!(" Stats sync failed: {e}"),
170 }
171 }
172
173 println!("Syncing commands...");
174 let command_entries = collect_command_entries(&store);
175 if command_entries.is_empty() {
176 println!(" No command data to sync.");
177 } else {
178 match cloud_client::push_commands(&command_entries) {
179 Ok(_) => println!(" Commands: synced"),
180 Err(e) => eprintln!(" Commands sync failed: {e}"),
181 }
182 }
183
184 println!("Syncing CEP scores...");
185 let cep_entries = collect_cep_entries(&store);
186 if cep_entries.is_empty() {
187 println!(" No CEP sessions to sync.");
188 } else {
189 match cloud_client::push_cep(&cep_entries) {
190 Ok(_) => println!(" CEP: synced"),
191 Err(e) => eprintln!(" CEP sync failed: {e}"),
192 }
193 }
194
195 println!("Syncing knowledge...");
196 let knowledge_entries = collect_knowledge_entries();
197 if knowledge_entries.is_empty() {
198 println!(" No knowledge to sync.");
199 } else {
200 match cloud_client::push_knowledge(&knowledge_entries) {
201 Ok(_) => println!(" Knowledge: synced"),
202 Err(e) => eprintln!(" Knowledge sync failed: {e}"),
203 }
204 }
205
206 println!("Syncing gotchas...");
207 let gotcha_entries = collect_gotcha_entries();
208 if gotcha_entries.is_empty() {
209 println!(" No gotchas to sync.");
210 } else {
211 match cloud_client::push_gotchas(&gotcha_entries) {
212 Ok(_) => println!(" Gotchas: synced"),
213 Err(e) => eprintln!(" Gotchas sync failed: {e}"),
214 }
215 }
216
217 println!("Syncing buddy...");
218 let buddy = core::buddy::BuddyState::compute();
219 let buddy_data = serde_json::to_value(&buddy).unwrap_or_default();
220 match cloud_client::push_buddy(&buddy_data) {
221 Ok(_) => println!(" Buddy: synced"),
222 Err(e) => eprintln!(" Buddy sync failed: {e}"),
223 }
224
225 println!("Syncing feedback thresholds...");
226 let feedback_entries = collect_feedback_entries();
227 if feedback_entries.is_empty() {
228 println!(" No feedback thresholds to sync.");
229 } else {
230 match cloud_client::push_feedback(&feedback_entries) {
231 Ok(_) => println!(" Feedback: synced"),
232 Err(e) => eprintln!(" Feedback sync failed: {e}"),
233 }
234 }
235
236 if let Ok(plan) = cloud_client::fetch_plan() {
237 let _ = cloud_client::save_plan(&plan);
238 }
239
240 println!("Sync complete.");
241}
242
243fn build_sync_entries(store: &core::stats::StatsStore) -> Vec<serde_json::Value> {
244 crate::cloud_sync::build_sync_entries(store)
245}
246
247fn collect_knowledge_entries() -> Vec<serde_json::Value> {
248 let home = match dirs::home_dir() {
249 Some(h) => h,
250 None => return Vec::new(),
251 };
252 let knowledge_dir = home.join(".lean-ctx").join("knowledge");
253 if !knowledge_dir.is_dir() {
254 return Vec::new();
255 }
256
257 let mut entries = Vec::new();
258
259 for project_entry in std::fs::read_dir(&knowledge_dir).into_iter().flatten() {
260 let project_entry = match project_entry {
261 Ok(e) => e,
262 Err(_) => continue,
263 };
264 let project_path = project_entry.path();
265 if !project_path.is_dir() {
266 continue;
267 }
268
269 for file_entry in std::fs::read_dir(&project_path).into_iter().flatten() {
270 let file_entry = match file_entry {
271 Ok(e) => e,
272 Err(_) => continue,
273 };
274 let file_path = file_entry.path();
275 if file_path.extension().and_then(|e| e.to_str()) != Some("json") {
276 continue;
277 }
278 let data = match std::fs::read_to_string(&file_path) {
279 Ok(d) => d,
280 Err(_) => continue,
281 };
282 let parsed: serde_json::Value = match serde_json::from_str(&data) {
283 Ok(v) => v,
284 Err(_) => continue,
285 };
286
287 if let Some(facts) = parsed["facts"].as_array() {
288 for fact in facts {
289 let cat = fact["category"].as_str().unwrap_or("general");
290 let key = fact["key"].as_str().unwrap_or("");
291 let val = fact["value"]
292 .as_str()
293 .or_else(|| fact["description"].as_str())
294 .unwrap_or("");
295 if !key.is_empty() {
296 entries.push(serde_json::json!({
297 "category": cat,
298 "key": key,
299 "value": val,
300 }));
301 }
302 }
303 }
304
305 if let Some(gotchas) = parsed["gotchas"].as_array() {
306 for g in gotchas {
307 let pattern = g["pattern"].as_str().unwrap_or("");
308 let fix = g["fix"].as_str().unwrap_or("");
309 if !pattern.is_empty() {
310 entries.push(serde_json::json!({
311 "category": "gotcha",
312 "key": pattern,
313 "value": fix,
314 }));
315 }
316 }
317 }
318 }
319 }
320
321 entries
322}
323
324fn collect_command_entries(store: &core::stats::StatsStore) -> Vec<serde_json::Value> {
325 store
326 .commands
327 .iter()
328 .map(|(name, stats)| {
329 let tokens_saved = stats.input_tokens.saturating_sub(stats.output_tokens);
330 serde_json::json!({
331 "command": name,
332 "source": if name.starts_with("ctx_") { "mcp" } else { "hook" },
333 "count": stats.count,
334 "input_tokens": stats.input_tokens,
335 "output_tokens": stats.output_tokens,
336 "tokens_saved": tokens_saved,
337 })
338 })
339 .collect()
340}
341
342fn complexity_to_float(s: &str) -> f64 {
343 match s.to_lowercase().as_str() {
344 "trivial" => 0.1,
345 "simple" => 0.3,
346 "moderate" => 0.5,
347 "complex" => 0.7,
348 "architectural" => 0.9,
349 other => other.parse::<f64>().unwrap_or(0.5),
350 }
351}
352
353fn collect_cep_entries(store: &core::stats::StatsStore) -> Vec<serde_json::Value> {
354 store
355 .cep
356 .scores
357 .iter()
358 .map(|s| {
359 serde_json::json!({
360 "recorded_at": s.timestamp,
361 "score": s.score as f64 / 100.0,
362 "cache_hit_rate": s.cache_hit_rate as f64 / 100.0,
363 "mode_diversity": s.mode_diversity as f64 / 100.0,
364 "compression_rate": s.compression_rate as f64 / 100.0,
365 "tool_calls": s.tool_calls,
366 "tokens_saved": s.tokens_saved,
367 "complexity": complexity_to_float(&s.complexity),
368 })
369 })
370 .collect()
371}
372
373fn collect_gotcha_entries() -> Vec<serde_json::Value> {
374 let mut all_gotchas = core::gotcha_tracker::load_universal_gotchas();
375
376 if let Some(home) = dirs::home_dir() {
377 let knowledge_dir = home.join(".lean-ctx").join("knowledge");
378 if let Ok(entries) = std::fs::read_dir(&knowledge_dir) {
379 for entry in entries.flatten() {
380 let gotcha_path = entry.path().join("gotchas.json");
381 if gotcha_path.exists() {
382 if let Ok(content) = std::fs::read_to_string(&gotcha_path) {
383 if let Ok(store) =
384 serde_json::from_str::<core::gotcha_tracker::GotchaStore>(&content)
385 {
386 for g in store.gotchas {
387 if !all_gotchas
388 .iter()
389 .any(|existing| existing.trigger == g.trigger)
390 {
391 all_gotchas.push(g);
392 }
393 }
394 }
395 }
396 }
397 }
398 }
399 }
400
401 all_gotchas
402 .iter()
403 .map(|g| {
404 serde_json::json!({
405 "pattern": g.trigger,
406 "fix": g.resolution,
407 "severity": format!("{:?}", g.severity).to_lowercase(),
408 "category": format!("{:?}", g.category).to_lowercase(),
409 "occurrences": g.occurrences,
410 "prevented_count": g.prevented_count,
411 "confidence": g.confidence,
412 })
413 })
414 .collect()
415}
416
417fn collect_feedback_entries() -> Vec<serde_json::Value> {
418 let store = core::feedback::FeedbackStore::load();
419 store
420 .learned_thresholds
421 .iter()
422 .map(|(lang, thresholds)| {
423 serde_json::json!({
424 "language": lang,
425 "entropy": thresholds.entropy,
426 "jaccard": thresholds.jaccard,
427 "sample_count": thresholds.sample_count,
428 "avg_efficiency": thresholds.avg_efficiency,
429 })
430 })
431 .collect()
432}
433
434pub fn cmd_contribute() {
435 let mut entries = Vec::new();
436
437 if let Some(home) = dirs::home_dir() {
438 let mode_stats_path = home.join(".lean-ctx").join("mode_stats.json");
439 if let Ok(data) = std::fs::read_to_string(&mode_stats_path) {
440 if let Ok(predictor) = serde_json::from_str::<serde_json::Value>(&data) {
441 if let Some(history) = predictor["history"].as_object() {
442 for (_sig_key, outcomes) in history {
443 if let Some(arr) = outcomes.as_array() {
444 for outcome in arr.iter().rev().take(5) {
445 let ext = outcome["ext"].as_str().unwrap_or("unknown");
446 let mode = outcome["mode"].as_str().unwrap_or("full");
447 let tokens_in = outcome["tokens_in"].as_u64().unwrap_or(0);
448 let tokens_out = outcome["tokens_out"].as_u64().unwrap_or(0);
449 let ratio = if tokens_in > 0 {
450 1.0 - tokens_out as f64 / tokens_in as f64
451 } else {
452 0.0
453 };
454 let bucket = match tokens_in {
455 0..=500 => "0-500",
456 501..=2000 => "500-2k",
457 2001..=10000 => "2k-10k",
458 _ => "10k+",
459 };
460 entries.push(serde_json::json!({
461 "file_ext": format!(".{ext}"),
462 "size_bucket": bucket,
463 "best_mode": mode,
464 "compression_ratio": (ratio * 100.0).round() / 100.0,
465 }));
466 if entries.len() >= 500 {
467 break;
468 }
469 }
470 }
471 if entries.len() >= 500 {
472 break;
473 }
474 }
475 }
476 }
477 }
478 }
479
480 if entries.is_empty() {
481 let stats_data = core::stats::format_gain_json();
482 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&stats_data) {
483 let original = parsed["cep"]["total_tokens_original"].as_u64().unwrap_or(0);
484 let compressed = parsed["cep"]["total_tokens_compressed"]
485 .as_u64()
486 .unwrap_or(0);
487 let overall_ratio = if original > 0 {
488 1.0 - compressed as f64 / original as f64
489 } else {
490 0.0
491 };
492
493 if let Some(modes) = parsed["cep"]["modes"].as_object() {
494 let read_modes = ["full", "map", "signatures", "auto", "aggressive", "entropy"];
495 for (mode, count) in modes {
496 if !read_modes.contains(&mode.as_str()) || count.as_u64().unwrap_or(0) == 0 {
497 continue;
498 }
499 entries.push(serde_json::json!({
500 "file_ext": "mixed",
501 "size_bucket": "mixed",
502 "best_mode": mode,
503 "compression_ratio": (overall_ratio * 100.0).round() / 100.0,
504 }));
505 }
506 }
507 }
508 }
509
510 if entries.is_empty() {
511 println!("No compression data to contribute yet. Use lean-ctx for a while first.");
512 return;
513 }
514
515 println!("Contributing {} data points...", entries.len());
516 match cloud_client::contribute(&entries) {
517 Ok(msg) => println!("{msg}"),
518 Err(e) => {
519 eprintln!("Contribute failed: {e}");
520 std::process::exit(1);
521 }
522 }
523}
524
525pub fn cmd_cloud(args: &[String]) {
526 let action = args.first().map(|s| s.as_str()).unwrap_or("help");
527
528 match action {
529 "pull-models" => {
530 println!("Updating adaptive models...");
531 match cloud_client::pull_cloud_models() {
532 Ok(data) => {
533 let count = data
534 .get("models")
535 .and_then(|v| v.as_array())
536 .map(|a| a.len())
537 .unwrap_or(0);
538
539 if let Err(e) = cloud_client::save_cloud_models(&data) {
540 eprintln!("Warning: Could not save models: {e}");
541 return;
542 }
543 println!("{count} adaptive models updated.");
544 if let Some(est) = data.get("improvement_estimate").and_then(|v| v.as_f64()) {
545 println!("Estimated compression improvement: +{:.0}%", est * 100.0);
546 }
547 }
548 Err(e) => {
549 eprintln!("{e}");
550 std::process::exit(1);
551 }
552 }
553 }
554 "status" => {
555 if cloud_client::is_logged_in() {
556 println!("Connected to LeanCTX Cloud.");
557 } else {
558 println!("Not connected to LeanCTX Cloud.");
559 println!("Get started: lean-ctx login <email>");
560 }
561 }
562 _ => {
563 println!("Usage: lean-ctx cloud <command>");
564 println!(" pull-models — Update adaptive compression models");
565 println!(" status — Show cloud connection status");
566 }
567 }
568}
569
570pub fn cmd_gotchas(args: &[String]) {
571 let action = args.first().map(|s| s.as_str()).unwrap_or("list");
572 let project_root = std::env::current_dir()
573 .map(|p| p.to_string_lossy().to_string())
574 .unwrap_or_else(|_| ".".to_string());
575
576 match action {
577 "list" | "ls" => {
578 let store = core::gotcha_tracker::GotchaStore::load(&project_root);
579 println!("{}", store.format_list());
580 }
581 "clear" => {
582 let mut store = core::gotcha_tracker::GotchaStore::load(&project_root);
583 let count = store.gotchas.len();
584 store.clear();
585 let _ = store.save(&project_root);
586 println!("Cleared {count} gotchas.");
587 }
588 "export" => {
589 let store = core::gotcha_tracker::GotchaStore::load(&project_root);
590 match serde_json::to_string_pretty(&store.gotchas) {
591 Ok(json) => println!("{json}"),
592 Err(e) => eprintln!("Export failed: {e}"),
593 }
594 }
595 "stats" => {
596 let store = core::gotcha_tracker::GotchaStore::load(&project_root);
597 println!("Bug Memory Stats:");
598 println!(" Active gotchas: {}", store.gotchas.len());
599 println!(
600 " Errors detected: {}",
601 store.stats.total_errors_detected
602 );
603 println!(
604 " Fixes correlated: {}",
605 store.stats.total_fixes_correlated
606 );
607 println!(" Bugs prevented: {}", store.stats.total_prevented);
608 println!(" Promoted to knowledge: {}", store.stats.gotchas_promoted);
609 println!(" Decayed/archived: {}", store.stats.gotchas_decayed);
610 println!(" Session logs: {}", store.error_log.len());
611 }
612 _ => {
613 println!("Usage: lean-ctx gotchas [list|clear|export|stats]");
614 }
615 }
616}
617
618pub fn cmd_buddy(args: &[String]) {
619 let cfg = core::config::Config::load();
620 if !cfg.buddy_enabled {
621 println!("Buddy is disabled. Enable with: lean-ctx config buddy_enabled true");
622 return;
623 }
624
625 let action = args.first().map(|s| s.as_str()).unwrap_or("show");
626 let buddy = core::buddy::BuddyState::compute();
627 let theme = core::theme::load_theme(&cfg.theme);
628
629 match action {
630 "show" | "status" => {
631 println!("{}", core::buddy::format_buddy_full(&buddy, &theme));
632 }
633 "stats" => {
634 println!("{}", core::buddy::format_buddy_full(&buddy, &theme));
635 }
636 "ascii" => {
637 for line in &buddy.ascii_art {
638 println!(" {line}");
639 }
640 }
641 "json" => match serde_json::to_string_pretty(&buddy) {
642 Ok(json) => println!("{json}"),
643 Err(e) => eprintln!("JSON error: {e}"),
644 },
645 _ => {
646 println!("Usage: lean-ctx buddy [show|stats|ascii|json]");
647 }
648 }
649}
650
651pub fn cmd_upgrade() {
652 println!("'upgrade' has been renamed to 'update'. Running 'lean-ctx update' instead.\n");
653 core::updater::run(&[]);
654}