1use crate::report::sink::outln;
2use fallow_output::CodeClimateIssue;
3use std::process::ExitCode;
4use std::sync::OnceLock;
5
6use serde_json::Value;
7
8pub use fallow_output::{
9 CiIssue, CiProvider as Provider, issues_from_codeclimate, issues_from_codeclimate_issues,
10};
11#[cfg(test)]
12use fallow_output::{escape_md, is_project_level_rule};
13
14static WORKSPACE_MARKER: OnceLock<String> = OnceLock::new();
23
24#[allow(
32 dead_code,
33 reason = "called from main.rs bin target; lib target sees no caller"
34)]
35pub fn set_workspace_marker_from_list(values: &[String]) {
36 let trimmed: Vec<&str> = values
37 .iter()
38 .map(|value| value.trim())
39 .filter(|value| !value.is_empty())
40 .collect();
41 if trimmed.is_empty() {
42 return;
43 }
44 let marker = if let [single] = trimmed.as_slice() {
45 (*single).to_owned()
46 } else {
47 let mut sorted = trimmed.iter().map(|s| (*s).to_owned()).collect::<Vec<_>>();
48 sorted.sort();
49 let joined = sorted.join(",");
50 format!("w-{}", short_hex_hash(&joined))
51 };
52 let _ = WORKSPACE_MARKER.set(marker);
53}
54
55fn short_hex_hash(value: &str) -> String {
59 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
60 for byte in value.bytes() {
61 hash ^= u64::from(byte);
62 hash = hash.wrapping_mul(0x0100_0000_01b3);
63 }
64 format!("{:06x}", (hash & 0x00ff_ffff) as u32)
65}
66
67#[must_use]
68pub fn render_pr_comment(command: &str, provider: Provider, issues: &[CiIssue]) -> String {
69 fallow_output::render_pr_comment(&fallow_output::PrCommentRenderInput {
70 command,
71 provider,
72 issues,
73 marker_id: sticky_marker_id(),
74 max_comments: max_comments(),
75 category_for_rule: &category_for_rule,
76 })
77}
78
79#[must_use]
87pub fn category_for_rule(rule_id: &str) -> &'static str {
88 crate::explain::rule_by_id(rule_id).map_or("Dead code", |def| def.category)
89}
90
91fn max_comments() -> usize {
92 std::env::var("FALLOW_MAX_COMMENTS")
93 .ok()
94 .and_then(|value| value.parse::<usize>().ok())
95 .unwrap_or(50)
96}
97
98fn sticky_marker_id() -> String {
111 if let Ok(value) = std::env::var("FALLOW_COMMENT_ID")
112 && !value.trim().is_empty()
113 {
114 return value;
115 }
116 let suffix = WORKSPACE_MARKER
117 .get()
118 .map(|value| value.trim())
119 .filter(|value| !value.is_empty())
120 .map(sanitize_marker_segment);
121 match suffix {
122 Some(workspace) => format!("fallow-results-{workspace}"),
123 None => "fallow-results".to_owned(),
124 }
125}
126
127fn sanitize_marker_segment(value: &str) -> String {
132 value
133 .chars()
134 .map(|ch| {
135 if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.' {
136 ch
137 } else {
138 '-'
139 }
140 })
141 .collect::<String>()
142 .trim_matches('-')
143 .to_owned()
144}
145
146#[must_use]
147pub fn print_pr_comment(command: &str, provider: Provider, codeclimate: &Value) -> ExitCode {
148 let issues =
149 super::diff_filter::filter_issues_for_summary(issues_from_codeclimate(codeclimate));
150 print_pr_comment_from_ci_issues(command, provider, &issues)
151}
152
153#[must_use]
154pub fn print_pr_comment_from_codeclimate_issues(
155 command: &str,
156 provider: Provider,
157 codeclimate: &[CodeClimateIssue],
158) -> ExitCode {
159 let issues =
160 super::diff_filter::filter_issues_for_summary(issues_from_codeclimate_issues(codeclimate));
161 print_pr_comment_from_ci_issues(command, provider, &issues)
162}
163
164#[must_use]
165fn print_pr_comment_from_ci_issues(
166 command: &str,
167 provider: Provider,
168 issues: &[CiIssue],
169) -> ExitCode {
170 outln!("{}", render_pr_comment(command, provider, issues));
171 ExitCode::SUCCESS
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177 use fallow_output::{
178 CodeClimateIssueKind, CodeClimateLines, CodeClimateLocation, CodeClimateSeverity,
179 };
180
181 #[test]
182 fn extracts_issues_from_codeclimate() {
183 let value = serde_json::json!([{
184 "check_name": "fallow/unused-export",
185 "description": "Export x is never imported",
186 "severity": "minor",
187 "fingerprint": "abc",
188 "location": { "path": "src/a.ts", "lines": { "begin": 7 } }
189 }]);
190 let issues = issues_from_codeclimate(&value);
191 assert_eq!(issues.len(), 1);
192 assert_eq!(issues[0].path, "src/a.ts");
193 assert_eq!(issues[0].line, 7);
194 }
195
196 #[test]
197 fn typed_codeclimate_issues_extract_like_json_codeclimate() {
198 let severities = [
199 (CodeClimateSeverity::Info, "info"),
200 (CodeClimateSeverity::Minor, "minor"),
201 (CodeClimateSeverity::Major, "major"),
202 (CodeClimateSeverity::Critical, "critical"),
203 (CodeClimateSeverity::Blocker, "blocker"),
204 ];
205 let typed = severities
206 .iter()
207 .enumerate()
208 .map(|(index, (severity, _))| CodeClimateIssue {
209 kind: CodeClimateIssueKind::Issue,
210 check_name: format!("fallow/rule-{index}"),
211 description: format!("Finding {index}"),
212 categories: vec!["Complexity".to_owned()],
213 severity: *severity,
214 fingerprint: format!("fp-{index}"),
215 location: CodeClimateLocation {
216 path: format!("src/{index}.ts"),
217 lines: CodeClimateLines {
218 begin: u32::try_from(index + 1).expect("small fixture index"),
219 },
220 },
221 owner: None,
222 group: None,
223 })
224 .collect::<Vec<_>>();
225 let value = serde_json::to_value(&typed).expect("typed fixture serializes");
226
227 assert_eq!(
228 issues_from_codeclimate_issues(&typed),
229 issues_from_codeclimate(&value)
230 );
231 let typed_labels = issues_from_codeclimate_issues(&typed)
232 .into_iter()
233 .map(|issue| issue.severity)
234 .collect::<Vec<_>>();
235 let expected_labels = severities
236 .iter()
237 .map(|(_, label)| (*label).to_owned())
238 .collect::<Vec<_>>();
239 assert_eq!(typed_labels, expected_labels);
240 }
241
242 #[test]
243 fn sticky_marker_id_default_when_nothing_set() {
244 let body = render_pr_comment("check", Provider::Github, &[]);
245 assert!(body.contains("<!-- fallow-id: fallow-results"));
246 assert!(body.contains("No GitHub PR/MR findings."));
247 }
248
249 #[test]
250 fn short_hex_hash_is_deterministic_and_six_chars() {
251 let a = short_hex_hash("api,worker");
252 assert_eq!(a.len(), 6);
253 assert_eq!(a, short_hex_hash("api,worker"));
254 assert_ne!(a, short_hex_hash("admin,web"));
255 }
256
257 #[test]
258 fn sanitize_marker_segment_collapses_unsafe_chars_to_dashes() {
259 assert_eq!(sanitize_marker_segment("@fallow/runtime"), "fallow-runtime");
260 assert_eq!(
261 sanitize_marker_segment("packages/web ui"),
262 "packages-web-ui"
263 );
264 assert_eq!(sanitize_marker_segment("plain"), "plain");
265 assert_eq!(
266 sanitize_marker_segment("--leading-trailing--"),
267 "leading-trailing"
268 );
269 }
270
271 #[test]
272 fn escape_md_escapes_inline_commonmark_specials() {
273 let raw = "foo*bar_baz [a](u) `c` <h> #x !i ~s | p";
274 let escaped = escape_md(raw);
275 for ch in [
276 '*', '_', '[', ']', '(', ')', '`', '<', '>', '#', '!', '~', '|',
277 ] {
278 let raw_count = raw.chars().filter(|c| c == &ch).count();
279 let escaped_count = escaped.matches(&format!("\\{ch}")).count();
280 assert_eq!(
281 raw_count, escaped_count,
282 "char {ch:?}: raw {raw_count} occurrences, escaped {escaped_count} in {escaped:?}"
283 );
284 }
285 }
286
287 #[test]
288 fn escape_md_escapes_ampersand_to_block_numeric_entity_bypass() {
289 let raw = "value *suspicious* here";
290 let escaped = escape_md(raw);
291 assert!(escaped.contains(r"\&"), "got: {escaped}");
292 assert!(escaped.contains(r"\#"), "got: {escaped}");
293 assert!(!escaped.contains(" *suspicious"), "got: {escaped}");
294 }
295
296 #[test]
297 fn summary_label_foreshadows_truncation() {
298 assert_eq!(
299 fallow_output::summary_label("Duplication", 160, 50),
300 "Duplication (160, showing 50)"
301 );
302 assert_eq!(
303 fallow_output::summary_label("Health", 12, 50),
304 "Health (12)"
305 );
306 assert_eq!(
307 fallow_output::summary_label("Dependencies", 50, 50),
308 "Dependencies (50)"
309 );
310 }
311
312 #[test]
313 fn escape_md_does_not_escape_block_only_markers() {
314 let raw = "fallow/test-only-dependency package.json:12";
315 let escaped = escape_md(raw);
316 assert!(!escaped.contains("\\-"), "should not escape `-`");
317 assert!(!escaped.contains("\\."), "should not escape `.`");
318 assert_eq!(escaped, raw);
319 }
320
321 #[test]
322 fn escape_md_collapses_newlines_to_spaces() {
323 let raw = "first\nsecond\nthird";
324 assert_eq!(escape_md(raw), "first second third");
325 }
326
327 #[test]
328 fn escape_md_leaves_safe_chars_unchanged() {
329 let raw = "Export 'helperFn' is never imported by other modules";
330 assert_eq!(
331 escape_md(raw),
332 r"Export 'helperFn' is never imported by other modules"
333 );
334 }
335
336 #[test]
337 fn is_project_level_rule_covers_config_anchored_dependency_findings() {
338 for rule_id in fallow_output::PROJECT_LEVEL_RULE_IDS {
339 assert!(
340 is_project_level_rule(rule_id),
341 "{rule_id} must be project-level"
342 );
343 }
344 for rule_id in [
345 "fallow/unused-file",
346 "fallow/unused-export",
347 "fallow/unused-type",
348 "fallow/unused-enum-member",
349 "fallow/unused-class-member",
350 "fallow/unused-store-member",
351 "fallow/unresolved-import",
352 "fallow/unlisted-dependency",
353 "fallow/duplicate-export",
354 "fallow/circular-dependency",
355 "fallow/re-export-cycle",
356 "fallow/boundary-violation",
357 "fallow/stale-suppression",
358 "fallow/private-type-leak",
359 "fallow/high-complexity",
360 "fallow/high-crap-score",
361 ] {
362 assert!(
363 !is_project_level_rule(rule_id),
364 "{rule_id} must NOT be project-level"
365 );
366 }
367 }
368
369 #[test]
370 fn project_level_rule_ids_each_register_in_explain_registry() {
371 for rule_id in fallow_output::PROJECT_LEVEL_RULE_IDS {
372 assert!(
373 crate::explain::rule_by_id(rule_id).is_some(),
374 "{rule_id} listed in PROJECT_LEVEL_RULE_IDS but not in explain registry"
375 );
376 }
377 }
378
379 #[test]
380 fn escape_md_double_apply_is_safe() {
381 let raw = "code with `backticks` and *stars*";
382 let once = escape_md(raw);
383 let twice = escape_md(&once);
384 assert!(twice.contains(r"\\"));
385 }
386}