rumdl_lib/output/formatters/
azure.rs

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