use std::sync::Arc;
use serde::{Deserialize, Serialize};
use brainwires_core::Provider;
use super::forge::{Issue, RepoRef};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InvestigationResult {
pub issue_number: u64,
pub confidence: f64,
pub summary: String,
pub affected_files: Vec<String>,
pub approach: String,
pub complexity: String,
}
pub struct IssueInvestigator {
provider: Arc<dyn Provider>,
}
impl IssueInvestigator {
pub fn new(provider: Arc<dyn Provider>) -> Self {
Self { provider }
}
pub async fn investigate(
&self,
issue: &Issue,
_repo: &RepoRef,
) -> anyhow::Result<InvestigationResult> {
let prompt = format!(
"Analyze this issue and determine what needs to be fixed:\n\n\
Title: {}\n\
Body:\n{}\n\
Labels: {}\n\n\
Provide:\n\
1. A brief summary of the problem\n\
2. Which files are likely affected\n\
3. A proposed approach to fix it\n\
4. Estimated complexity (low/medium/high)\n\
5. Your confidence (0.0-1.0) that this analysis is correct",
issue.title,
issue.body,
issue.labels.join(", ")
);
let messages = vec![brainwires_core::Message::user(prompt)];
let options = brainwires_core::ChatOptions::default();
let response = self.provider.chat(&messages, None, &options).await?;
let content = response.message.text().unwrap_or_default().to_string();
Ok(InvestigationResult {
issue_number: issue.number,
confidence: extract_confidence(&content),
summary: content.clone(),
affected_files: extract_files(&content),
approach: content.clone(),
complexity: extract_complexity(&content),
})
}
}
fn extract_confidence(text: &str) -> f64 {
for line in text.lines() {
let lower = line.to_lowercase();
if lower.contains("confidence") {
for word in lower.split_whitespace() {
if let Ok(val) = word
.trim_matches(|c: char| !c.is_ascii_digit() && c != '.')
.parse::<f64>()
&& (0.0..=1.0).contains(&val)
{
return val;
}
}
}
}
0.5 }
fn extract_files(text: &str) -> Vec<String> {
let mut files = Vec::new();
for line in text.lines() {
let trimmed = line
.trim()
.trim_start_matches("- ")
.trim_start_matches("* ");
if (trimmed.contains('/') || trimmed.contains('.')) && trimmed.ends_with(".rs")
|| trimmed.ends_with(".ts")
|| trimmed.ends_with(".py")
|| trimmed.ends_with(".js")
|| trimmed.ends_with(".toml")
{
let path = trimmed
.split_whitespace()
.find(|w| w.contains('.'))
.unwrap_or(trimmed)
.trim_matches('`')
.to_string();
if !path.is_empty() {
files.push(path);
}
}
}
files
}
fn extract_complexity(text: &str) -> String {
let lower = text.to_lowercase();
if lower.contains("high complexity") || lower.contains("complexity: high") {
"high".to_string()
} else if lower.contains("low complexity") || lower.contains("complexity: low") {
"low".to_string()
} else {
"medium".to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_confidence_from_text() {
assert!((extract_confidence("My confidence: 0.85") - 0.85).abs() < f64::EPSILON);
assert!((extract_confidence("I have 0.9 confidence in this") - 0.9).abs() < f64::EPSILON);
}
#[test]
fn extract_confidence_default_when_missing() {
assert!((extract_confidence("no score here") - 0.5).abs() < f64::EPSILON);
}
#[test]
fn extract_files_finds_file_paths() {
let text = "Affected files:\n- src/main.rs\n- src/lib.rs\n- README.md\n";
let files = extract_files(text);
assert!(files.contains(&"src/main.rs".to_string()));
assert!(files.contains(&"src/lib.rs".to_string()));
}
#[test]
fn extract_complexity_variants() {
assert_eq!(extract_complexity("This is high complexity work"), "high");
assert_eq!(extract_complexity("Complexity: high"), "high");
assert_eq!(extract_complexity("This is low complexity"), "low");
assert_eq!(extract_complexity("Complexity: low"), "low");
assert_eq!(extract_complexity("Something else entirely"), "medium");
}
}