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 pub fn check_promotions(&mut self) -> Vec<(String, String, String, f32)> {
270 let mut promoted = Vec::new();
271 for gotcha in &self.gotchas {
272 if gotcha.is_promotable() {
273 promoted.push((
274 gotcha.category.to_string(),
275 gotcha.trigger.clone(),
276 gotcha.resolution.clone(),
277 gotcha.confidence,
278 ));
279 }
280 }
281 self.stats.gotchas_promoted += promoted.len() as u64;
282 promoted
283 }
284
285 pub fn extract_universal(&self) -> Vec<Gotcha> {
288 self.gotchas
289 .iter()
290 .filter(|g| {
291 g.category == GotchaCategory::Platform
292 && g.occurrences >= 10
293 && g.session_ids.len() >= 5
294 })
295 .cloned()
296 .collect()
297 }
298
299 pub fn top_relevant(&self, files_touched: &[String], limit: usize) -> Vec<&Gotcha> {
302 let mut scored: Vec<(&Gotcha, f32)> = self
303 .gotchas
304 .iter()
305 .map(|g| (g, relevance_score(g, files_touched)))
306 .filter(|(_, s)| *s > 0.5)
307 .collect();
308
309 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
310 scored.into_iter().take(limit).map(|(g, _)| g).collect()
311 }
312
313 pub fn format_injection_block(&self, files_touched: &[String]) -> String {
314 let relevant = self.top_relevant(files_touched, 7);
315 if relevant.is_empty() {
316 return String::new();
317 }
318
319 let mut lines = Vec::with_capacity(relevant.len() + 2);
320 lines.push("--- PROJECT GOTCHAS (do NOT repeat these mistakes) ---".to_string());
321
322 for g in &relevant {
323 let prefix = g.severity.prefix();
324 let label = g.category.short_label();
325 let sessions = g.session_ids.len();
326 let age = format_age(g.last_seen);
327 let trigger = crate::core::sanitize::neutralize_metadata(&g.trigger);
328 let resolution = crate::core::sanitize::neutralize_metadata(&g.resolution);
329
330 let source_hint = match &g.source {
331 GotchaSource::AgentReported { .. } => ", agent-confirmed".to_string(),
332 GotchaSource::CrossSessionCorrelated { .. } => {
333 format!(", across {sessions} sessions")
334 }
335 GotchaSource::AutoDetected { .. } => ", auto-detected".to_string(),
336 GotchaSource::Promoted { .. } => ", proven".to_string(),
337 };
338
339 let prevented = if g.prevented_count > 0 {
340 format!(", prevented {}x", g.prevented_count)
341 } else {
342 String::new()
343 };
344
345 lines.push(format!("[{prefix}{label}] {trigger}"));
346 lines.push(format!(
347 " FIX: {} (seen {}x{}{}, {})",
348 resolution, g.occurrences, source_hint, prevented, age
349 ));
350 }
351
352 lines.push("---".to_string());
353 crate::core::sanitize::fence_content("project_gotchas", &lines.join("\n"))
354 }
355
356 pub fn mark_prevented(&mut self, gotcha_id: &str) {
359 if let Some(g) = self.gotchas.iter_mut().find(|g| g.id == gotcha_id) {
360 g.prevented_count += 1;
361 g.confidence = (g.confidence + 0.05).min(0.99);
362 self.stats.total_prevented += 1;
363 }
364 }
365
366 pub fn format_list(&self) -> String {
369 if self.gotchas.is_empty() {
370 return "No gotchas recorded for this project.".to_string();
371 }
372
373 let mut out = Vec::new();
374 out.push(format!(" {} active gotchas\n", self.gotchas.len()));
375
376 let mut sorted = self.gotchas.clone();
377 sorted.sort_by(|a, b| {
378 b.confidence
379 .partial_cmp(&a.confidence)
380 .unwrap_or(std::cmp::Ordering::Equal)
381 });
382
383 for g in &sorted {
384 let prefix = g.severity.prefix();
385 let label = g.category.short_label();
386 let conf = (g.confidence * 100.0) as u32;
387 let source = match &g.source {
388 GotchaSource::AutoDetected { .. } => "auto",
389 GotchaSource::AgentReported { .. } => "agent",
390 GotchaSource::CrossSessionCorrelated { .. } => "cross-session",
391 GotchaSource::Promoted { .. } => "promoted",
392 };
393 out.push(format!(
394 " [{prefix}{label:8}] {} ({}x, {} sessions, {source}, confidence: {conf}%)",
395 truncate_str(&g.trigger, 60),
396 g.occurrences,
397 g.session_ids.len(),
398 ));
399 out.push(format!(
400 " FIX: {}",
401 truncate_str(&g.resolution, 70)
402 ));
403 if g.prevented_count > 0 {
404 out.push(format!(" Prevented: {}x", g.prevented_count));
405 }
406 out.push(String::new());
407 }
408
409 out.push(format!(
410 " Stats: {} errors detected | {} fixes correlated | {} prevented",
411 self.stats.total_errors_detected,
412 self.stats.total_fixes_correlated,
413 self.stats.total_prevented,
414 ));
415
416 out.join("\n")
417 }
418}
419
420pub fn relevance_score(gotcha: &Gotcha, files_touched: &[String]) -> f32 {
425 let mut score: f32 = 0.0;
426
427 score += (gotcha.occurrences as f32 * gotcha.confidence).min(10.0);
428
429 let hours_ago = (Utc::now() - gotcha.last_seen).num_hours().max(0) as f32;
430 score += 5.0 * (-hours_ago / 168.0).exp();
431
432 let overlap = gotcha
433 .file_patterns
434 .iter()
435 .filter(|fp| {
436 files_touched
437 .iter()
438 .any(|ft| ft.contains(fp.as_str()) || fp.contains(ft.as_str()))
439 })
440 .count();
441 score += overlap as f32 * 3.0;
442
443 score *= gotcha.severity.multiplier();
444
445 if gotcha.session_ids.len() >= 3 {
446 score *= 1.3;
447 }
448
449 if gotcha.prevented_count > 0 {
450 score *= 1.2;
451 }
452
453 score
454}
455
456fn format_age(dt: DateTime<Utc>) -> String {
461 let diff = Utc::now() - dt;
462 let hours = diff.num_hours();
463 if hours < 1 {
464 format!("{}m ago", diff.num_minutes().max(1))
465 } else if hours < 24 {
466 format!("{hours}h ago")
467 } else {
468 format!("{}d ago", diff.num_days())
469 }
470}
471
472fn truncate_str(s: &str, max: usize) -> String {
473 if s.len() <= max {
474 s.to_string()
475 } else {
476 format!("{}...", &s[..s.floor_char_boundary(max.saturating_sub(3))])
477 }
478}
479
480#[cfg(test)]
485mod tests {
486 use super::*;
487
488 #[test]
489 fn detect_cargo_error() {
490 let output = r"error[E0507]: cannot move out of `self.field` which is behind a shared reference
491 --> src/server.rs:42:13";
492 let result = detect_error_pattern(output, "cargo build", 1);
493 assert!(result.is_some());
494 let d = result.unwrap();
495 assert_eq!(d.category, GotchaCategory::Build);
496 assert_eq!(d.severity, GotchaSeverity::Critical);
497 assert!(d.raw_message.contains("E0507"));
498 }
499
500 #[test]
501 fn detect_npm_error() {
502 let output = "npm ERR! ERESOLVE unable to resolve dependency tree";
503 let result = detect_error_pattern(output, "npm install", 1);
504 assert!(result.is_some());
505 assert_eq!(result.unwrap().category, GotchaCategory::Dependency);
506 }
507
508 #[test]
509 fn detect_python_traceback() {
510 let output = "Traceback (most recent call last):\n File \"app.py\", line 5\nImportError: No module named 'flask'";
511 let result = detect_error_pattern(output, "python app.py", 1);
512 assert!(result.is_some());
513 assert_eq!(result.unwrap().category, GotchaCategory::Runtime);
514 }
515
516 #[test]
517 fn detect_typescript_error() {
518 let output =
519 "src/index.ts(10,5): error TS2339: Property 'foo' does not exist on type 'Bar'.";
520 let result = detect_error_pattern(output, "npx tsc", 1);
521 assert!(result.is_some());
522 assert_eq!(result.unwrap().category, GotchaCategory::Build);
523 }
524
525 #[test]
526 fn detect_go_error() {
527 let output = "./main.go:15:2: undefined: SomeFunc";
528 let result = detect_error_pattern(output, "go build", 1);
529 assert!(result.is_some());
530 }
531
532 #[test]
533 fn detect_jest_failure() {
534 let output = "FAIL src/app.test.ts\n TypeError: Cannot read properties of undefined";
535 let result = detect_error_pattern(output, "npx jest", 1);
536 assert!(result.is_some());
537 assert_eq!(result.unwrap().category, GotchaCategory::Test);
538 }
539
540 #[test]
541 fn no_false_positive_on_success() {
542 let output = "Compiling lean-ctx v2.17.2\nFinished release target(s) in 30s";
543 let result = detect_error_pattern(output, "cargo build --release", 0);
544 assert!(result.is_none());
545 }
546
547 #[test]
548 fn normalize_signature_strips_paths() {
549 let raw = "error[E0507]: cannot move out of /Users/foo/project/src/main.rs:42:13";
550 let sig = normalize_error_signature(raw);
551 assert!(!sig.contains("/Users/foo"));
552 assert!(sig.contains("E0507"));
553 assert!(sig.contains(":_:_"));
554 }
555
556 #[test]
557 fn gotcha_store_add_and_merge() {
558 let mut store = GotchaStore::new("testhash");
559 let g1 = Gotcha::new(
560 GotchaCategory::Build,
561 GotchaSeverity::Critical,
562 "error E0507",
563 "use clone",
564 GotchaSource::AutoDetected {
565 command: "cargo build".into(),
566 exit_code: 1,
567 },
568 "s1",
569 );
570 store.add_or_merge(g1.clone());
571 assert_eq!(store.gotchas.len(), 1);
572
573 let g2 = Gotcha::new(
574 GotchaCategory::Build,
575 GotchaSeverity::Critical,
576 "error E0507",
577 "use ref pattern",
578 GotchaSource::AutoDetected {
579 command: "cargo build".into(),
580 exit_code: 1,
581 },
582 "s2",
583 );
584 store.add_or_merge(g2);
585 assert_eq!(store.gotchas.len(), 1);
586 assert_eq!(store.gotchas[0].occurrences, 2);
587 assert_eq!(store.gotchas[0].session_ids.len(), 2);
588 }
589
590 #[test]
591 fn gotcha_store_detect_and_resolve() {
592 let mut store = GotchaStore::new("testhash");
593
594 let error_output = "error[E0507]: cannot move out of `self.name`";
595 let detected = store.detect_error(error_output, "cargo build", 1, &[], "s1");
596 assert!(detected);
597 assert_eq!(store.pending_errors.len(), 1);
598
599 let resolved =
600 store.try_resolve_pending("cargo build --release", &["src/main.rs".into()], "s1");
601 assert!(resolved.is_some());
602 assert_eq!(store.gotchas.len(), 1);
603 assert!(store.gotchas[0].resolution.contains("src/main.rs"));
604 }
605
606 #[test]
607 fn agent_report_gotcha() {
608 let mut store = GotchaStore::new("testhash");
609 let g = store
610 .report_gotcha(
611 "Use thiserror not anyhow",
612 "Derive thiserror::Error in library code",
613 "convention",
614 "warning",
615 "s1",
616 )
617 .expect("gotcha should be retained in empty store");
618 assert_eq!(g.confidence, 0.9);
619 assert_eq!(g.category, GotchaCategory::Convention);
620 }
621
622 #[test]
623 fn decay_reduces_confidence() {
624 let mut store = GotchaStore::new("testhash");
625 let mut g = Gotcha::new(
626 GotchaCategory::Build,
627 GotchaSeverity::Warning,
628 "test error",
629 "test fix",
630 GotchaSource::AutoDetected {
631 command: "test".into(),
632 exit_code: 1,
633 },
634 "s1",
635 );
636 g.last_seen = Utc::now() - chrono::Duration::days(30);
637 g.confidence = 0.5;
638 store.gotchas.push(g);
639
640 store.apply_decay();
641 assert!(store.gotchas[0].confidence < 0.5);
642 }
643
644 #[test]
645 fn decay_archives_low_confidence() {
646 let mut store = GotchaStore::new("testhash");
647 let mut g = Gotcha::new(
648 GotchaCategory::Build,
649 GotchaSeverity::Info,
650 "old error",
651 "old fix",
652 GotchaSource::AutoDetected {
653 command: "test".into(),
654 exit_code: 1,
655 },
656 "s1",
657 );
658 g.last_seen = Utc::now() - chrono::Duration::days(90);
659 g.confidence = 0.16;
660 store.gotchas.push(g);
661
662 store.apply_decay();
663 assert!(store.gotchas.is_empty());
664 }
665
666 #[test]
667 fn relevance_score_higher_for_recent() {
668 let recent = Gotcha::new(
669 GotchaCategory::Build,
670 GotchaSeverity::Critical,
671 "error A",
672 "fix A",
673 GotchaSource::AutoDetected {
674 command: "test".into(),
675 exit_code: 1,
676 },
677 "s1",
678 );
679 let mut old = recent.clone();
680 old.last_seen = Utc::now() - chrono::Duration::days(14);
681
682 let score_recent = relevance_score(&recent, &[]);
683 let score_old = relevance_score(&old, &[]);
684 assert!(score_recent > score_old);
685 }
686
687 #[test]
688 fn relevance_score_file_overlap_boost() {
689 let mut g = Gotcha::new(
690 GotchaCategory::Build,
691 GotchaSeverity::Warning,
692 "error B",
693 "fix B",
694 GotchaSource::AutoDetected {
695 command: "test".into(),
696 exit_code: 1,
697 },
698 "s1",
699 );
700 g.file_patterns = vec!["src/server.rs".to_string()];
701
702 let with_overlap = relevance_score(&g, &["src/server.rs".to_string()]);
703 let without_overlap = relevance_score(&g, &["src/other.rs".to_string()]);
704 assert!(with_overlap > without_overlap);
705 }
706
707 #[test]
708 fn cross_session_boost_increases_confidence() {
709 let mut store = GotchaStore::new("testhash");
710 let mut g = Gotcha::new(
711 GotchaCategory::Build,
712 GotchaSeverity::Critical,
713 "recurring error",
714 "recurring fix",
715 GotchaSource::AutoDetected {
716 command: "cargo build".into(),
717 exit_code: 1,
718 },
719 "s1",
720 );
721 g.confidence = 0.6;
722 store.gotchas.push(g);
723
724 store.error_log.push(SessionErrorLog {
725 session_id: "s2".into(),
726 timestamp: Utc::now(),
727 errors: vec![ErrorEntry {
728 signature: "recurring error".into(),
729 command: "cargo build".into(),
730 timestamp: Utc::now(),
731 }],
732 fixes: vec![],
733 });
734 store.error_log.push(SessionErrorLog {
735 session_id: "s3".into(),
736 timestamp: Utc::now(),
737 errors: vec![ErrorEntry {
738 signature: "recurring error".into(),
739 command: "cargo build".into(),
740 timestamp: Utc::now(),
741 }],
742 fixes: vec![],
743 });
744
745 store.cross_session_boost();
746 assert!(store.gotchas[0].confidence > 0.6);
747 assert!(store.gotchas[0].session_ids.len() >= 3);
748 }
749
750 #[test]
751 fn promotion_criteria() {
752 let mut g = Gotcha::new(
753 GotchaCategory::Convention,
754 GotchaSeverity::Warning,
755 "use thiserror",
756 "derive thiserror::Error",
757 GotchaSource::AgentReported {
758 session_id: "s1".into(),
759 },
760 "s1",
761 );
762 g.confidence = 0.95;
763 g.occurrences = 6;
764 g.session_ids = vec!["s1".into(), "s2".into(), "s3".into()];
765 g.prevented_count = 3;
766 assert!(g.is_promotable());
767
768 g.occurrences = 2;
769 assert!(!g.is_promotable());
770 }
771
772 #[test]
773 fn format_injection_block_empty() {
774 let store = GotchaStore::new("testhash");
775 assert!(store.format_injection_block(&[]).is_empty());
776 }
777
778 #[test]
779 fn format_injection_block_with_gotchas() {
780 let mut store = GotchaStore::new("testhash");
781 store.add_or_merge(Gotcha::new(
782 GotchaCategory::Build,
783 GotchaSeverity::Critical,
784 "cargo E0507",
785 "use clone",
786 GotchaSource::AutoDetected {
787 command: "cargo build".into(),
788 exit_code: 1,
789 },
790 "s1",
791 ));
792
793 let block = store.format_injection_block(&[]);
794 assert!(block.contains("PROJECT GOTCHAS"));
795 assert!(block.contains("cargo E0507"));
796 assert!(block.contains("use clone"));
797 }
798
799 #[test]
800 fn truncate_str_utf8_umlaut_no_panic() {
801 let result = truncate_str("Fehler in Datei übergrößetest.rs", 20);
802 assert!(result.len() <= 23); assert!(result.ends_with("..."));
804 }
805
806 #[test]
807 fn truncate_str_utf8_emoji_no_panic() {
808 let result = truncate_str("Error 🔥 in module 🧪 test", 15);
809 assert!(result.ends_with("..."));
810 }
811
812 #[test]
813 fn truncate_str_short_utf8_unchanged() {
814 let input = "Ölüm";
815 assert_eq!(truncate_str(input, 20), input);
816 }
817
818 #[test]
819 fn correlatable_command_gate() {
820 for cmd in [
821 "cargo build",
822 "cargo test --release",
823 "npm install",
824 "pnpm run build",
825 "python app.py",
826 "go build ./...",
827 "npx tsc --noEmit",
828 "poetry run pytest -q",
829 "docker build .",
830 "git rebase main",
831 ] {
832 assert!(is_correlatable_command(cmd), "should correlate: {cmd}");
833 }
834 for cmd in ["ls -la", "cat README.md", "echo hi", "cd rust", "pwd"] {
835 assert!(!is_correlatable_command(cmd), "should skip: {cmd}");
836 }
837 }
838}