sniff-cli 0.1.4

An exhaustive LLM-backed slop finder for codebases
Documentation
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");
    }
}