use agnix_core::diagnostics::{Diagnostic, DiagnosticLevel};
use serde::Serialize;
use std::path::Path;
#[derive(Debug, Serialize)]
pub struct JsonOutput {
pub version: String,
pub files_checked: usize,
pub diagnostics: Vec<JsonDiagnostic>,
pub summary: JsonSummary,
}
#[derive(Debug, Serialize)]
pub struct JsonDiagnostic {
pub level: String,
pub rule: String,
pub file: String,
pub line: usize,
pub column: usize,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub suggestion: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub assumption: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rule_severity: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub applies_to_tool: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct JsonSummary {
pub errors: usize,
pub warnings: usize,
pub info: usize,
}
fn level_to_string(level: DiagnosticLevel) -> &'static str {
match level {
DiagnosticLevel::Error => "error",
DiagnosticLevel::Warning => "warning",
DiagnosticLevel::Info => "info",
}
}
fn path_to_string(path: &Path, base_path: &Path) -> String {
path.strip_prefix(base_path)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/")
}
pub fn diagnostics_to_json(
diagnostics: &[Diagnostic],
base_path: &Path,
files_checked: usize,
) -> JsonOutput {
let mut errors = 0;
let mut warnings = 0;
let mut info = 0;
let json_diagnostics: Vec<JsonDiagnostic> = diagnostics
.iter()
.map(|diag| {
match diag.level {
DiagnosticLevel::Error => errors += 1,
DiagnosticLevel::Warning => warnings += 1,
DiagnosticLevel::Info => info += 1,
}
JsonDiagnostic {
level: level_to_string(diag.level).to_string(),
rule: diag.rule.clone(),
file: path_to_string(&diag.file, base_path),
line: diag.line.max(1),
column: diag.column.max(1),
message: diag.message.clone(),
suggestion: diag.suggestion.clone(),
assumption: diag.assumption.clone(),
category: diag.metadata.as_ref().map(|m| m.category.clone()),
rule_severity: diag.metadata.as_ref().map(|m| m.severity.clone()),
applies_to_tool: diag
.metadata
.as_ref()
.and_then(|m| m.applies_to_tool.clone()),
}
})
.collect();
JsonOutput {
version: env!("CARGO_PKG_VERSION").to_string(),
files_checked,
diagnostics: json_diagnostics,
summary: JsonSummary {
errors,
warnings,
info,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn test_empty_diagnostics() {
let output = diagnostics_to_json(&[], Path::new("."), 0);
assert_eq!(output.files_checked, 0);
assert!(output.diagnostics.is_empty());
assert_eq!(output.summary.errors, 0);
assert_eq!(output.summary.warnings, 0);
assert_eq!(output.summary.info, 0);
}
#[test]
fn test_version_matches_cargo() {
let output = diagnostics_to_json(&[], Path::new("."), 0);
assert_eq!(output.version, env!("CARGO_PKG_VERSION"));
}
#[test]
fn test_level_conversion_error() {
assert_eq!(level_to_string(DiagnosticLevel::Error), "error");
}
#[test]
fn test_level_conversion_warning() {
assert_eq!(level_to_string(DiagnosticLevel::Warning), "warning");
}
#[test]
fn test_level_conversion_info() {
assert_eq!(level_to_string(DiagnosticLevel::Info), "info");
}
#[test]
fn test_path_normalization() {
let path = Path::new("foo\\bar\\baz.md");
let base = Path::new(".");
let result = path_to_string(path, base);
assert!(!result.contains('\\'), "Should use forward slashes");
}
#[test]
fn test_path_relative() {
let path = PathBuf::from("/project/src/file.md");
let base = Path::new("/project");
let result = path_to_string(&path, base);
assert_eq!(result, "src/file.md");
}
#[test]
fn test_diagnostic_conversion() {
let diag = Diagnostic::error(
PathBuf::from("/project/test.md"),
10,
5,
"AS-001",
"Missing frontmatter".to_string(),
);
let output = diagnostics_to_json(&[diag], Path::new("/project"), 1);
assert_eq!(output.files_checked, 1);
assert_eq!(output.diagnostics.len(), 1);
assert_eq!(output.summary.errors, 1);
let json_diag = &output.diagnostics[0];
assert_eq!(json_diag.level, "error");
assert_eq!(json_diag.rule, "AS-001");
assert_eq!(json_diag.file, "test.md");
assert_eq!(json_diag.line, 10);
assert_eq!(json_diag.column, 5);
assert_eq!(json_diag.message, "Missing frontmatter");
}
#[test]
fn test_summary_counts() {
let diags = vec![
Diagnostic::error(PathBuf::from("/p/a.md"), 1, 1, "AS-001", "A".to_string()),
Diagnostic::error(PathBuf::from("/p/b.md"), 2, 2, "AS-002", "B".to_string()),
Diagnostic::warning(PathBuf::from("/p/c.md"), 3, 3, "AS-003", "C".to_string()),
Diagnostic {
level: DiagnosticLevel::Info,
message: "Info".to_string(),
file: PathBuf::from("/p/d.md"),
line: 4,
column: 4,
rule: "AS-004".to_string(),
suggestion: None,
fixes: vec![],
assumption: None,
metadata: None,
},
];
let output = diagnostics_to_json(&diags, Path::new("/p"), 4);
assert_eq!(output.summary.errors, 2);
assert_eq!(output.summary.warnings, 1);
assert_eq!(output.summary.info, 1);
assert_eq!(output.files_checked, 4);
}
#[test]
fn test_files_checked_uses_passed_value() {
let diags = vec![
Diagnostic::error(PathBuf::from("/p/a.md"), 1, 1, "AS-001", "A".to_string()),
Diagnostic::error(PathBuf::from("/p/a.md"), 5, 1, "AS-002", "B".to_string()),
];
let output = diagnostics_to_json(&diags, Path::new("/p"), 5);
assert_eq!(output.files_checked, 5);
}
#[test]
fn test_suggestion_included_when_present() {
let mut diag = Diagnostic::error(
PathBuf::from("/p/test.md"),
1,
1,
"AS-004",
"Invalid name".to_string(),
);
diag.suggestion = Some("Use lowercase letters and hyphens only".to_string());
let output = diagnostics_to_json(&[diag], Path::new("/p"), 1);
assert_eq!(
output.diagnostics[0].suggestion,
Some("Use lowercase letters and hyphens only".to_string())
);
}
#[test]
fn test_suggestion_omitted_when_none() {
let diag = Diagnostic::error(
PathBuf::from("/p/test.md"),
1,
1,
"AS-001",
"Missing frontmatter".to_string(),
);
let output = diagnostics_to_json(&[diag], Path::new("/p"), 1);
assert!(output.diagnostics[0].suggestion.is_none());
}
#[test]
fn test_json_serialization() {
let output = diagnostics_to_json(&[], Path::new("."), 0);
let json = serde_json::to_string(&output);
assert!(json.is_ok(), "Should serialize to JSON");
let json_str = json.unwrap();
assert!(json_str.contains("\"version\""));
assert!(json_str.contains("\"files_checked\""));
assert!(json_str.contains("\"diagnostics\""));
assert!(json_str.contains("\"summary\""));
}
#[test]
fn test_metadata_included_for_known_rule() {
let diag = Diagnostic::error(
PathBuf::from("/p/test.md"),
1,
1,
"AS-001",
"Missing frontmatter",
);
let output = diagnostics_to_json(&[diag], Path::new("/p"), 1);
let json_diag = &output.diagnostics[0];
assert_eq!(json_diag.category, Some("agent-skills".to_string()));
assert_eq!(json_diag.rule_severity, Some("HIGH".to_string()));
assert!(json_diag.applies_to_tool.is_none());
}
#[test]
fn test_metadata_tool_included_for_tool_specific_rule() {
let diag = Diagnostic::error(
PathBuf::from("/p/test.md"),
1,
1,
"CC-HK-001",
"Invalid hook",
);
let output = diagnostics_to_json(&[diag], Path::new("/p"), 1);
let json_diag = &output.diagnostics[0];
assert!(json_diag.category.is_some());
assert_eq!(json_diag.applies_to_tool, Some("claude-code".to_string()));
}
#[test]
fn test_metadata_omitted_for_unknown_rule() {
let diag = Diagnostic {
level: DiagnosticLevel::Error,
message: "Unknown".to_string(),
file: PathBuf::from("/p/test.md"),
line: 1,
column: 1,
rule: "UNKNOWN-999".to_string(),
suggestion: None,
fixes: vec![],
assumption: None,
metadata: None,
};
let output = diagnostics_to_json(&[diag], Path::new("/p"), 1);
let json_diag = &output.diagnostics[0];
assert!(json_diag.category.is_none());
assert!(json_diag.rule_severity.is_none());
assert!(json_diag.applies_to_tool.is_none());
let json_str = serde_json::to_string(&output).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
let diag_obj = &parsed["diagnostics"][0];
assert!(
diag_obj.get("category").is_none(),
"category should be omitted when None"
);
assert!(
diag_obj.get("rule_severity").is_none(),
"rule_severity should be omitted when None"
);
}
#[test]
fn test_line_column_clamped_to_one() {
let diag = Diagnostic::error(
PathBuf::from("/p/test.md"),
0,
0,
"AS-001",
"Test".to_string(),
);
let output = diagnostics_to_json(&[diag], Path::new("/p"), 1);
assert_eq!(output.diagnostics[0].line, 1);
assert_eq!(output.diagnostics[0].column, 1);
}
}