tldr-core 0.1.5

Core analysis engine for TLDR code analysis tool
Documentation
//! Cargo/Clippy JSON output parser.
//!
//! Cargo outputs NDJSON (one JSON object per line) via `--message-format=json`:
//! ```json
//! {"reason":"compiler-message","message":{"level":"warning","message":"unused variable",...}}
//! ```
//!
//! Only messages with "reason": "compiler-message" are diagnostics.

use crate::diagnostics::{Diagnostic, Severity};
use crate::error::TldrError;
use serde::Deserialize;
use std::path::PathBuf;

/// Cargo JSON line (NDJSON)
#[derive(Debug, Deserialize)]
struct CargoLine {
    reason: String,
    message: Option<CargoMessage>,
}

/// Cargo compiler message
#[derive(Debug, Deserialize)]
struct CargoMessage {
    code: Option<CargoCode>,
    level: String,
    message: String,
    spans: Vec<CargoSpan>,
    #[allow(dead_code)]
    rendered: Option<String>,
}

/// Cargo error code
#[derive(Debug, Deserialize)]
struct CargoCode {
    code: String,
    #[allow(dead_code)]
    explanation: Option<String>,
}

/// Cargo source span
#[derive(Debug, Deserialize)]
struct CargoSpan {
    file_name: String,
    line_start: u32,
    line_end: u32,
    column_start: u32,
    column_end: u32,
    is_primary: bool,
    #[allow(dead_code)]
    label: Option<String>,
}

/// Parse cargo NDJSON output into unified Diagnostic structs.
///
/// # Arguments
/// * `output` - The raw NDJSON output from `cargo check --message-format=json`
///
/// # Returns
/// A vector of Diagnostic structs, or an error if parsing fails.
///
/// # Format
/// Each line is a separate JSON object. Only lines with
/// "reason": "compiler-message" are processed.
pub fn parse_cargo_output(output: &str) -> Result<Vec<Diagnostic>, TldrError> {
    let mut diagnostics = Vec::new();

    for line in output.lines() {
        let line = line.trim();
        if line.is_empty() {
            continue;
        }

        // Parse each line as JSON
        let cargo_line: CargoLine = match serde_json::from_str(line) {
            Ok(l) => l,
            Err(_) => continue, // Skip lines that don't parse (e.g., summary lines)
        };

        // Only process compiler messages
        if cargo_line.reason != "compiler-message" {
            continue;
        }

        let message = match cargo_line.message {
            Some(m) => m,
            None => continue,
        };

        // Find the primary span
        let primary_span = message.spans.iter().find(|s| s.is_primary);
        let span = match primary_span.or(message.spans.first()) {
            Some(s) => s,
            None => continue, // No span information
        };

        let severity = match message.level.as_str() {
            "error" => Severity::Error,
            "warning" => Severity::Warning,
            "note" => Severity::Information,
            "help" => Severity::Hint,
            _ => Severity::Warning,
        };

        let code = message.code.map(|c| c.code);

        diagnostics.push(Diagnostic {
            file: PathBuf::from(&span.file_name),
            line: span.line_start,
            column: span.column_start,
            end_line: Some(span.line_end),
            end_column: Some(span.column_end),
            severity,
            message: message.message,
            code,
            source: "cargo".to_string(),
            url: None,
        });
    }

    Ok(diagnostics)
}

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

    const CARGO_OUTPUT: &str = r#"{"reason":"compiler-message","package_id":"myproject 0.1.0","manifest_path":"/project/Cargo.toml","target":{"kind":["lib"],"crate_types":["lib"],"name":"myproject","src_path":"/project/src/lib.rs"},"message":{"code":{"code":"unused_variables","explanation":null},"level":"warning","message":"unused variable: `x`","spans":[{"file_name":"src/main.rs","byte_start":100,"byte_end":101,"line_start":10,"line_end":10,"column_start":5,"column_end":6,"is_primary":true,"text":[{"text":"    let x = 5;","highlight_start":5,"highlight_end":6}],"label":"help: if this is intentional, prefix it with an underscore: `_x`"}],"children":[],"rendered":"warning: unused variable"}}
{"reason":"compiler-message","package_id":"myproject 0.1.0","manifest_path":"/project/Cargo.toml","target":{"kind":["lib"],"crate_types":["lib"],"name":"myproject","src_path":"/project/src/lib.rs"},"message":{"code":{"code":"E0308","explanation":"Expected type did not match the received type."},"level":"error","message":"mismatched types","spans":[{"file_name":"src/lib.rs","byte_start":200,"byte_end":210,"line_start":20,"line_end":20,"column_start":10,"column_end":20,"is_primary":true,"text":[{"text":"    return \"hello\";","highlight_start":10,"highlight_end":17}],"label":"expected `i32`, found `&str`"}],"children":[],"rendered":"error[E0308]: mismatched types"}}"#;

    #[test]
    fn test_parse_cargo() {
        let result = parse_cargo_output(CARGO_OUTPUT).unwrap();
        assert_eq!(result.len(), 2);

        let warning = &result[0];
        assert_eq!(warning.file, PathBuf::from("src/main.rs"));
        assert_eq!(warning.line, 10);
        assert_eq!(warning.column, 5);
        assert_eq!(warning.severity, Severity::Warning);
        assert_eq!(warning.code, Some("unused_variables".to_string()));
        assert_eq!(warning.source, "cargo");

        let error = &result[1];
        assert_eq!(error.file, PathBuf::from("src/lib.rs"));
        assert_eq!(error.line, 20);
        assert_eq!(error.severity, Severity::Error);
        assert_eq!(error.code, Some("E0308".to_string()));
    }

    #[test]
    fn test_level_mapping() {
        let result = parse_cargo_output(CARGO_OUTPUT).unwrap();
        assert_eq!(result[0].severity, Severity::Warning);
        assert_eq!(result[1].severity, Severity::Error);
    }

    #[test]
    fn test_empty_output() {
        let result = parse_cargo_output("").unwrap();
        assert!(result.is_empty());
    }

    #[test]
    fn test_ndjson_parsing() {
        // Two lines = two diagnostics
        let result = parse_cargo_output(CARGO_OUTPUT).unwrap();
        assert_eq!(result.len(), 2);
    }

    #[test]
    fn test_skip_non_compiler_messages() {
        let output = r#"{"reason":"build-script-executed","package_id":"test 0.1.0"}
{"reason":"compiler-artifact","package_id":"test 0.1.0"}"#;
        let result = parse_cargo_output(output).unwrap();
        assert!(result.is_empty());
    }
}