lean_ctx/core/gotcha_tracker/
model.rs1use 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; pub(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#[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#[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#[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#[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#[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
288pub(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}