1use crate::core::config::Config;
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub enum AutoSyncOutcome {
6 Synced,
8 Gated,
10 NetworkFailure,
13}
14
15#[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#[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#[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 && let Ok(data) = crate::cloud_client::pull_cloud_models() {
125 let _ = crate::cloud_client::save_cloud_models(&data);
126 config.cloud.last_model_pull = Some(today.clone());
127 }
128
129 if should_auto_sync(
134 config.cloud.auto_sync,
135 true,
136 config.cloud.last_auto_sync.as_deref(),
137 &today,
138 ) && auto_sync_personal_cloud() != AutoSyncOutcome::NetworkFailure
139 {
140 config.cloud.last_auto_sync = Some(today.clone());
141 }
142
143 if let Ok(root) = std::env::current_dir() {
147 let project_hash = crate::core::index_namespace::namespace_hash(&root);
148 if should_auto_push_index(
149 config.cloud.auto_index,
150 true,
151 crate::core::index_bundle::local_index_present(&root),
152 config
153 .cloud
154 .last_index_push
155 .get(&project_hash)
156 .map(String::as_str),
157 &today,
158 ) {
159 match crate::cloud_client::push_index_bundle(&root) {
160 Ok((hash, bytes)) => {
161 tracing::debug!(project = %hash, bytes, "auto-index: pushed");
162 config
163 .cloud
164 .last_index_push
165 .insert(project_hash, today.clone());
166 }
167 Err(e) if e.contains("Pro") || e.contains("Quota") => {
168 tracing::debug!(error = %e, "auto-index: gated, retry tomorrow");
169 config
170 .cloud
171 .last_index_push
172 .insert(project_hash, today.clone());
173 }
174 Err(e) => {
175 tracing::debug!(error = %e, "auto-index: push failed, slot stays open");
176 }
177 }
178 }
179 }
180 }
181
182 let _ = config.save();
183}
184
185fn auto_sync_personal_cloud() -> AutoSyncOutcome {
188 let store = crate::core::stats::load();
189 let mut results: Vec<Result<(), String>> = Vec::new();
190
191 let mut push = |label: &str, result: Result<String, String>| match result {
192 Ok(_) => {
193 tracing::debug!(surface = label, "auto-sync: pushed");
194 results.push(Ok(()));
195 }
196 Err(e) => {
197 tracing::debug!(surface = label, error = %e, "auto-sync: push failed");
198 results.push(Err(e));
199 }
200 };
201
202 let commands = collect_command_entries(&store);
203 if !commands.is_empty() {
204 push("commands", crate::cloud_client::push_commands(&commands));
205 }
206 let cep = collect_cep_entries(&store);
207 if !cep.is_empty() {
208 push("cep", crate::cloud_client::push_cep(&cep));
209 }
210 let knowledge = collect_knowledge_entries();
211 if !knowledge.is_empty() {
212 push("knowledge", crate::cloud_client::push_knowledge(&knowledge));
213 }
214 let gotchas = collect_gotcha_entries();
215 if !gotchas.is_empty() {
216 push("gotchas", crate::cloud_client::push_gotchas(&gotchas));
217 }
218 let buddy = crate::core::buddy::BuddyState::compute();
219 if let Ok(buddy_data) = serde_json::to_value(&buddy) {
220 push("buddy", crate::cloud_client::push_buddy(&buddy_data));
221 }
222 let feedback = collect_feedback_entries();
223 if !feedback.is_empty() {
224 push("feedback", crate::cloud_client::push_feedback(&feedback));
225 }
226
227 let outcome = classify_outcomes(&results);
228 tracing::info!(
229 ?outcome,
230 surfaces = results.len(),
231 "personal-cloud auto-sync done"
232 );
233 outcome
234}
235
236pub fn build_sync_entries(store: &crate::core::stats::StatsStore) -> Vec<serde_json::Value> {
237 let mut entries = Vec::new();
238 let cep = &store.cep;
239 let today = chrono::Local::now().format("%Y-%m-%d").to_string();
240
241 let mut cep_cache_by_day: std::collections::HashMap<String, (u64, u64)> =
242 std::collections::HashMap::new();
243 for s in &cep.scores {
244 if let Some(date) = s.timestamp.get(..10) {
245 let entry = cep_cache_by_day.entry(date.to_string()).or_default();
246 let calls = s.tool_calls.max(1);
247 let hits = (calls as f64 * s.cache_hit_rate as f64 / 100.0).round() as u64;
248 entry.0 += calls;
249 entry.1 += hits;
250 }
251 }
252
253 let mut mcp_saved_total = 0u64;
254 for (cmd, s) in &store.commands {
255 if cmd.starts_with("ctx_") {
256 mcp_saved_total += s.input_tokens.saturating_sub(s.output_tokens);
257 }
258 }
259 let global_saved = store
260 .total_input_tokens
261 .saturating_sub(store.total_output_tokens)
262 .max(1);
263 let mcp_ratio = mcp_saved_total as f64 / global_saved as f64;
264
265 for day in &store.daily {
266 let tokens_original = day.input_tokens;
267 let tokens_compressed = day.output_tokens;
268 let tokens_saved = tokens_original.saturating_sub(tokens_compressed);
269 let (day_calls, day_hits) = cep_cache_by_day.get(&day.date).copied().unwrap_or((0, 0));
270 let day_mcp_saved = (tokens_saved as f64 * mcp_ratio).round() as u64;
271 let day_hook_saved = tokens_saved.saturating_sub(day_mcp_saved);
272 entries.push(serde_json::json!({
273 "date": day.date,
274 "tokens_original": tokens_original,
275 "tokens_compressed": tokens_compressed,
276 "tokens_saved": tokens_saved,
277 "mcp_tokens_saved": day_mcp_saved,
278 "hook_tokens_saved": day_hook_saved,
279 "tool_calls": day.commands,
280 "cache_hits": day_hits,
281 "cache_misses": day_calls.saturating_sub(day_hits),
282 }));
283 }
284
285 let has_today = entries.iter().any(|e| e["date"].as_str() == Some(&today));
286 if !has_today && (cep.total_tokens_original > 0 || store.total_commands > 0) {
287 let today_saved = cep
288 .total_tokens_original
289 .saturating_sub(cep.total_tokens_compressed);
290 let today_mcp = (today_saved as f64 * mcp_ratio).round() as u64;
291 entries.push(serde_json::json!({
292 "date": today,
293 "tokens_original": cep.total_tokens_original,
294 "tokens_compressed": cep.total_tokens_compressed,
295 "tokens_saved": today_saved,
296 "mcp_tokens_saved": today_mcp,
297 "hook_tokens_saved": today_saved.saturating_sub(today_mcp),
298 "tool_calls": store.total_commands,
299 "cache_hits": cep.total_cache_hits,
300 "cache_misses": cep.total_cache_reads.saturating_sub(cep.total_cache_hits),
301 }));
302 }
303
304 entries
305}
306
307pub fn collect_knowledge_entries() -> Vec<serde_json::Value> {
312 let Ok(data_dir) = crate::core::paths::data_dir() else {
313 return Vec::new();
314 };
315 let knowledge_dir = data_dir.join("knowledge");
316 if !knowledge_dir.is_dir() {
317 return Vec::new();
318 }
319
320 let mut entries = Vec::new();
321
322 for project_entry in std::fs::read_dir(&knowledge_dir).into_iter().flatten() {
323 let Ok(project_entry) = project_entry else {
324 continue;
325 };
326 let project_path = project_entry.path();
327 if !project_path.is_dir() {
328 continue;
329 }
330
331 for file_entry in std::fs::read_dir(&project_path).into_iter().flatten() {
332 let Ok(file_entry) = file_entry else { continue };
333 let file_path = file_entry.path();
334 if file_path.extension().and_then(|e| e.to_str()) != Some("json") {
335 continue;
336 }
337 let Ok(data) = std::fs::read_to_string(&file_path) else {
338 continue;
339 };
340 let parsed: serde_json::Value = match serde_json::from_str(&data) {
341 Ok(v) => v,
342 Err(_) => continue,
343 };
344
345 if let Some(facts) = parsed["facts"].as_array() {
346 for fact in facts {
347 let cat = fact["category"].as_str().unwrap_or("general");
348 let key = fact["key"].as_str().unwrap_or("");
349 let val = fact["value"]
350 .as_str()
351 .or_else(|| fact["description"].as_str())
352 .unwrap_or("");
353 if !key.is_empty() {
354 entries.push(serde_json::json!({
355 "category": cat,
356 "key": key,
357 "value": val,
358 }));
359 }
360 }
361 }
362
363 if let Some(gotchas) = parsed["gotchas"].as_array() {
364 for g in gotchas {
365 let pattern = g["pattern"].as_str().unwrap_or("");
366 let fix = g["fix"].as_str().unwrap_or("");
367 if !pattern.is_empty() {
368 entries.push(serde_json::json!({
369 "category": "gotcha",
370 "key": pattern,
371 "value": fix,
372 }));
373 }
374 }
375 }
376 }
377 }
378
379 entries
380}
381
382pub fn collect_command_entries(store: &crate::core::stats::StatsStore) -> Vec<serde_json::Value> {
383 store
384 .commands
385 .iter()
386 .map(|(name, stats)| {
387 let tokens_saved = stats.input_tokens.saturating_sub(stats.output_tokens);
388 serde_json::json!({
389 "command": name,
390 "source": if name.starts_with("ctx_") { "mcp" } else { "hook" },
391 "count": stats.count,
392 "input_tokens": stats.input_tokens,
393 "output_tokens": stats.output_tokens,
394 "tokens_saved": tokens_saved,
395 })
396 })
397 .collect()
398}
399
400fn complexity_to_float(s: &str) -> f64 {
401 match s.to_lowercase().as_str() {
402 "trivial" => 0.1,
403 "simple" => 0.3,
404 "moderate" => 0.5,
405 "complex" => 0.7,
406 "architectural" => 0.9,
407 other => other.parse::<f64>().unwrap_or(0.5),
408 }
409}
410
411pub fn collect_cep_entries(store: &crate::core::stats::StatsStore) -> Vec<serde_json::Value> {
412 store
413 .cep
414 .scores
415 .iter()
416 .map(|s| {
417 serde_json::json!({
418 "recorded_at": s.timestamp,
419 "score": s.score as f64 / 100.0,
420 "cache_hit_rate": s.cache_hit_rate as f64 / 100.0,
421 "mode_diversity": s.mode_diversity as f64 / 100.0,
422 "compression_rate": s.compression_rate as f64 / 100.0,
423 "tool_calls": s.tool_calls,
424 "tokens_saved": s.tokens_saved,
425 "complexity": complexity_to_float(&s.complexity),
426 })
427 })
428 .collect()
429}
430
431pub fn collect_gotcha_entries() -> Vec<serde_json::Value> {
432 let mut all_gotchas = crate::core::gotcha_tracker::load_universal_gotchas();
433
434 if let Ok(knowledge_dir) = crate::core::paths::data_dir().map(|d| d.join("knowledge"))
435 && let Ok(entries) = std::fs::read_dir(&knowledge_dir)
436 {
437 for entry in entries.flatten() {
438 let gotcha_path = entry.path().join("gotchas.json");
439 if gotcha_path.exists()
440 && let Ok(content) = std::fs::read_to_string(&gotcha_path)
441 && let Ok(store) =
442 serde_json::from_str::<crate::core::gotcha_tracker::GotchaStore>(&content)
443 {
444 for g in store.gotchas {
445 if !all_gotchas
446 .iter()
447 .any(|existing| existing.trigger == g.trigger)
448 {
449 all_gotchas.push(g);
450 }
451 }
452 }
453 }
454 }
455
456 all_gotchas
457 .iter()
458 .map(|g| {
459 serde_json::json!({
460 "pattern": g.trigger,
461 "fix": g.resolution,
462 "severity": format!("{:?}", g.severity).to_lowercase(),
463 "category": format!("{:?}", g.category).to_lowercase(),
464 "occurrences": g.occurrences,
465 "prevented_count": g.prevented_count,
466 "confidence": g.confidence,
467 })
468 })
469 .collect()
470}
471
472pub fn collect_feedback_entries() -> Vec<serde_json::Value> {
473 let store = crate::core::feedback::FeedbackStore::load();
474 store
475 .learned_thresholds
476 .iter()
477 .map(|(lang, thresholds)| {
478 serde_json::json!({
479 "language": lang,
480 "entropy": thresholds.entropy,
481 "jaccard": thresholds.jaccard,
482 "sample_count": thresholds.sample_count,
483 "avg_efficiency": thresholds.avg_efficiency,
484 })
485 })
486 .collect()
487}
488
489pub fn collect_contribute_entries() -> Vec<serde_json::Value> {
490 let mut entries = Vec::new();
491
492 if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
493 let mode_stats_path = data_dir.join("mode_stats.json");
494 if let Ok(data) = std::fs::read_to_string(&mode_stats_path)
495 && let Ok(predictor) = serde_json::from_str::<serde_json::Value>(&data)
496 && let Some(history) = predictor["history"].as_object()
497 {
498 for (_key, outcomes) in history {
499 if let Some(arr) = outcomes.as_array() {
500 for outcome in arr.iter().rev().take(3) {
501 let ext = outcome["ext"].as_str().unwrap_or("unknown");
502 let mode = outcome["mode"].as_str().unwrap_or("full");
503 let t_in = outcome["tokens_in"].as_u64().unwrap_or(0);
504 let t_out = outcome["tokens_out"].as_u64().unwrap_or(0);
505 let ratio = if t_in > 0 {
506 1.0 - t_out as f64 / t_in as f64
507 } else {
508 0.0
509 };
510 let bucket = match t_in {
511 0..=500 => "0-500",
512 501..=2000 => "500-2k",
513 2001..=10000 => "2k-10k",
514 _ => "10k+",
515 };
516 entries.push(serde_json::json!({
517 "file_ext": format!(".{ext}"),
518 "size_bucket": bucket,
519 "best_mode": mode,
520 "compression_ratio": (ratio * 100.0).round() / 100.0,
521 }));
522 if entries.len() >= 200 {
523 return entries;
524 }
525 }
526 }
527 }
528 }
529 }
530
531 if entries.is_empty() {
532 let stats_data = crate::core::stats::format_gain_json();
533 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&stats_data) {
534 let original = parsed["cep"]["total_tokens_original"].as_u64().unwrap_or(0);
535 let compressed = parsed["cep"]["total_tokens_compressed"]
536 .as_u64()
537 .unwrap_or(0);
538 let ratio = if original > 0 {
539 1.0 - compressed as f64 / original as f64
540 } else {
541 0.0
542 };
543 if let Some(modes) = parsed["cep"]["modes"].as_object() {
544 let read_modes = [
545 "full",
546 "map",
547 "signatures",
548 "auto",
549 "aggressive",
550 "entropy",
551 "diff",
552 "lines",
553 "task",
554 "reference",
555 ];
556 for (mode, count) in modes {
557 if !read_modes.contains(&mode.as_str()) || count.as_u64().unwrap_or(0) == 0 {
558 continue;
559 }
560 entries.push(serde_json::json!({
561 "file_ext": "mixed",
562 "size_bucket": "mixed",
563 "best_mode": mode,
564 "compression_ratio": (ratio * 100.0).round() / 100.0,
565 }));
566 }
567 }
568 }
569 }
570
571 entries
572}
573
574#[cfg(test)]
575mod tests {
576 use super::*;
577
578 #[test]
579 fn auto_sync_requires_flag_login_and_unused_slot() {
580 assert!(!should_auto_sync(false, true, None, "2026-06-10"));
582 assert!(!should_auto_sync(true, false, None, "2026-06-10"));
584 assert!(should_auto_sync(true, true, None, "2026-06-10"));
586 assert!(!should_auto_sync(
588 true,
589 true,
590 Some("2026-06-10"),
591 "2026-06-10"
592 ));
593 assert!(should_auto_sync(
595 true,
596 true,
597 Some("2026-06-09"),
598 "2026-06-10"
599 ));
600 }
601
602 #[test]
603 fn auto_index_push_needs_flag_login_index_and_fresh_slot() {
604 let t = "2026-06-10";
605 assert!(should_auto_push_index(true, true, true, None, t));
607 assert!(!should_auto_push_index(false, true, true, None, t));
609 assert!(!should_auto_push_index(true, false, true, None, t));
611 assert!(!should_auto_push_index(true, true, false, None, t));
612 assert!(!should_auto_push_index(true, true, true, Some(t), t));
614 assert!(should_auto_push_index(
615 true,
616 true,
617 true,
618 Some("2026-06-09"),
619 t
620 ));
621 }
622
623 #[test]
624 fn outcome_classification_is_gate_then_network_then_synced() {
625 assert_eq!(classify_outcomes(&[]), AutoSyncOutcome::Synced);
627 assert_eq!(
629 classify_outcomes(&[
630 Err("HTTP 402: upgrade required".into()),
631 Err("connection refused".into()),
632 ]),
633 AutoSyncOutcome::Gated
634 );
635 assert_eq!(
637 classify_outcomes(&[Err("connection refused".into()), Err("timeout".into()),]),
638 AutoSyncOutcome::NetworkFailure
639 );
640 assert_eq!(
642 classify_outcomes(&[Ok(()), Err("timeout".into())]),
643 AutoSyncOutcome::Synced
644 );
645 }
646}