use std::collections::BTreeMap;
use std::fmt;
use std::path::PathBuf;
use crate::analysis::findings::Finding;
use crate::llm::error::BackendErrorKind;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FailureReason {
Transport {
status: Option<u16>,
message: String,
},
Backend {
kind: BackendErrorKind,
message: String,
},
Unparseable(String),
CacheMiss,
ReviewLimit { completed: u32, limit: u32 },
ModelStopped { finish: String, message: String },
Truncated,
MalformedFinding(String),
ToolUnavailable { tool: String, detail: String },
SitePolicyRefused { marker: PathBuf, policy: PathBuf },
FileTooLarge { bytes: u64, limit: u64 },
PayloadTooLarge { bytes: u64, limit: u64 },
Unreadable(String),
Unsupported {
extension: Option<String>,
hint: Option<String>,
},
ChainFailed(Vec<ProviderFailure>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderFailure {
pub provider: usize,
pub model: String,
pub reason: FailureReason,
pub skipped: bool,
}
impl FailureReason {
pub fn unsupported(path: &std::path::Path, hint: Option<String>) -> Self {
FailureReason::Unsupported {
extension: path
.extension()
.map(|ext| format!(".{}", ext.to_string_lossy())),
hint,
}
}
pub fn one_line(&self) -> String {
match self {
FailureReason::Transport {
status: Some(code),
message,
} => {
format!("LLM transport failed (HTTP {code}): {message}")
}
FailureReason::Transport {
status: None,
message,
} => {
format!("LLM transport failed: {message}")
}
FailureReason::Unparseable(message) => {
format!("LLM response was unparseable: {message}")
}
FailureReason::CacheMiss => {
"LLM review is not cached; run a normal check to warm it".to_owned()
}
FailureReason::ReviewLimit { completed, limit } if completed < limit => format!(
"fresh LLM review capacity is currently reserved ({completed} completed of \
{limit}); wait for the in-flight review, pass `--max-review-rounds N`, or pass \
`--unlimited-reviews` to authorize another round"
),
FailureReason::ReviewLimit { completed, limit } => format!(
"fresh LLM review limit reached ({completed} of {limit}); raise \
`max_review_rounds`, pass `--max-review-rounds N`, or pass \
`--unlimited-reviews` to authorize another round"
),
FailureReason::Backend { kind, message } => {
format!("LLM backend {kind}: {message}")
}
FailureReason::Unsupported { extension, hint } => {
let what = match extension {
Some(ext) => format!("`{ext}` files"),
None => "files with no extension".to_owned(),
};
match hint {
Some(hint) => format!("no analyzer for {what}: {hint}"),
None => format!("no analyzer for {what}"),
}
}
FailureReason::ModelStopped { message, .. } => message.clone(),
FailureReason::Truncated => "response was truncated".to_owned(),
FailureReason::MalformedFinding(detail) => format!("malformed finding: {detail}"),
FailureReason::ToolUnavailable { tool, detail } => {
format!("{tool} could not run: {detail}")
}
FailureReason::SitePolicyRefused { marker, policy } => format!(
"semantic review is refused by site policy: {} is present (policy: {})",
marker.display(),
policy.display()
),
FailureReason::FileTooLarge { bytes, limit } => {
format!("file is too large to read ({bytes} bytes; limit is {limit})")
}
FailureReason::PayloadTooLarge { bytes, limit } => {
format!("the code sent for review is too large ({bytes} bytes; limit is {limit})")
}
FailureReason::Unreadable(detail) => format!("file could not be read: {detail}"),
FailureReason::ChainFailed(failures) => {
let each: Vec<String> = failures.iter().map(ProviderFailure::one_line).collect();
if each.is_empty() {
"no LLM provider analyzed this file".to_owned()
} else {
format!("no LLM provider analyzed this file: {}", each.join("; "))
}
}
}
}
pub fn status(&self) -> Option<u16> {
match self {
FailureReason::Transport { status, .. } => *status,
_ => None,
}
}
}
impl ProviderFailure {
pub fn one_line(&self) -> String {
let skipped = if self.skipped {
" (already down earlier in this run)"
} else {
""
};
format!(
"[{}] {}: {}{}",
self.provider + 1,
self.model,
self.reason.one_line(),
skipped
)
}
}
impl fmt::Display for FailureReason {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.pad(&self.one_line())
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct AnalysisResult {
pub findings: Vec<Finding>,
pub failed_files: BTreeMap<PathBuf, FailureReason>,
pub dropped_out_of_range: usize,
}
pub fn union_failures(
dst: &mut BTreeMap<PathBuf, FailureReason>,
src: BTreeMap<PathBuf, FailureReason>,
) {
for (path, reason) in src {
dst.entry(path).or_insert(reason);
}
}
impl AnalysisResult {
pub fn failed(path: PathBuf, reason: FailureReason) -> Self {
let mut result = Self::default();
result.failed_files.insert(path, reason);
result
}
pub fn merge(&mut self, other: AnalysisResult) {
self.findings.extend(other.findings);
union_failures(&mut self.failed_files, other.failed_files);
self.dropped_out_of_range = self
.dropped_out_of_range
.saturating_add(other.dropped_out_of_range);
}
pub fn has_failures(&self) -> bool {
!self.failed_files.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn merge_keeps_first_reason_on_key_collision() {
let mut a = AnalysisResult::default();
a.failed_files.insert(
PathBuf::from("src/lib.rs"),
FailureReason::Transport {
status: Some(429),
message: "rate limited".to_owned(),
},
);
let mut b = AnalysisResult::default();
b.failed_files.insert(
PathBuf::from("src/lib.rs"),
FailureReason::Transport {
status: Some(500),
message: "internal".to_owned(),
},
);
a.merge(b);
assert_eq!(a.failed_files.len(), 1);
let reason = a.failed_files.get(&PathBuf::from("src/lib.rs")).unwrap();
assert_eq!(
reason,
&FailureReason::Transport {
status: Some(429),
message: "rate limited".to_owned(),
},
"first reason wins on collision"
);
}
#[test]
fn transport_render_includes_the_http_status() {
let reason = FailureReason::Transport {
status: Some(429),
message: "rate limited".to_owned(),
};
let rendered = reason.one_line();
assert!(
rendered.contains("429"),
"rendered line must contain 429, got {rendered:?}"
);
}
#[test]
fn site_policy_refusal_names_the_marker_and_the_policy_file() {
let reason = FailureReason::SitePolicyRefused {
marker: PathBuf::from("/work/repo/.drep-no-llm"),
policy: PathBuf::from("/etc/drep/site.toml"),
};
let rendered = reason.one_line();
assert!(
rendered.contains("/work/repo/.drep-no-llm"),
"must name the marker, got {rendered:?}"
);
assert!(
rendered.contains("/etc/drep/site.toml"),
"must name the policy, got {rendered:?}"
);
}
#[test]
fn display_honours_formatter_width_and_alignment() {
let reason = FailureReason::Truncated;
assert_eq!(
format!("{reason:>30}"),
format!("{:>30}", reason.one_line())
);
}
}