Skip to main content

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::new(100..110, "\n# Heading\n".to_string())),
143        }];
144
145        let output = formatter.format_warnings(&warnings, "doc.md");
146        // Azure format doesn't show fix indicator
147        assert_eq!(
148            output,
149            "##vso[task.logissue type=warning;sourcepath=doc.md;linenumber=15;columnnumber=1;code=MD022]Headings should be surrounded by blank lines"
150        );
151    }
152
153    #[test]
154    fn test_format_warning_unknown_rule() {
155        let formatter = AzureFormatter::new();
156        let warnings = vec![LintWarning {
157            line: 1,
158            column: 1,
159            end_line: 1,
160            end_column: 5,
161            rule_name: None,
162            message: "Unknown rule warning".to_string(),
163            severity: Severity::Warning,
164            fix: None,
165        }];
166
167        let output = formatter.format_warnings(&warnings, "file.md");
168        assert_eq!(
169            output,
170            "##vso[task.logissue type=warning;sourcepath=file.md;linenumber=1;columnnumber=1;code=unknown]Unknown rule warning"
171        );
172    }
173
174    #[test]
175    fn test_edge_cases() {
176        let formatter = AzureFormatter::new();
177
178        // Test large line/column numbers
179        let warnings = vec![LintWarning {
180            line: 99999,
181            column: 12345,
182            end_line: 100000,
183            end_column: 12350,
184            rule_name: Some("MD999".to_string()),
185            message: "Edge case warning".to_string(),
186            severity: Severity::Error,
187            fix: None,
188        }];
189
190        let output = formatter.format_warnings(&warnings, "large.md");
191        assert_eq!(
192            output,
193            "##vso[task.logissue type=error;sourcepath=large.md;linenumber=99999;columnnumber=12345;code=MD999]Edge case warning"
194        );
195    }
196
197    #[test]
198    fn test_special_characters_in_message() {
199        let formatter = AzureFormatter::new();
200        let warnings = vec![LintWarning {
201            line: 1,
202            column: 1,
203            end_line: 1,
204            end_column: 5,
205            rule_name: Some("MD001".to_string()),
206            message: "Warning with \"quotes\" and 'apostrophes' and \n newline".to_string(),
207            severity: Severity::Warning,
208            fix: None,
209        }];
210
211        let output = formatter.format_warnings(&warnings, "test.md");
212        // Note: Azure DevOps should handle special characters in messages
213        assert_eq!(
214            output,
215            "##vso[task.logissue type=warning;sourcepath=test.md;linenumber=1;columnnumber=1;code=MD001]Warning with \"quotes\" and 'apostrophes' and \n newline"
216        );
217    }
218
219    #[test]
220    fn test_special_characters_in_file_path() {
221        let formatter = AzureFormatter::new();
222        let warnings = vec![LintWarning {
223            line: 1,
224            column: 1,
225            end_line: 1,
226            end_column: 5,
227            rule_name: Some("MD001".to_string()),
228            message: "Test".to_string(),
229            severity: Severity::Warning,
230            fix: None,
231        }];
232
233        let output = formatter.format_warnings(&warnings, "path/with spaces/and-dashes.md");
234        assert_eq!(
235            output,
236            "##vso[task.logissue type=warning;sourcepath=path/with spaces/and-dashes.md;linenumber=1;columnnumber=1;code=MD001]Test"
237        );
238    }
239
240    #[test]
241    fn test_azure_format_structure() {
242        let formatter = AzureFormatter::new();
243        let warnings = vec![LintWarning {
244            line: 42,
245            column: 7,
246            end_line: 42,
247            end_column: 10,
248            rule_name: Some("MD010".to_string()),
249            message: "Hard tabs".to_string(),
250            severity: Severity::Warning,
251            fix: None,
252        }];
253
254        let output = formatter.format_warnings(&warnings, "test.md");
255
256        // Verify Azure DevOps logging command structure
257        assert!(output.starts_with("##vso[task.logissue "));
258        assert!(output.contains("type=warning"));
259        assert!(output.contains("sourcepath=test.md"));
260        assert!(output.contains("linenumber=42"));
261        assert!(output.contains("columnnumber=7"));
262        assert!(output.contains("code=MD010"));
263        assert!(output.ends_with("]Hard tabs"));
264    }
265
266    #[test]
267    fn test_severity_levels() {
268        let formatter = AzureFormatter::new();
269
270        // Test that severity levels are correctly mapped to Azure types
271        // Azure DevOps only supports "warning" and "error"
272        let warnings = vec![
273            LintWarning {
274                line: 1,
275                column: 1,
276                end_line: 1,
277                end_column: 5,
278                rule_name: Some("MD001".to_string()),
279                message: "Warning severity".to_string(),
280                severity: Severity::Warning,
281                fix: None,
282            },
283            LintWarning {
284                line: 2,
285                column: 1,
286                end_line: 2,
287                end_column: 5,
288                rule_name: Some("MD002".to_string()),
289                message: "Error severity".to_string(),
290                severity: Severity::Error,
291                fix: None,
292            },
293            LintWarning {
294                line: 3,
295                column: 1,
296                end_line: 3,
297                end_column: 5,
298                rule_name: Some("MD003".to_string()),
299                message: "Info severity".to_string(),
300                severity: Severity::Info,
301                fix: None,
302            },
303        ];
304
305        let output = formatter.format_warnings(&warnings, "test.md");
306        let lines: Vec<&str> = output.lines().collect();
307
308        // Warning → warning, Error → error, Info → warning (Azure only supports warning/error)
309        assert!(lines[0].contains("type=warning"));
310        assert!(lines[1].contains("type=error"));
311        assert!(lines[2].contains("type=warning"));
312    }
313
314    #[test]
315    fn test_semicolons_in_parameters() {
316        let formatter = AzureFormatter::new();
317
318        // Test that semicolons in the code don't break the format
319        let warnings = vec![LintWarning {
320            line: 1,
321            column: 1,
322            end_line: 1,
323            end_column: 5,
324            rule_name: Some("MD;001".to_string()), // Unlikely but test edge case
325            message: "Test message; with semicolon".to_string(),
326            severity: Severity::Warning,
327            fix: None,
328        }];
329
330        let output = formatter.format_warnings(&warnings, "file;with;semicolons.md");
331        // The format should still be parseable by Azure DevOps
332        assert_eq!(
333            output,
334            "##vso[task.logissue type=warning;sourcepath=file;with;semicolons.md;linenumber=1;columnnumber=1;code=MD;001]Test message; with semicolon"
335        );
336    }
337
338    #[test]
339    fn test_brackets_in_message() {
340        let formatter = AzureFormatter::new();
341
342        // Test that brackets in the message don't break the format
343        let warnings = vec![LintWarning {
344            line: 1,
345            column: 1,
346            end_line: 1,
347            end_column: 5,
348            rule_name: Some("MD001".to_string()),
349            message: "Message with [brackets] and ]unmatched".to_string(),
350            severity: Severity::Warning,
351            fix: None,
352        }];
353
354        let output = formatter.format_warnings(&warnings, "test.md");
355        assert_eq!(
356            output,
357            "##vso[task.logissue type=warning;sourcepath=test.md;linenumber=1;columnnumber=1;code=MD001]Message with [brackets] and ]unmatched"
358        );
359    }
360}