lean_ctx/core/gotcha_tracker/
runtime.rs1use std::sync::{Mutex, OnceLock};
21
22use super::GotchaStore;
23
24impl GotchaStore {
25 pub fn learn_from_shell(
32 &mut self,
33 command: &str,
34 output: &str,
35 exit_code: i32,
36 files_touched: &[String],
37 session_id: &str,
38 ) -> bool {
39 if self.detect_error(output, command, exit_code, files_touched, session_id) {
40 return true;
42 }
43 self.try_resolve_pending(command, files_touched, session_id)
44 .is_some()
45 }
46}
47
48struct Active {
51 project_hash: String,
52 store: GotchaStore,
53}
54
55static ACTIVE: OnceLock<Mutex<Option<Active>>> = OnceLock::new();
56
57fn active() -> &'static Mutex<Option<Active>> {
58 ACTIVE.get_or_init(|| Mutex::new(None))
59}
60
61fn process_session_id() -> &'static str {
65 static ID: OnceLock<String> = OnceLock::new();
66 ID.get_or_init(|| format!("proc-{}", std::process::id()))
67}
68
69pub fn record_shell_outcome(command: &str, output: &str, exit_code: i32) {
77 if !super::detect::is_correlatable_command(command) {
78 return;
79 }
80
81 let cwd = std::env::current_dir()
82 .map(|p| p.to_string_lossy().to_string())
83 .unwrap_or_default();
84 let project_root = crate::core::protocol::detect_project_root_or_cwd(&cwd);
85 let hash = crate::core::project_hash::hash_project_root(&project_root);
86
87 let Ok(mut guard) = active().try_lock() else {
88 return;
89 };
90
91 let needs_load = guard.as_ref().is_none_or(|a| a.project_hash != hash);
92 if needs_load {
93 *guard = Some(Active {
94 project_hash: hash,
95 store: GotchaStore::load(&project_root),
96 });
97 }
98
99 let Some(entry) = guard.as_mut() else {
100 return;
101 };
102 let changed =
103 entry
104 .store
105 .learn_from_shell(command, output, exit_code, &[], process_session_id());
106 if changed {
107 let _ = entry.store.save(&project_root);
108 }
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114 use crate::core::gotcha_tracker::GotchaSource;
115
116 #[test]
117 fn learn_records_error_then_correlates_fix() {
118 let mut store = GotchaStore::new("h");
119
120 let changed = store.learn_from_shell(
121 "cargo build",
122 "error[E0507]: cannot move out of `self.name`",
123 1,
124 &[],
125 "s1",
126 );
127 assert!(changed, "failing build must record a pending error");
128 assert_eq!(store.pending_errors.len(), 1);
129 assert!(store.gotchas.is_empty(), "no gotcha until a fix correlates");
130
131 let fixed = store.learn_from_shell(
132 "cargo build --release",
133 "Finished `release` profile [optimized] target(s)",
134 0,
135 &["src/main.rs".into()],
136 "s1",
137 );
138 assert!(fixed, "green run of same base must correlate the fix");
139 assert_eq!(store.gotchas.len(), 1);
140 assert!(matches!(
141 store.gotchas[0].source,
142 GotchaSource::AutoDetected { .. }
143 ));
144 assert_eq!(store.pending_errors.len(), 0, "pending consumed by the fix");
145 }
146
147 #[test]
148 fn learn_ignores_clean_runs() {
149 let mut store = GotchaStore::new("h");
150 let changed = store.learn_from_shell("cargo build", "Finished `dev` profile", 0, &[], "s1");
151 assert!(
152 !changed,
153 "a green run with no pending error changes nothing"
154 );
155 assert!(store.gotchas.is_empty());
156 assert!(store.pending_errors.is_empty());
157 }
158
159 #[test]
160 fn record_shell_outcome_skips_non_correlatable() {
161 record_shell_outcome("ls -la", "file1\nfile2", 0);
163 record_shell_outcome("echo hello", "hello", 0);
164 }
165}