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; 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 ProvenanceRef {
111 pub kind: String,
112 pub url: Option<String>,
113 pub commit_hash: Option<String>,
114 pub tool_call_id: Option<String>,
115 pub session_id: Option<String>,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct Gotcha {
120 pub id: String,
121 pub category: GotchaCategory,
122 pub severity: GotchaSeverity,
123 pub trigger: String,
124 pub resolution: String,
125 pub file_patterns: Vec<String>,
126 pub occurrences: u32,
127 pub session_ids: Vec<String>,
128 pub first_seen: DateTime<Utc>,
129 pub last_seen: DateTime<Utc>,
130 pub confidence: f32,
131 pub source: GotchaSource,
132 pub prevented_count: u32,
133 pub tags: Vec<String>,
134 #[serde(default)]
135 pub provenance: Vec<ProvenanceRef>,
136 #[serde(default)]
137 pub expires_at: Option<DateTime<Utc>>,
138 #[serde(default)]
139 pub decay_rate_override: Option<f32>,
140}
141
142impl Gotcha {
143 pub fn new(
144 category: GotchaCategory,
145 severity: GotchaSeverity,
146 trigger: &str,
147 resolution: &str,
148 source: GotchaSource,
149 session_id: &str,
150 ) -> Self {
151 let now = Utc::now();
152 let confidence = match &source {
153 GotchaSource::AgentReported { .. } => 0.9,
154 GotchaSource::CrossSessionCorrelated { .. } => 0.85,
155 GotchaSource::AutoDetected { .. } => 0.6,
156 GotchaSource::Promoted { .. } => 0.95,
157 };
158 Self {
159 id: gotcha_id(trigger, &category),
160 category,
161 severity,
162 trigger: trigger.to_string(),
163 resolution: resolution.to_string(),
164 file_patterns: Vec::new(),
165 occurrences: 1,
166 session_ids: vec![session_id.to_string()],
167 first_seen: now,
168 last_seen: now,
169 confidence,
170 source,
171 prevented_count: 0,
172 tags: Vec::new(),
173 provenance: Vec::new(),
174 expires_at: None,
175 decay_rate_override: None,
176 }
177 }
178
179 pub fn merge_with(&mut self, other: &Gotcha) {
180 self.occurrences += other.occurrences;
181 for sid in &other.session_ids {
182 if !self.session_ids.contains(sid) {
183 self.session_ids.push(sid.clone());
184 }
185 }
186 for fp in &other.file_patterns {
187 if !self.file_patterns.contains(fp) {
188 self.file_patterns.push(fp.clone());
189 }
190 }
191 if other.last_seen > self.last_seen {
192 self.last_seen = other.last_seen;
193 self.resolution.clone_from(&other.resolution);
194 }
195 self.confidence = self.confidence.max(other.confidence);
196 }
197
198 pub fn is_promotable(&self) -> bool {
199 self.confidence >= PROMOTION_CONFIDENCE
200 && self.occurrences >= PROMOTION_OCCURRENCES
201 && self.session_ids.len() >= PROMOTION_SESSIONS
202 && self.prevented_count >= PROMOTION_PREVENTED
203 }
204}
205
206#[derive(Debug, Clone, Serialize, Deserialize)]
211pub struct PendingError {
212 pub error_signature: String,
213 pub category: GotchaCategory,
214 pub severity: GotchaSeverity,
215 pub command: String,
216 pub exit_code: i32,
217 pub files_at_error: Vec<String>,
218 pub timestamp: DateTime<Utc>,
219 pub raw_snippet: String,
220 pub session_id: String,
221}
222
223impl PendingError {
224 pub fn is_expired(&self) -> bool {
225 (Utc::now() - self.timestamp).num_seconds() > PENDING_TIMEOUT_SECS
226 }
227}
228
229#[derive(Debug, Clone, Serialize, Deserialize)]
234pub struct SessionErrorLog {
235 pub session_id: String,
236 pub timestamp: DateTime<Utc>,
237 pub errors: Vec<ErrorEntry>,
238 pub fixes: Vec<FixEntry>,
239}
240
241#[derive(Debug, Clone, Serialize, Deserialize)]
242pub struct ErrorEntry {
243 pub signature: String,
244 pub command: String,
245 pub timestamp: DateTime<Utc>,
246}
247
248#[derive(Debug, Clone, Serialize, Deserialize)]
249pub struct FixEntry {
250 pub error_signature: String,
251 pub resolution: String,
252 pub files_changed: Vec<String>,
253 pub timestamp: DateTime<Utc>,
254}
255
256#[derive(Debug, Clone, Serialize, Deserialize, Default)]
261pub struct GotchaStats {
262 pub total_errors_detected: u64,
263 pub total_fixes_correlated: u64,
264 pub total_prevented: u64,
265 pub gotchas_promoted: u64,
266 pub gotchas_decayed: u64,
267}
268
269#[derive(Debug, Clone, Serialize, Deserialize)]
274pub struct GotchaStore {
275 pub project_hash: String,
276 pub gotchas: Vec<Gotcha>,
277 #[serde(default)]
278 pub error_log: Vec<SessionErrorLog>,
279 #[serde(default)]
280 pub stats: GotchaStats,
281 pub updated_at: DateTime<Utc>,
282
283 #[serde(skip)]
284 pub pending_errors: Vec<PendingError>,
285}
286
287impl GotchaStore {
288 pub fn new(project_hash: &str) -> Self {
289 Self {
290 project_hash: project_hash.to_string(),
291 gotchas: Vec::new(),
292 error_log: Vec::new(),
293 stats: GotchaStats::default(),
294 updated_at: Utc::now(),
295 pending_errors: Vec::new(),
296 }
297 }
298
299 pub fn clear(&mut self) {
300 self.gotchas.clear();
301 self.pending_errors.clear();
302 self.updated_at = Utc::now();
303 }
304}
305
306pub(super) fn gotcha_id(trigger: &str, category: &GotchaCategory) -> String {
311 let mut hasher = DefaultHasher::new();
312 trigger.hash(&mut hasher);
313 category.short_label().hash(&mut hasher);
314 format!("{:016x}", hasher.finish())
315}