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