use serde::{Deserialize, Serialize};
use crate::provider::{ContentPart, Message};
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RelevanceMeta {
#[serde(default)]
pub files: Vec<String>,
#[serde(default)]
pub tools: Vec<String>,
#[serde(default)]
pub error_classes: Vec<String>,
#[serde(default)]
pub explicit_refs: Vec<usize>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Difficulty {
Easy,
Medium,
Hard,
}
impl Difficulty {
pub const fn as_str(self) -> &'static str {
match self {
Difficulty::Easy => "easy",
Difficulty::Medium => "medium",
Difficulty::Hard => "hard",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Dependency {
Isolated,
Chained,
}
impl Dependency {
pub const fn as_str(self) -> &'static str {
match self {
Dependency::Isolated => "isolated",
Dependency::Chained => "chained",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolUse {
No,
Yes,
}
impl ToolUse {
pub const fn as_str(self) -> &'static str {
match self {
ToolUse::No => "no",
ToolUse::Yes => "yes",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Bucket {
pub difficulty: Difficulty,
pub dependency: Dependency,
pub tool_use: ToolUse,
}
impl RelevanceMeta {
pub fn project_bucket(&self) -> Bucket {
let tool_use = if self.tools.is_empty() {
ToolUse::No
} else {
ToolUse::Yes
};
let dependency = if self.files.len() >= 2 || self.files.iter().any(|f| f.contains('/')) {
Dependency::Chained
} else {
Dependency::Isolated
};
let difficulty = match self.error_classes.len() {
0 => Difficulty::Easy,
1 | 2 => Difficulty::Medium,
_ => Difficulty::Hard,
};
Bucket {
difficulty,
dependency,
tool_use,
}
}
}
const ERROR_MARKERS: &[&str] = &[
"error:",
"error[e",
"failed",
"panicked",
"traceback",
"stack trace",
];
pub fn extract(msg: &Message) -> RelevanceMeta {
let mut meta = RelevanceMeta::default();
for part in &msg.content {
match part {
ContentPart::Text { text } => {
append_files(text, &mut meta.files);
append_error_classes(text, &mut meta.error_classes);
}
ContentPart::ToolCall { name, .. } => {
if !meta.tools.contains(name) {
meta.tools.push(name.clone());
}
}
ContentPart::ToolResult { content, .. } => {
append_error_classes(content, &mut meta.error_classes);
}
_ => {}
}
}
dedupe_preserving_order(&mut meta.files);
dedupe_preserving_order(&mut meta.error_classes);
meta
}
fn append_files(text: &str, out: &mut Vec<String>) {
for raw in text.split(|c: char| c.is_whitespace() || matches!(c, ',' | ';' | '(' | ')' | '`')) {
let trimmed = raw.trim_matches(|c: char| matches!(c, '"' | '\'' | '.'));
if trimmed.is_empty() || trimmed.len() < 3 {
continue;
}
let looks_like_path =
(trimmed.contains('/') && !trimmed.contains("://") && trimmed.len() > 3)
|| ends_with_source_ext(trimmed);
if looks_like_path && !out.contains(&trimmed.to_string()) {
out.push(trimmed.to_string());
}
}
}
fn ends_with_source_ext(s: &str) -> bool {
[
".rs", ".ts", ".tsx", ".js", ".jsx", ".py", ".go", ".md", ".json", ".toml", ".yaml",
".yml", ".html", ".css", ".c", ".cpp", ".h", ".hpp",
]
.iter()
.any(|ext| s.ends_with(ext))
}
fn append_error_classes(text: &str, out: &mut Vec<String>) {
let lower = text.to_lowercase();
for marker in ERROR_MARKERS {
if lower.contains(marker) {
let tag = marker.trim_end_matches(':').to_string();
if !out.contains(&tag) {
out.push(tag);
}
}
}
}
fn dedupe_preserving_order(items: &mut Vec<String>) {
let mut seen: std::collections::HashSet<String> =
std::collections::HashSet::with_capacity(items.len());
items.retain(|item| seen.insert(item.clone()));
}
#[cfg(test)]
mod tests {
use super::*;
use crate::provider::{ContentPart, Role};
fn text(s: &str) -> Message {
Message {
role: Role::Assistant,
content: vec![ContentPart::Text {
text: s.to_string(),
}],
}
}
fn tool_call(name: &str) -> Message {
Message {
role: Role::Assistant,
content: vec![ContentPart::ToolCall {
id: "call-1".to_string(),
name: name.to_string(),
arguments: "{}".to_string(),
thought_signature: None,
}],
}
}
fn tool_result(body: &str) -> Message {
Message {
role: Role::Tool,
content: vec![ContentPart::ToolResult {
tool_call_id: "call-1".to_string(),
content: body.to_string(),
}],
}
}
#[test]
fn extract_picks_up_paths_and_dedupes() {
let meta = extract(&text(
"Edited src/lib.rs and src/lib.rs again, plus tests/a.rs",
));
assert_eq!(meta.files.len(), 2);
assert!(meta.files.contains(&"src/lib.rs".to_string()));
assert!(meta.files.contains(&"tests/a.rs".to_string()));
}
#[test]
fn extract_recognises_source_extensions_without_slash() {
let meta = extract(&text("check lib.rs and index.tsx"));
assert!(meta.files.iter().any(|f| f == "lib.rs"));
assert!(meta.files.iter().any(|f| f == "index.tsx"));
}
#[test]
fn extract_captures_tool_names_from_tool_calls() {
let meta = extract(&tool_call("Shell"));
assert_eq!(meta.tools, vec!["Shell".to_string()]);
}
#[test]
fn extract_tags_error_markers_from_tool_results() {
let meta = extract(&tool_result(
"Error: file not found\n panicked at main.rs:12",
));
assert!(meta.error_classes.contains(&"error".to_string()));
assert!(meta.error_classes.contains(&"panicked".to_string()));
}
#[test]
fn project_bucket_maps_axes_correctly() {
let meta = RelevanceMeta {
files: vec!["src/a.rs".into()],
tools: Vec::new(),
error_classes: Vec::new(),
explicit_refs: Vec::new(),
};
let bucket = meta.project_bucket();
assert_eq!(bucket.tool_use, ToolUse::No);
assert_eq!(bucket.dependency, Dependency::Chained); assert_eq!(bucket.difficulty, Difficulty::Easy);
}
#[test]
fn project_bucket_escalates_difficulty_with_error_count() {
let meta = RelevanceMeta {
error_classes: vec!["error".into(), "failed".into(), "panicked".into()],
..Default::default()
};
assert_eq!(meta.project_bucket().difficulty, Difficulty::Hard);
}
}