Skip to main content

lean_ctx/
cloud_sync.rs

1use crate::core::config::Config;
2
3/// Outcome of one background Personal-Cloud auto-push (GL #384).
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub enum AutoSyncOutcome {
6    /// At least one surface pushed (or there was nothing to push).
7    Synced,
8    /// The server gated sync behind Pro (HTTP 402) — stop for today.
9    Gated,
10    /// Every push failed without a 402 (offline / server down) — try again
11    /// at the next opportunity, do not consume today's slot.
12    NetworkFailure,
13}
14
15/// Whether the auto-sync should run now: opt-in flag, logged in, and not
16/// already synced today (the debounce). Pure for unit testing.
17#[must_use]
18pub fn should_auto_sync(
19    auto_sync: bool,
20    logged_in: bool,
21    last_auto_sync: Option<&str>,
22    today: &str,
23) -> bool {
24    auto_sync && logged_in && last_auto_sync != Some(today)
25}
26
27/// Whether the background index push should run for this project (GL #392):
28/// separate opt-in, logged in, a local index actually exists, and this
29/// project hasn't pushed today. Pure for unit testing.
30#[must_use]
31pub fn should_auto_push_index(
32    auto_index: bool,
33    logged_in: bool,
34    local_index_exists: bool,
35    last_push_for_project: Option<&str>,
36    today: &str,
37) -> bool {
38    auto_index && logged_in && local_index_exists && last_push_for_project != Some(today)
39}
40
41/// Classify per-surface push results into one [`AutoSyncOutcome`]. A 402
42/// anywhere wins (the account is gated); otherwise total failure means the
43/// network is down; anything else counts as synced.
44#[must_use]
45pub fn classify_outcomes(results: &[Result<(), String>]) -> AutoSyncOutcome {
46    if results
47        .iter()
48        .any(|r| r.as_ref().is_err_and(|e| e.contains("402")))
49    {
50        return AutoSyncOutcome::Gated;
51    }
52    if !results.is_empty() && results.iter().all(Result::is_err) {
53        return AutoSyncOutcome::NetworkFailure;
54    }
55    AutoSyncOutcome::Synced
56}
57
58pub fn cloud_background_tasks() {
59    let mut config = Config::load();
60    let today = chrono::Local::now().format("%Y-%m-%d").to_string();
61
62    let already_contributed = config
63        .cloud
64        .last_contribute
65        .as_deref()
66        .is_some_and(|d| d == today);
67    let already_synced = config
68        .cloud
69        .last_sync
70        .as_deref()
71        .is_some_and(|d| d == today);
72    let already_gain_synced = config
73        .cloud
74        .last_gain_sync
75        .as_deref()
76        .is_some_and(|d| d == today);
77    let already_pulled = config
78        .cloud
79        .last_model_pull
80        .as_deref()
81        .is_some_and(|d| d == today);
82
83    if config.cloud.contribute_enabled && !already_contributed {
84        let entries = collect_contribute_entries();
85        if !entries.is_empty() && crate::cloud_client::contribute(&entries).is_ok() {
86            config.cloud.last_contribute = Some(today.clone());
87        }
88    }
89
90    if crate::cloud_client::is_logged_in() {
91        if !already_synced {
92            let store = crate::core::stats::load();
93            let entries = build_sync_entries(&store);
94            if !entries.is_empty() && crate::cloud_client::sync_stats(&entries).is_ok() {
95                config.cloud.last_sync = Some(today.clone());
96            }
97        }
98
99        if !already_gain_synced {
100            let engine = crate::core::gain::GainEngine::load();
101            let summary = engine.summary(None);
102            let trend = match summary.score.trend {
103                crate::core::gain::gain_score::Trend::Rising => "rising",
104                crate::core::gain::gain_score::Trend::Stable => "stable",
105                crate::core::gain::gain_score::Trend::Declining => "declining",
106            };
107            let entry = serde_json::json!({
108                "recorded_at": format!("{today}T00:00:00Z"),
109                "total": summary.score.total as f64,
110                "compression": summary.score.compression as f64,
111                "cost_efficiency": summary.score.cost_efficiency as f64,
112                "quality": summary.score.quality as f64,
113                "consistency": summary.score.consistency as f64,
114                "trend": trend,
115                "avoided_usd": summary.avoided_usd,
116                "tool_spend_usd": summary.tool_spend_usd,
117                "model_key": summary.model.model_key,
118            });
119            if crate::cloud_client::push_gain(&[entry]).is_ok() {
120                config.cloud.last_gain_sync = Some(today.clone());
121            }
122        }
123
124        if !already_pulled {
125            if let Ok(data) = crate::cloud_client::pull_cloud_models() {
126                let _ = crate::cloud_client::save_cloud_models(&data);
127                config.cloud.last_model_pull = Some(today.clone());
128            }
129        }
130
131        // Opt-in Personal-Cloud auto-push (GL #384): silent, once per day,
132        // offline-tolerant. A network failure leaves the slot open so the
133        // next background cycle retries; a Pro gate consumes it (one quiet
134        // attempt per day on a Free account, never error spam).
135        if should_auto_sync(
136            config.cloud.auto_sync,
137            true,
138            config.cloud.last_auto_sync.as_deref(),
139            &today,
140        ) && auto_sync_personal_cloud() != AutoSyncOutcome::NetworkFailure
141        {
142            config.cloud.last_auto_sync = Some(today.clone());
143        }
144
145        // Opt-in hosted-index auto-push (GL #392): once per project per day,
146        // only when a local index exists. Quota/Pro rejections consume the
147        // slot (one quiet attempt per day); network failures leave it open.
148        if let Ok(root) = std::env::current_dir() {
149            let project_hash = crate::core::index_namespace::namespace_hash(&root);
150            if should_auto_push_index(
151                config.cloud.auto_index,
152                true,
153                crate::core::index_bundle::local_index_present(&root),
154                config
155                    .cloud
156                    .last_index_push
157                    .get(&project_hash)
158                    .map(String::as_str),
159                &today,
160            ) {
161                match crate::cloud_client::push_index_bundle(&root) {
162                    Ok((hash, bytes)) => {
163                        tracing::debug!(project = %hash, bytes, "auto-index: pushed");
164                        config
165                            .cloud
166                            .last_index_push
167                            .insert(project_hash, today.clone());
168                    }
169                    Err(e) if e.contains("Pro") || e.contains("Quota") => {
170                        tracing::debug!(error = %e, "auto-index: gated, retry tomorrow");
171                        config
172                            .cloud
173                            .last_index_push
174                            .insert(project_hash, today.clone());
175                    }
176                    Err(e) => {
177                        tracing::debug!(error = %e, "auto-index: push failed, slot stays open");
178                    }
179                }
180            }
181        }
182    }
183
184    let _ = config.save();
185}
186
187/// Push every Personal-Cloud surface silently (background variant of
188/// `lean-ctx sync`'s interactive flow — tracing instead of stdout).
189fn auto_sync_personal_cloud() -> AutoSyncOutcome {
190    let store = crate::core::stats::load();
191    let mut results: Vec<Result<(), String>> = Vec::new();
192
193    let mut push = |label: &str, result: Result<String, String>| match result {
194        Ok(_) => {
195            tracing::debug!(surface = label, "auto-sync: pushed");
196            results.push(Ok(()));
197        }
198        Err(e) => {
199            tracing::debug!(surface = label, error = %e, "auto-sync: push failed");
200            results.push(Err(e));
201        }
202    };
203
204    let commands = collect_command_entries(&store);
205    if !commands.is_empty() {
206        push("commands", crate::cloud_client::push_commands(&commands));
207    }
208    let cep = collect_cep_entries(&store);
209    if !cep.is_empty() {
210        push("cep", crate::cloud_client::push_cep(&cep));
211    }
212    let knowledge = collect_knowledge_entries();
213    if !knowledge.is_empty() {
214        push("knowledge", crate::cloud_client::push_knowledge(&knowledge));
215    }
216    let gotchas = collect_gotcha_entries();
217    if !gotchas.is_empty() {
218        push("gotchas", crate::cloud_client::push_gotchas(&gotchas));
219    }
220    let buddy = crate::core::buddy::BuddyState::compute();
221    if let Ok(buddy_data) = serde_json::to_value(&buddy) {
222        push("buddy", crate::cloud_client::push_buddy(&buddy_data));
223    }
224    let feedback = collect_feedback_entries();
225    if !feedback.is_empty() {
226        push("feedback", crate::cloud_client::push_feedback(&feedback));
227    }
228
229    let outcome = classify_outcomes(&results);
230    tracing::info!(
231        ?outcome,
232        surfaces = results.len(),
233        "personal-cloud auto-sync done"
234    );
235    outcome
236}
237
238pub fn build_sync_entries(store: &crate::core::stats::StatsStore) -> Vec<serde_json::Value> {
239    let mut entries = Vec::new();
240    let cep = &store.cep;
241    let today = chrono::Local::now().format("%Y-%m-%d").to_string();
242
243    let mut cep_cache_by_day: std::collections::HashMap<String, (u64, u64)> =
244        std::collections::HashMap::new();
245    for s in &cep.scores {
246        if let Some(date) = s.timestamp.get(..10) {
247            let entry = cep_cache_by_day.entry(date.to_string()).or_default();
248            let calls = s.tool_calls.max(1);
249            let hits = (calls as f64 * s.cache_hit_rate as f64 / 100.0).round() as u64;
250            entry.0 += calls;
251            entry.1 += hits;
252        }
253    }
254
255    let mut mcp_saved_total = 0u64;
256    for (cmd, s) in &store.commands {
257        if cmd.starts_with("ctx_") {
258            mcp_saved_total += s.input_tokens.saturating_sub(s.output_tokens);
259        }
260    }
261    let global_saved = store
262        .total_input_tokens
263        .saturating_sub(store.total_output_tokens)
264        .max(1);
265    let mcp_ratio = mcp_saved_total as f64 / global_saved as f64;
266
267    for day in &store.daily {
268        let tokens_original = day.input_tokens;
269        let tokens_compressed = day.output_tokens;
270        let tokens_saved = tokens_original.saturating_sub(tokens_compressed);
271        let (day_calls, day_hits) = cep_cache_by_day.get(&day.date).copied().unwrap_or((0, 0));
272        let day_mcp_saved = (tokens_saved as f64 * mcp_ratio).round() as u64;
273        let day_hook_saved = tokens_saved.saturating_sub(day_mcp_saved);
274        entries.push(serde_json::json!({
275            "date": day.date,
276            "tokens_original": tokens_original,
277            "tokens_compressed": tokens_compressed,
278            "tokens_saved": tokens_saved,
279            "mcp_tokens_saved": day_mcp_saved,
280            "hook_tokens_saved": day_hook_saved,
281            "tool_calls": day.commands,
282            "cache_hits": day_hits,
283            "cache_misses": day_calls.saturating_sub(day_hits),
284        }));
285    }
286
287    let has_today = entries.iter().any(|e| e["date"].as_str() == Some(&today));
288    if !has_today && (cep.total_tokens_original > 0 || store.total_commands > 0) {
289        let today_saved = cep
290            .total_tokens_original
291            .saturating_sub(cep.total_tokens_compressed);
292        let today_mcp = (today_saved as f64 * mcp_ratio).round() as u64;
293        entries.push(serde_json::json!({
294            "date": today,
295            "tokens_original": cep.total_tokens_original,
296            "tokens_compressed": cep.total_tokens_compressed,
297            "tokens_saved": today_saved,
298            "mcp_tokens_saved": today_mcp,
299            "hook_tokens_saved": today_saved.saturating_sub(today_mcp),
300            "tool_calls": store.total_commands,
301            "cache_hits": cep.total_cache_hits,
302            "cache_misses": cep.total_cache_reads.saturating_sub(cep.total_cache_hits),
303        }));
304    }
305
306    entries
307}
308
309// ── Personal-Cloud surface collectors ────────────────────────────────────────
310// Shared by the interactive `lean-ctx sync` flow and the background auto-sync
311// (GL #384): pure local reads, no network, no stdout.
312
313pub fn collect_knowledge_entries() -> Vec<serde_json::Value> {
314    let Some(home) = dirs::home_dir() else {
315        return Vec::new();
316    };
317    let knowledge_dir = home.join(".lean-ctx").join("knowledge");
318    if !knowledge_dir.is_dir() {
319        return Vec::new();
320    }
321
322    let mut entries = Vec::new();
323
324    for project_entry in std::fs::read_dir(&knowledge_dir).into_iter().flatten() {
325        let Ok(project_entry) = project_entry else {
326            continue;
327        };
328        let project_path = project_entry.path();
329        if !project_path.is_dir() {
330            continue;
331        }
332
333        for file_entry in std::fs::read_dir(&project_path).into_iter().flatten() {
334            let Ok(file_entry) = file_entry else { continue };
335            let file_path = file_entry.path();
336            if file_path.extension().and_then(|e| e.to_str()) != Some("json") {
337                continue;
338            }
339            let Ok(data) = std::fs::read_to_string(&file_path) else {
340                continue;
341            };
342            let parsed: serde_json::Value = match serde_json::from_str(&data) {
343                Ok(v) => v,
344                Err(_) => continue,
345            };
346
347            if let Some(facts) = parsed["facts"].as_array() {
348                for fact in facts {
349                    let cat = fact["category"].as_str().unwrap_or("general");
350                    let key = fact["key"].as_str().unwrap_or("");
351                    let val = fact["value"]
352                        .as_str()
353                        .or_else(|| fact["description"].as_str())
354                        .unwrap_or("");
355                    if !key.is_empty() {
356                        entries.push(serde_json::json!({
357                            "category": cat,
358                            "key": key,
359                            "value": val,
360                        }));
361                    }
362                }
363            }
364
365            if let Some(gotchas) = parsed["gotchas"].as_array() {
366                for g in gotchas {
367                    let pattern = g["pattern"].as_str().unwrap_or("");
368                    let fix = g["fix"].as_str().unwrap_or("");
369                    if !pattern.is_empty() {
370                        entries.push(serde_json::json!({
371                            "category": "gotcha",
372                            "key": pattern,
373                            "value": fix,
374                        }));
375                    }
376                }
377            }
378        }
379    }
380
381    entries
382}
383
384pub fn collect_command_entries(store: &crate::core::stats::StatsStore) -> Vec<serde_json::Value> {
385    store
386        .commands
387        .iter()
388        .map(|(name, stats)| {
389            let tokens_saved = stats.input_tokens.saturating_sub(stats.output_tokens);
390            serde_json::json!({
391                "command": name,
392                "source": if name.starts_with("ctx_") { "mcp" } else { "hook" },
393                "count": stats.count,
394                "input_tokens": stats.input_tokens,
395                "output_tokens": stats.output_tokens,
396                "tokens_saved": tokens_saved,
397            })
398        })
399        .collect()
400}
401
402fn complexity_to_float(s: &str) -> f64 {
403    match s.to_lowercase().as_str() {
404        "trivial" => 0.1,
405        "simple" => 0.3,
406        "moderate" => 0.5,
407        "complex" => 0.7,
408        "architectural" => 0.9,
409        other => other.parse::<f64>().unwrap_or(0.5),
410    }
411}
412
413pub fn collect_cep_entries(store: &crate::core::stats::StatsStore) -> Vec<serde_json::Value> {
414    store
415        .cep
416        .scores
417        .iter()
418        .map(|s| {
419            serde_json::json!({
420                "recorded_at": s.timestamp,
421                "score": s.score as f64 / 100.0,
422                "cache_hit_rate": s.cache_hit_rate as f64 / 100.0,
423                "mode_diversity": s.mode_diversity as f64 / 100.0,
424                "compression_rate": s.compression_rate as f64 / 100.0,
425                "tool_calls": s.tool_calls,
426                "tokens_saved": s.tokens_saved,
427                "complexity": complexity_to_float(&s.complexity),
428            })
429        })
430        .collect()
431}
432
433pub fn collect_gotcha_entries() -> Vec<serde_json::Value> {
434    let mut all_gotchas = crate::core::gotcha_tracker::load_universal_gotchas();
435
436    if let Some(home) = dirs::home_dir() {
437        let knowledge_dir = home.join(".lean-ctx").join("knowledge");
438        if let Ok(entries) = std::fs::read_dir(&knowledge_dir) {
439            for entry in entries.flatten() {
440                let gotcha_path = entry.path().join("gotchas.json");
441                if gotcha_path.exists() {
442                    if let Ok(content) = std::fs::read_to_string(&gotcha_path) {
443                        if let Ok(store) = serde_json::from_str::<
444                            crate::core::gotcha_tracker::GotchaStore,
445                        >(&content)
446                        {
447                            for g in store.gotchas {
448                                if !all_gotchas
449                                    .iter()
450                                    .any(|existing| existing.trigger == g.trigger)
451                                {
452                                    all_gotchas.push(g);
453                                }
454                            }
455                        }
456                    }
457                }
458            }
459        }
460    }
461
462    all_gotchas
463        .iter()
464        .map(|g| {
465            serde_json::json!({
466                "pattern": g.trigger,
467                "fix": g.resolution,
468                "severity": format!("{:?}", g.severity).to_lowercase(),
469                "category": format!("{:?}", g.category).to_lowercase(),
470                "occurrences": g.occurrences,
471                "prevented_count": g.prevented_count,
472                "confidence": g.confidence,
473            })
474        })
475        .collect()
476}
477
478pub fn collect_feedback_entries() -> Vec<serde_json::Value> {
479    let store = crate::core::feedback::FeedbackStore::load();
480    store
481        .learned_thresholds
482        .iter()
483        .map(|(lang, thresholds)| {
484            serde_json::json!({
485                "language": lang,
486                "entropy": thresholds.entropy,
487                "jaccard": thresholds.jaccard,
488                "sample_count": thresholds.sample_count,
489                "avg_efficiency": thresholds.avg_efficiency,
490            })
491        })
492        .collect()
493}
494
495pub fn collect_contribute_entries() -> Vec<serde_json::Value> {
496    let mut entries = Vec::new();
497
498    if let Some(home) = dirs::home_dir() {
499        let mode_stats_path = crate::core::data_dir::lean_ctx_data_dir()
500            .unwrap_or_else(|_| home.join(".lean-ctx"))
501            .join("mode_stats.json");
502        if let Ok(data) = std::fs::read_to_string(&mode_stats_path) {
503            if let Ok(predictor) = serde_json::from_str::<serde_json::Value>(&data) {
504                if let Some(history) = predictor["history"].as_object() {
505                    for (_key, outcomes) in history {
506                        if let Some(arr) = outcomes.as_array() {
507                            for outcome in arr.iter().rev().take(3) {
508                                let ext = outcome["ext"].as_str().unwrap_or("unknown");
509                                let mode = outcome["mode"].as_str().unwrap_or("full");
510                                let t_in = outcome["tokens_in"].as_u64().unwrap_or(0);
511                                let t_out = outcome["tokens_out"].as_u64().unwrap_or(0);
512                                let ratio = if t_in > 0 {
513                                    1.0 - t_out as f64 / t_in as f64
514                                } else {
515                                    0.0
516                                };
517                                let bucket = match t_in {
518                                    0..=500 => "0-500",
519                                    501..=2000 => "500-2k",
520                                    2001..=10000 => "2k-10k",
521                                    _ => "10k+",
522                                };
523                                entries.push(serde_json::json!({
524                                    "file_ext": format!(".{ext}"),
525                                    "size_bucket": bucket,
526                                    "best_mode": mode,
527                                    "compression_ratio": (ratio * 100.0).round() / 100.0,
528                                }));
529                                if entries.len() >= 200 {
530                                    return entries;
531                                }
532                            }
533                        }
534                    }
535                }
536            }
537        }
538    }
539
540    if entries.is_empty() {
541        let stats_data = crate::core::stats::format_gain_json();
542        if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&stats_data) {
543            let original = parsed["cep"]["total_tokens_original"].as_u64().unwrap_or(0);
544            let compressed = parsed["cep"]["total_tokens_compressed"]
545                .as_u64()
546                .unwrap_or(0);
547            let ratio = if original > 0 {
548                1.0 - compressed as f64 / original as f64
549            } else {
550                0.0
551            };
552            if let Some(modes) = parsed["cep"]["modes"].as_object() {
553                let read_modes = [
554                    "full",
555                    "map",
556                    "signatures",
557                    "auto",
558                    "aggressive",
559                    "entropy",
560                    "diff",
561                    "lines",
562                    "task",
563                    "reference",
564                ];
565                for (mode, count) in modes {
566                    if !read_modes.contains(&mode.as_str()) || count.as_u64().unwrap_or(0) == 0 {
567                        continue;
568                    }
569                    entries.push(serde_json::json!({
570                        "file_ext": "mixed",
571                        "size_bucket": "mixed",
572                        "best_mode": mode,
573                        "compression_ratio": (ratio * 100.0).round() / 100.0,
574                    }));
575                }
576            }
577        }
578    }
579
580    entries
581}
582
583#[cfg(test)]
584mod tests {
585    use super::*;
586
587    #[test]
588    fn auto_sync_requires_flag_login_and_unused_slot() {
589        // Disabled flag blocks everything else.
590        assert!(!should_auto_sync(false, true, None, "2026-06-10"));
591        // Logged out never syncs.
592        assert!(!should_auto_sync(true, false, None, "2026-06-10"));
593        // Fresh slot + flag + login → go.
594        assert!(should_auto_sync(true, true, None, "2026-06-10"));
595        // Already synced today → debounced.
596        assert!(!should_auto_sync(
597            true,
598            true,
599            Some("2026-06-10"),
600            "2026-06-10"
601        ));
602        // Synced yesterday → today's slot is free.
603        assert!(should_auto_sync(
604            true,
605            true,
606            Some("2026-06-09"),
607            "2026-06-10"
608        ));
609    }
610
611    #[test]
612    fn auto_index_push_needs_flag_login_index_and_fresh_slot() {
613        let t = "2026-06-10";
614        // All preconditions met → push.
615        assert!(should_auto_push_index(true, true, true, None, t));
616        // Separate opt-in: auto_sync users are NOT auto-enrolled.
617        assert!(!should_auto_push_index(false, true, true, None, t));
618        // Logged out / no local index → silently skip, no error path.
619        assert!(!should_auto_push_index(true, false, true, None, t));
620        assert!(!should_auto_push_index(true, true, false, None, t));
621        // Per-project debounce: today consumed, yesterday frees the slot.
622        assert!(!should_auto_push_index(true, true, true, Some(t), t));
623        assert!(should_auto_push_index(
624            true,
625            true,
626            true,
627            Some("2026-06-09"),
628            t
629        ));
630    }
631
632    #[test]
633    fn outcome_classification_is_gate_then_network_then_synced() {
634        // Nothing to push counts as synced (slot consumed, no retry storm).
635        assert_eq!(classify_outcomes(&[]), AutoSyncOutcome::Synced);
636        // Any 402 means the account is gated, even with other failures.
637        assert_eq!(
638            classify_outcomes(&[
639                Err("HTTP 402: upgrade required".into()),
640                Err("connection refused".into()),
641            ]),
642            AutoSyncOutcome::Gated
643        );
644        // All failed without a 402 → offline, keep the slot open.
645        assert_eq!(
646            classify_outcomes(&[Err("connection refused".into()), Err("timeout".into()),]),
647            AutoSyncOutcome::NetworkFailure
648        );
649        // Partial success is success.
650        assert_eq!(
651            classify_outcomes(&[Ok(()), Err("timeout".into())]),
652            AutoSyncOutcome::Synced
653        );
654    }
655}