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;
7pub mod 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_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() > self.capacity {
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(self.capacity);
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
254                .decay_rate_override
255                .unwrap_or_else(|| gotcha.source.decay_rate());
256            let occurrence_factor = 1.0 / (1.0 + gotcha.occurrences as f32 * 0.1);
257            let decay = base_rate * occurrence_factor * (days_since / 7.0);
258            gotcha.confidence = (gotcha.confidence - decay).max(0.0);
259        }
260
261        let before = self.gotchas.len();
262        self.gotchas.retain(|g| {
263            g.confidence >= DECAY_ARCHIVE_THRESHOLD && g.expires_at.is_none_or(|at| at > now)
264        });
265        decayed += (before - self.gotchas.len()) as u64;
266
267        self.stats.gotchas_decayed += decayed;
268    }
269
270    // -- Promotion ----------------------------------------------------------
271
272    /// High-value gotchas eligible for promotion into durable project knowledge
273    /// (the loop-weighting bridge, #980): proven across sessions and shown to
274    /// prevent real errors. Read-only companion to [`Self::check_promotions`],
275    /// sorted by confidence so callers can take the strongest first.
276    #[must_use]
277    pub fn promotable(&self) -> Vec<&Gotcha> {
278        let mut v: Vec<&Gotcha> = self.gotchas.iter().filter(|g| g.is_promotable()).collect();
279        v.sort_by(|a, b| {
280            b.confidence
281                .partial_cmp(&a.confidence)
282                .unwrap_or(std::cmp::Ordering::Equal)
283        });
284        v
285    }
286
287    pub fn check_promotions(&mut self) -> Vec<(String, String, String, f32)> {
288        let mut promoted = Vec::new();
289        for gotcha in &self.gotchas {
290            if gotcha.is_promotable() {
291                promoted.push((
292                    gotcha.category.to_string(),
293                    gotcha.trigger.clone(),
294                    gotcha.resolution.clone(),
295                    gotcha.confidence,
296                ));
297            }
298        }
299        self.stats.gotchas_promoted += promoted.len() as u64;
300        promoted
301    }
302
303    // -- Universal Gotchas --------------------------------------------------
304
305    pub fn extract_universal(&self) -> Vec<Gotcha> {
306        self.gotchas
307            .iter()
308            .filter(|g| {
309                g.category == GotchaCategory::Platform
310                    && g.occurrences >= 10
311                    && g.session_ids.len() >= 5
312            })
313            .cloned()
314            .collect()
315    }
316
317    // -- Relevance scoring --------------------------------------------------
318
319    pub fn top_relevant(&self, files_touched: &[String], limit: usize) -> Vec<&Gotcha> {
320        let mut scored: Vec<(&Gotcha, f32)> = self
321            .gotchas
322            .iter()
323            .map(|g| (g, relevance_score(g, files_touched)))
324            .filter(|(_, s)| *s > 0.5)
325            .collect();
326
327        scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
328        scored.into_iter().take(limit).map(|(g, _)| g).collect()
329    }
330
331    pub fn format_injection_block(&self, files_touched: &[String]) -> String {
332        let relevant = self.top_relevant(files_touched, 7);
333        if relevant.is_empty() {
334            return String::new();
335        }
336
337        let mut lines = Vec::with_capacity(relevant.len() + 2);
338        lines.push("--- PROJECT GOTCHAS (do NOT repeat these mistakes) ---".to_string());
339
340        for g in &relevant {
341            let prefix = g.severity.prefix();
342            let label = g.category.short_label();
343            let sessions = g.session_ids.len();
344            let age = format_age(g.last_seen);
345            let trigger = crate::core::sanitize::neutralize_metadata(&g.trigger);
346            let resolution = crate::core::sanitize::neutralize_metadata(&g.resolution);
347
348            let source_hint = match &g.source {
349                GotchaSource::AgentReported { .. } => ", agent-confirmed".to_string(),
350                GotchaSource::CrossSessionCorrelated { .. } => {
351                    format!(", across {sessions} sessions")
352                }
353                GotchaSource::AutoDetected { .. } => ", auto-detected".to_string(),
354                GotchaSource::Promoted { .. } => ", proven".to_string(),
355            };
356
357            let prevented = if g.prevented_count > 0 {
358                format!(", prevented {}x", g.prevented_count)
359            } else {
360                String::new()
361            };
362
363            lines.push(format!("[{prefix}{label}] {trigger}"));
364            lines.push(format!(
365                "  FIX: {} (seen {}x{}{}, {})",
366                resolution, g.occurrences, source_hint, prevented, age
367            ));
368        }
369
370        lines.push("---".to_string());
371        crate::core::sanitize::fence_content("project_gotchas", &lines.join("\n"))
372    }
373
374    // -- Prevention tracking ------------------------------------------------
375
376    pub fn add_universal(&mut self, gotcha: Gotcha) {
377        if !self.gotchas.iter().any(|g| g.id == gotcha.id) {
378            self.gotchas.push(gotcha);
379        }
380    }
381
382    pub fn mark_prevented(&mut self, gotcha_id: &str) {
383        if let Some(g) = self.gotchas.iter_mut().find(|g| g.id == gotcha_id) {
384            g.prevented_count += 1;
385            g.confidence = (g.confidence + 0.05).min(0.99);
386            self.stats.total_prevented += 1;
387        }
388    }
389
390    // -- CLI ----------------------------------------------------------------
391
392    pub fn format_list(&self) -> String {
393        if self.gotchas.is_empty() {
394            return "No gotchas recorded for this project.".to_string();
395        }
396
397        let mut out = Vec::new();
398        out.push(format!("  {} active gotchas\n", self.gotchas.len()));
399
400        let mut sorted = self.gotchas.clone();
401        sorted.sort_by(|a, b| {
402            b.confidence
403                .partial_cmp(&a.confidence)
404                .unwrap_or(std::cmp::Ordering::Equal)
405        });
406
407        for g in &sorted {
408            let prefix = g.severity.prefix();
409            let label = g.category.short_label();
410            let conf = (g.confidence * 100.0) as u32;
411            let source = match &g.source {
412                GotchaSource::AutoDetected { .. } => "auto",
413                GotchaSource::AgentReported { .. } => "agent",
414                GotchaSource::CrossSessionCorrelated { .. } => "cross-session",
415                GotchaSource::Promoted { .. } => "promoted",
416            };
417            out.push(format!(
418                "  [{prefix}{label:8}] {} ({}x, {} sessions, {source}, confidence: {conf}%)",
419                truncate_str(&g.trigger, 60),
420                g.occurrences,
421                g.session_ids.len(),
422            ));
423            out.push(format!(
424                "             FIX: {}",
425                truncate_str(&g.resolution, 70)
426            ));
427            if g.prevented_count > 0 {
428                out.push(format!("             Prevented: {}x", g.prevented_count));
429            }
430            out.push(String::new());
431        }
432
433        out.push(format!(
434            "  Stats: {} errors detected | {} fixes correlated | {} prevented",
435            self.stats.total_errors_detected,
436            self.stats.total_fixes_correlated,
437            self.stats.total_prevented,
438        ));
439
440        out.join("\n")
441    }
442}
443
444// ---------------------------------------------------------------------------
445// Relevance scoring
446// ---------------------------------------------------------------------------
447
448pub fn relevance_score(gotcha: &Gotcha, files_touched: &[String]) -> f32 {
449    let mut score: f32 = 0.0;
450
451    score += (gotcha.occurrences as f32 * gotcha.confidence).min(10.0);
452
453    let hours_ago = (Utc::now() - gotcha.last_seen).num_hours().max(0) as f32;
454    score += 5.0 * (-hours_ago / 168.0).exp();
455
456    let overlap = gotcha
457        .file_patterns
458        .iter()
459        .filter(|fp| {
460            files_touched
461                .iter()
462                .any(|ft| ft.contains(fp.as_str()) || fp.contains(ft.as_str()))
463        })
464        .count();
465    score += overlap as f32 * 3.0;
466
467    score *= gotcha.severity.multiplier();
468
469    if gotcha.session_ids.len() >= 3 {
470        score *= 1.3;
471    }
472
473    if gotcha.prevented_count > 0 {
474        score *= 1.2;
475    }
476
477    score
478}
479
480// ---------------------------------------------------------------------------
481// Helpers
482// ---------------------------------------------------------------------------
483
484fn format_age(dt: DateTime<Utc>) -> String {
485    let diff = Utc::now() - dt;
486    let hours = diff.num_hours();
487    if hours < 1 {
488        format!("{}m ago", diff.num_minutes().max(1))
489    } else if hours < 24 {
490        format!("{hours}h ago")
491    } else {
492        format!("{}d ago", diff.num_days())
493    }
494}
495
496fn truncate_str(s: &str, max: usize) -> String {
497    if s.len() <= max {
498        s.to_string()
499    } else {
500        format!("{}...", &s[..s.floor_char_boundary(max.saturating_sub(3))])
501    }
502}
503
504// ---------------------------------------------------------------------------
505// Tests
506// ---------------------------------------------------------------------------
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511
512    #[test]
513    fn detect_cargo_error() {
514        let output = r"error[E0507]: cannot move out of `self.field` which is behind a shared reference
515   --> src/server.rs:42:13";
516        let result = detect_error_pattern(output, "cargo build", 1);
517        assert!(result.is_some());
518        let d = result.unwrap();
519        assert_eq!(d.category, GotchaCategory::Build);
520        assert_eq!(d.severity, GotchaSeverity::Critical);
521        assert!(d.raw_message.contains("E0507"));
522    }
523
524    #[test]
525    fn detect_npm_error() {
526        let output = "npm ERR! ERESOLVE unable to resolve dependency tree";
527        let result = detect_error_pattern(output, "npm install", 1);
528        assert!(result.is_some());
529        assert_eq!(result.unwrap().category, GotchaCategory::Dependency);
530    }
531
532    #[test]
533    fn detect_python_traceback() {
534        let output = "Traceback (most recent call last):\n  File \"app.py\", line 5\nImportError: No module named 'flask'";
535        let result = detect_error_pattern(output, "python app.py", 1);
536        assert!(result.is_some());
537        assert_eq!(result.unwrap().category, GotchaCategory::Runtime);
538    }
539
540    #[test]
541    fn detect_typescript_error() {
542        let output =
543            "src/index.ts(10,5): error TS2339: Property 'foo' does not exist on type 'Bar'.";
544        let result = detect_error_pattern(output, "npx tsc", 1);
545        assert!(result.is_some());
546        assert_eq!(result.unwrap().category, GotchaCategory::Build);
547    }
548
549    #[test]
550    fn detect_go_error() {
551        let output = "./main.go:15:2: undefined: SomeFunc";
552        let result = detect_error_pattern(output, "go build", 1);
553        assert!(result.is_some());
554    }
555
556    #[test]
557    fn detect_jest_failure() {
558        let output = "FAIL src/app.test.ts\n  TypeError: Cannot read properties of undefined";
559        let result = detect_error_pattern(output, "npx jest", 1);
560        assert!(result.is_some());
561        assert_eq!(result.unwrap().category, GotchaCategory::Test);
562    }
563
564    #[test]
565    fn no_false_positive_on_success() {
566        let output = "Compiling lean-ctx v2.17.2\nFinished release target(s) in 30s";
567        let result = detect_error_pattern(output, "cargo build --release", 0);
568        assert!(result.is_none());
569    }
570
571    #[test]
572    fn normalize_signature_strips_paths() {
573        let raw = "error[E0507]: cannot move out of /Users/foo/project/src/main.rs:42:13";
574        let sig = normalize_error_signature(raw);
575        assert!(!sig.contains("/Users/foo"));
576        assert!(sig.contains("E0507"));
577        assert!(sig.contains(":_:_"));
578    }
579
580    #[test]
581    fn gotcha_store_add_and_merge() {
582        let mut store = GotchaStore::new("testhash");
583        let g1 = Gotcha::new(
584            GotchaCategory::Build,
585            GotchaSeverity::Critical,
586            "error E0507",
587            "use clone",
588            GotchaSource::AutoDetected {
589                command: "cargo build".into(),
590                exit_code: 1,
591            },
592            "s1",
593        );
594        store.add_or_merge(g1.clone());
595        assert_eq!(store.gotchas.len(), 1);
596
597        let g2 = Gotcha::new(
598            GotchaCategory::Build,
599            GotchaSeverity::Critical,
600            "error E0507",
601            "use ref pattern",
602            GotchaSource::AutoDetected {
603                command: "cargo build".into(),
604                exit_code: 1,
605            },
606            "s2",
607        );
608        store.add_or_merge(g2);
609        assert_eq!(store.gotchas.len(), 1);
610        assert_eq!(store.gotchas[0].occurrences, 2);
611        assert_eq!(store.gotchas[0].session_ids.len(), 2);
612    }
613
614    #[test]
615    fn gotcha_store_detect_and_resolve() {
616        let mut store = GotchaStore::new("testhash");
617
618        let error_output = "error[E0507]: cannot move out of `self.name`";
619        let detected = store.detect_error(error_output, "cargo build", 1, &[], "s1");
620        assert!(detected);
621        assert_eq!(store.pending_errors.len(), 1);
622
623        let resolved =
624            store.try_resolve_pending("cargo build --release", &["src/main.rs".into()], "s1");
625        assert!(resolved.is_some());
626        assert_eq!(store.gotchas.len(), 1);
627        assert!(store.gotchas[0].resolution.contains("src/main.rs"));
628    }
629
630    #[test]
631    fn agent_report_gotcha() {
632        let mut store = GotchaStore::new("testhash");
633        let g = store
634            .report_gotcha(
635                "Use thiserror not anyhow",
636                "Derive thiserror::Error in library code",
637                "convention",
638                "warning",
639                "s1",
640            )
641            .expect("gotcha should be retained in empty store");
642        assert_eq!(g.confidence, 0.9);
643        assert_eq!(g.category, GotchaCategory::Convention);
644    }
645
646    #[test]
647    fn decay_reduces_confidence() {
648        let mut store = GotchaStore::new("testhash");
649        let mut g = Gotcha::new(
650            GotchaCategory::Build,
651            GotchaSeverity::Warning,
652            "test error",
653            "test fix",
654            GotchaSource::AutoDetected {
655                command: "test".into(),
656                exit_code: 1,
657            },
658            "s1",
659        );
660        g.last_seen = Utc::now() - chrono::Duration::days(30);
661        g.confidence = 0.5;
662        store.gotchas.push(g);
663
664        store.apply_decay();
665        assert!(store.gotchas[0].confidence < 0.5);
666    }
667
668    #[test]
669    fn decay_archives_low_confidence() {
670        let mut store = GotchaStore::new("testhash");
671        let mut g = Gotcha::new(
672            GotchaCategory::Build,
673            GotchaSeverity::Info,
674            "old error",
675            "old fix",
676            GotchaSource::AutoDetected {
677                command: "test".into(),
678                exit_code: 1,
679            },
680            "s1",
681        );
682        g.last_seen = Utc::now() - chrono::Duration::days(90);
683        g.confidence = 0.16;
684        store.gotchas.push(g);
685
686        store.apply_decay();
687        assert!(store.gotchas.is_empty());
688    }
689
690    /// The contract's per-gotcha `expires_at` and `decay_rate_override` were
691    /// stored but never applied before 2026-10-16.
692    #[test]
693    fn decay_honours_expiry_and_rate_override() {
694        let gotcha = |trigger: &str| {
695            let mut g = Gotcha::new(
696                GotchaCategory::Build,
697                GotchaSeverity::Warning,
698                trigger,
699                "fix",
700                GotchaSource::AutoDetected {
701                    command: "test".into(),
702                    exit_code: 1,
703                },
704                "s1",
705            );
706            g.last_seen = Utc::now() - chrono::Duration::days(14);
707            g.confidence = 0.5;
708            g
709        };
710        let mut store = GotchaStore::new("testhash");
711        let mut expired = gotcha("expired");
712        expired.expires_at = Some(Utc::now() - chrono::Duration::minutes(1));
713        let mut frozen = gotcha("frozen");
714        frozen.decay_rate_override = Some(0.0);
715        store.gotchas.extend([expired, frozen, gotcha("default")]);
716
717        store.apply_decay();
718
719        let left: Vec<(&str, f32)> = store
720            .gotchas
721            .iter()
722            .map(|g| (g.trigger.as_str(), g.confidence))
723            .collect();
724        assert_eq!(left.len(), 2, "{left:?}");
725        assert_eq!(left[0], ("frozen", 0.5));
726        assert!(left[1].0 == "default" && left[1].1 < 0.5);
727    }
728
729    #[test]
730    fn store_keeps_at_most_its_capacity_dropping_lowest_confidence() {
731        let mut store = GotchaStore::new("testhash");
732        store.capacity = 2;
733        for (i, confidence) in [0.9_f32, 0.3, 0.6].into_iter().enumerate() {
734            let mut g = Gotcha::new(
735                GotchaCategory::Build,
736                GotchaSeverity::Warning,
737                &format!("error {i}"),
738                "fix",
739                GotchaSource::AutoDetected {
740                    command: "test".into(),
741                    exit_code: 1,
742                },
743                "s1",
744            );
745            g.confidence = confidence;
746            store.add_or_merge(g);
747        }
748        let kept: Vec<f32> = store.gotchas.iter().map(|g| g.confidence).collect();
749        assert_eq!(kept, vec![0.9, 0.6]);
750    }
751
752    #[test]
753    fn relevance_score_higher_for_recent() {
754        let recent = Gotcha::new(
755            GotchaCategory::Build,
756            GotchaSeverity::Critical,
757            "error A",
758            "fix A",
759            GotchaSource::AutoDetected {
760                command: "test".into(),
761                exit_code: 1,
762            },
763            "s1",
764        );
765        let mut old = recent.clone();
766        old.last_seen = Utc::now() - chrono::Duration::days(14);
767
768        let score_recent = relevance_score(&recent, &[]);
769        let score_old = relevance_score(&old, &[]);
770        assert!(score_recent > score_old);
771    }
772
773    #[test]
774    fn relevance_score_file_overlap_boost() {
775        let mut g = Gotcha::new(
776            GotchaCategory::Build,
777            GotchaSeverity::Warning,
778            "error B",
779            "fix B",
780            GotchaSource::AutoDetected {
781                command: "test".into(),
782                exit_code: 1,
783            },
784            "s1",
785        );
786        g.file_patterns = vec!["src/server.rs".to_string()];
787
788        let with_overlap = relevance_score(&g, &["src/server.rs".to_string()]);
789        let without_overlap = relevance_score(&g, &["src/other.rs".to_string()]);
790        assert!(with_overlap > without_overlap);
791    }
792
793    #[test]
794    fn cross_session_boost_increases_confidence() {
795        let mut store = GotchaStore::new("testhash");
796        let mut g = Gotcha::new(
797            GotchaCategory::Build,
798            GotchaSeverity::Critical,
799            "recurring error",
800            "recurring fix",
801            GotchaSource::AutoDetected {
802                command: "cargo build".into(),
803                exit_code: 1,
804            },
805            "s1",
806        );
807        g.confidence = 0.6;
808        store.gotchas.push(g);
809
810        store.error_log.push(SessionErrorLog {
811            session_id: "s2".into(),
812            timestamp: Utc::now(),
813            errors: vec![ErrorEntry {
814                signature: "recurring error".into(),
815                command: "cargo build".into(),
816                timestamp: Utc::now(),
817            }],
818            fixes: vec![],
819        });
820        store.error_log.push(SessionErrorLog {
821            session_id: "s3".into(),
822            timestamp: Utc::now(),
823            errors: vec![ErrorEntry {
824                signature: "recurring error".into(),
825                command: "cargo build".into(),
826                timestamp: Utc::now(),
827            }],
828            fixes: vec![],
829        });
830
831        store.cross_session_boost();
832        assert!(store.gotchas[0].confidence > 0.6);
833        assert!(store.gotchas[0].session_ids.len() >= 3);
834    }
835
836    #[test]
837    fn promotion_criteria() {
838        let mut g = Gotcha::new(
839            GotchaCategory::Convention,
840            GotchaSeverity::Warning,
841            "use thiserror",
842            "derive thiserror::Error",
843            GotchaSource::AgentReported {
844                session_id: "s1".into(),
845            },
846            "s1",
847        );
848        g.confidence = 0.95;
849        g.occurrences = 6;
850        g.session_ids = vec!["s1".into(), "s2".into(), "s3".into()];
851        g.prevented_count = 3;
852        assert!(g.is_promotable());
853
854        g.occurrences = 2;
855        assert!(!g.is_promotable());
856    }
857
858    #[test]
859    fn promotable_keeps_only_proven_and_sorts_by_confidence() {
860        // #980 loop-weighting bridge: only gotchas that cleared every threshold
861        // (incl. having prevented real errors) are eligible, strongest first.
862        let mut store = GotchaStore::new("testhash");
863        let mut strong = Gotcha::new(
864            GotchaCategory::Convention,
865            GotchaSeverity::Warning,
866            "use thiserror",
867            "derive thiserror::Error",
868            GotchaSource::AgentReported {
869                session_id: "s1".into(),
870            },
871            "s1",
872        );
873        strong.confidence = 0.92;
874        strong.occurrences = 6;
875        strong.session_ids = vec!["s1".into(), "s2".into(), "s3".into()];
876        strong.prevented_count = 3;
877
878        let mut strongest = strong.clone();
879        strongest.trigger = "always run fmt".into();
880        strongest.confidence = 0.98;
881
882        // Never prevented anything → not promotable despite high confidence.
883        let mut weak = strong.clone();
884        weak.trigger = "unproven".into();
885        weak.confidence = 0.99;
886        weak.prevented_count = 0;
887
888        store.gotchas = vec![strong, weak, strongest];
889        let promotable = store.promotable();
890        assert_eq!(promotable.len(), 2, "the unproven gotcha is excluded");
891        assert_eq!(
892            promotable[0].trigger, "always run fmt",
893            "highest confidence first"
894        );
895    }
896
897    #[test]
898    fn format_injection_block_empty() {
899        let store = GotchaStore::new("testhash");
900        {
901            let found = &store.format_injection_block(&[]);
902            assert!(found.is_empty(), "{found:?}");
903        }
904    }
905
906    #[test]
907    fn format_injection_block_with_gotchas() {
908        let mut store = GotchaStore::new("testhash");
909        store.add_or_merge(Gotcha::new(
910            GotchaCategory::Build,
911            GotchaSeverity::Critical,
912            "cargo E0507",
913            "use clone",
914            GotchaSource::AutoDetected {
915                command: "cargo build".into(),
916                exit_code: 1,
917            },
918            "s1",
919        ));
920
921        let block = store.format_injection_block(&[]);
922        assert!(block.contains("PROJECT GOTCHAS"));
923        assert!(block.contains("cargo E0507"));
924        assert!(block.contains("use clone"));
925    }
926
927    #[test]
928    fn truncate_str_utf8_umlaut_no_panic() {
929        let result = truncate_str("Fehler in Datei übergrößetest.rs", 20);
930        assert!(result.len() <= 23); // 20 + "..."
931        assert!(result.ends_with("..."));
932    }
933
934    #[test]
935    fn truncate_str_utf8_emoji_no_panic() {
936        let result = truncate_str("Error 🔥 in module 🧪 test", 15);
937        assert!(result.ends_with("..."));
938    }
939
940    #[test]
941    fn truncate_str_short_utf8_unchanged() {
942        let input = "Ölüm";
943        assert_eq!(truncate_str(input, 20), input);
944    }
945
946    #[test]
947    fn correlatable_command_gate() {
948        for cmd in [
949            "cargo build",
950            "cargo test --release",
951            "npm install",
952            "pnpm run build",
953            "python app.py",
954            "go build ./...",
955            "npx tsc --noEmit",
956            "poetry run pytest -q",
957            "docker build .",
958            "git rebase main",
959        ] {
960            assert!(is_correlatable_command(cmd), "should correlate: {cmd}");
961        }
962        for cmd in ["ls -la", "cat README.md", "echo hi", "cd rust", "pwd"] {
963            assert!(!is_correlatable_command(cmd), "should skip: {cmd}");
964        }
965    }
966}