use std::path::Path;
use futures::future::join_all;
use serde_json::Value;
use crate::analysis::findings::{Finding, LlmSeverity};
use crate::analysis::payload;
use crate::analysis::prompt::build_analysis_prompt;
use crate::analysis::response_contract::{CATEGORY, ISSUES, LINE, MESSAGE, SEVERITY, SUGGESTION};
use crate::analysis::result::{AnalysisResult, FailureReason, ProviderFailure};
use crate::diff::hunks::Hunk;
use crate::languages;
use crate::llm::cache::Cache;
use crate::llm::chain::{ChainError, ProviderChain};
use crate::llm::error::LlmError;
use crate::llm::json_parsing::Extracted;
pub struct CodeQualityAnalyzer {
pub(crate) chain: ProviderChain,
pub(crate) cache: Cache,
}
impl CodeQualityAnalyzer {
pub fn new(chain: ProviderChain, cache: Cache) -> Self {
Self { chain, cache }
}
pub fn chain(&self) -> &ProviderChain {
&self.chain
}
pub async fn analyze_file(&self, hunks: &[Hunk]) -> AnalysisResult {
let Some(first) = hunks.first() else {
return AnalysisResult::default();
};
let Some(language) = languages::detect(&first.file_path) else {
return AnalysisResult::default();
};
let Some(payload) = payload::render(language, hunks) else {
return AnalysisResult::default();
};
let rendered = payload.text.len() as u64;
if rendered > payload::PAYLOAD_MAX_BYTES {
return AnalysisResult::failed(
first.file_path.clone(),
FailureReason::PayloadTooLarge {
bytes: rendered,
limit: payload::PAYLOAD_MAX_BYTES,
},
);
}
let system_prompt = build_analysis_prompt(language);
match self
.chain
.complete_json(&system_prompt, &payload.text, &self.cache)
.await
{
Ok(served) => {
let result = parse_response(&payload, &first.file_path, &served.extracted);
if let Extracted::Complete(value) = &served.extracted
&& !served.from_cache
&& result.failed_files.is_empty()
{
let _ = self.cache.put(&served.key, value);
}
result
}
Err(err) => AnalysisResult::failed(first.file_path.clone(), chain_failure_reason(err)),
}
}
pub async fn analyze_files(&self, by_file: &[Vec<Hunk>]) -> AnalysisResult {
let futures = by_file.iter().map(|hunks| self.analyze_file(hunks));
let results = join_all(futures).await;
let mut merged = AnalysisResult::default();
for result in results {
merged.merge(result);
}
merged
}
}
pub(crate) fn into_failure_reason(err: LlmError) -> FailureReason {
match err {
LlmError::Transport { status, message } => FailureReason::Transport { status, message },
LlmError::Unparseable(message) => FailureReason::Unparseable(message),
LlmError::ModelStopped { finish, message } => {
FailureReason::ModelStopped { finish, message }
}
LlmError::NotConfigured(message) => FailureReason::Transport {
status: None,
message,
},
LlmError::Backend { kind, message } => FailureReason::Backend { kind, message },
}
}
fn chain_failure_reason(err: ChainError) -> FailureReason {
let mut attempts = err.attempts;
if err.chain_len == 1 {
let only = attempts.pop().expect("a chain always reports one attempt");
return into_failure_reason(only.error);
}
FailureReason::ChainFailed(
attempts
.into_iter()
.map(|attempt| ProviderFailure {
provider: attempt.provider,
model: attempt.model,
reason: into_failure_reason(attempt.error),
skipped: attempt.skipped,
})
.collect(),
)
}
fn parse_response(
payload: &payload::Payload,
file_path: &Path,
extracted: &Extracted,
) -> AnalysisResult {
let mut result = AnalysisResult::default();
let (value, truncated) = match extracted {
Extracted::Complete(value) => (value, false),
Extracted::Truncated(value) => (value, true),
};
let Some(issues) = value.get(ISSUES).and_then(Value::as_array) else {
let reason = if truncated {
FailureReason::Truncated
} else {
FailureReason::MalformedFinding("response has no `issues` array".to_owned())
};
result.failed_files.insert(file_path.to_path_buf(), reason);
return result;
};
let path_string = file_path.to_string_lossy().into_owned();
let mut failure = truncated.then_some(FailureReason::Truncated);
for issue in issues {
match parse_issue(issue, payload, &path_string) {
IssueOutcome::Finding(finding) => result.findings.push(finding),
IssueOutcome::Dropped => result.dropped_out_of_range += 1,
IssueOutcome::Malformed(detail) => {
failure.get_or_insert(FailureReason::MalformedFinding(detail));
}
}
}
if let Some(reason) = failure {
result.failed_files.insert(file_path.to_path_buf(), reason);
}
result
}
fn parse_issue(issue: &Value, payload: &payload::Payload, file_path: &str) -> IssueOutcome {
let Some(line) = issue.get(LINE).and_then(Value::as_u64) else {
return IssueOutcome::Malformed("missing or non-integer `line`".to_owned());
};
if line == 0 {
return IssueOutcome::Malformed("`line` is zero".to_owned());
}
let Ok(line) = u32::try_from(line) else {
return IssueOutcome::Malformed("`line` is beyond u32".to_owned());
};
let Some(severity_str) = issue.get(SEVERITY).and_then(Value::as_str) else {
return IssueOutcome::Malformed("missing `severity`".to_owned());
};
let Ok(severity) = severity_str.parse::<LlmSeverity>() else {
return IssueOutcome::Malformed(format!("unknown severity `{severity_str}`"));
};
let severity = severity.to_severity();
let Some(message) = issue.get(MESSAGE).and_then(Value::as_str) else {
return IssueOutcome::Malformed("missing or non-string `message`".to_owned());
};
let kind = match issue.get(CATEGORY) {
None => "unknown".to_owned(),
Some(Value::String(text)) => text.clone(),
Some(_) => return IssueOutcome::Malformed("non-string `category`".to_owned()),
};
let message = message.to_owned();
let suggestion = match issue.get(SUGGESTION) {
None => None,
Some(Value::String(text)) if text.is_empty() => None,
Some(Value::String(text)) => Some(text.clone()),
Some(_) => return IssueOutcome::Malformed("non-string `suggestion`".to_owned()),
};
if !payload.valid_lines.contains(&line) {
return IssueOutcome::Dropped;
}
IssueOutcome::Finding(Finding {
kind,
severity,
file_path: file_path.to_owned(),
line,
column: None,
message,
suggestion,
})
}
enum IssueOutcome {
Finding(Finding),
Dropped,
Malformed(String),
}