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