alint-output 0.12.0

Internal: output formatters for alint reports (human, json, ...). Not a stable public API.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
//! `JUnit` XML output — the de-facto-standard CI test-report
//! format consumed by Jenkins, Azure DevOps, GitLab CI's `JUnit`
//! integration, GitHub's `dorny/test-reporter`, and similar
//! tooling.
//!
//! The shipped shape is the common-denominator schema (no
//! Surefire / Ant-Junit extensions): a single `<testsuites>`
//! wrapping a single `<testsuite name="alint">`, with one
//! `<testcase>` per (rule, file/path-less-bucket). A passing
//! rule contributes one passing testcase; each violation
//! contributes one testcase with a `<failure>` child. The
//! `failures` count on `<testsuite>` equals the total violation
//! count regardless of level — consumers filter by the failure
//! `type` attribute (`error` / `warning` / `info`) when they
//! want level-specific behaviour.
//!
//! XML 1.0 disallows most C0 control characters in element /
//! attribute content; we strip them on the way out so a stray
//! NUL or `\x01` in a violation message doesn't produce
//! consumer-rejected XML.

use std::io::Write;

use alint_core::{Level, Report, RuleResult, Violation};

pub fn write_junit(report: &Report, w: &mut dyn Write) -> std::io::Result<()> {
    // `Level::Off` rules are silenced at format time — they
    // contribute neither testcases nor failures, matching the
    // human / github / sarif formatters.
    let active: Vec<&RuleResult> = report
        .results
        .iter()
        .filter(|r| r.level != Level::Off)
        .collect();

    let total_violations: usize = active.iter().map(|r| r.violations.len()).sum();
    let passing_rules = active.iter().filter(|r| r.passed()).count();
    let total_cases = passing_rules + total_violations;

    writeln!(w, r#"<?xml version="1.0" encoding="UTF-8"?>"#)?;
    writeln!(
        w,
        r#"<testsuites name="alint" tests="{total_cases}" failures="{total_violations}" errors="0" time="0">"#
    )?;
    writeln!(
        w,
        r#"  <testsuite name="alint" tests="{total_cases}" failures="{total_violations}" errors="0" time="0">"#
    )?;

    // Stable iteration order: results in their original order
    // (which the engine already sorts by rule), violations in
    // the order the rule produced them. Consumers don't sort
    // testcases themselves so determinism here matters for
    // report-diff workflows.
    for result in active {
        if result.passed() {
            // Passing rule → one self-closed testcase. Provides
            // a "tests run" denominator for consumers that show
            // pass-rate metrics.
            writeln!(
                w,
                r#"    <testcase classname="alint.{}" name="{}" time="0"/>"#,
                xml_attr(&result.rule_id),
                xml_attr(&result.rule_id),
            )?;
            continue;
        }
        for violation in &result.violations {
            write_failure_case(w, result, violation)?;
        }
    }

    writeln!(w, "  </testsuite>")?;
    writeln!(w, "</testsuites>")?;
    Ok(())
}

fn write_failure_case(
    w: &mut dyn Write,
    result: &RuleResult,
    violation: &Violation,
) -> std::io::Result<()> {
    let case_name = match &violation.path {
        Some(p) => p.display().to_string(),
        None => "(repository)".to_string(),
    };
    let level_attr = match result.level {
        Level::Error => "error",
        Level::Warning => "warning",
        Level::Info => "info",
        // `Off` rules never reach this path — they're filtered
        // out at the top of `write_junit`. Match arm exists for
        // exhaustiveness only.
        Level::Off => "off",
    };

    writeln!(
        w,
        r#"    <testcase classname="alint.{rule}" name="{name}" time="0">"#,
        rule = xml_attr(&result.rule_id),
        name = xml_attr(&case_name),
    )?;
    writeln!(
        w,
        r#"      <failure message="{msg}" type="{level_attr}">{body}</failure>"#,
        msg = xml_attr(&violation.message),
        body = xml_text(&format_failure_body(result, violation)),
    )?;
    writeln!(w, "    </testcase>")?;
    Ok(())
}

/// Body text that goes inside the `<failure>` element. Includes
/// path:line:col and the policy URL when present, so consumers
/// that show only the failure body (and not the message attr)
/// still get the full picture.
fn format_failure_body(result: &RuleResult, violation: &Violation) -> String {
    let mut s = String::new();
    if let Some(p) = &violation.path {
        s.push_str(&p.display().to_string());
        if let Some(line) = violation.line {
            s.push(':');
            s.push_str(&line.to_string());
            if let Some(col) = violation.column {
                s.push(':');
                s.push_str(&col.to_string());
            }
        }
        s.push_str(": ");
    }
    s.push_str(&violation.message);
    if let Some(url) = &result.policy_url
        && !url.is_empty()
    {
        s.push_str("\nPolicy: ");
        s.push_str(url);
    }
    s
}

/// Escape a string for XML element content (`<elem>HERE</elem>`).
/// Strips XML 1.0-illegal control characters; replaces `& < >`
/// with their entity references. Quotes don't need escaping in
/// element content but it's harmless to do.
fn xml_text(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for ch in s.chars() {
        if is_xml_illegal_control(ch) {
            continue;
        }
        match ch {
            '&' => out.push_str("&amp;"),
            '<' => out.push_str("&lt;"),
            '>' => out.push_str("&gt;"),
            _ => out.push(ch),
        }
    }
    out
}

/// Escape a string for an XML attribute value (`name="HERE"`).
/// Same as element content, plus quotes and apostrophes —
/// every XML attribute parser respects either, so we escape
/// both rather than picking the right pair.
fn xml_attr(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for ch in s.chars() {
        if is_xml_illegal_control(ch) {
            continue;
        }
        match ch {
            '&' => out.push_str("&amp;"),
            '<' => out.push_str("&lt;"),
            '>' => out.push_str("&gt;"),
            '"' => out.push_str("&quot;"),
            '\'' => out.push_str("&apos;"),
            // Tabs / newlines / CR survive in attribute values
            // but normalize to space per XML attr-value rules.
            // We pre-replace so the consumer sees a single line.
            '\n' | '\r' | '\t' => out.push(' '),
            _ => out.push(ch),
        }
    }
    out
}

/// XML 1.0 disallows most of the C0 control range. The only
/// permitted ones are TAB (0x09), LF (0x0A), CR (0x0D); the
/// rest must be stripped or the whole document is invalid.
fn is_xml_illegal_control(ch: char) -> bool {
    let cp = ch as u32;
    (cp < 0x20 && cp != 0x09 && cp != 0x0A && cp != 0x0D) || cp == 0xFFFE || cp == 0xFFFF
}

#[cfg(test)]
mod tests {
    use super::*;
    use alint_core::{Report, RuleResult, Violation};
    use std::path::{Path, PathBuf};

    fn render(report: &Report) -> String {
        let mut buf = Vec::new();
        write_junit(report, &mut buf).unwrap();
        String::from_utf8(buf).unwrap()
    }

    fn rule(id: &str, level: Level, violations: Vec<Violation>) -> RuleResult {
        RuleResult {
            rule_id: id.into(),
            level,
            policy_url: None,
            violations,
            notes: Vec::new(),
            is_fixable: false,
        }
    }

    #[test]
    fn empty_report_has_zero_tests_zero_failures() {
        let out = render(&Report {
            results: Vec::new(),
        });
        assert!(out.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"));
        assert!(out.contains(r#"<testsuites name="alint" tests="0" failures="0" errors="0""#));
        assert!(out.contains("</testsuite>\n</testsuites>\n"));
    }

    #[test]
    fn passing_rule_emits_self_closed_testcase() {
        let report = Report {
            results: vec![rule("ok", Level::Error, vec![])],
        };
        let out = render(&report);
        assert!(out.contains(r#"<testcase classname="alint.ok" name="ok" time="0"/>"#));
        assert!(out.contains(r#"tests="1" failures="0""#));
    }

    #[test]
    fn single_violation_renders_failure_with_path_line_col() {
        let report = Report {
            results: vec![rule(
                "no-todo",
                Level::Error,
                vec![Violation {
                    path: Some(Path::new("src/lib.rs").into()),
                    message: "TODO marker found".into(),
                    line: Some(12),
                    column: Some(4),
                    is_note: false,
                }],
            )],
        };
        let out = render(&report);
        assert!(out.contains(r#"<testcase classname="alint.no-todo" name="src/lib.rs" time="0">"#));
        assert!(out.contains(r#"<failure message="TODO marker found" type="error">"#));
        assert!(out.contains("src/lib.rs:12:4: TODO marker found"));
        assert!(out.contains(r#"tests="1" failures="1""#));
    }

    #[test]
    fn level_warning_and_info_use_distinct_failure_types() {
        let report = Report {
            results: vec![
                rule(
                    "w",
                    Level::Warning,
                    vec![Violation::new("warn-msg").with_path(PathBuf::from("a"))],
                ),
                rule(
                    "i",
                    Level::Info,
                    vec![Violation::new("info-msg").with_path(PathBuf::from("b"))],
                ),
            ],
        };
        let out = render(&report);
        assert!(out.contains(r#"type="warning""#));
        assert!(out.contains(r#"type="info""#));
        assert!(out.contains(r#"failures="2""#));
    }

    #[test]
    fn cross_file_violation_uses_repository_marker_for_name() {
        let report = Report {
            results: vec![rule(
                "unique-pkg",
                Level::Error,
                vec![Violation::new("dup")],
            )],
        };
        let out = render(&report);
        assert!(out.contains(r#"name="(repository)""#));
    }

    #[test]
    fn xml_special_chars_are_escaped() {
        let report = Report {
            results: vec![rule(
                "r&<>\"'",
                Level::Error,
                vec![Violation {
                    path: Some(Path::new("a&b.rs").into()),
                    message: "<bad> & \"quoted\"".into(),
                    line: None,
                    column: None,
                    is_note: false,
                }],
            )],
        };
        let out = render(&report);
        assert!(out.contains(r#"classname="alint.r&amp;&lt;&gt;&quot;&apos;""#));
        assert!(out.contains(r#"name="a&amp;b.rs""#));
        assert!(out.contains(r#"message="&lt;bad&gt; &amp; &quot;quoted&quot;""#));
        assert!(out.contains("&lt;bad&gt; &amp; \"quoted\""));
    }

    #[test]
    fn control_characters_are_stripped() {
        let report = Report {
            results: vec![rule(
                "ctrl",
                Level::Error,
                vec![Violation {
                    path: Some(Path::new("a.rs").into()),
                    message: "before\u{0001}\u{0008}after".into(),
                    line: None,
                    column: None,
                    is_note: false,
                }],
            )],
        };
        let out = render(&report);
        assert!(out.contains("beforeafter"));
        assert!(!out.contains('\u{0001}'));
    }

    #[test]
    fn newline_in_attribute_normalizes_to_space() {
        let report = Report {
            results: vec![rule(
                "r",
                Level::Error,
                vec![Violation {
                    path: Some(Path::new("a").into()),
                    message: "line1\nline2".into(),
                    line: None,
                    column: None,
                    is_note: false,
                }],
            )],
        };
        let out = render(&report);
        // The `message` attribute should have the newline replaced.
        assert!(out.contains(r#"message="line1 line2""#));
        // The body keeps the newline (legal in element content).
        assert!(out.contains("line1\nline2"));
    }

    #[test]
    fn policy_url_appended_to_failure_body() {
        let report = Report {
            results: vec![RuleResult {
                rule_id: "r".into(),
                level: Level::Error,
                policy_url: Some("https://example.com/p".into()),
                violations: vec![Violation::new("x").with_path(PathBuf::from("a"))],
                notes: Vec::new(),
                is_fixable: false,
            }],
        };
        let out = render(&report);
        assert!(out.contains("Policy: https://example.com/p"));
    }

    #[test]
    fn level_off_rule_is_silenced_entirely() {
        let report = Report {
            results: vec![RuleResult {
                rule_id: "off".into(),
                level: Level::Off,
                policy_url: None,
                violations: vec![Violation::new("ignored").with_path(PathBuf::from("a"))],
                notes: Vec::new(),
                is_fixable: false,
            }],
        };
        let out = render(&report);
        // `Level::Off` rules contribute zero testcases — neither
        // passing nor failing — to keep the test count meaningful
        // for consumers.
        assert!(out.contains(r#"tests="0" failures="0""#));
        assert!(!out.contains("<testcase"));
        assert!(!out.contains("<failure"));
    }

    #[test]
    fn multiple_violations_one_rule_emit_separate_testcases() {
        let report = Report {
            results: vec![rule(
                "r",
                Level::Error,
                vec![
                    Violation::new("v1").with_path(PathBuf::from("a")),
                    Violation::new("v2").with_path(PathBuf::from("b")),
                ],
            )],
        };
        let out = render(&report);
        assert_eq!(out.matches("<testcase").count(), 2);
        assert_eq!(out.matches("<failure").count(), 2);
        assert!(out.contains(r#"failures="2""#));
    }

    #[test]
    fn output_is_deterministic_for_identical_input() {
        let report = Report {
            results: vec![rule(
                "r",
                Level::Error,
                vec![
                    Violation::new("v1").with_path(PathBuf::from("a")),
                    Violation::new("v2").with_path(PathBuf::from("b")),
                ],
            )],
        };
        assert_eq!(render(&report), render(&report));
    }
}