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 add_universal(&mut self, gotcha: Gotcha) {
374 if !self.gotchas.iter().any(|g| g.id == gotcha.id) {
375 self.gotchas.push(gotcha);
376 }
377 }
378
379 pub fn mark_prevented(&mut self, gotcha_id: &str) {
380 if let Some(g) = self.gotchas.iter_mut().find(|g| g.id == gotcha_id) {
381 g.prevented_count += 1;
382 g.confidence = (g.confidence + 0.05).min(0.99);
383 self.stats.total_prevented += 1;
384 }
385 }
386
387 pub fn format_list(&self) -> String {
390 if self.gotchas.is_empty() {
391 return "No gotchas recorded for this project.".to_string();
392 }
393
394 let mut out = Vec::new();
395 out.push(format!(" {} active gotchas\n", self.gotchas.len()));
396
397 let mut sorted = self.gotchas.clone();
398 sorted.sort_by(|a, b| {
399 b.confidence
400 .partial_cmp(&a.confidence)
401 .unwrap_or(std::cmp::Ordering::Equal)
402 });
403
404 for g in &sorted {
405 let prefix = g.severity.prefix();
406 let label = g.category.short_label();
407 let conf = (g.confidence * 100.0) as u32;
408 let source = match &g.source {
409 GotchaSource::AutoDetected { .. } => "auto",
410 GotchaSource::AgentReported { .. } => "agent",
411 GotchaSource::CrossSessionCorrelated { .. } => "cross-session",
412 GotchaSource::Promoted { .. } => "promoted",
413 };
414 out.push(format!(
415 " [{prefix}{label:8}] {} ({}x, {} sessions, {source}, confidence: {conf}%)",
416 truncate_str(&g.trigger, 60),
417 g.occurrences,
418 g.session_ids.len(),
419 ));
420 out.push(format!(
421 " FIX: {}",
422 truncate_str(&g.resolution, 70)
423 ));
424 if g.prevented_count > 0 {
425 out.push(format!(" Prevented: {}x", g.prevented_count));
426 }
427 out.push(String::new());
428 }
429
430 out.push(format!(
431 " Stats: {} errors detected | {} fixes correlated | {} prevented",
432 self.stats.total_errors_detected,
433 self.stats.total_fixes_correlated,
434 self.stats.total_prevented,
435 ));
436
437 out.join("\n")
438 }
439}
440
441pub fn relevance_score(gotcha: &Gotcha, files_touched: &[String]) -> f32 {
446 let mut score: f32 = 0.0;
447
448 score += (gotcha.occurrences as f32 * gotcha.confidence).min(10.0);
449
450 let hours_ago = (Utc::now() - gotcha.last_seen).num_hours().max(0) as f32;
451 score += 5.0 * (-hours_ago / 168.0).exp();
452
453 let overlap = gotcha
454 .file_patterns
455 .iter()
456 .filter(|fp| {
457 files_touched
458 .iter()
459 .any(|ft| ft.contains(fp.as_str()) || fp.contains(ft.as_str()))
460 })
461 .count();
462 score += overlap as f32 * 3.0;
463
464 score *= gotcha.severity.multiplier();
465
466 if gotcha.session_ids.len() >= 3 {
467 score *= 1.3;
468 }
469
470 if gotcha.prevented_count > 0 {
471 score *= 1.2;
472 }
473
474 score
475}
476
477fn format_age(dt: DateTime<Utc>) -> String {
482 let diff = Utc::now() - dt;
483 let hours = diff.num_hours();
484 if hours < 1 {
485 format!("{}m ago", diff.num_minutes().max(1))
486 } else if hours < 24 {
487 format!("{hours}h ago")
488 } else {
489 format!("{}d ago", diff.num_days())
490 }
491}
492
493fn truncate_str(s: &str, max: usize) -> String {
494 if s.len() <= max {
495 s.to_string()
496 } else {
497 format!("{}...", &s[..s.floor_char_boundary(max.saturating_sub(3))])
498 }
499}
500
501#[cfg(test)]
506mod tests {
507 use super::*;
508
509 #[test]
510 fn detect_cargo_error() {
511 let output = r"error[E0507]: cannot move out of `self.field` which is behind a shared reference
512 --> src/server.rs:42:13";
513 let result = detect_error_pattern(output, "cargo build", 1);
514 assert!(result.is_some());
515 let d = result.unwrap();
516 assert_eq!(d.category, GotchaCategory::Build);
517 assert_eq!(d.severity, GotchaSeverity::Critical);
518 assert!(d.raw_message.contains("E0507"));
519 }
520
521 #[test]
522 fn detect_npm_error() {
523 let output = "npm ERR! ERESOLVE unable to resolve dependency tree";
524 let result = detect_error_pattern(output, "npm install", 1);
525 assert!(result.is_some());
526 assert_eq!(result.unwrap().category, GotchaCategory::Dependency);
527 }
528
529 #[test]
530 fn detect_python_traceback() {
531 let output = "Traceback (most recent call last):\n File \"app.py\", line 5\nImportError: No module named 'flask'";
532 let result = detect_error_pattern(output, "python app.py", 1);
533 assert!(result.is_some());
534 assert_eq!(result.unwrap().category, GotchaCategory::Runtime);
535 }
536
537 #[test]
538 fn detect_typescript_error() {
539 let output =
540 "src/index.ts(10,5): error TS2339: Property 'foo' does not exist on type 'Bar'.";
541 let result = detect_error_pattern(output, "npx tsc", 1);
542 assert!(result.is_some());
543 assert_eq!(result.unwrap().category, GotchaCategory::Build);
544 }
545
546 #[test]
547 fn detect_go_error() {
548 let output = "./main.go:15:2: undefined: SomeFunc";
549 let result = detect_error_pattern(output, "go build", 1);
550 assert!(result.is_some());
551 }
552
553 #[test]
554 fn detect_jest_failure() {
555 let output = "FAIL src/app.test.ts\n TypeError: Cannot read properties of undefined";
556 let result = detect_error_pattern(output, "npx jest", 1);
557 assert!(result.is_some());
558 assert_eq!(result.unwrap().category, GotchaCategory::Test);
559 }
560
561 #[test]
562 fn no_false_positive_on_success() {
563 let output = "Compiling lean-ctx v2.17.2\nFinished release target(s) in 30s";
564 let result = detect_error_pattern(output, "cargo build --release", 0);
565 assert!(result.is_none());
566 }
567
568 #[test]
569 fn normalize_signature_strips_paths() {
570 let raw = "error[E0507]: cannot move out of /Users/foo/project/src/main.rs:42:13";
571 let sig = normalize_error_signature(raw);
572 assert!(!sig.contains("/Users/foo"));
573 assert!(sig.contains("E0507"));
574 assert!(sig.contains(":_:_"));
575 }
576
577 #[test]
578 fn gotcha_store_add_and_merge() {
579 let mut store = GotchaStore::new("testhash");
580 let g1 = Gotcha::new(
581 GotchaCategory::Build,
582 GotchaSeverity::Critical,
583 "error E0507",
584 "use clone",
585 GotchaSource::AutoDetected {
586 command: "cargo build".into(),
587 exit_code: 1,
588 },
589 "s1",
590 );
591 store.add_or_merge(g1.clone());
592 assert_eq!(store.gotchas.len(), 1);
593
594 let g2 = Gotcha::new(
595 GotchaCategory::Build,
596 GotchaSeverity::Critical,
597 "error E0507",
598 "use ref pattern",
599 GotchaSource::AutoDetected {
600 command: "cargo build".into(),
601 exit_code: 1,
602 },
603 "s2",
604 );
605 store.add_or_merge(g2);
606 assert_eq!(store.gotchas.len(), 1);
607 assert_eq!(store.gotchas[0].occurrences, 2);
608 assert_eq!(store.gotchas[0].session_ids.len(), 2);
609 }
610
611 #[test]
612 fn gotcha_store_detect_and_resolve() {
613 let mut store = GotchaStore::new("testhash");
614
615 let error_output = "error[E0507]: cannot move out of `self.name`";
616 let detected = store.detect_error(error_output, "cargo build", 1, &[], "s1");
617 assert!(detected);
618 assert_eq!(store.pending_errors.len(), 1);
619
620 let resolved =
621 store.try_resolve_pending("cargo build --release", &["src/main.rs".into()], "s1");
622 assert!(resolved.is_some());
623 assert_eq!(store.gotchas.len(), 1);
624 assert!(store.gotchas[0].resolution.contains("src/main.rs"));
625 }
626
627 #[test]
628 fn agent_report_gotcha() {
629 let mut store = GotchaStore::new("testhash");
630 let g = store
631 .report_gotcha(
632 "Use thiserror not anyhow",
633 "Derive thiserror::Error in library code",
634 "convention",
635 "warning",
636 "s1",
637 )
638 .expect("gotcha should be retained in empty store");
639 assert_eq!(g.confidence, 0.9);
640 assert_eq!(g.category, GotchaCategory::Convention);
641 }
642
643 #[test]
644 fn decay_reduces_confidence() {
645 let mut store = GotchaStore::new("testhash");
646 let mut g = Gotcha::new(
647 GotchaCategory::Build,
648 GotchaSeverity::Warning,
649 "test error",
650 "test fix",
651 GotchaSource::AutoDetected {
652 command: "test".into(),
653 exit_code: 1,
654 },
655 "s1",
656 );
657 g.last_seen = Utc::now() - chrono::Duration::days(30);
658 g.confidence = 0.5;
659 store.gotchas.push(g);
660
661 store.apply_decay();
662 assert!(store.gotchas[0].confidence < 0.5);
663 }
664
665 #[test]
666 fn decay_archives_low_confidence() {
667 let mut store = GotchaStore::new("testhash");
668 let mut g = Gotcha::new(
669 GotchaCategory::Build,
670 GotchaSeverity::Info,
671 "old error",
672 "old fix",
673 GotchaSource::AutoDetected {
674 command: "test".into(),
675 exit_code: 1,
676 },
677 "s1",
678 );
679 g.last_seen = Utc::now() - chrono::Duration::days(90);
680 g.confidence = 0.16;
681 store.gotchas.push(g);
682
683 store.apply_decay();
684 assert!(store.gotchas.is_empty());
685 }
686
687 #[test]
688 fn relevance_score_higher_for_recent() {
689 let recent = Gotcha::new(
690 GotchaCategory::Build,
691 GotchaSeverity::Critical,
692 "error A",
693 "fix A",
694 GotchaSource::AutoDetected {
695 command: "test".into(),
696 exit_code: 1,
697 },
698 "s1",
699 );
700 let mut old = recent.clone();
701 old.last_seen = Utc::now() - chrono::Duration::days(14);
702
703 let score_recent = relevance_score(&recent, &[]);
704 let score_old = relevance_score(&old, &[]);
705 assert!(score_recent > score_old);
706 }
707
708 #[test]
709 fn relevance_score_file_overlap_boost() {
710 let mut g = Gotcha::new(
711 GotchaCategory::Build,
712 GotchaSeverity::Warning,
713 "error B",
714 "fix B",
715 GotchaSource::AutoDetected {
716 command: "test".into(),
717 exit_code: 1,
718 },
719 "s1",
720 );
721 g.file_patterns = vec!["src/server.rs".to_string()];
722
723 let with_overlap = relevance_score(&g, &["src/server.rs".to_string()]);
724 let without_overlap = relevance_score(&g, &["src/other.rs".to_string()]);
725 assert!(with_overlap > without_overlap);
726 }
727
728 #[test]
729 fn cross_session_boost_increases_confidence() {
730 let mut store = GotchaStore::new("testhash");
731 let mut g = Gotcha::new(
732 GotchaCategory::Build,
733 GotchaSeverity::Critical,
734 "recurring error",
735 "recurring fix",
736 GotchaSource::AutoDetected {
737 command: "cargo build".into(),
738 exit_code: 1,
739 },
740 "s1",
741 );
742 g.confidence = 0.6;
743 store.gotchas.push(g);
744
745 store.error_log.push(SessionErrorLog {
746 session_id: "s2".into(),
747 timestamp: Utc::now(),
748 errors: vec![ErrorEntry {
749 signature: "recurring error".into(),
750 command: "cargo build".into(),
751 timestamp: Utc::now(),
752 }],
753 fixes: vec![],
754 });
755 store.error_log.push(SessionErrorLog {
756 session_id: "s3".into(),
757 timestamp: Utc::now(),
758 errors: vec![ErrorEntry {
759 signature: "recurring error".into(),
760 command: "cargo build".into(),
761 timestamp: Utc::now(),
762 }],
763 fixes: vec![],
764 });
765
766 store.cross_session_boost();
767 assert!(store.gotchas[0].confidence > 0.6);
768 assert!(store.gotchas[0].session_ids.len() >= 3);
769 }
770
771 #[test]
772 fn promotion_criteria() {
773 let mut g = Gotcha::new(
774 GotchaCategory::Convention,
775 GotchaSeverity::Warning,
776 "use thiserror",
777 "derive thiserror::Error",
778 GotchaSource::AgentReported {
779 session_id: "s1".into(),
780 },
781 "s1",
782 );
783 g.confidence = 0.95;
784 g.occurrences = 6;
785 g.session_ids = vec!["s1".into(), "s2".into(), "s3".into()];
786 g.prevented_count = 3;
787 assert!(g.is_promotable());
788
789 g.occurrences = 2;
790 assert!(!g.is_promotable());
791 }
792
793 #[test]
794 fn promotable_keeps_only_proven_and_sorts_by_confidence() {
795 let mut store = GotchaStore::new("testhash");
798 let mut strong = Gotcha::new(
799 GotchaCategory::Convention,
800 GotchaSeverity::Warning,
801 "use thiserror",
802 "derive thiserror::Error",
803 GotchaSource::AgentReported {
804 session_id: "s1".into(),
805 },
806 "s1",
807 );
808 strong.confidence = 0.92;
809 strong.occurrences = 6;
810 strong.session_ids = vec!["s1".into(), "s2".into(), "s3".into()];
811 strong.prevented_count = 3;
812
813 let mut strongest = strong.clone();
814 strongest.trigger = "always run fmt".into();
815 strongest.confidence = 0.98;
816
817 let mut weak = strong.clone();
819 weak.trigger = "unproven".into();
820 weak.confidence = 0.99;
821 weak.prevented_count = 0;
822
823 store.gotchas = vec![strong, weak, strongest];
824 let promotable = store.promotable();
825 assert_eq!(promotable.len(), 2, "the unproven gotcha is excluded");
826 assert_eq!(
827 promotable[0].trigger, "always run fmt",
828 "highest confidence first"
829 );
830 }
831
832 #[test]
833 fn format_injection_block_empty() {
834 let store = GotchaStore::new("testhash");
835 assert!(store.format_injection_block(&[]).is_empty());
836 }
837
838 #[test]
839 fn format_injection_block_with_gotchas() {
840 let mut store = GotchaStore::new("testhash");
841 store.add_or_merge(Gotcha::new(
842 GotchaCategory::Build,
843 GotchaSeverity::Critical,
844 "cargo E0507",
845 "use clone",
846 GotchaSource::AutoDetected {
847 command: "cargo build".into(),
848 exit_code: 1,
849 },
850 "s1",
851 ));
852
853 let block = store.format_injection_block(&[]);
854 assert!(block.contains("PROJECT GOTCHAS"));
855 assert!(block.contains("cargo E0507"));
856 assert!(block.contains("use clone"));
857 }
858
859 #[test]
860 fn truncate_str_utf8_umlaut_no_panic() {
861 let result = truncate_str("Fehler in Datei übergrößetest.rs", 20);
862 assert!(result.len() <= 23); assert!(result.ends_with("..."));
864 }
865
866 #[test]
867 fn truncate_str_utf8_emoji_no_panic() {
868 let result = truncate_str("Error 🔥 in module 🧪 test", 15);
869 assert!(result.ends_with("..."));
870 }
871
872 #[test]
873 fn truncate_str_short_utf8_unchanged() {
874 let input = "Ölüm";
875 assert_eq!(truncate_str(input, 20), input);
876 }
877
878 #[test]
879 fn correlatable_command_gate() {
880 for cmd in [
881 "cargo build",
882 "cargo test --release",
883 "npm install",
884 "pnpm run build",
885 "python app.py",
886 "go build ./...",
887 "npx tsc --noEmit",
888 "poetry run pytest -q",
889 "docker build .",
890 "git rebase main",
891 ] {
892 assert!(is_correlatable_command(cmd), "should correlate: {cmd}");
893 }
894 for cmd in ["ls -la", "cat README.md", "echo hi", "cd rust", "pwd"] {
895 assert!(!is_correlatable_command(cmd), "should skip: {cmd}");
896 }
897 }
898}