1use crate::report::sink::outln;
2use std::fmt::Write as _;
3use std::process::ExitCode;
4use std::sync::OnceLock;
5
6use serde_json::Value;
7
8use crate::output_envelope::{CodeClimateIssue, CodeClimateSeverity};
9
10static WORKSPACE_MARKER: OnceLock<String> = OnceLock::new();
19
20#[allow(
28 dead_code,
29 reason = "called from main.rs bin target; lib target sees no caller"
30)]
31pub fn set_workspace_marker_from_list(values: &[String]) {
32 let trimmed: Vec<&str> = values
33 .iter()
34 .map(|value| value.trim())
35 .filter(|value| !value.is_empty())
36 .collect();
37 if trimmed.is_empty() {
38 return;
39 }
40 let marker = if let [single] = trimmed.as_slice() {
41 (*single).to_owned()
42 } else {
43 let mut sorted = trimmed.iter().map(|s| (*s).to_owned()).collect::<Vec<_>>();
44 sorted.sort();
45 let joined = sorted.join(",");
46 format!("w-{}", short_hex_hash(&joined))
47 };
48 let _ = WORKSPACE_MARKER.set(marker);
49}
50
51fn short_hex_hash(value: &str) -> String {
55 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
56 for byte in value.bytes() {
57 hash ^= u64::from(byte);
58 hash = hash.wrapping_mul(0x0100_0000_01b3);
59 }
60 format!("{:06x}", (hash & 0x00ff_ffff) as u32)
61}
62
63#[derive(Clone, Copy, Debug, PartialEq, Eq)]
64pub enum Provider {
65 Github,
66 Gitlab,
67}
68
69impl Provider {
70 #[must_use]
71 pub const fn name(self) -> &'static str {
72 match self {
73 Self::Github => "GitHub",
74 Self::Gitlab => "GitLab",
75 }
76 }
77}
78
79#[derive(Clone, Debug, PartialEq, Eq)]
80pub struct CiIssue {
81 pub rule_id: String,
82 pub description: String,
83 pub severity: String,
84 pub path: String,
85 pub line: u64,
86 pub fingerprint: String,
87}
88
89#[must_use]
90pub fn issues_from_codeclimate(value: &Value) -> Vec<CiIssue> {
91 let mut issues = value
92 .as_array()
93 .into_iter()
94 .flatten()
95 .filter_map(issue_from_codeclimate)
96 .collect::<Vec<_>>();
97 sort_ci_issues(&mut issues);
98 issues
99}
100
101#[must_use]
102pub fn issues_from_codeclimate_issues(issues: &[CodeClimateIssue]) -> Vec<CiIssue> {
103 let mut issues = issues
104 .iter()
105 .map(issue_from_codeclimate_issue)
106 .collect::<Vec<_>>();
107 sort_ci_issues(&mut issues);
108 issues
109}
110
111fn issue_from_codeclimate(value: &Value) -> Option<CiIssue> {
112 let path = value.pointer("/location/path")?.as_str()?.to_string();
113 let line = value
114 .pointer("/location/lines/begin")
115 .and_then(Value::as_u64)
116 .unwrap_or(1);
117 Some(CiIssue {
118 rule_id: value
119 .get("check_name")
120 .and_then(Value::as_str)
121 .unwrap_or("fallow/finding")
122 .to_string(),
123 description: value
124 .get("description")
125 .and_then(Value::as_str)
126 .unwrap_or("Fallow finding")
127 .to_string(),
128 severity: value
129 .get("severity")
130 .and_then(Value::as_str)
131 .unwrap_or("minor")
132 .to_string(),
133 fingerprint: value
134 .get("fingerprint")
135 .and_then(Value::as_str)
136 .unwrap_or("")
137 .to_string(),
138 path,
139 line,
140 })
141}
142
143fn issue_from_codeclimate_issue(issue: &CodeClimateIssue) -> CiIssue {
144 CiIssue {
145 rule_id: issue.check_name.clone(),
146 description: issue.description.clone(),
147 severity: codeclimate_severity_label(issue.severity).to_owned(),
148 path: issue.location.path.clone(),
149 line: u64::from(issue.location.lines.begin),
150 fingerprint: issue.fingerprint.clone(),
151 }
152}
153
154const fn codeclimate_severity_label(severity: CodeClimateSeverity) -> &'static str {
155 match severity {
156 CodeClimateSeverity::Info => "info",
157 CodeClimateSeverity::Minor => "minor",
158 CodeClimateSeverity::Major => "major",
159 CodeClimateSeverity::Critical => "critical",
160 CodeClimateSeverity::Blocker => "blocker",
161 }
162}
163
164fn sort_ci_issues(issues: &mut [CiIssue]) {
165 issues
166 .sort_by(|a, b| (&a.path, a.line, &a.fingerprint).cmp(&(&b.path, b.line, &b.fingerprint)));
167}
168
169#[must_use]
170#[expect(clippy::expect_used, reason = "formatting into String is infallible")]
171pub fn render_pr_comment(command: &str, provider: Provider, issues: &[CiIssue]) -> String {
172 let marker_id = sticky_marker_id();
173 let marker = format!("<!-- fallow-id: {marker_id} -->");
174 let max = max_comments();
175 let title = command_title(command);
176 let count = issues.len();
177 let noun = if count == 1 { "finding" } else { "findings" };
178
179 let mut out = String::new();
180 out.push_str(&marker);
181 out.push('\n');
182 write!(&mut out, "### Fallow {title}\n\n").expect("write to string");
183 if count == 0 {
184 writeln!(
185 &mut out,
186 "No {provider} PR/MR findings.",
187 provider = provider.name()
188 )
189 .expect("write to string");
190 } else {
191 write!(&mut out, "Found **{count}** {noun}.\n\n").expect("write to string");
192 let groups = group_by_category(issues);
193 if let [(_, group_issues)] = groups.as_slice() {
194 render_findings_table(&mut out, group_issues, max, "Details");
195 } else {
196 for (category, group_issues) in &groups {
197 let summary_label = summary_label(category, group_issues.len(), max);
198 render_findings_table(&mut out, group_issues, max, &summary_label);
199 }
200 }
201 }
202 out.push_str("\nGenerated by fallow.");
203 out
204}
205
206fn summary_label(category: &str, total: usize, max: usize) -> String {
212 if total > max {
213 format!("{category} ({total}, showing {max})")
214 } else {
215 format!("{category} ({total})")
216 }
217}
218
219#[expect(clippy::expect_used, reason = "formatting into String is infallible")]
220fn render_findings_table(out: &mut String, issues: &[&CiIssue], max: usize, summary: &str) {
221 writeln!(out, "<details>\n<summary>{summary}</summary>\n").expect("write to string");
222 out.push_str("| Severity | Rule | Location | Description |\n");
223 out.push_str("| --- | --- | --- | --- |\n");
224 for issue in issues.iter().take(max) {
225 writeln!(
226 out,
227 "| {} | `{}` | `{}`:{} | {} |",
228 escape_md(&issue.severity),
229 escape_md(&issue.rule_id),
230 escape_md(&issue.path),
231 issue.line,
232 escape_md(&issue.description),
233 )
234 .expect("write to string");
235 }
236 if issues.len() > max {
237 writeln!(
238 out,
239 "\nShowing {max} of {} findings. Run fallow locally or inspect the CI output for the full report.",
240 issues.len(),
241 )
242 .expect("write to string");
243 }
244 out.push_str("\n</details>\n\n");
245}
246
247#[must_use]
255pub fn category_for_rule(rule_id: &str) -> &'static str {
256 crate::explain::rule_by_id(rule_id).map_or("Dead code", |def| def.category)
257}
258
259const PROJECT_LEVEL_RULE_IDS: &[&str] = &[
273 "fallow/unused-catalog-entry",
274 "fallow/empty-catalog-group",
275 "fallow/unresolved-catalog-reference",
276 "fallow/unused-dependency-override",
277 "fallow/misconfigured-dependency-override",
278 "fallow/unused-dependency",
279 "fallow/unused-dev-dependency",
280 "fallow/unused-optional-dependency",
281 "fallow/type-only-dependency",
282 "fallow/test-only-dependency",
283];
284
285#[must_use]
289pub fn is_project_level_rule(rule_id: &str) -> bool {
290 PROJECT_LEVEL_RULE_IDS.contains(&rule_id)
291}
292
293const CATEGORY_ORDER: [&str; 6] = [
296 "Dead code",
297 "Dependencies",
298 "Duplication",
299 "Health",
300 "Architecture",
301 "Suppressions",
302];
303
304fn group_by_category(issues: &[CiIssue]) -> Vec<(&'static str, Vec<&CiIssue>)> {
305 let mut buckets: std::collections::BTreeMap<&'static str, Vec<&CiIssue>> =
306 std::collections::BTreeMap::new();
307 for issue in issues {
308 let category = category_for_rule(&issue.rule_id);
309 buckets.entry(category).or_default().push(issue);
310 }
311 let mut ordered: Vec<(&'static str, Vec<&CiIssue>)> = Vec::with_capacity(buckets.len());
312 for category in CATEGORY_ORDER {
313 if let Some(items) = buckets.remove(category) {
314 ordered.push((category, items));
315 }
316 }
317 for (category, items) in buckets {
318 ordered.push((category, items));
319 }
320 ordered
321}
322
323fn max_comments() -> usize {
324 std::env::var("FALLOW_MAX_COMMENTS")
325 .ok()
326 .and_then(|value| value.parse::<usize>().ok())
327 .unwrap_or(50)
328}
329
330fn sticky_marker_id() -> String {
343 if let Ok(value) = std::env::var("FALLOW_COMMENT_ID")
344 && !value.trim().is_empty()
345 {
346 return value;
347 }
348 let suffix = WORKSPACE_MARKER
349 .get()
350 .map(|value| value.trim())
351 .filter(|value| !value.is_empty())
352 .map(sanitize_marker_segment);
353 match suffix {
354 Some(workspace) => format!("fallow-results-{workspace}"),
355 None => "fallow-results".to_owned(),
356 }
357}
358
359fn sanitize_marker_segment(value: &str) -> String {
364 value
365 .chars()
366 .map(|ch| {
367 if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.' {
368 ch
369 } else {
370 '-'
371 }
372 })
373 .collect::<String>()
374 .trim_matches('-')
375 .to_owned()
376}
377
378#[must_use]
379pub fn print_pr_comment(command: &str, provider: Provider, codeclimate: &Value) -> ExitCode {
380 let issues =
381 super::diff_filter::filter_issues_for_summary(issues_from_codeclimate(codeclimate));
382 print_pr_comment_from_ci_issues(command, provider, &issues)
383}
384
385#[must_use]
386pub fn print_pr_comment_from_codeclimate_issues(
387 command: &str,
388 provider: Provider,
389 codeclimate: &[CodeClimateIssue],
390) -> ExitCode {
391 let issues =
392 super::diff_filter::filter_issues_for_summary(issues_from_codeclimate_issues(codeclimate));
393 print_pr_comment_from_ci_issues(command, provider, &issues)
394}
395
396#[must_use]
397fn print_pr_comment_from_ci_issues(
398 command: &str,
399 provider: Provider,
400 issues: &[CiIssue],
401) -> ExitCode {
402 outln!("{}", render_pr_comment(command, provider, issues));
403 ExitCode::SUCCESS
404}
405
406#[must_use]
407pub fn command_title(command: &str) -> &'static str {
408 match command {
409 "dead-code" | "check" => "dead-code report",
410 "dupes" => "duplication report",
411 "health" => "health report",
412 "audit" => "audit report",
413 "" | "combined" => "combined report",
414 _ => "report",
415 }
416}
417
418#[must_use]
444pub fn escape_md(value: &str) -> String {
445 let collapsed = value.replace('\n', " ");
446 let mut out = String::with_capacity(collapsed.len());
447 for ch in collapsed.chars() {
448 if matches!(
449 ch,
450 '\\' | '`'
451 | '*'
452 | '_'
453 | '['
454 | ']'
455 | '('
456 | ')'
457 | '!'
458 | '<'
459 | '>'
460 | '#'
461 | '|'
462 | '~'
463 | '&'
464 ) {
465 out.push('\\');
466 }
467 out.push(ch);
468 }
469 out.trim().to_owned()
470}
471
472#[cfg(test)]
473mod tests {
474 use super::*;
475
476 #[test]
477 fn extracts_issues_from_codeclimate() {
478 let value = serde_json::json!([{
479 "check_name": "fallow/unused-export",
480 "description": "Export x is never imported",
481 "severity": "minor",
482 "fingerprint": "abc",
483 "location": { "path": "src/a.ts", "lines": { "begin": 7 } }
484 }]);
485 let issues = issues_from_codeclimate(&value);
486 assert_eq!(issues.len(), 1);
487 assert_eq!(issues[0].path, "src/a.ts");
488 assert_eq!(issues[0].line, 7);
489 }
490
491 #[test]
492 fn typed_codeclimate_issues_extract_like_json_codeclimate() {
493 let severities = [
494 (CodeClimateSeverity::Info, "info"),
495 (CodeClimateSeverity::Minor, "minor"),
496 (CodeClimateSeverity::Major, "major"),
497 (CodeClimateSeverity::Critical, "critical"),
498 (CodeClimateSeverity::Blocker, "blocker"),
499 ];
500 let typed = severities
501 .iter()
502 .enumerate()
503 .map(|(index, (severity, _))| CodeClimateIssue {
504 kind: crate::output_envelope::CodeClimateIssueKind::Issue,
505 check_name: format!("fallow/rule-{index}"),
506 description: format!("Finding {index}"),
507 categories: vec!["Complexity".to_owned()],
508 severity: *severity,
509 fingerprint: format!("fp-{index}"),
510 location: crate::output_envelope::CodeClimateLocation {
511 path: format!("src/{index}.ts"),
512 lines: crate::output_envelope::CodeClimateLines {
513 begin: u32::try_from(index + 1).expect("small fixture index"),
514 },
515 },
516 })
517 .collect::<Vec<_>>();
518 let value = serde_json::to_value(&typed).expect("typed fixture serializes");
519
520 assert_eq!(
521 issues_from_codeclimate_issues(&typed),
522 issues_from_codeclimate(&value)
523 );
524 let typed_labels = issues_from_codeclimate_issues(&typed)
525 .into_iter()
526 .map(|issue| issue.severity)
527 .collect::<Vec<_>>();
528 let expected_labels = severities
529 .iter()
530 .map(|(_, label)| (*label).to_owned())
531 .collect::<Vec<_>>();
532 assert_eq!(typed_labels, expected_labels);
533 }
534
535 #[test]
536 fn sticky_marker_id_default_when_nothing_set() {
537 let body = render_pr_comment("check", Provider::Github, &[]);
538 assert!(body.contains("<!-- fallow-id: fallow-results"));
539 assert!(body.contains("No GitHub PR/MR findings."));
540 }
541
542 #[test]
543 fn short_hex_hash_is_deterministic_and_six_chars() {
544 let a = short_hex_hash("api,worker");
545 assert_eq!(a.len(), 6);
546 assert_eq!(a, short_hex_hash("api,worker"));
547 assert_ne!(a, short_hex_hash("admin,web"));
548 }
549
550 #[test]
551 fn sanitize_marker_segment_collapses_unsafe_chars_to_dashes() {
552 assert_eq!(sanitize_marker_segment("@fallow/runtime"), "fallow-runtime");
553 assert_eq!(
554 sanitize_marker_segment("packages/web ui"),
555 "packages-web-ui"
556 );
557 assert_eq!(sanitize_marker_segment("plain"), "plain");
558 assert_eq!(
559 sanitize_marker_segment("--leading-trailing--"),
560 "leading-trailing"
561 );
562 }
563
564 #[test]
565 fn escape_md_escapes_inline_commonmark_specials() {
566 let raw = "foo*bar_baz [a](u) `c` <h> #x !i ~s | p";
567 let escaped = escape_md(raw);
568 for ch in [
569 '*', '_', '[', ']', '(', ')', '`', '<', '>', '#', '!', '~', '|',
570 ] {
571 let raw_count = raw.chars().filter(|c| c == &ch).count();
572 let escaped_count = escaped.matches(&format!("\\{ch}")).count();
573 assert_eq!(
574 raw_count, escaped_count,
575 "char {ch:?}: raw {raw_count} occurrences, escaped {escaped_count} in {escaped:?}"
576 );
577 }
578 }
579
580 #[test]
581 fn escape_md_escapes_ampersand_to_block_numeric_entity_bypass() {
582 let raw = "value *suspicious* here";
583 let escaped = escape_md(raw);
584 assert!(escaped.contains(r"\&"), "got: {escaped}");
585 assert!(escaped.contains(r"\#"), "got: {escaped}");
586 assert!(!escaped.contains(" *suspicious"), "got: {escaped}");
587 }
588
589 #[test]
590 fn summary_label_foreshadows_truncation() {
591 assert_eq!(
592 summary_label("Duplication", 160, 50),
593 "Duplication (160, showing 50)"
594 );
595 assert_eq!(summary_label("Health", 12, 50), "Health (12)");
596 assert_eq!(summary_label("Dependencies", 50, 50), "Dependencies (50)");
597 }
598
599 #[test]
600 fn escape_md_does_not_escape_block_only_markers() {
601 let raw = "fallow/test-only-dependency package.json:12";
602 let escaped = escape_md(raw);
603 assert!(!escaped.contains("\\-"), "should not escape `-`");
604 assert!(!escaped.contains("\\."), "should not escape `.`");
605 assert_eq!(escaped, raw);
606 }
607
608 #[test]
609 fn escape_md_collapses_newlines_to_spaces() {
610 let raw = "first\nsecond\nthird";
611 assert_eq!(escape_md(raw), "first second third");
612 }
613
614 #[test]
615 fn escape_md_leaves_safe_chars_unchanged() {
616 let raw = "Export 'helperFn' is never imported by other modules";
617 assert_eq!(
618 escape_md(raw),
619 r"Export 'helperFn' is never imported by other modules"
620 );
621 }
622
623 #[test]
624 fn is_project_level_rule_covers_config_anchored_dependency_findings() {
625 for rule_id in PROJECT_LEVEL_RULE_IDS {
626 assert!(
627 is_project_level_rule(rule_id),
628 "{rule_id} must be project-level"
629 );
630 }
631 for rule_id in [
632 "fallow/unused-file",
633 "fallow/unused-export",
634 "fallow/unused-type",
635 "fallow/unused-enum-member",
636 "fallow/unused-class-member",
637 "fallow/unresolved-import",
638 "fallow/unlisted-dependency",
639 "fallow/duplicate-export",
640 "fallow/circular-dependency",
641 "fallow/re-export-cycle",
642 "fallow/boundary-violation",
643 "fallow/stale-suppression",
644 "fallow/private-type-leak",
645 "fallow/high-complexity",
646 "fallow/high-crap-score",
647 ] {
648 assert!(
649 !is_project_level_rule(rule_id),
650 "{rule_id} must NOT be project-level"
651 );
652 }
653 }
654
655 #[test]
656 fn project_level_rule_ids_each_register_in_explain_registry() {
657 for rule_id in PROJECT_LEVEL_RULE_IDS {
658 assert!(
659 crate::explain::rule_by_id(rule_id).is_some(),
660 "{rule_id} listed in PROJECT_LEVEL_RULE_IDS but not in explain registry"
661 );
662 }
663 }
664
665 #[test]
666 fn escape_md_double_apply_is_safe() {
667 let raw = "code with `backticks` and *stars*";
668 let once = escape_md(raw);
669 let twice = escape_md(&once);
670 assert!(twice.contains(r"\\"));
671 }
672}