1use 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}