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