rumdl_lib/output/formatters/
azure.rs

1//! Azure Pipeline logging format
2
3use crate::output::OutputFormatter;
4use crate::rule::{LintWarning, Severity};
5
6/// Azure Pipeline formatter
7/// Outputs in the format: `##vso[task.logissue type=<severity>;sourcepath=<file>;linenumber=<line>;columnnumber=<col>;code=<rule>]<message>`
8pub struct AzureFormatter;
9
10impl Default for AzureFormatter {
11    fn default() -> Self {
12        Self
13    }
14}
15
16impl AzureFormatter {
17    pub fn new() -> Self {
18        Self
19    }
20}
21
22impl OutputFormatter for AzureFormatter {
23    fn format_warnings(&self, warnings: &[LintWarning], file_path: &str) -> String {
24        let mut output = String::new();
25
26        for warning in warnings {
27            let rule_name = warning.rule_name.as_deref().unwrap_or("unknown");
28
29            // Map severity to Azure DevOps type (only supports "warning" and "error")
30            let issue_type = match warning.severity {
31                Severity::Error => "error",
32                Severity::Warning | Severity::Info => "warning",
33            };
34
35            // Azure Pipeline logging command format
36            let line = format!(
37                "##vso[task.logissue type={};sourcepath={};linenumber={};columnnumber={};code={}]{}",
38                issue_type, file_path, warning.line, warning.column, rule_name, warning.message
39            );
40
41            output.push_str(&line);
42            output.push('\n');
43        }
44
45        // Remove trailing newline
46        if output.ends_with('\n') {
47            output.pop();
48        }
49
50        output
51    }
52}
53
54#[cfg(test)]
55mod tests {
56    use super::*;
57    use crate::rule::{Fix, Severity};
58
59    #[test]
60    fn test_azure_formatter_default() {
61        let _formatter = AzureFormatter;
62        // No fields to test, just ensure it constructs
63    }
64
65    #[test]
66    fn test_azure_formatter_new() {
67        let _formatter = AzureFormatter::new();
68        // No fields to test, just ensure it constructs
69    }
70
71    #[test]
72    fn test_format_warnings_empty() {
73        let formatter = AzureFormatter::new();
74        let warnings = vec![];
75        let output = formatter.format_warnings(&warnings, "test.md");
76        assert_eq!(output, "");
77    }
78
79    #[test]
80    fn test_format_single_warning() {
81        let formatter = AzureFormatter::new();
82        let warnings = vec![LintWarning {
83            line: 10,
84            column: 5,
85            end_line: 10,
86            end_column: 15,
87            rule_name: Some("MD001".to_string()),
88            message: "Heading levels should only increment by one level at a time".to_string(),
89            severity: Severity::Warning,
90            fix: None,
91        }];
92
93        let output = formatter.format_warnings(&warnings, "README.md");
94        assert_eq!(
95            output,
96            "##vso[task.logissue type=warning;sourcepath=README.md;linenumber=10;columnnumber=5;code=MD001]Heading levels should only increment by one level at a time"
97        );
98    }
99
100    #[test]
101    fn test_format_multiple_warnings() {
102        let formatter = AzureFormatter::new();
103        let warnings = vec![
104            LintWarning {
105                line: 5,
106                column: 1,
107                end_line: 5,
108                end_column: 10,
109                rule_name: Some("MD001".to_string()),
110                message: "First warning".to_string(),
111                severity: Severity::Warning,
112                fix: None,
113            },
114            LintWarning {
115                line: 10,
116                column: 3,
117                end_line: 10,
118                end_column: 20,
119                rule_name: Some("MD013".to_string()),
120                message: "Second warning".to_string(),
121                severity: Severity::Error,
122                fix: None,
123            },
124        ];
125
126        let output = formatter.format_warnings(&warnings, "test.md");
127        let expected = "##vso[task.logissue type=warning;sourcepath=test.md;linenumber=5;columnnumber=1;code=MD001]First warning\n##vso[task.logissue type=error;sourcepath=test.md;linenumber=10;columnnumber=3;code=MD013]Second warning";
128        assert_eq!(output, expected);
129    }
130
131    #[test]
132    fn test_format_warning_with_fix() {
133        let formatter = AzureFormatter::new();
134        let warnings = vec![LintWarning {
135            line: 15,
136            column: 1,
137            end_line: 15,
138            end_column: 10,
139            rule_name: Some("MD022".to_string()),
140            message: "Headings should be surrounded by blank lines".to_string(),
141            severity: Severity::Warning,
142            fix: Some(Fix {
143                range: 100..110,
144                replacement: "\n# Heading\n".to_string(),
145            }),
146        }];
147
148        let output = formatter.format_warnings(&warnings, "doc.md");
149        // Azure format doesn't show fix indicator
150        assert_eq!(
151            output,
152            "##vso[task.logissue type=warning;sourcepath=doc.md;linenumber=15;columnnumber=1;code=MD022]Headings should be surrounded by blank lines"
153        );
154    }
155
156    #[test]
157    fn test_format_warning_unknown_rule() {
158        let formatter = AzureFormatter::new();
159        let warnings = vec![LintWarning {
160            line: 1,
161            column: 1,
162            end_line: 1,
163            end_column: 5,
164            rule_name: None,
165            message: "Unknown rule warning".to_string(),
166            severity: Severity::Warning,
167            fix: None,
168        }];
169
170        let output = formatter.format_warnings(&warnings, "file.md");
171        assert_eq!(
172            output,
173            "##vso[task.logissue type=warning;sourcepath=file.md;linenumber=1;columnnumber=1;code=unknown]Unknown rule warning"
174        );
175    }
176
177    #[test]
178    fn test_edge_cases() {
179        let formatter = AzureFormatter::new();
180
181        // Test large line/column numbers
182        let warnings = vec![LintWarning {
183            line: 99999,
184            column: 12345,
185            end_line: 100000,
186            end_column: 12350,
187            rule_name: Some("MD999".to_string()),
188            message: "Edge case warning".to_string(),
189            severity: Severity::Error,
190            fix: None,
191        }];
192
193        let output = formatter.format_warnings(&warnings, "large.md");
194        assert_eq!(
195            output,
196            "##vso[task.logissue type=error;sourcepath=large.md;linenumber=99999;columnnumber=12345;code=MD999]Edge case warning"
197        );
198    }
199
200    #[test]
201    fn test_special_characters_in_message() {
202        let formatter = AzureFormatter::new();
203        let warnings = vec![LintWarning {
204            line: 1,
205            column: 1,
206            end_line: 1,
207            end_column: 5,
208            rule_name: Some("MD001".to_string()),
209            message: "Warning with \"quotes\" and 'apostrophes' and \n newline".to_string(),
210            severity: Severity::Warning,
211            fix: None,
212        }];
213
214        let output = formatter.format_warnings(&warnings, "test.md");
215        // Note: Azure DevOps should handle special characters in messages
216        assert_eq!(
217            output,
218            "##vso[task.logissue type=warning;sourcepath=test.md;linenumber=1;columnnumber=1;code=MD001]Warning with \"quotes\" and 'apostrophes' and \n newline"
219        );
220    }
221
222    #[test]
223    fn test_special_characters_in_file_path() {
224        let formatter = AzureFormatter::new();
225        let warnings = vec![LintWarning {
226            line: 1,
227            column: 1,
228            end_line: 1,
229            end_column: 5,
230            rule_name: Some("MD001".to_string()),
231            message: "Test".to_string(),
232            severity: Severity::Warning,
233            fix: None,
234        }];
235
236        let output = formatter.format_warnings(&warnings, "path/with spaces/and-dashes.md");
237        assert_eq!(
238            output,
239            "##vso[task.logissue type=warning;sourcepath=path/with spaces/and-dashes.md;linenumber=1;columnnumber=1;code=MD001]Test"
240        );
241    }
242
243    #[test]
244    fn test_azure_format_structure() {
245        let formatter = AzureFormatter::new();
246        let warnings = vec![LintWarning {
247            line: 42,
248            column: 7,
249            end_line: 42,
250            end_column: 10,
251            rule_name: Some("MD010".to_string()),
252            message: "Hard tabs".to_string(),
253            severity: Severity::Warning,
254            fix: None,
255        }];
256
257        let output = formatter.format_warnings(&warnings, "test.md");
258
259        // Verify Azure DevOps logging command structure
260        assert!(output.starts_with("##vso[task.logissue "));
261        assert!(output.contains("type=warning"));
262        assert!(output.contains("sourcepath=test.md"));
263        assert!(output.contains("linenumber=42"));
264        assert!(output.contains("columnnumber=7"));
265        assert!(output.contains("code=MD010"));
266        assert!(output.ends_with("]Hard tabs"));
267    }
268
269    #[test]
270    fn test_severity_levels() {
271        let formatter = AzureFormatter::new();
272
273        // Test that severity levels are correctly mapped to Azure types
274        // Azure DevOps only supports "warning" and "error"
275        let warnings = vec![
276            LintWarning {
277                line: 1,
278                column: 1,
279                end_line: 1,
280                end_column: 5,
281                rule_name: Some("MD001".to_string()),
282                message: "Warning severity".to_string(),
283                severity: Severity::Warning,
284                fix: None,
285            },
286            LintWarning {
287                line: 2,
288                column: 1,
289                end_line: 2,
290                end_column: 5,
291                rule_name: Some("MD002".to_string()),
292                message: "Error severity".to_string(),
293                severity: Severity::Error,
294                fix: None,
295            },
296            LintWarning {
297                line: 3,
298                column: 1,
299                end_line: 3,
300                end_column: 5,
301                rule_name: Some("MD003".to_string()),
302                message: "Info severity".to_string(),
303                severity: Severity::Info,
304                fix: None,
305            },
306        ];
307
308        let output = formatter.format_warnings(&warnings, "test.md");
309        let lines: Vec<&str> = output.lines().collect();
310
311        // Warning → warning, Error → error, Info → warning (Azure only supports warning/error)
312        assert!(lines[0].contains("type=warning"));
313        assert!(lines[1].contains("type=error"));
314        assert!(lines[2].contains("type=warning"));
315    }
316
317    #[test]
318    fn test_semicolons_in_parameters() {
319        let formatter = AzureFormatter::new();
320
321        // Test that semicolons in the code don't break the format
322        let warnings = vec![LintWarning {
323            line: 1,
324            column: 1,
325            end_line: 1,
326            end_column: 5,
327            rule_name: Some("MD;001".to_string()), // Unlikely but test edge case
328            message: "Test message; with semicolon".to_string(),
329            severity: Severity::Warning,
330            fix: None,
331        }];
332
333        let output = formatter.format_warnings(&warnings, "file;with;semicolons.md");
334        // The format should still be parseable by Azure DevOps
335        assert_eq!(
336            output,
337            "##vso[task.logissue type=warning;sourcepath=file;with;semicolons.md;linenumber=1;columnnumber=1;code=MD;001]Test message; with semicolon"
338        );
339    }
340
341    #[test]
342    fn test_brackets_in_message() {
343        let formatter = AzureFormatter::new();
344
345        // Test that brackets in the message don't break the format
346        let warnings = vec![LintWarning {
347            line: 1,
348            column: 1,
349            end_line: 1,
350            end_column: 5,
351            rule_name: Some("MD001".to_string()),
352            message: "Message with [brackets] and ]unmatched".to_string(),
353            severity: Severity::Warning,
354            fix: None,
355        }];
356
357        let output = formatter.format_warnings(&warnings, "test.md");
358        assert_eq!(
359            output,
360            "##vso[task.logissue type=warning;sourcepath=test.md;linenumber=1;columnnumber=1;code=MD001]Message with [brackets] and ]unmatched"
361        );
362    }
363}