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, COMPILE_FAILURE, ISSUES, LINE, MESSAGE, SEVERITY, SUGGESTION,
};
use crate::analysis::result::{AnalysisResult, FailureReason, ProviderFailure};
use crate::diff::hunks::Hunk;
use crate::diff::hunks::group_by_file;
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,
cache_only: bool,
}
#[derive(Clone, Copy)]
enum CacheMode {
Prefer,
Only,
Bypass,
}
impl CodeQualityAnalyzer {
pub fn new(chain: ProviderChain, cache: Cache) -> Self {
Self {
chain,
cache,
cache_only: false,
}
}
pub fn with_cache_only(mut self, cache_only: bool) -> Self {
self.cache_only = cache_only;
self
}
pub fn chain(&self) -> &ProviderChain {
&self.chain
}
pub async fn analyze_file(&self, hunks: &[Hunk]) -> AnalysisResult {
self.analyze_files_in_mode(std::iter::once(hunks), self.configured_cache_mode())
.await
}
async fn analyze_file_in_mode(&self, hunks: &[Hunk], cache_mode: CacheMode) -> 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 = u64::try_from(payload.text.len()).unwrap_or(u64::MAX);
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);
let served = match cache_mode {
CacheMode::Only => {
match self
.chain
.cached_json(&system_prompt, &payload.text, &self.cache)
{
Some(served) => Ok(served),
None => {
return AnalysisResult::failed(
first.file_path.clone(),
FailureReason::CacheMiss,
);
}
}
}
CacheMode::Prefer => {
self.chain
.complete_json(&system_prompt, &payload.text, &self.cache)
.await
}
CacheMode::Bypass => {
self.chain
.complete_json_fresh(&system_prompt, &payload.text, &self.cache)
.await
}
};
match served {
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 {
self.analyze_files_in_mode(
by_file.iter().map(Vec::as_slice),
self.configured_cache_mode(),
)
.await
}
pub async fn analyze_files_live(&self, by_file: &[&[Hunk]]) -> AnalysisResult {
self.analyze_files_in_mode(by_file.iter().copied(), CacheMode::Bypass)
.await
}
fn configured_cache_mode(&self) -> CacheMode {
if self.cache_only {
CacheMode::Only
} else {
CacheMode::Prefer
}
}
async fn analyze_files_in_mode<'a>(
&self,
by_file: impl IntoIterator<Item = &'a [Hunk]>,
cache_mode: CacheMode,
) -> AnalysisResult {
let groups: Vec<HunkGroup<'a>> = by_file.into_iter().flat_map(partition_hunks).collect();
let futures = groups
.iter()
.map(|group| self.analyze_file_in_mode(group.as_slice(), cache_mode));
let results = join_all(futures).await;
let mut merged = AnalysisResult::default();
for result in results {
merged.merge(result);
}
merged
}
}
enum HunkGroup<'a> {
Borrowed(&'a [Hunk]),
Owned(Vec<Hunk>),
}
impl HunkGroup<'_> {
fn as_slice(&self) -> &[Hunk] {
match self {
Self::Borrowed(hunks) => hunks,
Self::Owned(hunks) => hunks,
}
}
}
fn partition_hunks(hunks: &[Hunk]) -> Vec<HunkGroup<'_>> {
let mixed = hunks
.first()
.is_some_and(|first| hunks.iter().any(|hunk| hunk.file_path != first.file_path));
if !mixed {
return vec![HunkGroup::Borrowed(hunks)];
}
group_by_file(hunks.iter().cloned())
.into_iter()
.map(HunkGroup::Owned)
.collect()
}
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 Some(only) = attempts.pop()
{
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 asserts_compile_failure = match issue.get(COMPILE_FAILURE) {
None => false,
Some(Value::Bool(value)) => *value,
Some(_) => return IssueOutcome::Malformed("non-boolean `compile_failure`".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,
asserts_compile_failure,
fingerprint: None,
})
}
enum IssueOutcome {
Finding(Finding),
Dropped,
Malformed(String),
}
#[cfg(test)]
mod partition_tests {
use super::{HunkGroup, partition_hunks};
use crate::diff::hunks::Hunk;
use std::path::PathBuf;
#[test]
fn already_grouped_hunks_remain_borrowed() {
let hunks = [Hunk::whole_file(PathBuf::from("same.rs"), "one\ntwo\n")];
let groups = partition_hunks(&hunks);
let [HunkGroup::Borrowed(group)] = groups.as_slice() else {
panic!("an already-grouped slice must stay on the zero-copy path");
};
assert!(std::ptr::eq(group.as_ptr(), hunks.as_ptr()));
}
}