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 tracing::error!("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 tracing::error!("Could not read password: {e}");
53 std::process::exit(1);
54 }
55 },
56 };
57 if pw.len() < 8 {
58 tracing::error!("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 tracing::warn!("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 tracing::error!("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 tracing::error!("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 tracing::error!("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 tracing::error!("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 tracing::error!("An account with this email already exists.");
145 eprintln!("Run: lean-ctx login <email>");
146 std::process::exit(1);
147 }
148 Err(e) => {
149 tracing::error!("Registration failed: {e}");
150 std::process::exit(1);
151 }
152 }
153}
154
155pub fn cmd_sync() {
156 if !cloud_client::is_logged_in() {
157 tracing::error!("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) => tracing::error!("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) => tracing::error!("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) => tracing::error!("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) => tracing::error!("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) => tracing::error!("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) => tracing::error!("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) => tracing::error!("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 Some(home) = dirs::home_dir() else {
249 return Vec::new();
250 };
251 let knowledge_dir = home.join(".lean-ctx").join("knowledge");
252 if !knowledge_dir.is_dir() {
253 return Vec::new();
254 }
255
256 let mut entries = Vec::new();
257
258 for project_entry in std::fs::read_dir(&knowledge_dir).into_iter().flatten() {
259 let Ok(project_entry) = project_entry else {
260 continue;
261 };
262 let project_path = project_entry.path();
263 if !project_path.is_dir() {
264 continue;
265 }
266
267 for file_entry in std::fs::read_dir(&project_path).into_iter().flatten() {
268 let Ok(file_entry) = file_entry else { continue };
269 let file_path = file_entry.path();
270 if file_path.extension().and_then(|e| e.to_str()) != Some("json") {
271 continue;
272 }
273 let Ok(data) = std::fs::read_to_string(&file_path) else {
274 continue;
275 };
276 let parsed: serde_json::Value = match serde_json::from_str(&data) {
277 Ok(v) => v,
278 Err(_) => continue,
279 };
280
281 if let Some(facts) = parsed["facts"].as_array() {
282 for fact in facts {
283 let cat = fact["category"].as_str().unwrap_or("general");
284 let key = fact["key"].as_str().unwrap_or("");
285 let val = fact["value"]
286 .as_str()
287 .or_else(|| fact["description"].as_str())
288 .unwrap_or("");
289 if !key.is_empty() {
290 entries.push(serde_json::json!({
291 "category": cat,
292 "key": key,
293 "value": val,
294 }));
295 }
296 }
297 }
298
299 if let Some(gotchas) = parsed["gotchas"].as_array() {
300 for g in gotchas {
301 let pattern = g["pattern"].as_str().unwrap_or("");
302 let fix = g["fix"].as_str().unwrap_or("");
303 if !pattern.is_empty() {
304 entries.push(serde_json::json!({
305 "category": "gotcha",
306 "key": pattern,
307 "value": fix,
308 }));
309 }
310 }
311 }
312 }
313 }
314
315 entries
316}
317
318fn collect_command_entries(store: &core::stats::StatsStore) -> Vec<serde_json::Value> {
319 store
320 .commands
321 .iter()
322 .map(|(name, stats)| {
323 let tokens_saved = stats.input_tokens.saturating_sub(stats.output_tokens);
324 serde_json::json!({
325 "command": name,
326 "source": if name.starts_with("ctx_") { "mcp" } else { "hook" },
327 "count": stats.count,
328 "input_tokens": stats.input_tokens,
329 "output_tokens": stats.output_tokens,
330 "tokens_saved": tokens_saved,
331 })
332 })
333 .collect()
334}
335
336fn complexity_to_float(s: &str) -> f64 {
337 match s.to_lowercase().as_str() {
338 "trivial" => 0.1,
339 "simple" => 0.3,
340 "moderate" => 0.5,
341 "complex" => 0.7,
342 "architectural" => 0.9,
343 other => other.parse::<f64>().unwrap_or(0.5),
344 }
345}
346
347fn collect_cep_entries(store: &core::stats::StatsStore) -> Vec<serde_json::Value> {
348 store
349 .cep
350 .scores
351 .iter()
352 .map(|s| {
353 serde_json::json!({
354 "recorded_at": s.timestamp,
355 "score": s.score as f64 / 100.0,
356 "cache_hit_rate": s.cache_hit_rate as f64 / 100.0,
357 "mode_diversity": s.mode_diversity as f64 / 100.0,
358 "compression_rate": s.compression_rate as f64 / 100.0,
359 "tool_calls": s.tool_calls,
360 "tokens_saved": s.tokens_saved,
361 "complexity": complexity_to_float(&s.complexity),
362 })
363 })
364 .collect()
365}
366
367fn collect_gotcha_entries() -> Vec<serde_json::Value> {
368 let mut all_gotchas = core::gotcha_tracker::load_universal_gotchas();
369
370 if let Some(home) = dirs::home_dir() {
371 let knowledge_dir = home.join(".lean-ctx").join("knowledge");
372 if let Ok(entries) = std::fs::read_dir(&knowledge_dir) {
373 for entry in entries.flatten() {
374 let gotcha_path = entry.path().join("gotchas.json");
375 if gotcha_path.exists() {
376 if let Ok(content) = std::fs::read_to_string(&gotcha_path) {
377 if let Ok(store) =
378 serde_json::from_str::<core::gotcha_tracker::GotchaStore>(&content)
379 {
380 for g in store.gotchas {
381 if !all_gotchas
382 .iter()
383 .any(|existing| existing.trigger == g.trigger)
384 {
385 all_gotchas.push(g);
386 }
387 }
388 }
389 }
390 }
391 }
392 }
393 }
394
395 all_gotchas
396 .iter()
397 .map(|g| {
398 serde_json::json!({
399 "pattern": g.trigger,
400 "fix": g.resolution,
401 "severity": format!("{:?}", g.severity).to_lowercase(),
402 "category": format!("{:?}", g.category).to_lowercase(),
403 "occurrences": g.occurrences,
404 "prevented_count": g.prevented_count,
405 "confidence": g.confidence,
406 })
407 })
408 .collect()
409}
410
411fn collect_feedback_entries() -> Vec<serde_json::Value> {
412 let store = core::feedback::FeedbackStore::load();
413 store
414 .learned_thresholds
415 .iter()
416 .map(|(lang, thresholds)| {
417 serde_json::json!({
418 "language": lang,
419 "entropy": thresholds.entropy,
420 "jaccard": thresholds.jaccard,
421 "sample_count": thresholds.sample_count,
422 "avg_efficiency": thresholds.avg_efficiency,
423 })
424 })
425 .collect()
426}
427
428pub fn cmd_contribute() {
429 let mut entries = Vec::new();
430
431 if let Some(home) = dirs::home_dir() {
432 let mode_stats_path = home.join(".lean-ctx").join("mode_stats.json");
433 if let Ok(data) = std::fs::read_to_string(&mode_stats_path) {
434 if let Ok(predictor) = serde_json::from_str::<serde_json::Value>(&data) {
435 if let Some(history) = predictor["history"].as_object() {
436 for (_sig_key, outcomes) in history {
437 if let Some(arr) = outcomes.as_array() {
438 for outcome in arr.iter().rev().take(5) {
439 let ext = outcome["ext"].as_str().unwrap_or("unknown");
440 let mode = outcome["mode"].as_str().unwrap_or("full");
441 let tokens_in = outcome["tokens_in"].as_u64().unwrap_or(0);
442 let tokens_out = outcome["tokens_out"].as_u64().unwrap_or(0);
443 let ratio = if tokens_in > 0 {
444 1.0 - tokens_out as f64 / tokens_in as f64
445 } else {
446 0.0
447 };
448 let bucket = match tokens_in {
449 0..=500 => "0-500",
450 501..=2000 => "500-2k",
451 2001..=10000 => "2k-10k",
452 _ => "10k+",
453 };
454 entries.push(serde_json::json!({
455 "file_ext": format!(".{ext}"),
456 "size_bucket": bucket,
457 "best_mode": mode,
458 "compression_ratio": (ratio * 100.0).round() / 100.0,
459 }));
460 if entries.len() >= 500 {
461 break;
462 }
463 }
464 }
465 if entries.len() >= 500 {
466 break;
467 }
468 }
469 }
470 }
471 }
472 }
473
474 if entries.is_empty() {
475 let stats_data = core::stats::format_gain_json();
476 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&stats_data) {
477 let original = parsed["cep"]["total_tokens_original"].as_u64().unwrap_or(0);
478 let compressed = parsed["cep"]["total_tokens_compressed"]
479 .as_u64()
480 .unwrap_or(0);
481 let overall_ratio = if original > 0 {
482 1.0 - compressed as f64 / original as f64
483 } else {
484 0.0
485 };
486
487 if let Some(modes) = parsed["cep"]["modes"].as_object() {
488 let read_modes = ["full", "map", "signatures", "auto", "aggressive", "entropy"];
489 for (mode, count) in modes {
490 if !read_modes.contains(&mode.as_str()) || count.as_u64().unwrap_or(0) == 0 {
491 continue;
492 }
493 entries.push(serde_json::json!({
494 "file_ext": "mixed",
495 "size_bucket": "mixed",
496 "best_mode": mode,
497 "compression_ratio": (overall_ratio * 100.0).round() / 100.0,
498 }));
499 }
500 }
501 }
502 }
503
504 if entries.is_empty() {
505 println!("No compression data to contribute yet. Use lean-ctx for a while first.");
506 return;
507 }
508
509 println!("Contributing {} data points...", entries.len());
510 match cloud_client::contribute(&entries) {
511 Ok(msg) => println!("{msg}"),
512 Err(e) => {
513 tracing::error!("Contribute failed: {e}");
514 std::process::exit(1);
515 }
516 }
517}
518
519pub fn cmd_cloud(args: &[String]) {
520 let action = args.first().map_or("help", std::string::String::as_str);
521
522 match action {
523 "pull-models" => {
524 println!("Updating adaptive models...");
525 match cloud_client::pull_cloud_models() {
526 Ok(data) => {
527 let count = data
528 .get("models")
529 .and_then(|v| v.as_array())
530 .map_or(0, std::vec::Vec::len);
531
532 if let Err(e) = cloud_client::save_cloud_models(&data) {
533 tracing::warn!("Could not save models: {e}");
534 return;
535 }
536 println!("{count} adaptive models updated.");
537 if let Some(est) = data
538 .get("improvement_estimate")
539 .and_then(serde_json::Value::as_f64)
540 {
541 println!("Estimated compression improvement: +{:.0}%", est * 100.0);
542 }
543 }
544 Err(e) => {
545 tracing::error!("{e}");
546 std::process::exit(1);
547 }
548 }
549 }
550 "status" => {
551 if cloud_client::is_logged_in() {
552 println!("Connected to LeanCTX Cloud.");
553 } else {
554 println!("Not connected to LeanCTX Cloud.");
555 println!("Get started: lean-ctx login <email>");
556 }
557 }
558 _ => {
559 println!("Usage: lean-ctx cloud <command>");
560 println!(" pull-models — Update adaptive compression models");
561 println!(" status — Show cloud connection status");
562 }
563 }
564}
565
566pub fn cmd_gotchas(args: &[String]) {
567 let action = args.first().map_or("list", std::string::String::as_str);
568 let project_root = std::env::current_dir()
569 .map_or_else(|_| ".".to_string(), |p| p.to_string_lossy().to_string());
570
571 match action {
572 "list" | "ls" => {
573 let store = core::gotcha_tracker::GotchaStore::load(&project_root);
574 println!("{}", store.format_list());
575 }
576 "clear" => {
577 let mut store = core::gotcha_tracker::GotchaStore::load(&project_root);
578 let count = store.gotchas.len();
579 store.clear();
580 let _ = store.save(&project_root);
581 println!("Cleared {count} gotchas.");
582 }
583 "export" => {
584 let store = core::gotcha_tracker::GotchaStore::load(&project_root);
585 match serde_json::to_string_pretty(&store.gotchas) {
586 Ok(json) => println!("{json}"),
587 Err(e) => tracing::error!("Export failed: {e}"),
588 }
589 }
590 "stats" => {
591 let store = core::gotcha_tracker::GotchaStore::load(&project_root);
592 println!("Bug Memory Stats:");
593 println!(" Active gotchas: {}", store.gotchas.len());
594 println!(
595 " Errors detected: {}",
596 store.stats.total_errors_detected
597 );
598 println!(
599 " Fixes correlated: {}",
600 store.stats.total_fixes_correlated
601 );
602 println!(" Bugs prevented: {}", store.stats.total_prevented);
603 println!(" Promoted to knowledge: {}", store.stats.gotchas_promoted);
604 println!(" Decayed/archived: {}", store.stats.gotchas_decayed);
605 println!(" Session logs: {}", store.error_log.len());
606 }
607 _ => {
608 println!("Usage: lean-ctx gotchas [list|clear|export|stats]");
609 }
610 }
611}
612
613pub fn cmd_buddy(args: &[String]) {
614 let cfg = core::config::Config::load();
615 if !cfg.buddy_enabled {
616 println!("Buddy is disabled. Enable with: lean-ctx config buddy_enabled true");
617 return;
618 }
619
620 let action = args.first().map_or("show", std::string::String::as_str);
621 let buddy = core::buddy::BuddyState::compute();
622 let theme = core::theme::load_theme(&cfg.theme);
623
624 match action {
625 "show" | "status" | "stats" => {
626 println!("{}", core::buddy::format_buddy_full(&buddy, &theme));
627 }
628 "ascii" => {
629 for line in &buddy.ascii_art {
630 println!(" {line}");
631 }
632 }
633 "json" => match serde_json::to_string_pretty(&buddy) {
634 Ok(json) => println!("{json}"),
635 Err(e) => tracing::error!("JSON error: {e}"),
636 },
637 _ => {
638 println!("Usage: lean-ctx buddy [show|stats|ascii|json]");
639 }
640 }
641}
642
643pub fn cmd_upgrade() {
644 println!("'upgrade' has been renamed to 'update'. Running 'lean-ctx update' instead.\n");
645 core::updater::run(&[]);
646}