Skip to main content

lean_ctx/core/gotcha_tracker/
mod.rs

1mod detect;
2pub mod learn;
3pub mod mining;
4mod model;
5mod persist;
6mod reflector;
7mod runtime;
8
9pub use detect::{
10    DetectedError, detect_error_pattern, is_correlatable_command, normalize_error_signature,
11};
12pub use model::{
13    ErrorEntry, FixEntry, Gotcha, GotchaCategory, GotchaSeverity, GotchaSource, GotchaStats,
14    GotchaStore, PendingError, SessionErrorLog,
15};
16pub use persist::{load_universal_gotchas, save_universal_gotchas};
17pub use reflector::{ReflectionInsight, fold_into_playbook, format_ledger, reflect};
18pub use runtime::record_shell_outcome;
19
20use chrono::{DateTime, Utc};
21use detect::command_base;
22use model::{DECAY_ARCHIVE_THRESHOLD, MAX_GOTCHAS, MAX_PENDING, MAX_SESSION_LOGS, gotcha_id};
23
24impl GotchaStore {
25    // -- Detection ----------------------------------------------------------
26
27    pub fn detect_error(
28        &mut self,
29        output: &str,
30        command: &str,
31        exit_code: i32,
32        files_touched: &[String],
33        session_id: &str,
34    ) -> bool {
35        self.pending_errors.retain(|p| !p.is_expired());
36
37        let Some(detected) = detect_error_pattern(output, command, exit_code) else {
38            return false;
39        };
40
41        let signature = normalize_error_signature(&detected.raw_message);
42        let snippet = output.chars().take(500).collect::<String>();
43
44        self.pending_errors.push(PendingError {
45            error_signature: signature.clone(),
46            category: detected.category,
47            severity: detected.severity,
48            command: command.to_string(),
49            exit_code,
50            files_at_error: files_touched.to_vec(),
51            timestamp: Utc::now(),
52            raw_snippet: snippet,
53            session_id: session_id.to_string(),
54        });
55
56        if self.pending_errors.len() > MAX_PENDING {
57            self.pending_errors.remove(0);
58        }
59
60        self.log_error(session_id, &signature, command);
61        self.stats.total_errors_detected += 1;
62        true
63    }
64
65    pub fn try_resolve_pending(
66        &mut self,
67        command: &str,
68        files_touched: &[String],
69        session_id: &str,
70    ) -> Option<Gotcha> {
71        self.pending_errors.retain(|p| !p.is_expired());
72
73        let cmd_base = command_base(command);
74        let idx = self
75            .pending_errors
76            .iter()
77            .position(|p| command_base(&p.command) == cmd_base)?;
78
79        let pending = self.pending_errors.remove(idx);
80
81        let changed_files: Vec<String> = files_touched
82            .iter()
83            .filter(|f| !pending.files_at_error.contains(f))
84            .cloned()
85            .collect();
86
87        let resolution = if changed_files.is_empty() {
88            format!("Fixed after re-running {cmd_base}")
89        } else {
90            format!("Fixed by editing: {}", changed_files.join(", "))
91        };
92
93        let mut gotcha = Gotcha::new(
94            pending.category,
95            pending.severity,
96            &pending.error_signature,
97            &resolution,
98            GotchaSource::AutoDetected {
99                command: command.to_string(),
100                exit_code: pending.exit_code,
101            },
102            session_id,
103        );
104        gotcha.file_patterns.clone_from(&changed_files);
105
106        self.add_or_merge(gotcha.clone());
107        self.log_fix(
108            session_id,
109            &pending.error_signature,
110            &resolution,
111            &changed_files,
112        );
113        self.stats.total_fixes_correlated += 1;
114        self.updated_at = Utc::now();
115
116        Some(gotcha)
117    }
118
119    // -- Agent-reported -----------------------------------------------------
120
121    pub fn report_gotcha(
122        &mut self,
123        trigger: &str,
124        resolution: &str,
125        category: &str,
126        severity: &str,
127        session_id: &str,
128    ) -> Option<&Gotcha> {
129        let cat = GotchaCategory::from_str_loose(category);
130        let sev = match severity.to_lowercase().as_str() {
131            "critical" => GotchaSeverity::Critical,
132            "info" => GotchaSeverity::Info,
133            _ => GotchaSeverity::Warning,
134        };
135        let id = gotcha_id(trigger, &cat);
136        let gotcha = Gotcha::new(
137            cat,
138            sev,
139            trigger,
140            resolution,
141            GotchaSource::AgentReported {
142                session_id: session_id.to_string(),
143            },
144            session_id,
145        );
146        self.add_or_merge(gotcha);
147        self.updated_at = Utc::now();
148        self.gotchas.iter().find(|g| g.id == id)
149    }
150
151    // -- Add / Merge --------------------------------------------------------
152
153    fn add_or_merge(&mut self, new: Gotcha) {
154        if let Some(existing) = self.gotchas.iter_mut().find(|g| g.id == new.id) {
155            existing.merge_with(&new);
156        } else {
157            self.gotchas.push(new);
158            if self.gotchas.len() > MAX_GOTCHAS {
159                self.gotchas.sort_by(|a, b| {
160                    b.confidence
161                        .partial_cmp(&a.confidence)
162                        .unwrap_or(std::cmp::Ordering::Equal)
163                });
164                self.gotchas.truncate(MAX_GOTCHAS);
165            }
166        }
167    }
168
169    // -- Cross-Session ------------------------------------------------------
170
171    fn log_error(&mut self, session_id: &str, signature: &str, command: &str) {
172        let log = self.get_or_create_session_log(session_id);
173        log.errors.push(ErrorEntry {
174            signature: signature.to_string(),
175            command: command.to_string(),
176            timestamp: Utc::now(),
177        });
178    }
179
180    fn log_fix(&mut self, session_id: &str, error_sig: &str, resolution: &str, files: &[String]) {
181        let log = self.get_or_create_session_log(session_id);
182        log.fixes.push(FixEntry {
183            error_signature: error_sig.to_string(),
184            resolution: resolution.to_string(),
185            files_changed: files.to_vec(),
186            timestamp: Utc::now(),
187        });
188    }
189
190    fn get_or_create_session_log(&mut self, session_id: &str) -> &mut SessionErrorLog {
191        if !self.error_log.iter().any(|l| l.session_id == session_id) {
192            self.error_log.push(SessionErrorLog {
193                session_id: session_id.to_string(),
194                timestamp: Utc::now(),
195                errors: Vec::new(),
196                fixes: Vec::new(),
197            });
198            if self.error_log.len() > MAX_SESSION_LOGS {
199                self.error_log.remove(0);
200            }
201        }
202        self.error_log
203            .iter_mut()
204            .find(|l| l.session_id == session_id)
205            .expect("session log must exist after push")
206    }
207
208    pub fn cross_session_boost(&mut self) {
209        let mut sig_sessions: std::collections::HashMap<String, Vec<String>> =
210            std::collections::HashMap::new();
211
212        for log in &self.error_log {
213            for err in &log.errors {
214                sig_sessions
215                    .entry(err.signature.clone())
216                    .or_default()
217                    .push(log.session_id.clone());
218            }
219        }
220
221        for gotcha in &mut self.gotchas {
222            if let Some(sessions) = sig_sessions.get(&gotcha.trigger) {
223                let unique: Vec<String> = sessions
224                    .iter()
225                    .filter(|s| !gotcha.session_ids.contains(s))
226                    .cloned()
227                    .collect();
228                if !unique.is_empty() {
229                    let boost = 0.15 * unique.len() as f32;
230                    gotcha.confidence = (gotcha.confidence + boost).min(0.95);
231                    for s in unique {
232                        gotcha.session_ids.push(s);
233                    }
234                    gotcha.source = GotchaSource::CrossSessionCorrelated {
235                        sessions: gotcha.session_ids.clone(),
236                    };
237                }
238            }
239        }
240    }
241
242    // -- Decay --------------------------------------------------------------
243
244    pub fn apply_decay(&mut self) {
245        let now = Utc::now();
246        let mut decayed = 0u64;
247
248        for gotcha in &mut self.gotchas {
249            let days_since = (now - gotcha.last_seen).num_days().max(0) as f32;
250            if days_since < 1.0 {
251                continue;
252            }
253            let base_rate = gotcha.source.decay_rate();
254            let occurrence_factor = 1.0 / (1.0 + gotcha.occurrences as f32 * 0.1);
255            let decay = base_rate * occurrence_factor * (days_since / 7.0);
256            gotcha.confidence = (gotcha.confidence - decay).max(0.0);
257        }
258
259        let before = self.gotchas.len();
260        self.gotchas
261            .retain(|g| g.confidence >= DECAY_ARCHIVE_THRESHOLD);
262        decayed += (before - self.gotchas.len()) as u64;
263
264        self.stats.gotchas_decayed += decayed;
265    }
266
267    // -- Promotion ----------------------------------------------------------
268
269    /// High-value gotchas eligible for promotion into durable project knowledge
270    /// (the loop-weighting bridge, #980): proven across sessions and shown to
271    /// prevent real errors. Read-only companion to [`Self::check_promotions`],
272    /// sorted by confidence so callers can take the strongest first.
273    #[must_use]
274    pub fn promotable(&self) -> Vec<&Gotcha> {
275        let mut v: Vec<&Gotcha> = self.gotchas.iter().filter(|g| g.is_promotable()).collect();
276        v.sort_by(|a, b| {
277            b.confidence
278                .partial_cmp(&a.confidence)
279                .unwrap_or(std::cmp::Ordering::Equal)
280        });
281        v
282    }
283
284    pub fn check_promotions(&mut self) -> Vec<(String, String, String, f32)> {
285        let mut promoted = Vec::new();
286        for gotcha in &self.gotchas {
287            if gotcha.is_promotable() {
288                promoted.push((
289                    gotcha.category.to_string(),
290                    gotcha.trigger.clone(),
291                    gotcha.resolution.clone(),
292                    gotcha.confidence,
293                ));
294            }
295        }
296        self.stats.gotchas_promoted += promoted.len() as u64;
297        promoted
298    }
299
300    // -- Universal Gotchas --------------------------------------------------
301
302    pub fn extract_universal(&self) -> Vec<Gotcha> {
303        self.gotchas
304            .iter()
305            .filter(|g| {
306                g.category == GotchaCategory::Platform
307                    && g.occurrences >= 10
308                    && g.session_ids.len() >= 5
309            })
310            .cloned()
311            .collect()
312    }
313
314    // -- Relevance scoring --------------------------------------------------
315
316    pub fn top_relevant(&self, files_touched: &[String], limit: usize) -> Vec<&Gotcha> {
317        let mut scored: Vec<(&Gotcha, f32)> = self
318            .gotchas
319            .iter()
320            .map(|g| (g, relevance_score(g, files_touched)))
321            .filter(|(_, s)| *s > 0.5)
322            .collect();
323
324        scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
325        scored.into_iter().take(limit).map(|(g, _)| g).collect()
326    }
327
328    pub fn format_injection_block(&self, files_touched: &[String]) -> String {
329        let relevant = self.top_relevant(files_touched, 7);
330        if relevant.is_empty() {
331            return String::new();
332        }
333
334        let mut lines = Vec::with_capacity(relevant.len() + 2);
335        lines.push("--- PROJECT GOTCHAS (do NOT repeat these mistakes) ---".to_string());
336
337        for g in &relevant {
338            let prefix = g.severity.prefix();
339            let label = g.category.short_label();
340            let sessions = g.session_ids.len();
341            let age = format_age(g.last_seen);
342            let trigger = crate::core::sanitize::neutralize_metadata(&g.trigger);
343            let resolution = crate::core::sanitize::neutralize_metadata(&g.resolution);
344
345            let source_hint = match &g.source {
346                GotchaSource::AgentReported { .. } => ", agent-confirmed".to_string(),
347                GotchaSource::CrossSessionCorrelated { .. } => {
348                    format!(", across {sessions} sessions")
349                }
350                GotchaSource::AutoDetected { .. } => ", auto-detected".to_string(),
351                GotchaSource::Promoted { .. } => ", proven".to_string(),
352            };
353
354            let prevented = if g.prevented_count > 0 {
355                format!(", prevented {}x", g.prevented_count)
356            } else {
357                String::new()
358            };
359
360            lines.push(format!("[{prefix}{label}] {trigger}"));
361            lines.push(format!(
362                "  FIX: {} (seen {}x{}{}, {})",
363                resolution, g.occurrences, source_hint, prevented, age
364            ));
365        }
366
367        lines.push("---".to_string());
368        crate::core::sanitize::fence_content("project_gotchas", &lines.join("\n"))
369    }
370
371    // -- Prevention tracking ------------------------------------------------
372
373    pub fn mark_prevented(&mut self, gotcha_id: &str) {
374        if let Some(g) = self.gotchas.iter_mut().find(|g| g.id == gotcha_id) {
375            g.prevented_count += 1;
376            g.confidence = (g.confidence + 0.05).min(0.99);
377            self.stats.total_prevented += 1;
378        }
379    }
380
381    // -- CLI ----------------------------------------------------------------
382
383    pub fn format_list(&self) -> String {
384        if self.gotchas.is_empty() {
385            return "No gotchas recorded for this project.".to_string();
386        }
387
388        let mut out = Vec::new();
389        out.push(format!("  {} active gotchas\n", self.gotchas.len()));
390
391        let mut sorted = self.gotchas.clone();
392        sorted.sort_by(|a, b| {
393            b.confidence
394                .partial_cmp(&a.confidence)
395                .unwrap_or(std::cmp::Ordering::Equal)
396        });
397
398        for g in &sorted {
399            let prefix = g.severity.prefix();
400            let label = g.category.short_label();
401            let conf = (g.confidence * 100.0) as u32;
402            let source = match &g.source {
403                GotchaSource::AutoDetected { .. } => "auto",
404                GotchaSource::AgentReported { .. } => "agent",
405                GotchaSource::CrossSessionCorrelated { .. } => "cross-session",
406                GotchaSource::Promoted { .. } => "promoted",
407            };
408            out.push(format!(
409                "  [{prefix}{label:8}] {} ({}x, {} sessions, {source}, confidence: {conf}%)",
410                truncate_str(&g.trigger, 60),
411                g.occurrences,
412                g.session_ids.len(),
413            ));
414            out.push(format!(
415                "             FIX: {}",
416                truncate_str(&g.resolution, 70)
417            ));
418            if g.prevented_count > 0 {
419                out.push(format!("             Prevented: {}x", g.prevented_count));
420            }
421            out.push(String::new());
422        }
423
424        out.push(format!(
425            "  Stats: {} errors detected | {} fixes correlated | {} prevented",
426            self.stats.total_errors_detected,
427            self.stats.total_fixes_correlated,
428            self.stats.total_prevented,
429        ));
430
431        out.join("\n")
432    }
433}
434
435// ---------------------------------------------------------------------------
436// Relevance scoring
437// ---------------------------------------------------------------------------
438
439pub fn relevance_score(gotcha: &Gotcha, files_touched: &[String]) -> f32 {
440    let mut score: f32 = 0.0;
441
442    score += (gotcha.occurrences as f32 * gotcha.confidence).min(10.0);
443
444    let hours_ago = (Utc::now() - gotcha.last_seen).num_hours().max(0) as f32;
445    score += 5.0 * (-hours_ago / 168.0).exp();
446
447    let overlap = gotcha
448        .file_patterns
449        .iter()
450        .filter(|fp| {
451            files_touched
452                .iter()
453                .any(|ft| ft.contains(fp.as_str()) || fp.contains(ft.as_str()))
454        })
455        .count();
456    score += overlap as f32 * 3.0;
457
458    score *= gotcha.severity.multiplier();
459
460    if gotcha.session_ids.len() >= 3 {
461        score *= 1.3;
462    }
463
464    if gotcha.prevented_count > 0 {
465        score *= 1.2;
466    }
467
468    score
469}
470
471// ---------------------------------------------------------------------------
472// Helpers
473// ---------------------------------------------------------------------------
474
475fn format_age(dt: DateTime<Utc>) -> String {
476    let diff = Utc::now() - dt;
477    let hours = diff.num_hours();
478    if hours < 1 {
479        format!("{}m ago", diff.num_minutes().max(1))
480    } else if hours < 24 {
481        format!("{hours}h ago")
482    } else {
483        format!("{}d ago", diff.num_days())
484    }
485}
486
487fn truncate_str(s: &str, max: usize) -> String {
488    if s.len() <= max {
489        s.to_string()
490    } else {
491        format!("{}...", &s[..s.floor_char_boundary(max.saturating_sub(3))])
492    }
493}
494
495// ---------------------------------------------------------------------------
496// Tests
497// ---------------------------------------------------------------------------
498
499#[cfg(test)]
500mod tests {
501    use super::*;
502
503    #[test]
504    fn detect_cargo_error() {
505        let output = r"error[E0507]: cannot move out of `self.field` which is behind a shared reference
506   --> src/server.rs:42:13";
507        let result = detect_error_pattern(output, "cargo build", 1);
508        assert!(result.is_some());
509        let d = result.unwrap();
510        assert_eq!(d.category, GotchaCategory::Build);
511        assert_eq!(d.severity, GotchaSeverity::Critical);
512        assert!(d.raw_message.contains("E0507"));
513    }
514
515    #[test]
516    fn detect_npm_error() {
517        let output = "npm ERR! ERESOLVE unable to resolve dependency tree";
518        let result = detect_error_pattern(output, "npm install", 1);
519        assert!(result.is_some());
520        assert_eq!(result.unwrap().category, GotchaCategory::Dependency);
521    }
522
523    #[test]
524    fn detect_python_traceback() {
525        let output = "Traceback (most recent call last):\n  File \"app.py\", line 5\nImportError: No module named 'flask'";
526        let result = detect_error_pattern(output, "python app.py", 1);
527        assert!(result.is_some());
528        assert_eq!(result.unwrap().category, GotchaCategory::Runtime);
529    }
530
531    #[test]
532    fn detect_typescript_error() {
533        let output =
534            "src/index.ts(10,5): error TS2339: Property 'foo' does not exist on type 'Bar'.";
535        let result = detect_error_pattern(output, "npx tsc", 1);
536        assert!(result.is_some());
537        assert_eq!(result.unwrap().category, GotchaCategory::Build);
538    }
539
540    #[test]
541    fn detect_go_error() {
542        let output = "./main.go:15:2: undefined: SomeFunc";
543        let result = detect_error_pattern(output, "go build", 1);
544        assert!(result.is_some());
545    }
546
547    #[test]
548    fn detect_jest_failure() {
549        let output = "FAIL src/app.test.ts\n  TypeError: Cannot read properties of undefined";
550        let result = detect_error_pattern(output, "npx jest", 1);
551        assert!(result.is_some());
552        assert_eq!(result.unwrap().category, GotchaCategory::Test);
553    }
554
555    #[test]
556    fn no_false_positive_on_success() {
557        let output = "Compiling lean-ctx v2.17.2\nFinished release target(s) in 30s";
558        let result = detect_error_pattern(output, "cargo build --release", 0);
559        assert!(result.is_none());
560    }
561
562    #[test]
563    fn normalize_signature_strips_paths() {
564        let raw = "error[E0507]: cannot move out of /Users/foo/project/src/main.rs:42:13";
565        let sig = normalize_error_signature(raw);
566        assert!(!sig.contains("/Users/foo"));
567        assert!(sig.contains("E0507"));
568        assert!(sig.contains(":_:_"));
569    }
570
571    #[test]
572    fn gotcha_store_add_and_merge() {
573        let mut store = GotchaStore::new("testhash");
574        let g1 = Gotcha::new(
575            GotchaCategory::Build,
576            GotchaSeverity::Critical,
577            "error E0507",
578            "use clone",
579            GotchaSource::AutoDetected {
580                command: "cargo build".into(),
581                exit_code: 1,
582            },
583            "s1",
584        );
585        store.add_or_merge(g1.clone());
586        assert_eq!(store.gotchas.len(), 1);
587
588        let g2 = Gotcha::new(
589            GotchaCategory::Build,
590            GotchaSeverity::Critical,
591            "error E0507",
592            "use ref pattern",
593            GotchaSource::AutoDetected {
594                command: "cargo build".into(),
595                exit_code: 1,
596            },
597            "s2",
598        );
599        store.add_or_merge(g2);
600        assert_eq!(store.gotchas.len(), 1);
601        assert_eq!(store.gotchas[0].occurrences, 2);
602        assert_eq!(store.gotchas[0].session_ids.len(), 2);
603    }
604
605    #[test]
606    fn gotcha_store_detect_and_resolve() {
607        let mut store = GotchaStore::new("testhash");
608
609        let error_output = "error[E0507]: cannot move out of `self.name`";
610        let detected = store.detect_error(error_output, "cargo build", 1, &[], "s1");
611        assert!(detected);
612        assert_eq!(store.pending_errors.len(), 1);
613
614        let resolved =
615            store.try_resolve_pending("cargo build --release", &["src/main.rs".into()], "s1");
616        assert!(resolved.is_some());
617        assert_eq!(store.gotchas.len(), 1);
618        assert!(store.gotchas[0].resolution.contains("src/main.rs"));
619    }
620
621    #[test]
622    fn agent_report_gotcha() {
623        let mut store = GotchaStore::new("testhash");
624        let g = store
625            .report_gotcha(
626                "Use thiserror not anyhow",
627                "Derive thiserror::Error in library code",
628                "convention",
629                "warning",
630                "s1",
631            )
632            .expect("gotcha should be retained in empty store");
633        assert_eq!(g.confidence, 0.9);
634        assert_eq!(g.category, GotchaCategory::Convention);
635    }
636
637    #[test]
638    fn decay_reduces_confidence() {
639        let mut store = GotchaStore::new("testhash");
640        let mut g = Gotcha::new(
641            GotchaCategory::Build,
642            GotchaSeverity::Warning,
643            "test error",
644            "test fix",
645            GotchaSource::AutoDetected {
646                command: "test".into(),
647                exit_code: 1,
648            },
649            "s1",
650        );
651        g.last_seen = Utc::now() - chrono::Duration::days(30);
652        g.confidence = 0.5;
653        store.gotchas.push(g);
654
655        store.apply_decay();
656        assert!(store.gotchas[0].confidence < 0.5);
657    }
658
659    #[test]
660    fn decay_archives_low_confidence() {
661        let mut store = GotchaStore::new("testhash");
662        let mut g = Gotcha::new(
663            GotchaCategory::Build,
664            GotchaSeverity::Info,
665            "old error",
666            "old fix",
667            GotchaSource::AutoDetected {
668                command: "test".into(),
669                exit_code: 1,
670            },
671            "s1",
672        );
673        g.last_seen = Utc::now() - chrono::Duration::days(90);
674        g.confidence = 0.16;
675        store.gotchas.push(g);
676
677        store.apply_decay();
678        assert!(store.gotchas.is_empty());
679    }
680
681    #[test]
682    fn relevance_score_higher_for_recent() {
683        let recent = Gotcha::new(
684            GotchaCategory::Build,
685            GotchaSeverity::Critical,
686            "error A",
687            "fix A",
688            GotchaSource::AutoDetected {
689                command: "test".into(),
690                exit_code: 1,
691            },
692            "s1",
693        );
694        let mut old = recent.clone();
695        old.last_seen = Utc::now() - chrono::Duration::days(14);
696
697        let score_recent = relevance_score(&recent, &[]);
698        let score_old = relevance_score(&old, &[]);
699        assert!(score_recent > score_old);
700    }
701
702    #[test]
703    fn relevance_score_file_overlap_boost() {
704        let mut g = Gotcha::new(
705            GotchaCategory::Build,
706            GotchaSeverity::Warning,
707            "error B",
708            "fix B",
709            GotchaSource::AutoDetected {
710                command: "test".into(),
711                exit_code: 1,
712            },
713            "s1",
714        );
715        g.file_patterns = vec!["src/server.rs".to_string()];
716
717        let with_overlap = relevance_score(&g, &["src/server.rs".to_string()]);
718        let without_overlap = relevance_score(&g, &["src/other.rs".to_string()]);
719        assert!(with_overlap > without_overlap);
720    }
721
722    #[test]
723    fn cross_session_boost_increases_confidence() {
724        let mut store = GotchaStore::new("testhash");
725        let mut g = Gotcha::new(
726            GotchaCategory::Build,
727            GotchaSeverity::Critical,
728            "recurring error",
729            "recurring fix",
730            GotchaSource::AutoDetected {
731                command: "cargo build".into(),
732                exit_code: 1,
733            },
734            "s1",
735        );
736        g.confidence = 0.6;
737        store.gotchas.push(g);
738
739        store.error_log.push(SessionErrorLog {
740            session_id: "s2".into(),
741            timestamp: Utc::now(),
742            errors: vec![ErrorEntry {
743                signature: "recurring error".into(),
744                command: "cargo build".into(),
745                timestamp: Utc::now(),
746            }],
747            fixes: vec![],
748        });
749        store.error_log.push(SessionErrorLog {
750            session_id: "s3".into(),
751            timestamp: Utc::now(),
752            errors: vec![ErrorEntry {
753                signature: "recurring error".into(),
754                command: "cargo build".into(),
755                timestamp: Utc::now(),
756            }],
757            fixes: vec![],
758        });
759
760        store.cross_session_boost();
761        assert!(store.gotchas[0].confidence > 0.6);
762        assert!(store.gotchas[0].session_ids.len() >= 3);
763    }
764
765    #[test]
766    fn promotion_criteria() {
767        let mut g = Gotcha::new(
768            GotchaCategory::Convention,
769            GotchaSeverity::Warning,
770            "use thiserror",
771            "derive thiserror::Error",
772            GotchaSource::AgentReported {
773                session_id: "s1".into(),
774            },
775            "s1",
776        );
777        g.confidence = 0.95;
778        g.occurrences = 6;
779        g.session_ids = vec!["s1".into(), "s2".into(), "s3".into()];
780        g.prevented_count = 3;
781        assert!(g.is_promotable());
782
783        g.occurrences = 2;
784        assert!(!g.is_promotable());
785    }
786
787    #[test]
788    fn promotable_keeps_only_proven_and_sorts_by_confidence() {
789        // #980 loop-weighting bridge: only gotchas that cleared every threshold
790        // (incl. having prevented real errors) are eligible, strongest first.
791        let mut store = GotchaStore::new("testhash");
792        let mut strong = Gotcha::new(
793            GotchaCategory::Convention,
794            GotchaSeverity::Warning,
795            "use thiserror",
796            "derive thiserror::Error",
797            GotchaSource::AgentReported {
798                session_id: "s1".into(),
799            },
800            "s1",
801        );
802        strong.confidence = 0.92;
803        strong.occurrences = 6;
804        strong.session_ids = vec!["s1".into(), "s2".into(), "s3".into()];
805        strong.prevented_count = 3;
806
807        let mut strongest = strong.clone();
808        strongest.trigger = "always run fmt".into();
809        strongest.confidence = 0.98;
810
811        // Never prevented anything → not promotable despite high confidence.
812        let mut weak = strong.clone();
813        weak.trigger = "unproven".into();
814        weak.confidence = 0.99;
815        weak.prevented_count = 0;
816
817        store.gotchas = vec![strong, weak, strongest];
818        let promotable = store.promotable();
819        assert_eq!(promotable.len(), 2, "the unproven gotcha is excluded");
820        assert_eq!(
821            promotable[0].trigger, "always run fmt",
822            "highest confidence first"
823        );
824    }
825
826    #[test]
827    fn format_injection_block_empty() {
828        let store = GotchaStore::new("testhash");
829        assert!(store.format_injection_block(&[]).is_empty());
830    }
831
832    #[test]
833    fn format_injection_block_with_gotchas() {
834        let mut store = GotchaStore::new("testhash");
835        store.add_or_merge(Gotcha::new(
836            GotchaCategory::Build,
837            GotchaSeverity::Critical,
838            "cargo E0507",
839            "use clone",
840            GotchaSource::AutoDetected {
841                command: "cargo build".into(),
842                exit_code: 1,
843            },
844            "s1",
845        ));
846
847        let block = store.format_injection_block(&[]);
848        assert!(block.contains("PROJECT GOTCHAS"));
849        assert!(block.contains("cargo E0507"));
850        assert!(block.contains("use clone"));
851    }
852
853    #[test]
854    fn truncate_str_utf8_umlaut_no_panic() {
855        let result = truncate_str("Fehler in Datei übergrößetest.rs", 20);
856        assert!(result.len() <= 23); // 20 + "..."
857        assert!(result.ends_with("..."));
858    }
859
860    #[test]
861    fn truncate_str_utf8_emoji_no_panic() {
862        let result = truncate_str("Error 🔥 in module 🧪 test", 15);
863        assert!(result.ends_with("..."));
864    }
865
866    #[test]
867    fn truncate_str_short_utf8_unchanged() {
868        let input = "Ölüm";
869        assert_eq!(truncate_str(input, 20), input);
870    }
871
872    #[test]
873    fn correlatable_command_gate() {
874        for cmd in [
875            "cargo build",
876            "cargo test --release",
877            "npm install",
878            "pnpm run build",
879            "python app.py",
880            "go build ./...",
881            "npx tsc --noEmit",
882            "poetry run pytest -q",
883            "docker build .",
884            "git rebase main",
885        ] {
886            assert!(is_correlatable_command(cmd), "should correlate: {cmd}");
887        }
888        for cmd in ["ls -la", "cat README.md", "echo hi", "cd rust", "pwd"] {
889            assert!(!is_correlatable_command(cmd), "should skip: {cmd}");
890        }
891    }
892}