use crate::utils::content_hash;
use super::normalize_query;
#[derive(Debug, Clone, Default)]
pub struct Situation<'a> {
pub query: Option<&'a str>,
pub last_error: Option<&'a str>,
pub recent_actions: &'a [String],
pub stage: Option<&'a str>,
pub file_context: Option<&'a str>,
}
impl<'a> Situation<'a> {
pub fn from_query(query: &'a str) -> Self {
Situation {
query: Some(query),
..Default::default()
}
}
fn is_query_only(&self) -> bool {
self.last_error.map(str::trim).unwrap_or("").is_empty()
&& self.recent_actions.iter().all(|a| a.trim().is_empty())
&& self.stage.map(str::trim).unwrap_or("").is_empty()
&& self.file_context.map(str::trim).unwrap_or("").is_empty()
}
pub fn embed_text(&self) -> String {
let query = self.query.map(str::trim).unwrap_or("");
if self.is_query_only() {
return query.to_string();
}
let mut parts: Vec<String> = Vec::new();
if !query.is_empty() {
parts.push(format!("[query] {query}"));
}
if let Some(err) = self.last_error.map(str::trim).filter(|s| !s.is_empty()) {
parts.push(format!("[error] {err}"));
}
let actions: Vec<&str> = self
.recent_actions
.iter()
.map(|a| a.trim())
.filter(|a| !a.is_empty())
.collect();
if !actions.is_empty() {
parts.push(format!("[actions] {}", actions.join(" ; ")));
}
if let Some(stage) = self.stage.map(str::trim).filter(|s| !s.is_empty()) {
parts.push(format!("[stage] {stage}"));
}
if let Some(files) = self.file_context.map(str::trim).filter(|s| !s.is_empty()) {
parts.push(format!("[files] {files}"));
}
parts.join("\n")
}
pub fn context_key(&self, coarse_keys: &str) -> String {
if self.is_query_only() {
return content_hash(&normalize_query(self.query.unwrap_or("")));
}
content_hash(&self.coarse_signature(coarse_keys))
}
pub fn coarse_signature(&self, coarse_keys: &str) -> String {
let keys: Vec<&str> = coarse_keys
.split(',')
.map(str::trim)
.filter(|k| !k.is_empty())
.collect();
let mut parts: Vec<String> = Vec::new();
for key in keys {
match key {
"stage" => parts.push(format!(
"stage={}",
self.stage.map(str::trim).unwrap_or("").to_lowercase()
)),
"error_class" => parts.push(format!("err={}", self.error_class())),
"file_type" => parts.push(format!("file={}", self.file_type())),
_ => {}
}
}
parts.join("|")
}
fn error_class(&self) -> String {
let err = self.last_error.map(str::trim).unwrap_or("");
if err.is_empty() {
return String::new();
}
if let Some(code) = find_rust_error_code(err) {
return code;
}
if let Some(name) = err
.split(|c: char| !(c.is_alphanumeric() || c == '_'))
.find(|tok| tok.len() > 3 && (tok.ends_with("Error") || tok.ends_with("Exception")))
{
return name.to_string();
}
let low = err.to_lowercase();
if low.contains("panic") {
return "panic".to_string();
}
low.split(|c: char| !c.is_alphabetic())
.find(|t| !t.is_empty())
.map(|t| t.chars().take(24).collect())
.unwrap_or_default()
}
fn file_type(&self) -> String {
let ctx = self.file_context.map(str::trim).unwrap_or("");
if ctx.is_empty() {
return String::new();
}
let token = ctx
.split(|c: char| c.is_whitespace() || c == ',')
.find(|t| !t.is_empty())
.unwrap_or("");
match token.rsplit_once('.') {
Some((_, ext)) if !ext.is_empty() && ext.len() <= 8 => ext.to_lowercase(),
_ => token
.rsplit(['/', '\\'])
.next()
.unwrap_or(token)
.to_lowercase(),
}
}
}
fn find_rust_error_code(err: &str) -> Option<String> {
let bytes = err.as_bytes();
let mut i = 0;
while i < bytes.len() {
if (bytes[i] == b'E' || bytes[i] == b'e')
&& i + 1 < bytes.len()
&& bytes[i + 1].is_ascii_digit()
{
let start = i;
let mut j = i + 1;
while j < bytes.len() && bytes[j].is_ascii_digit() {
j += 1;
}
if j - (start + 1) >= 3 {
return Some(format!("E{}", &err[start + 1..j]));
}
}
i += 1;
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn query_only_degrades_to_legacy_key() {
let s = Situation::from_query("How to fix the Merge?");
let legacy = content_hash(&normalize_query("How to fix the Merge?"));
assert_eq!(s.context_key("stage,error_class,file_type"), legacy);
assert_eq!(s.embed_text(), "How to fix the Merge?");
}
#[test]
fn context_key_stable_across_differing_error_text() {
let a = Situation {
stage: Some("merge"),
last_error: Some("TypeError: cannot read property 'x' of undefined at line 42"),
file_context: Some("src/components/Foo.tsx"),
..Default::default()
};
let b = Situation {
stage: Some("merge"),
last_error: Some("TypeError: undefined is not a function in handler"),
file_context: Some("src/pages/Bar.tsx"),
..Default::default()
};
let keys = "stage,error_class,file_type";
assert_eq!(a.context_key(keys), b.context_key(keys));
assert_eq!(
a.coarse_signature(keys),
"stage=merge|err=TypeError|file=tsx"
);
}
#[test]
fn differing_class_yields_different_key() {
let keys = "stage,error_class,file_type";
let a = Situation {
stage: Some("merge"),
last_error: Some("TypeError: boom"),
file_context: Some("a.tsx"),
..Default::default()
};
let b = Situation {
stage: Some("merge"),
last_error: Some("RangeError: boom"),
file_context: Some("a.tsx"),
..Default::default()
};
assert_ne!(a.context_key(keys), b.context_key(keys));
}
#[test]
fn rust_error_code_classified() {
let s = Situation {
stage: Some("build"),
last_error: Some("error[E0599]: no method named `foo` found"),
file_context: Some("src/lib.rs"),
..Default::default()
};
assert_eq!(s.coarse_signature("error_class"), "err=E0599");
}
#[test]
fn embed_text_includes_all_nonempty_fields() {
let actions = vec!["git merge".to_string(), "cargo test".to_string()];
let s = Situation {
query: Some("why did merge fail"),
last_error: Some("conflict"),
recent_actions: &actions,
stage: Some("merge"),
file_context: Some("Cargo.toml"),
};
let text = s.embed_text();
assert!(text.contains("[query] why did merge fail"));
assert!(text.contains("[error] conflict"));
assert!(text.contains("git merge"));
assert!(text.contains("[stage] merge"));
assert!(text.contains("[files] Cargo.toml"));
}
}