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