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