engram-core 0.21.1

AI Memory Infrastructure - Persistent memory for AI agents with semantic search
Documentation
use super::output::ReducerOutput;
use super::redaction::{redact_text, NoopRedactor, Redactor};
use super::util::{extract_clippy_rule, parse_path_line_col, strip_ansi};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CargoClippyDiagnostic {
    pub severity: String,
    pub message: String,
    pub file: Option<String>,
    pub line: Option<u64>,
    pub column: Option<u64>,
    pub rule: Option<String>,
    pub help: Vec<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CargoClippyDiagnosticGroup {
    pub file: String,
    pub rule: String,
    pub severity: String,
    pub diagnostics: usize,
    pub spans: Vec<ActionableSpan>,
    pub messages: Vec<String>,
    pub help: Vec<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct ActionableSpan {
    pub file: String,
    pub line: u64,
    pub column: Option<u64>,
}

pub fn parse_cargo_clippy_diagnostics(log: &str) -> Vec<CargoClippyDiagnostic> {
    let mut diagnostics = Vec::new();
    let mut current: Option<CargoClippyDiagnostic> = None;

    for raw_line in log.lines() {
        let line = strip_ansi(raw_line);
        let trimmed = line.trim();

        if let Some((severity, message)) = parse_severity_line(trimmed) {
            if let Some(diagnostic) = current.take() {
                diagnostics.push(diagnostic);
            }
            current = Some(CargoClippyDiagnostic {
                severity,
                rule: extract_clippy_rule(trimmed),
                message,
                file: None,
                line: None,
                column: None,
                help: Vec::new(),
            });
            continue;
        }

        let Some(diagnostic) = current.as_mut() else {
            continue;
        };

        if diagnostic.rule.is_none() {
            diagnostic.rule = extract_clippy_rule(trimmed);
        }

        if let Some((file, line_no, column)) = parse_location_marker(trimmed) {
            diagnostic.file = Some(file);
            diagnostic.line = Some(line_no);
            diagnostic.column = column;
        }

        if let Some(help) = parse_help_line(trimmed) {
            diagnostic.help.push(help.to_string());
        }
    }

    if let Some(diagnostic) = current {
        diagnostics.push(diagnostic);
    }

    diagnostics
}

pub fn reduce_cargo_clippy(log: &str, exit_code: i32) -> ReducerOutput {
    reduce_cargo_clippy_with_redactor(log, exit_code, &NoopRedactor)
}

pub fn reduce_cargo_clippy_with_redactor(
    log: &str,
    exit_code: i32,
    redactor: &dyn Redactor,
) -> ReducerOutput {
    let diagnostics = parse_cargo_clippy_diagnostics(log);
    let groups = group_diagnostics(&diagnostics);
    let summary = format!(
        "cargo_clippy@v1: diagnostic_groups={}; diagnostics={}; exit_code={exit_code}",
        groups.len(),
        diagnostics.len()
    );
    let mut output = ReducerOutput::new(summary);

    output.lossy = true;
    output.raw_required_for_full_debug = !diagnostics.is_empty();
    output.confidence = if !diagnostics.is_empty() || exit_code == 0 {
        0.9
    } else {
        0.6
    };

    output.add_fact("reducer", "cargo_clippy@v1");
    output.add_fact("exit_code", exit_code.to_string());

    if diagnostics.is_empty() && exit_code != 0 {
        output.add_warning("cargo_clippy@v1 saw a non-zero exit code but parsed no diagnostics");
    }

    for group in groups.values() {
        let mut metadata = BTreeMap::new();
        metadata.insert("file".to_string(), group.file.clone());
        metadata.insert("rule".to_string(), group.rule.clone());
        metadata.insert("severity".to_string(), group.severity.clone());
        metadata.insert("diagnostics".to_string(), group.diagnostics.to_string());
        let value = format!(
            "{} {} {} count={}",
            group.file, group.rule, group.severity, group.diagnostics
        );
        let value = redact_text(redactor, &value, &mut output);
        output.add_fact_with_metadata("diagnostic_group", value, metadata);

        for span in &group.spans {
            let location = match span.column {
                Some(column) => format!("{}:{line}:{column}", span.file, line = span.line),
                None => format!("{}:{}", span.file, span.line),
            };
            let mut metadata = BTreeMap::new();
            metadata.insert("file".to_string(), span.file.clone());
            metadata.insert("line".to_string(), span.line.to_string());
            metadata.insert("rule".to_string(), group.rule.clone());
            metadata.insert("severity".to_string(), group.severity.clone());
            let value = redact_text(redactor, &location, &mut output);
            output.add_fact_with_metadata("actionable_span", value, metadata);
        }

        for message in &group.messages {
            let value = redact_text(redactor, message, &mut output);
            output.add_fact("diagnostic_message", value);
        }

        for help in &group.help {
            let value = redact_text(redactor, help, &mut output);
            output.add_fact("diagnostic_help", value);
        }
    }

    output.add_evidence("exit_code", true);
    output.add_evidence(
        "diagnostic_groups_by_file_rule_severity",
        !groups.is_empty(),
    );
    output.add_evidence(
        "actionable_file_line_column_spans",
        diagnostics
            .iter()
            .any(|diagnostic| diagnostic.file.is_some() && diagnostic.line.is_some()),
    );
    output.add_evidence(
        "clippy_rules",
        diagnostics
            .iter()
            .any(|diagnostic| diagnostic.rule.is_some()),
    );
    output.add_evidence("raw_code_snippets", false);

    output
}

fn parse_severity_line(line: &str) -> Option<(String, String)> {
    let colon = line.find(':')?;
    let head = &line[..colon];
    let severity = if head == "warning" || head.starts_with("warning[") {
        "warning"
    } else if head == "error" || head.starts_with("error[") {
        "error"
    } else {
        return None;
    };
    let message = line[colon + 1..].trim().to_string();
    Some((severity.to_string(), message))
}

fn parse_location_marker(line: &str) -> Option<(String, u64, Option<u64>)> {
    let rest = line.strip_prefix("--> ")?;
    let location = rest.split_whitespace().next()?;
    parse_path_line_col(location)
}

fn parse_help_line(line: &str) -> Option<&str> {
    line.strip_prefix("help:")
        .or_else(|| line.strip_prefix("= help:"))
        .map(str::trim)
}

fn group_diagnostics(
    diagnostics: &[CargoClippyDiagnostic],
) -> BTreeMap<(String, String, String), CargoClippyDiagnosticGroup> {
    let mut groups = BTreeMap::new();

    for diagnostic in diagnostics {
        let file = diagnostic
            .file
            .clone()
            .unwrap_or_else(|| "<unknown>".to_string());
        let rule = diagnostic
            .rule
            .clone()
            .unwrap_or_else(|| "<unknown>".to_string());
        let key = (file.clone(), rule.clone(), diagnostic.severity.clone());
        let group = groups
            .entry(key)
            .or_insert_with(|| CargoClippyDiagnosticGroup {
                file,
                rule,
                severity: diagnostic.severity.clone(),
                diagnostics: 0,
                spans: Vec::new(),
                messages: Vec::new(),
                help: Vec::new(),
            });

        group.diagnostics += 1;
        group.messages.push(diagnostic.message.clone());
        group.help.extend(diagnostic.help.clone());

        if let (Some(file), Some(line)) = (&diagnostic.file, diagnostic.line) {
            let span = ActionableSpan {
                file: file.clone(),
                line,
                column: diagnostic.column,
            };
            if !group.spans.contains(&span) {
                group.spans.push(span);
            }
        }
    }

    for group in groups.values_mut() {
        let mut seen_messages = BTreeSet::new();
        group
            .messages
            .retain(|message| seen_messages.insert(message.clone()));

        let mut seen_help = BTreeSet::new();
        group.help.retain(|help| seen_help.insert(help.clone()));
    }

    groups
}

#[cfg(test)]
mod tests {
    use super::*;

    fn has_fact(output: &ReducerOutput, kind: &str, needle: &str) -> bool {
        output
            .observed_facts
            .iter()
            .any(|fact| fact.kind == kind && fact.value.contains(needle))
    }

    #[test]
    fn cargo_clippy_groups_by_file_rule_severity_and_preserves_span() {
        let log = r#"
warning: used `unwrap()` on a `Result` value
  --> src/mcp/handler.rs:42:18
   |
42 |     value.unwrap()
   |                  ^ help: consider using `expect`
   = note: `-D clippy::unwrap_used` implied by `-D warnings`
help: consider handling the error explicitly
error: this expression creates a reference which is immediately dereferenced
  --> src/mcp/handler.rs:50:9
   = note: `-D clippy::needless_borrow` implied by `-D warnings`
"#;

        let output = reduce_cargo_clippy(log, 1);

        assert!(has_fact(
            &output,
            "diagnostic_group",
            "src/mcp/handler.rs clippy::unwrap_used warning count=1"
        ));
        assert!(has_fact(
            &output,
            "diagnostic_group",
            "src/mcp/handler.rs clippy::needless_borrow error count=1"
        ));
        assert!(has_fact(
            &output,
            "actionable_span",
            "src/mcp/handler.rs:42:18"
        ));
        assert!(has_fact(
            &output,
            "actionable_span",
            "src/mcp/handler.rs:50:9"
        ));
        assert!(has_fact(
            &output,
            "diagnostic_help",
            "consider handling the error explicitly"
        ));
        assert!(output.raw_required_for_full_debug);
    }
}