Skip to main content

alint_output/
github.rs

1//! GitHub Actions workflow-command annotations.
2//!
3//! Each violation emits one line of the form:
4//!
5//! ```text
6//! ::<level> title=<rule-id>,file=<path>,line=<L>,col=<C>::<message>
7//! ```
8//!
9//! `<level>` maps `Error → error`, `Warning → warning`, `Info → notice`.
10//! GitHub renders these inline on the PR files-changed view and in the
11//! workflow log.
12//!
13//! Escaping follows the toolkit rules: property values (after the space,
14//! before the final `::`) additionally escape `,` and `:`; message bodies
15//! only escape `%`, `\r`, `\n`.
16
17use std::io::Write;
18
19use alint_core::{Level, Report};
20
21pub fn write_github(report: &Report, w: &mut dyn Write) -> std::io::Result<()> {
22    for rr in &report.results {
23        let keyword = match rr.level {
24            Level::Error => "error",
25            Level::Warning => "warning",
26            Level::Info => "notice",
27            Level::Off => continue,
28        };
29        for v in &rr.violations {
30            let mut props: Vec<String> = vec![format!("title={}", escape_prop(&rr.rule_id))];
31            if let Some(path) = &v.path {
32                props.push(format!("file={}", escape_prop(&path.display().to_string())));
33            }
34            if let Some(line) = v.line {
35                props.push(format!("line={line}"));
36            }
37            if let Some(col) = v.column {
38                props.push(format!("col={col}"));
39            }
40            let body = escape_body(&v.message);
41            writeln!(w, "::{keyword} {}::{body}", props.join(","))?;
42        }
43    }
44    Ok(())
45}
46
47fn escape_prop(s: &str) -> String {
48    s.replace('%', "%25")
49        .replace('\r', "%0D")
50        .replace('\n', "%0A")
51        .replace(':', "%3A")
52        .replace(',', "%2C")
53}
54
55fn escape_body(s: &str) -> String {
56    s.replace('%', "%25")
57        .replace('\r', "%0D")
58        .replace('\n', "%0A")
59}
60
61#[cfg(test)]
62mod tests {
63    use super::*;
64    use alint_core::{Report, RuleResult, Violation};
65    use std::path::Path;
66
67    fn render(report: &Report) -> String {
68        let mut buf = Vec::new();
69        write_github(report, &mut buf).unwrap();
70        String::from_utf8(buf).unwrap()
71    }
72
73    #[test]
74    fn error_warning_info_map_to_distinct_keywords() {
75        let report = Report {
76            results: vec![
77                RuleResult {
78                    rule_id: "rule-err".into(),
79                    level: Level::Error,
80                    policy_url: None,
81                    violations: vec![Violation::new("boom")],
82                    notes: Vec::new(),
83                    is_fixable: false,
84                },
85                RuleResult {
86                    rule_id: "rule-warn".into(),
87                    level: Level::Warning,
88                    policy_url: None,
89                    violations: vec![Violation::new("careful")],
90                    notes: Vec::new(),
91                    is_fixable: false,
92                },
93                RuleResult {
94                    rule_id: "rule-info".into(),
95                    level: Level::Info,
96                    policy_url: None,
97                    violations: vec![Violation::new("fyi")],
98                    notes: Vec::new(),
99                    is_fixable: false,
100                },
101            ],
102        };
103        let out = render(&report);
104        assert!(out.contains("::error title=rule-err::boom"));
105        assert!(out.contains("::warning title=rule-warn::careful"));
106        assert!(out.contains("::notice title=rule-info::fyi"));
107    }
108
109    #[test]
110    fn level_off_is_skipped() {
111        let report = Report {
112            results: vec![RuleResult {
113                rule_id: "silenced".into(),
114                level: Level::Off,
115                policy_url: None,
116                violations: vec![Violation::new("should not appear")],
117                notes: Vec::new(),
118                is_fixable: false,
119            }],
120        };
121        assert_eq!(render(&report), "");
122    }
123
124    #[test]
125    fn path_line_column_are_emitted_as_properties() {
126        let report = Report {
127            results: vec![RuleResult {
128                rule_id: "at-loc".into(),
129                level: Level::Error,
130                policy_url: None,
131                violations: vec![Violation {
132                    path: Some(Path::new("src/lib.rs").into()),
133                    message: "bad".into(),
134                    line: Some(12),
135                    column: Some(4),
136                    is_note: false,
137                }],
138                notes: Vec::new(),
139                is_fixable: false,
140            }],
141        };
142        let out = render(&report);
143        assert_eq!(
144            out.trim_end(),
145            "::error title=at-loc,file=src/lib.rs,line=12,col=4::bad"
146        );
147    }
148
149    #[test]
150    fn property_commas_and_colons_are_escaped() {
151        let report = Report {
152            results: vec![RuleResult {
153                rule_id: "r,1:x".into(),
154                level: Level::Error,
155                policy_url: None,
156                violations: vec![Violation::new("m")],
157                notes: Vec::new(),
158                is_fixable: false,
159            }],
160        };
161        let out = render(&report);
162        assert!(out.contains("title=r%2C1%3Ax"));
163    }
164
165    #[test]
166    fn message_body_escapes_newlines_but_keeps_colons() {
167        let report = Report {
168            results: vec![RuleResult {
169                rule_id: "r".into(),
170                level: Level::Error,
171                policy_url: None,
172                violations: vec![Violation::new("a: b\nc")],
173                notes: Vec::new(),
174                is_fixable: false,
175            }],
176        };
177        let out = render(&report);
178        assert!(out.contains("::a: b%0Ac"));
179    }
180}