innate_core/kb/
situation.rs1use crate::utils::content_hash;
19
20use super::normalize_query;
21
22#[derive(Debug, Clone, Default)]
24pub struct Situation<'a> {
25 pub query: Option<&'a str>,
27 pub last_error: Option<&'a str>,
29 pub recent_actions: &'a [String],
31 pub stage: Option<&'a str>,
33 pub file_context: Option<&'a str>,
35}
36
37impl<'a> Situation<'a> {
38 pub fn from_query(query: &'a str) -> Self {
41 Situation {
42 query: Some(query),
43 ..Default::default()
44 }
45 }
46
47 fn is_query_only(&self) -> bool {
50 self.last_error.map(str::trim).unwrap_or("").is_empty()
51 && self.recent_actions.iter().all(|a| a.trim().is_empty())
52 && self.stage.map(str::trim).unwrap_or("").is_empty()
53 && self.file_context.map(str::trim).unwrap_or("").is_empty()
54 }
55
56 pub fn embed_text(&self) -> String {
62 let query = self.query.map(str::trim).unwrap_or("");
63 if self.is_query_only() {
64 return query.to_string();
65 }
66 let mut parts: Vec<String> = Vec::new();
67 if !query.is_empty() {
68 parts.push(format!("[query] {query}"));
69 }
70 if let Some(err) = self.last_error.map(str::trim).filter(|s| !s.is_empty()) {
71 parts.push(format!("[error] {err}"));
72 }
73 let actions: Vec<&str> = self
74 .recent_actions
75 .iter()
76 .map(|a| a.trim())
77 .filter(|a| !a.is_empty())
78 .collect();
79 if !actions.is_empty() {
80 parts.push(format!("[actions] {}", actions.join(" ; ")));
81 }
82 if let Some(stage) = self.stage.map(str::trim).filter(|s| !s.is_empty()) {
83 parts.push(format!("[stage] {stage}"));
84 }
85 if let Some(files) = self.file_context.map(str::trim).filter(|s| !s.is_empty()) {
86 parts.push(format!("[files] {files}"));
87 }
88 parts.join("\n")
89 }
90
91 pub fn context_key(&self, coarse_keys: &str) -> String {
97 if self.is_query_only() {
98 return content_hash(&normalize_query(self.query.unwrap_or("")));
99 }
100 content_hash(&self.coarse_signature(coarse_keys))
101 }
102
103 pub fn coarse_signature(&self, coarse_keys: &str) -> String {
107 let keys: Vec<&str> = coarse_keys
108 .split(',')
109 .map(str::trim)
110 .filter(|k| !k.is_empty())
111 .collect();
112 let mut parts: Vec<String> = Vec::new();
113 for key in keys {
114 match key {
115 "stage" => parts.push(format!(
116 "stage={}",
117 self.stage.map(str::trim).unwrap_or("").to_lowercase()
118 )),
119 "error_class" => parts.push(format!("err={}", self.error_class())),
120 "file_type" => parts.push(format!("file={}", self.file_type())),
121 _ => {}
123 }
124 }
125 parts.join("|")
126 }
127
128 fn error_class(&self) -> String {
135 let err = self.last_error.map(str::trim).unwrap_or("");
136 if err.is_empty() {
137 return String::new();
138 }
139 if let Some(code) = find_rust_error_code(err) {
141 return code;
142 }
143 if let Some(name) = err
145 .split(|c: char| !(c.is_alphanumeric() || c == '_'))
146 .find(|tok| {
147 tok.len() > 3 && (tok.ends_with("Error") || tok.ends_with("Exception"))
148 })
149 {
150 return name.to_string();
151 }
152 let low = err.to_lowercase();
153 if low.contains("panic") {
154 return "panic".to_string();
155 }
156 low.split(|c: char| !c.is_alphabetic())
158 .find(|t| !t.is_empty())
159 .map(|t| t.chars().take(24).collect())
160 .unwrap_or_default()
161 }
162
163 fn file_type(&self) -> String {
166 let ctx = self.file_context.map(str::trim).unwrap_or("");
167 if ctx.is_empty() {
168 return String::new();
169 }
170 let token = ctx
172 .split(|c: char| c.is_whitespace() || c == ',')
173 .find(|t| !t.is_empty())
174 .unwrap_or("");
175 match token.rsplit_once('.') {
176 Some((_, ext)) if !ext.is_empty() && ext.len() <= 8 => ext.to_lowercase(),
177 _ => token
178 .rsplit(['/', '\\'])
179 .next()
180 .unwrap_or(token)
181 .to_lowercase(),
182 }
183 }
184}
185
186fn find_rust_error_code(err: &str) -> Option<String> {
188 let bytes = err.as_bytes();
189 let mut i = 0;
190 while i < bytes.len() {
191 if (bytes[i] == b'E' || bytes[i] == b'e') && i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit() {
192 let start = i;
193 let mut j = i + 1;
194 while j < bytes.len() && bytes[j].is_ascii_digit() {
195 j += 1;
196 }
197 if j - (start + 1) >= 3 {
199 return Some(format!("E{}", &err[start + 1..j]));
200 }
201 }
202 i += 1;
203 }
204 None
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211 #[test]
212 fn query_only_degrades_to_legacy_key() {
213 let s = Situation::from_query("How to fix the Merge?");
214 let legacy = content_hash(&normalize_query("How to fix the Merge?"));
215 assert_eq!(s.context_key("stage,error_class,file_type"), legacy);
216 assert_eq!(s.embed_text(), "How to fix the Merge?");
218 }
219
220 #[test]
221 fn context_key_stable_across_differing_error_text() {
222 let a = Situation {
224 stage: Some("merge"),
225 last_error: Some("TypeError: cannot read property 'x' of undefined at line 42"),
226 file_context: Some("src/components/Foo.tsx"),
227 ..Default::default()
228 };
229 let b = Situation {
230 stage: Some("merge"),
231 last_error: Some("TypeError: undefined is not a function in handler"),
232 file_context: Some("src/pages/Bar.tsx"),
233 ..Default::default()
234 };
235 let keys = "stage,error_class,file_type";
236 assert_eq!(a.context_key(keys), b.context_key(keys));
237 assert_eq!(a.coarse_signature(keys), "stage=merge|err=TypeError|file=tsx");
238 }
239
240 #[test]
241 fn differing_class_yields_different_key() {
242 let keys = "stage,error_class,file_type";
243 let a = Situation {
244 stage: Some("merge"),
245 last_error: Some("TypeError: boom"),
246 file_context: Some("a.tsx"),
247 ..Default::default()
248 };
249 let b = Situation {
250 stage: Some("merge"),
251 last_error: Some("RangeError: boom"),
252 file_context: Some("a.tsx"),
253 ..Default::default()
254 };
255 assert_ne!(a.context_key(keys), b.context_key(keys));
256 }
257
258 #[test]
259 fn rust_error_code_classified() {
260 let s = Situation {
261 stage: Some("build"),
262 last_error: Some("error[E0599]: no method named `foo` found"),
263 file_context: Some("src/lib.rs"),
264 ..Default::default()
265 };
266 assert_eq!(s.coarse_signature("error_class"), "err=E0599");
267 }
268
269 #[test]
270 fn embed_text_includes_all_nonempty_fields() {
271 let actions = vec!["git merge".to_string(), "cargo test".to_string()];
272 let s = Situation {
273 query: Some("why did merge fail"),
274 last_error: Some("conflict"),
275 recent_actions: &actions,
276 stage: Some("merge"),
277 file_context: Some("Cargo.toml"),
278 };
279 let text = s.embed_text();
280 assert!(text.contains("[query] why did merge fail"));
281 assert!(text.contains("[error] conflict"));
282 assert!(text.contains("git merge"));
283 assert!(text.contains("[stage] merge"));
284 assert!(text.contains("[files] Cargo.toml"));
285 }
286}