Skip to main content

lean_ctx/core/gotcha_tracker/
model.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::collections::hash_map::DefaultHasher;
4use std::hash::{Hash, Hasher};
5
6pub(super) const MAX_GOTCHAS: usize = 100;
7pub(super) const MAX_SESSION_LOGS: usize = 20;
8pub(super) const MAX_PENDING: usize = 10;
9pub(super) const PENDING_TIMEOUT_SECS: i64 = 900; // 15 minutes
10pub(super) const DECAY_ARCHIVE_THRESHOLD: f32 = 0.15;
11const PROMOTION_CONFIDENCE: f32 = 0.9;
12const PROMOTION_OCCURRENCES: u32 = 5;
13const PROMOTION_SESSIONS: usize = 3;
14const PROMOTION_PREVENTED: u32 = 2;
15
16// ---------------------------------------------------------------------------
17// Core types
18// ---------------------------------------------------------------------------
19
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
21pub enum GotchaCategory {
22    Build,
23    Test,
24    Config,
25    Runtime,
26    Dependency,
27    Platform,
28    Convention,
29    Security,
30}
31
32impl GotchaCategory {
33    pub fn from_str_loose(s: &str) -> Self {
34        match s.to_lowercase().as_str() {
35            "build" | "compile" => Self::Build,
36            "test" => Self::Test,
37            "config" | "configuration" => Self::Config,
38            "runtime" => Self::Runtime,
39            "dependency" | "dep" | "deps" => Self::Dependency,
40            "platform" | "os" => Self::Platform,
41            "security" | "sec" => Self::Security,
42            _ => Self::Convention,
43        }
44    }
45
46    pub fn short_label(&self) -> &'static str {
47        match self {
48            Self::Build => "build",
49            Self::Test => "test",
50            Self::Config => "config",
51            Self::Runtime => "runtime",
52            Self::Dependency => "dep",
53            Self::Platform => "platform",
54            Self::Convention => "conv",
55            Self::Security => "sec",
56        }
57    }
58}
59
60impl std::fmt::Display for GotchaCategory {
61    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        f.write_str(self.short_label())
63    }
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
67pub enum GotchaSeverity {
68    Critical,
69    Warning,
70    Info,
71}
72
73impl GotchaSeverity {
74    pub fn multiplier(&self) -> f32 {
75        match self {
76            Self::Critical => 1.5,
77            Self::Warning => 1.0,
78            Self::Info => 0.7,
79        }
80    }
81
82    pub fn prefix(&self) -> &'static str {
83        match self {
84            Self::Critical | Self::Warning => "!",
85            Self::Info => "",
86        }
87    }
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub enum GotchaSource {
92    AutoDetected { command: String, exit_code: i32 },
93    AgentReported { session_id: String },
94    CrossSessionCorrelated { sessions: Vec<String> },
95    Promoted { from_knowledge_key: String },
96}
97
98impl GotchaSource {
99    pub fn decay_rate(&self) -> f32 {
100        match self {
101            Self::Promoted { .. } => 0.01,
102            Self::AgentReported { .. } => 0.02,
103            Self::CrossSessionCorrelated { .. } => 0.03,
104            Self::AutoDetected { .. } => 0.05,
105        }
106    }
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct Gotcha {
111    pub id: String,
112    pub category: GotchaCategory,
113    pub severity: GotchaSeverity,
114    pub trigger: String,
115    pub resolution: String,
116    pub file_patterns: Vec<String>,
117    pub occurrences: u32,
118    pub session_ids: Vec<String>,
119    pub first_seen: DateTime<Utc>,
120    pub last_seen: DateTime<Utc>,
121    pub confidence: f32,
122    pub source: GotchaSource,
123    pub prevented_count: u32,
124    pub tags: Vec<String>,
125}
126
127impl Gotcha {
128    pub fn new(
129        category: GotchaCategory,
130        severity: GotchaSeverity,
131        trigger: &str,
132        resolution: &str,
133        source: GotchaSource,
134        session_id: &str,
135    ) -> Self {
136        let now = Utc::now();
137        let confidence = match &source {
138            GotchaSource::AgentReported { .. } => 0.9,
139            GotchaSource::CrossSessionCorrelated { .. } => 0.85,
140            GotchaSource::AutoDetected { .. } => 0.6,
141            GotchaSource::Promoted { .. } => 0.95,
142        };
143        Self {
144            id: gotcha_id(trigger, &category),
145            category,
146            severity,
147            trigger: trigger.to_string(),
148            resolution: resolution.to_string(),
149            file_patterns: Vec::new(),
150            occurrences: 1,
151            session_ids: vec![session_id.to_string()],
152            first_seen: now,
153            last_seen: now,
154            confidence,
155            source,
156            prevented_count: 0,
157            tags: Vec::new(),
158        }
159    }
160
161    pub fn merge_with(&mut self, other: &Gotcha) {
162        self.occurrences += other.occurrences;
163        for sid in &other.session_ids {
164            if !self.session_ids.contains(sid) {
165                self.session_ids.push(sid.clone());
166            }
167        }
168        for fp in &other.file_patterns {
169            if !self.file_patterns.contains(fp) {
170                self.file_patterns.push(fp.clone());
171            }
172        }
173        if other.last_seen > self.last_seen {
174            self.last_seen = other.last_seen;
175            self.resolution.clone_from(&other.resolution);
176        }
177        self.confidence = self.confidence.max(other.confidence);
178    }
179
180    pub fn is_promotable(&self) -> bool {
181        self.confidence >= PROMOTION_CONFIDENCE
182            && self.occurrences >= PROMOTION_OCCURRENCES
183            && self.session_ids.len() >= PROMOTION_SESSIONS
184            && self.prevented_count >= PROMOTION_PREVENTED
185    }
186}
187
188// ---------------------------------------------------------------------------
189// Pending errors (in-memory, not persisted)
190// ---------------------------------------------------------------------------
191
192#[derive(Debug, Clone, Serialize, Deserialize)]
193pub struct PendingError {
194    pub error_signature: String,
195    pub category: GotchaCategory,
196    pub severity: GotchaSeverity,
197    pub command: String,
198    pub exit_code: i32,
199    pub files_at_error: Vec<String>,
200    pub timestamp: DateTime<Utc>,
201    pub raw_snippet: String,
202    pub session_id: String,
203}
204
205impl PendingError {
206    pub fn is_expired(&self) -> bool {
207        (Utc::now() - self.timestamp).num_seconds() > PENDING_TIMEOUT_SECS
208    }
209}
210
211// ---------------------------------------------------------------------------
212// Session error log (for cross-session correlation)
213// ---------------------------------------------------------------------------
214
215#[derive(Debug, Clone, Serialize, Deserialize)]
216pub struct SessionErrorLog {
217    pub session_id: String,
218    pub timestamp: DateTime<Utc>,
219    pub errors: Vec<ErrorEntry>,
220    pub fixes: Vec<FixEntry>,
221}
222
223#[derive(Debug, Clone, Serialize, Deserialize)]
224pub struct ErrorEntry {
225    pub signature: String,
226    pub command: String,
227    pub timestamp: DateTime<Utc>,
228}
229
230#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct FixEntry {
232    pub error_signature: String,
233    pub resolution: String,
234    pub files_changed: Vec<String>,
235    pub timestamp: DateTime<Utc>,
236}
237
238// ---------------------------------------------------------------------------
239// Stats
240// ---------------------------------------------------------------------------
241
242#[derive(Debug, Clone, Serialize, Deserialize, Default)]
243pub struct GotchaStats {
244    pub total_errors_detected: u64,
245    pub total_fixes_correlated: u64,
246    pub total_prevented: u64,
247    pub gotchas_promoted: u64,
248    pub gotchas_decayed: u64,
249}
250
251// ---------------------------------------------------------------------------
252// GotchaStore
253// ---------------------------------------------------------------------------
254
255#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct GotchaStore {
257    pub project_hash: String,
258    pub gotchas: Vec<Gotcha>,
259    #[serde(default)]
260    pub error_log: Vec<SessionErrorLog>,
261    #[serde(default)]
262    pub stats: GotchaStats,
263    pub updated_at: DateTime<Utc>,
264
265    #[serde(skip)]
266    pub pending_errors: Vec<PendingError>,
267}
268
269impl GotchaStore {
270    pub fn new(project_hash: &str) -> Self {
271        Self {
272            project_hash: project_hash.to_string(),
273            gotchas: Vec::new(),
274            error_log: Vec::new(),
275            stats: GotchaStats::default(),
276            updated_at: Utc::now(),
277            pending_errors: Vec::new(),
278        }
279    }
280
281    pub fn clear(&mut self) {
282        self.gotchas.clear();
283        self.pending_errors.clear();
284        self.updated_at = Utc::now();
285    }
286}
287
288// ---------------------------------------------------------------------------
289// Helpers
290// ---------------------------------------------------------------------------
291
292pub(super) fn gotcha_id(trigger: &str, category: &GotchaCategory) -> String {
293    let mut hasher = DefaultHasher::new();
294    trigger.hash(&mut hasher);
295    category.short_label().hash(&mut hasher);
296    format!("{:016x}", hasher.finish())
297}