//! Parser for cargo compiler output (errors + warnings).
//!
//! Cargo emits diagnostics in a recognisable two-line shape:
//!
//! ```text
//! error[E0061]: this function takes 4 arguments but 3 arguments were supplied
//!   --> server/src/agents/pool/auto_assign/merge.rs:86:29
//! ```
//!
//! or, without a code:
//!
//! ```text
//! warning: unused import: `Foo`
//!   --> server/src/lib.rs:3:5
//! ```
//!
//! This module extracts those headers into structured [`Diagnostic`] entries so
//! that MCP shell tools (`run_check`, `run_build`, `run_lint`) can return a
//! compact error list instead of the full multi-hundred-KB cargo log.
//! See bug 886.
use regex::Regex;
use serde::Serialize;
use std::sync::OnceLock;

/// One compiler diagnostic extracted from cargo output.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Diagnostic {
    /// Either `"error"` or `"warning"`.
    pub kind: String,
    /// Rust diagnostic code if present, e.g. `"E0061"`. `None` for plain
    /// `error: ...` / `warning: ...` headers.
    pub code: Option<String>,
    /// The message text on the same line as the header.
    pub message: String,
    /// Source file path from the `-->` line, when present.
    pub file: Option<String>,
    /// 1-based line number from the `-->` line, when present.
    pub line: Option<u32>,
}

/// Compact summary of a diagnostics scan.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct DiagnosticSummary {
    pub error_count: usize,
    pub warning_count: usize,
}

/// Parse cargo / rustc style output into structured diagnostics.
///
/// Recognises lines starting with `error[CODE]:`, `error:`, `warning[CODE]:`,
/// `warning:`. If the next non-blank line starts with ` --> file:line(:col)?`
/// the location is attached to the diagnostic. Lines that do not match either
/// pattern are ignored, so the parser tolerates arbitrary surrounding output
/// (Compiling/Checking progress, source snippets, etc.).
pub fn parse_diagnostics(output: &str) -> Vec<Diagnostic> {
    static HEADER_RE: OnceLock<Regex> = OnceLock::new();
    static LOC_RE: OnceLock<Regex> = OnceLock::new();
    let header_re = HEADER_RE.get_or_init(|| {
        // Anchored to start-of-line: "error" or "warning", optional [CODE], colon, message.
        Regex::new(r"^(error|warning)(?:\[([A-Za-z0-9]+)\])?: (.+)$").unwrap()
    });
    let loc_re =
        LOC_RE.get_or_init(|| Regex::new(r"^\s*-->\s+([^:\s][^:]*):(\d+)(?::\d+)?\s*$").unwrap());

    let lines: Vec<&str> = output.lines().collect();
    let mut diagnostics = Vec::new();

    for (i, line) in lines.iter().enumerate() {
        let Some(caps) = header_re.captures(line) else {
            continue;
        };
        let kind = caps.get(1).unwrap().as_str().to_string();
        let code = caps.get(2).map(|m| m.as_str().to_string());
        let message = caps.get(3).unwrap().as_str().to_string();

        // Look ahead a few lines for the `-->` location marker. Cargo
        // typically puts it 1 line after the header, but allow up to 3 in
        // case of formatting quirks.
        let mut file = None;
        let mut line_num = None;
        for look in 1..=3 {
            if let Some(next) = lines.get(i + look)
                && let Some(loc) = loc_re.captures(next)
            {
                file = Some(loc.get(1).unwrap().as_str().to_string());
                line_num = loc.get(2).unwrap().as_str().parse::<u32>().ok();
                break;
            }
        }

        diagnostics.push(Diagnostic {
            kind,
            code,
            message,
            file,
            line: line_num,
        });
    }

    diagnostics
}

