Skip to main content

lean_ctx/core/gotcha_tracker/
mod.rs

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