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 || crate::core::protocol::request_project_unresolved()
79 {
80 return;
81 }
82
83 let cwd = std::env::current_dir()
84 .map(|p| p.to_string_lossy().to_string())
85 .unwrap_or_default();
86 let project_root = crate::core::protocol::detect_project_root_or_cwd(&cwd);
87 let hash = crate::core::project_hash::hash_project_root(&project_root);
88
89 let Ok(mut guard) = active().try_lock() else {
90 return;
91 };
92
93 let needs_load = guard.as_ref().is_none_or(|a| a.project_hash != hash);
94 if needs_load {
95 *guard = Some(Active {
96 project_hash: hash,
97 store: GotchaStore::load(&project_root),
98 });
99 }
100
101 let Some(entry) = guard.as_mut() else {
102 return;
103 };
104 let changed =
105 entry
106 .store
107 .learn_from_shell(command, output, exit_code, &[], process_session_id());
108 if changed {
109 let _ = entry.store.save(&project_root);
110 }
111}
112
113#[cfg(test)]
114mod tests {
115 use super::*;
116 use crate::core::gotcha_tracker::GotchaSource;
117
118 #[test]
119 fn learn_records_error_then_correlates_fix() {
120 let mut store = GotchaStore::new("h");
121
122 let changed = store.learn_from_shell(
123 "cargo build",
124 "error[E0507]: cannot move out of `self.name`",
125 1,
126 &[],
127 "s1",
128 );
129 assert!(changed, "failing build must record a pending error");
130 assert_eq!(store.pending_errors.len(), 1);
131 assert!(store.gotchas.is_empty(), "no gotcha until a fix correlates");
132
133 let fixed = store.learn_from_shell(
134 "cargo build --release",
135 "Finished `release` profile [optimized] target(s)",
136 0,
137 &["src/main.rs".into()],
138 "s1",
139 );
140 assert!(fixed, "green run of same base must correlate the fix");
141 assert_eq!(store.gotchas.len(), 1);
142 assert!(matches!(
143 store.gotchas[0].source,
144 GotchaSource::AutoDetected { .. }
145 ));
146 assert_eq!(store.pending_errors.len(), 0, "pending consumed by the fix");
147 }
148
149 #[test]
150 fn learn_ignores_clean_runs() {
151 let mut store = GotchaStore::new("h");
152 let changed = store.learn_from_shell("cargo build", "Finished `dev` profile", 0, &[], "s1");
153 assert!(
154 !changed,
155 "a green run with no pending error changes nothing"
156 );
157 assert!(store.gotchas.is_empty());
158 assert!(store.pending_errors.is_empty());
159 }
160
161 #[test]
162 fn record_shell_outcome_skips_non_correlatable() {
163 record_shell_outcome("ls -la", "file1\nfile2", 0);
165 record_shell_outcome("echo hello", "hello", 0);
166 }
167}