/// Count errors vs warnings in a parsed diagnostics list.
pub fn summarise(diagnostics: &[Diagnostic]) -> DiagnosticSummary {
    let error_count = diagnostics.iter().filter(|d| d.kind == "error").count();
    let warning_count = diagnostics.iter().filter(|d| d.kind == "warning").count();
    DiagnosticSummary {
        error_count,
        warning_count,
    }
}

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

    #[test]
    fn parses_error_with_code_and_location() {
        let input = "error[E0061]: this function takes 4 arguments but 3 arguments were supplied\n  --> server/src/agents/pool/auto_assign/merge.rs:86:29\n   |\n86 | foo();\n";
        let diags = parse_diagnostics(input);
        assert_eq!(diags.len(), 1);
        assert_eq!(diags[0].kind, "error");
        assert_eq!(diags[0].code.as_deref(), Some("E0061"));
        assert!(diags[0].message.contains("4 arguments"));
        assert_eq!(
            diags[0].file.as_deref(),
            Some("server/src/agents/pool/auto_assign/merge.rs")
        );
        assert_eq!(diags[0].line, Some(86));
    }

    #[test]
    fn parses_warning_without_code() {
        let input = "warning: unused import: `Foo`\n  --> server/src/lib.rs:3:5\n";
        let diags = parse_diagnostics(input);
        assert_eq!(diags.len(), 1);
        assert_eq!(diags[0].kind, "warning");
        assert!(diags[0].code.is_none());
        assert_eq!(diags[0].message, "unused import: `Foo`");
        assert_eq!(diags[0].file.as_deref(), Some("server/src/lib.rs"));
        assert_eq!(diags[0].line, Some(3));
    }

    #[test]
    fn parses_header_without_location() {
        // Plain `error:` / `warning:` headers without a `-->` marker still
        // produce a diagnostic — just with file/line as None.
        let input = "error: could not compile `huskies` (bin \"huskies\") due to 3 previous errors";
        let diags = parse_diagnostics(input);
        assert_eq!(diags.len(), 1);
        assert_eq!(diags[0].kind, "error");
        assert!(diags[0].file.is_none());
        assert!(diags[0].line.is_none());
    }

    #[test]
    fn ignores_non_diagnostic_lines() {
        let input =
            "    Checking huskies v0.10.4\n   Compiling foo v0.1.0\n    Finished dev profile\n";
        assert!(parse_diagnostics(input).is_empty());
    }

    #[test]
    fn parses_three_compile_errors_from_real_cargo_output() {
        // Regression for bug 886: real-world output from 864's failing run.
        let input = "    Checking huskies v0.10.4 (/workspace/server)\n\
warning: unused imports: `ItemMetadata` and `OwnedItemMetadata`\n  \
--> server/src/db/mod.rs:25:5\n\
   |\n\
25 |     ItemMetadata, OwnedItemMetadata, delete_item, move_item_stage, next_item_number,\n   \
|     ^^^^^^^^^^^^  ^^^^^^^^^^^^^^^^^\n\n\
error[E0061]: this function takes 4 arguments but 3 arguments were supplied\n  \
--> server/src/agents/pool/auto_assign/merge.rs:86:29\n\
   |\n\
86 | crate::db::write_item_with_content(story_id, \"4_merge\", &updated);\n\n\
error[E0061]: this function takes 4 arguments but 3 arguments were supplied\n  \
--> server/src/agents/pool/pipeline/advance/helpers.rs:91:9\n\
   |\n\
91 | crate::db::write_item_with_content(story_id, &stage, &updated);\n\n\
error[E0061]: this function takes 4 arguments but 3 arguments were supplied\n  \
--> server/src/chat/commands/unblock.rs:103:13\n\
   |\n\
103| crate::db::write_item_with_content(story_id, &stage, &updated);\n\n\
For more information about this error, try `rustc --explain E0061`.\n\
warning: `huskies` (bin \"huskies\") generated 1 warning\n\
error: could not compile `huskies` (bin \"huskies\") due to 3 previous errors; 1 warning emitted\n";

        let diags = parse_diagnostics(input);
        let summary = summarise(&diags);

        // 3 compile errors + 1 unused-import warning + 1 generated-warning summary line + 1 final "could not compile" error
        assert_eq!(summary.error_count, 4);
        assert_eq!(summary.warning_count, 2);

        // The 3 main compile errors must be present with file + line.
        let e0061: Vec<&Diagnostic> = diags
            .iter()
            .filter(|d| d.code.as_deref() == Some("E0061"))
            .collect();
        assert_eq!(e0061.len(), 3);
        let files: Vec<Option<&str>> = e0061.iter().map(|d| d.file.as_deref()).collect();
        assert!(files.contains(&Some("server/src/agents/pool/auto_assign/merge.rs")));
        assert!(files.contains(&Some("server/src/agents/pool/pipeline/advance/helpers.rs")));
        assert!(files.contains(&Some("server/src/chat/commands/unblock.rs")));
    }
}
