use crate::output::OutputFormatter;
use crate::rule::{LintWarning, Severity};
use serde_json::{Value, json};
pub struct GitLabFormatter;
impl Default for GitLabFormatter {
fn default() -> Self {
Self
}
}
impl GitLabFormatter {
pub fn new() -> Self {
Self
}
}
impl OutputFormatter for GitLabFormatter {
fn format_warnings(&self, warnings: &[LintWarning], file_path: &str) -> String {
let issues: Vec<Value> = warnings
.iter()
.map(|warning| code_quality_issue(warning, file_path))
.collect();
serde_json::to_string_pretty(&issues).unwrap_or_else(|_| "[]".to_string())
}
}
pub fn format_gitlab_report(all_warnings: &[(String, Vec<LintWarning>)]) -> String {
let issues: Vec<Value> = all_warnings
.iter()
.flat_map(|(file_path, warnings)| {
warnings
.iter()
.map(move |warning| code_quality_issue(warning, file_path))
})
.collect();
serde_json::to_string_pretty(&issues).unwrap_or_else(|_| "[]".to_string())
}
fn code_quality_issue(warning: &LintWarning, file_path: &str) -> Value {
let rule_name = warning.rule_name.as_deref().unwrap_or("unknown");
let severity = match warning.severity {
Severity::Error => "major",
Severity::Warning => "minor",
Severity::Info => "info",
};
json!({
"description": warning.message,
"check_name": rule_name,
"fingerprint": format!("{}-{}-{}-{}", file_path, warning.line, warning.column, rule_name),
"severity": severity,
"location": {
"path": file_path,
"lines": {
"begin": warning.line
}
}
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::rule::Fix;
#[test]
fn test_gitlab_formatter_default() {
let _formatter = GitLabFormatter;
}
#[test]
fn test_gitlab_formatter_new() {
let _formatter = GitLabFormatter::new();
}
#[test]
fn test_format_warnings_empty() {
let formatter = GitLabFormatter::new();
let warnings = vec![];
let output = formatter.format_warnings(&warnings, "test.md");
assert_eq!(output, "[]");
}
#[test]
fn test_format_single_warning() {
let formatter = GitLabFormatter::new();
let warnings = vec![LintWarning {
line: 10,
column: 5,
end_line: 10,
end_column: 15,
rule_name: Some("MD001".to_string()),
message: "Heading levels should only increment by one level at a time".to_string(),
severity: Severity::Warning,
fix: None,
}];
let output = formatter.format_warnings(&warnings, "README.md");
let issues: Vec<Value> = serde_json::from_str(&output).unwrap();
assert_eq!(issues.len(), 1);
let issue = &issues[0];
assert_eq!(
issue["description"],
"Heading levels should only increment by one level at a time"
);
assert_eq!(issue["check_name"], "MD001");
assert_eq!(issue["fingerprint"], "README.md-10-5-MD001");
assert_eq!(issue["severity"], "minor");
assert_eq!(issue["location"]["path"], "README.md");
assert_eq!(issue["location"]["lines"]["begin"], 10);
}
#[test]
fn test_format_single_warning_with_fix() {
let formatter = GitLabFormatter::new();
let warnings = vec![LintWarning {
line: 10,
column: 5,
end_line: 10,
end_column: 15,
rule_name: Some("MD001".to_string()),
message: "Heading levels should only increment by one level at a time".to_string(),
severity: Severity::Warning,
fix: Some(Fix::new(100..110, "## Heading".to_string())),
}];
let output = formatter.format_warnings(&warnings, "README.md");
let issues: Vec<Value> = serde_json::from_str(&output).unwrap();
assert_eq!(issues.len(), 1);
assert_eq!(issues[0]["check_name"], "MD001");
}
#[test]
fn test_format_multiple_warnings() {
let formatter = GitLabFormatter::new();
let warnings = vec![
LintWarning {
line: 5,
column: 1,
end_line: 5,
end_column: 10,
rule_name: Some("MD001".to_string()),
message: "First warning".to_string(),
severity: Severity::Warning,
fix: None,
},
LintWarning {
line: 10,
column: 3,
end_line: 10,
end_column: 20,
rule_name: Some("MD013".to_string()),
message: "Second warning".to_string(),
severity: Severity::Error,
fix: None,
},
];
let output = formatter.format_warnings(&warnings, "test.md");
let issues: Vec<Value> = serde_json::from_str(&output).unwrap();
assert_eq!(issues.len(), 2);
assert_eq!(issues[0]["check_name"], "MD001");
assert_eq!(issues[0]["location"]["lines"]["begin"], 5);
assert_eq!(issues[1]["check_name"], "MD013");
assert_eq!(issues[1]["location"]["lines"]["begin"], 10);
}
#[test]
fn test_format_warning_unknown_rule() {
let formatter = GitLabFormatter::new();
let warnings = vec![LintWarning {
line: 1,
column: 1,
end_line: 1,
end_column: 5,
rule_name: None,
message: "Unknown rule warning".to_string(),
severity: Severity::Warning,
fix: None,
}];
let output = formatter.format_warnings(&warnings, "file.md");
let issues: Vec<Value> = serde_json::from_str(&output).unwrap();
assert_eq!(issues[0]["check_name"], "unknown");
assert_eq!(issues[0]["fingerprint"], "file.md-1-1-unknown");
}
#[test]
fn test_gitlab_report_empty() {
let warnings = vec![];
let output = format_gitlab_report(&warnings);
assert_eq!(output, "[]");
}
#[test]
fn test_gitlab_report_single_file() {
let warnings = vec![(
"test.md".to_string(),
vec![LintWarning {
line: 10,
column: 5,
end_line: 10,
end_column: 15,
rule_name: Some("MD001".to_string()),
message: "Test warning".to_string(),
severity: Severity::Warning,
fix: None,
}],
)];
let output = format_gitlab_report(&warnings);
let issues: Vec<Value> = serde_json::from_str(&output).unwrap();
assert_eq!(issues.len(), 1);
assert_eq!(issues[0]["location"]["path"], "test.md");
}
#[test]
fn test_gitlab_report_multiple_files() {
let warnings = vec![
(
"file1.md".to_string(),
vec![LintWarning {
line: 1,
column: 1,
end_line: 1,
end_column: 5,
rule_name: Some("MD001".to_string()),
message: "Warning in file 1".to_string(),
severity: Severity::Warning,
fix: None,
}],
),
(
"file2.md".to_string(),
vec![
LintWarning {
line: 5,
column: 1,
end_line: 5,
end_column: 10,
rule_name: Some("MD013".to_string()),
message: "Warning 1 in file 2".to_string(),
severity: Severity::Warning,
fix: None,
},
LintWarning {
line: 10,
column: 1,
end_line: 10,
end_column: 10,
rule_name: Some("MD022".to_string()),
message: "Warning 2 in file 2".to_string(),
severity: Severity::Error,
fix: None,
},
],
),
];
let output = format_gitlab_report(&warnings);
let issues: Vec<Value> = serde_json::from_str(&output).unwrap();
assert_eq!(issues.len(), 3);
assert_eq!(issues[0]["location"]["path"], "file1.md");
assert_eq!(issues[1]["location"]["path"], "file2.md");
assert_eq!(issues[2]["location"]["path"], "file2.md");
}
#[test]
fn test_fingerprint_uniqueness() {
let formatter = GitLabFormatter::new();
let warnings = vec![
LintWarning {
line: 10,
column: 5,
end_line: 10,
end_column: 15,
rule_name: Some("MD001".to_string()),
message: "First rule".to_string(),
severity: Severity::Warning,
fix: None,
},
LintWarning {
line: 10,
column: 5,
end_line: 10,
end_column: 15,
rule_name: Some("MD002".to_string()),
message: "Second rule".to_string(),
severity: Severity::Warning,
fix: None,
},
];
let output = formatter.format_warnings(&warnings, "test.md");
let issues: Vec<Value> = serde_json::from_str(&output).unwrap();
assert_ne!(issues[0]["fingerprint"], issues[1]["fingerprint"]);
assert_eq!(issues[0]["fingerprint"], "test.md-10-5-MD001");
assert_eq!(issues[1]["fingerprint"], "test.md-10-5-MD002");
}
#[test]
fn test_severity_maps_to_gitlab_levels() {
let warning = |line, rule: &str, severity| LintWarning {
line,
column: 1,
end_line: line,
end_column: 5,
rule_name: Some(rule.to_string()),
message: "message".to_string(),
severity,
fix: None,
};
let warnings = vec![
warning(1, "MD001", Severity::Error),
warning(2, "MD002", Severity::Warning),
warning(3, "MD003", Severity::Info),
];
let expected = ["major", "minor", "info"];
let per_file: Vec<Value> =
serde_json::from_str(&GitLabFormatter::new().format_warnings(&warnings, "test.md")).unwrap();
let report: Vec<Value> =
serde_json::from_str(&format_gitlab_report(&[("test.md".to_string(), warnings)])).unwrap();
for issues in [&per_file, &report] {
let severities: Vec<&str> = issues.iter().map(|issue| issue["severity"].as_str().unwrap()).collect();
assert_eq!(severities, expected);
}
}
#[test]
fn test_special_characters_in_message() {
let formatter = GitLabFormatter::new();
let warnings = vec![LintWarning {
line: 1,
column: 1,
end_line: 1,
end_column: 5,
rule_name: Some("MD001".to_string()),
message: "Warning with \"quotes\" and 'apostrophes' and \n newline".to_string(),
severity: Severity::Warning,
fix: None,
}];
let output = formatter.format_warnings(&warnings, "test.md");
let issues: Vec<Value> = serde_json::from_str(&output).unwrap();
assert_eq!(
issues[0]["description"],
"Warning with \"quotes\" and 'apostrophes' and \n newline"
);
}
#[test]
fn test_special_characters_in_file_path() {
let formatter = GitLabFormatter::new();
let warnings = vec![LintWarning {
line: 1,
column: 1,
end_line: 1,
end_column: 5,
rule_name: Some("MD001".to_string()),
message: "Test".to_string(),
severity: Severity::Warning,
fix: None,
}];
let output = formatter.format_warnings(&warnings, "path/with spaces/and-dashes.md");
let issues: Vec<Value> = serde_json::from_str(&output).unwrap();
assert_eq!(issues[0]["location"]["path"], "path/with spaces/and-dashes.md");
assert_eq!(issues[0]["fingerprint"], "path/with spaces/and-dashes.md-1-1-MD001");
}
#[test]
fn test_json_pretty_formatting() {
let formatter = GitLabFormatter::new();
let warnings = vec![LintWarning {
line: 1,
column: 1,
end_line: 1,
end_column: 5,
rule_name: Some("MD001".to_string()),
message: "Test".to_string(),
severity: Severity::Warning,
fix: None,
}];
let output = formatter.format_warnings(&warnings, "test.md");
assert!(output.contains('\n'));
assert!(output.contains(" "));
}
}