Skip to main content

lean_ctx/core/gotcha_tracker/
mod.rs

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