use crate::llm::ResponseSchema;
use crate::report_types::LLMVerdict;
use crate::types::{FileRecord, MethodRecord};
use std::env;
use std::fmt::Display;
use std::io::Write;
use super::verdicts::{clear_unsupported_verdict, evidence_matches_source};
use super::{ReviewProgress, ReviewProgressCallback};
pub(super) fn render_template(template: &str, values: &[&dyn Display]) -> String {
let mut rendered = String::with_capacity(template.len());
let mut remainder = template;
for value in values {
let Some((before, after)) = remainder.split_once("{}") else {
rendered.push_str(remainder);
return rendered;
};
rendered.push_str(before);
rendered.push_str(&value.to_string());
remainder = after;
}
rendered.push_str(remainder);
rendered
}
pub(super) fn truncate_source(source: &str, max_chars: usize) -> String {
if source.chars().count() <= max_chars {
return source.to_string();
}
let head = max_chars / 2;
let tail = max_chars - head;
let head_text: String = source.chars().take(head).collect();
let tail_text: String = source
.chars()
.rev()
.take(tail)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
format!("{head_text}\n...\n{tail_text}")
}
pub(super) fn review_key(file_path: &str, method_name: &str) -> String {
format!("{}::{}", file_path, method_name)
}
pub(super) fn format_static_signals(signals: &[String]) -> String {
if signals.is_empty() {
"none".to_string()
} else {
signals
.iter()
.map(|s| format!("- {}", s))
.collect::<Vec<String>>()
.join("\n")
}
}
fn rust_cfg_test_start_line(source: &str) -> Option<usize> {
source
.lines()
.position(|line| line.trim().starts_with("#[cfg(test)]"))
.map(|idx| idx + 1)
}
pub(crate) fn is_rust_cfg_test_method(file: &FileRecord, method: &MethodRecord) -> bool {
if !file.file_path.ends_with(".rs") {
return false;
}
let Some(cfg_test_line) = rust_cfg_test_start_line(&file.source) else {
return false;
};
method.start_line > cfg_test_line
}
pub(super) fn strip_rust_cfg_test_source(source: &str) -> String {
let Some(cfg_test_line) = rust_cfg_test_start_line(source) else {
return source.to_string();
};
source
.lines()
.take(cfg_test_line.saturating_sub(1))
.collect::<Vec<_>>()
.join("\n")
}
pub(super) fn format_method_inventory(file: &FileRecord) -> String {
let methods: Vec<&MethodRecord> = file
.methods
.iter()
.filter(|method| !is_rust_cfg_test_method(file, method))
.collect();
if methods.is_empty() {
return "none".to_string();
}
let mut entries = methods
.iter()
.map(|method| (method.loc, method.name.as_str(), method.param_count))
.collect::<Vec<_>>();
entries.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(b.1)));
let total = entries.len();
let mut rendered = entries
.into_iter()
.take(8)
.map(|(loc, name, param_count)| format!("- {} ({} LOC, {} params)", name, loc, param_count))
.collect::<Vec<_>>();
if total > 8 {
rendered.push(format!("- ... (+{} more)", total - 8));
}
rendered.join("\n")
}
pub(super) fn surrounding_file_context(file: &FileRecord, method: &MethodRecord) -> String {
let lines = file.source.lines().collect::<Vec<_>>();
if lines.is_empty() {
return "none".to_string();
}
let start = method.start_line.saturating_sub(1).saturating_sub(12);
let end = method.end_line.saturating_add(12).min(lines.len());
let context = lines[start..end]
.iter()
.enumerate()
.map(|(offset, line)| format!("{:>4}: {}", start + offset + 1, line))
.collect::<Vec<_>>()
.join("\n");
truncate_source(&context, 6000)
}
pub(super) fn log_ai_review_miss(kind: &str, path: &str, name: Option<&str>) {
let enabled = env::var("SNIFF_LOG_AI_MISSES")
.ok()
.map(|value| {
let lowered = value.trim().to_lowercase();
!lowered.is_empty() && lowered != "0" && lowered != "false"
})
.unwrap_or(false);
if !enabled {
return;
}
match name {
Some(name) => {
eprintln!("LLM review missed for {kind}: {path}::{name}");
let _ = std::io::stderr().flush();
}
None => {
eprintln!("LLM review missed for {kind}: {path}");
let _ = std::io::stderr().flush();
}
}
}
pub(super) fn build_invalid_evidence_retry_prompt(
original_prompt: &str,
evidence: &str,
source: &str,
) -> String {
let evidence = evidence.trim();
if evidence.is_empty() {
return original_prompt.to_string();
}
format!(
"{original_prompt}\n\nYour previous answer used evidence that was not an exact substring of the source: `{evidence}`. Re-evaluate the same source and return a corrected JSON object.\n\nFull source for evidence validation:\n---\n{source}\n---\n\nIf you cannot quote exact source evidence, return tier: \"clean\" and an empty reason."
)
}
pub(super) struct RetryContext<'a> {
pub(super) source: &'a str,
pub(super) label: &'a str,
pub(super) on_progress: Option<&'a ReviewProgressCallback>,
}
pub(super) async fn retry_invalid_evidence<F>(
analyzer: &super::Analyzer,
prompt: &str,
schema: ResponseSchema,
mut verdict: LLMVerdict,
retry: RetryContext<'_>,
mut rebuild: F,
) -> Result<(LLMVerdict, usize, usize), String>
where
F: FnMut(&serde_json::Value) -> Result<LLMVerdict, String> + Send,
{
if !verdict.smelly || evidence_matches_source(retry.source, &verdict.evidence) {
return Ok((verdict, 0, 0));
}
if let Some(callback) = retry.on_progress {
callback(ReviewProgress::RetryingEvidence {
label: retry.label.to_string(),
});
}
let retry_prompt = build_invalid_evidence_retry_prompt(prompt, &verdict.evidence, retry.source);
let (retry_result, input_tokens, output_tokens) =
analyzer.llm_client.call(&retry_prompt, schema).await?;
if let Some(callback) = retry.on_progress {
callback(ReviewProgress::Started {
label: retry.label.to_string(),
});
}
let Some(retry_result) = retry_result else {
clear_unsupported_verdict(&mut verdict);
return Ok((verdict, input_tokens, output_tokens));
};
verdict = rebuild(&retry_result)?;
if verdict.smelly && !evidence_matches_source(retry.source, &verdict.evidence) {
clear_unsupported_verdict(&mut verdict);
}
Ok((verdict, input_tokens, output_tokens))
}
#[cfg(test)]
mod tests {
use super::render_template;
#[test]
fn template_interpolation_does_not_consume_braces_from_inserted_source() {
let source = "const value = { ok: true };";
let rendered = render_template("source={} | next={}", &[&source, &"tail"]);
assert_eq!(rendered, "source=const value = { ok: true }; | next=tail");
}
}