1use crate::report::sink::outln;
2use std::process::ExitCode;
3use std::sync::OnceLock;
4
5use serde_json::Value;
6
7use fallow_output::issues_from_codeclimate_issues;
8pub use fallow_output::{
9 CiIssue, CiProvider as Provider, CodeClimateIssue, PR_DECISION_SCHEMA, PR_DETAILS_SCHEMA,
10 PrCommentEnvelope, PrCommentLayout, PrCommentTruncation, PrDecisionAnnotation,
11 PrDecisionAnnotationLevel, PrDecisionConclusion, PrDecisionDetails, PrDecisionGate,
12 PrDecisionSurface, PrDetailsArtifact, PrDetailsRow, PrDetailsSection, command_title,
13 issues_from_codeclimate,
14};
15#[cfg(test)]
16use fallow_output::{escape_md, is_project_level_rule};
17
18static WORKSPACE_MARKER: OnceLock<String> = OnceLock::new();
27
28#[allow(
36 dead_code,
37 reason = "called from main.rs bin target; lib target sees no caller"
38)]
39pub fn set_workspace_marker_from_list(values: &[String]) {
40 let trimmed: Vec<&str> = values
41 .iter()
42 .map(|value| value.trim())
43 .filter(|value| !value.is_empty())
44 .collect();
45 if trimmed.is_empty() {
46 return;
47 }
48 let marker = if let [single] = trimmed.as_slice() {
49 (*single).to_owned()
50 } else {
51 let mut sorted = trimmed.iter().map(|s| (*s).to_owned()).collect::<Vec<_>>();
52 sorted.sort();
53 let joined = sorted.join(",");
54 format!("w-{}", short_hex_hash(&joined))
55 };
56 let _ = WORKSPACE_MARKER.set(marker);
57}
58
59fn short_hex_hash(value: &str) -> String {
63 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
64 for byte in value.bytes() {
65 hash ^= u64::from(byte);
66 hash = hash.wrapping_mul(0x0100_0000_01b3);
67 }
68 format!("{:06x}", (hash & 0x00ff_ffff) as u32)
69}
70
71#[must_use]
72pub fn render_pr_comment(command: &str, provider: Provider, issues: &[CiIssue]) -> String {
73 fallow_output::render_pr_comment(&fallow_output::PrCommentRenderInput {
74 command,
75 provider,
76 issues,
77 marker_id: sticky_marker_id(),
78 max_comments: max_comments(),
79 category_for_rule: &category_for_rule,
80 })
81}
82
83#[must_use]
91fn category_for_rule(rule_id: &str) -> &'static str {
92 crate::explain::rule_by_id(rule_id).map_or("Dead code", |def| def.category)
93}
94
95pub(crate) fn max_comments() -> usize {
96 std::env::var("FALLOW_MAX_COMMENTS")
97 .ok()
98 .and_then(|value| value.parse::<usize>().ok())
99 .unwrap_or(50)
100}
101
102#[must_use]
103pub(crate) fn pr_comment_layout_from_env() -> PrCommentLayout {
104 match std::env::var("FALLOW_PR_COMMENT_LAYOUT").as_deref() {
105 Ok("compact") => PrCommentLayout::Compact,
106 Ok("gate-only") => PrCommentLayout::GateOnly,
107 Ok("details") => PrCommentLayout::Details,
108 _ => PrCommentLayout::Default,
109 }
110}
111
112pub(crate) fn sticky_marker_id() -> String {
125 if let Ok(value) = std::env::var("FALLOW_COMMENT_ID")
126 && !value.trim().is_empty()
127 {
128 return value;
129 }
130 let suffix = WORKSPACE_MARKER
131 .get()
132 .map(|value| value.trim())
133 .filter(|value| !value.is_empty())
134 .map(sanitize_marker_segment);
135 match suffix {
136 Some(workspace) => format!("fallow-results-{workspace}"),
137 None => "fallow-results".to_owned(),
138 }
139}
140
141fn sanitize_marker_segment(value: &str) -> String {
146 value
147 .chars()
148 .map(|ch| {
149 if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.' {
150 ch
151 } else {
152 '-'
153 }
154 })
155 .collect::<String>()
156 .trim_matches('-')
157 .to_owned()
158}
159
160#[must_use]
161pub(crate) fn print_pr_comment(command: &str, provider: Provider, codeclimate: &Value) -> ExitCode {
162 let issues = rebase_issue_paths(super::diff_filter::filter_issues_for_summary(
163 issues_from_codeclimate(codeclimate),
164 ));
165 let conclusion = issue_decision_conclusion(issues.is_empty());
166 print_pr_comment_from_ci_issues(command, provider, &issues, conclusion, None)
167}
168
169#[must_use]
170pub(crate) fn print_pr_comment_with_status(
171 command: &str,
172 provider: Provider,
173 codeclimate: &Value,
174 conclusion: PrDecisionConclusion,
175 status_message: Option<&str>,
176) -> ExitCode {
177 let issues = rebase_issue_paths(super::diff_filter::filter_issues_for_summary(
178 issues_from_codeclimate(codeclimate),
179 ));
180 print_pr_comment_from_ci_issues(command, provider, &issues, conclusion, status_message)
181}
182
183#[must_use]
184pub(crate) fn print_pr_comment_from_codeclimate_issues(
185 command: &str,
186 provider: Provider,
187 codeclimate: &[CodeClimateIssue],
188 conclusion: Option<PrDecisionConclusion>,
189 status_message: Option<&str>,
190) -> ExitCode {
191 let issues = rebase_issue_paths(super::diff_filter::filter_issues_for_summary(
192 issues_from_codeclimate_issues(codeclimate),
193 ));
194 let conclusion = conclusion.unwrap_or_else(|| issue_decision_conclusion(issues.is_empty()));
195 print_pr_comment_from_ci_issues(command, provider, &issues, conclusion, status_message)
196}
197
198fn rebase_issue_paths(mut issues: Vec<CiIssue>) -> Vec<CiIssue> {
199 let prefix = crate::report::github::report_prefix();
200 if !prefix.is_empty() {
201 for issue in &mut issues {
202 issue.path = fallow_output::apply_path_prefix(prefix, &issue.path);
203 }
204 }
205 issues
206}
207
208#[must_use]
209fn print_pr_comment_from_ci_issues(
210 command: &str,
211 provider: Provider,
212 issues: &[CiIssue],
213 conclusion: PrDecisionConclusion,
214 status_message: Option<&str>,
215) -> ExitCode {
216 let mut body = render_pr_comment(command, provider, issues);
217 if let Some(message) = status_message {
218 body.push_str("\n\n> ");
219 body.push_str(message);
220 }
221 let max_comments = max_comments();
222 let envelope = PrCommentEnvelope {
223 marker_id: sticky_marker_id(),
224 body,
225 is_clean: issues.is_empty() && conclusion == PrDecisionConclusion::Success,
226 details_url: None,
227 check_summary: Some(decision_summary_label(conclusion).to_owned()),
228 truncation: PrCommentTruncation {
229 truncated: issues.len() > max_comments,
230 shown_findings: issues.len().min(max_comments),
231 total_findings: issues.len(),
232 },
233 };
234 let decision =
235 build_issue_decision_surface(command, issues, &envelope, conclusion, status_message);
236 let details = build_pr_details_artifact(command, issues);
237 write_pr_comment_envelope_sidecar(&envelope);
238 write_pr_decision_sidecar(&decision);
239 write_pr_details_sidecar(&details);
240 outln!("{}", envelope.body());
241 ExitCode::SUCCESS
242}
243
244#[must_use]
245fn build_issue_decision_surface(
246 command: &str,
247 issues: &[CiIssue],
248 envelope: &PrCommentEnvelope,
249 conclusion: PrDecisionConclusion,
250 status_message: Option<&str>,
251) -> PrDecisionSurface {
252 PrDecisionSurface {
253 schema: PR_DECISION_SCHEMA.to_owned(),
254 title: "Fallow".to_owned(),
255 conclusion,
256 gates: vec![PrDecisionGate {
257 id: command.to_owned(),
258 label: command_title(command).to_owned(),
259 status: conclusion,
260 observed: count_label(issues.len(), "finding", "findings"),
261 threshold: None,
262 scope: "new code".to_owned(),
263 }],
264 annotations: issues
265 .iter()
266 .take(max_comments())
267 .map(decision_annotation_from_issue)
268 .collect(),
269 details: PrDecisionDetails {
270 summary_markdown: decision_summary_markdown(conclusion, issues.len(), status_message),
271 full_report_path: None,
272 details_url: envelope.details_url.clone(),
273 },
274 }
275}
276
277fn issue_decision_conclusion(is_clean: bool) -> PrDecisionConclusion {
278 if is_clean {
279 PrDecisionConclusion::Success
280 } else {
281 PrDecisionConclusion::Neutral
282 }
283}
284
285fn decision_summary_label(conclusion: PrDecisionConclusion) -> &'static str {
286 match conclusion {
287 PrDecisionConclusion::Success => "pass",
288 PrDecisionConclusion::Failure => "fail",
289 PrDecisionConclusion::Neutral => "warn",
290 PrDecisionConclusion::Skipped => "skipped",
291 }
292}
293
294fn decision_summary_markdown(
295 conclusion: PrDecisionConclusion,
296 issue_count: usize,
297 status_message: Option<&str>,
298) -> String {
299 let summary = if issue_count == 0 {
300 match conclusion {
301 PrDecisionConclusion::Failure => {
302 "Fallow quality gates failed without renderable findings.".to_owned()
303 }
304 PrDecisionConclusion::Neutral => {
305 "Fallow needs review without renderable findings.".to_owned()
306 }
307 PrDecisionConclusion::Success | PrDecisionConclusion::Skipped => {
308 "Fallow found no actionable PR findings.".to_owned()
309 }
310 }
311 } else {
312 let findings = count_label(issue_count, "finding", "findings");
313 match conclusion {
314 PrDecisionConclusion::Failure => {
315 format!("Fallow quality gates failed with {findings}.")
316 }
317 PrDecisionConclusion::Neutral => format!("Fallow found {findings} for review."),
318 PrDecisionConclusion::Success | PrDecisionConclusion::Skipped => {
319 format!("Fallow found {findings}.")
320 }
321 }
322 };
323 match status_message {
324 Some(message) => format!("{summary}\n\n> {message}"),
325 None => summary,
326 }
327}
328
329#[must_use]
330pub(crate) fn build_pr_details_artifact(command: &str, issues: &[CiIssue]) -> PrDetailsArtifact {
331 PrDetailsArtifact {
332 schema: PR_DETAILS_SCHEMA.to_owned(),
333 title: format!("Fallow {}", command_title(command)),
334 sections: vec![PrDetailsSection {
335 id: "findings".to_owned(),
336 title: "Findings".to_owned(),
337 rows: issues.iter().map(pr_details_row_from_issue).collect(),
338 }],
339 }
340}
341
342fn pr_details_row_from_issue(issue: &CiIssue) -> PrDetailsRow {
343 PrDetailsRow {
344 location: format!("{}:{}", issue.path, issue.line),
345 rule: issue.rule_id.clone(),
346 description: issue.description.clone(),
347 fix: super::suggestion::fix_intent(issue).map(str::to_owned),
348 fingerprint: (!issue.fingerprint.trim().is_empty()).then(|| issue.fingerprint.clone()),
349 }
350}
351
352#[must_use]
353pub(crate) fn decision_annotation_from_issue(issue: &CiIssue) -> PrDecisionAnnotation {
354 PrDecisionAnnotation {
355 path: issue.path.clone(),
356 line: u32::try_from(issue.line).unwrap_or(u32::MAX),
357 level: decision_level_from_severity(&issue.severity),
358 title: issue.rule_id.clone(),
359 message: issue.description.clone(),
360 raw_details: super::suggestion::fix_intent(issue).map(str::to_owned),
361 }
362}
363
364fn decision_level_from_severity(severity: &str) -> PrDecisionAnnotationLevel {
365 match severity {
366 "blocker" | "critical" | "major" => PrDecisionAnnotationLevel::Failure,
367 "minor" => PrDecisionAnnotationLevel::Warning,
368 _ => PrDecisionAnnotationLevel::Notice,
369 }
370}
371
372fn count_label(count: usize, singular: &str, plural: &str) -> String {
373 let noun = if count == 1 { singular } else { plural };
374 format!("{count} {noun}")
375}
376
377pub(crate) fn write_pr_comment_envelope_sidecar(envelope: &PrCommentEnvelope) {
378 let Ok(path) = std::env::var("FALLOW_PR_COMMENT_ENVELOPE_FILE") else {
379 return;
380 };
381 if path.trim().is_empty() {
382 return;
383 }
384 match serde_json::to_string_pretty(envelope)
385 .map_err(|e| e.to_string())
386 .and_then(|json| std::fs::write(&path, json).map_err(|e| e.to_string()))
387 {
388 Ok(()) => {}
389 Err(e) => eprintln!("warning: failed to write PR comment envelope '{path}': {e}"),
390 }
391}
392
393pub(crate) fn write_pr_decision_sidecar(surface: &PrDecisionSurface) {
394 let Ok(path) = std::env::var("FALLOW_PR_DECISION_FILE") else {
395 return;
396 };
397 if path.trim().is_empty() {
398 return;
399 }
400 match serde_json::to_string_pretty(surface)
401 .map_err(|e| e.to_string())
402 .and_then(|json| std::fs::write(&path, json).map_err(|e| e.to_string()))
403 {
404 Ok(()) => {}
405 Err(e) => eprintln!("warning: failed to write PR decision '{path}': {e}"),
406 }
407}
408
409pub(crate) fn write_pr_details_sidecar(artifact: &PrDetailsArtifact) {
410 let Ok(path) = std::env::var("FALLOW_PR_DETAILS_FILE") else {
411 return;
412 };
413 if path.trim().is_empty() {
414 return;
415 }
416 match serde_json::to_string_pretty(artifact)
417 .map_err(|e| e.to_string())
418 .and_then(|json| std::fs::write(&path, json).map_err(|e| e.to_string()))
419 {
420 Ok(()) => {}
421 Err(e) => eprintln!("warning: failed to write PR details '{path}': {e}"),
422 }
423}
424
425#[cfg(test)]
426mod tests {
427 use super::*;
428 use fallow_output::{
429 CodeClimateIssueKind, CodeClimateLines, CodeClimateLocation, CodeClimateSeverity,
430 };
431
432 #[test]
433 fn extracts_issues_from_codeclimate() {
434 let value = serde_json::json!([{
435 "check_name": "fallow/unused-export",
436 "description": "Export x is never imported",
437 "severity": "minor",
438 "fingerprint": "abc",
439 "location": { "path": "src/a.ts", "lines": { "begin": 7 } }
440 }]);
441 let issues = issues_from_codeclimate(&value);
442 assert_eq!(issues.len(), 1);
443 assert_eq!(issues[0].path, "src/a.ts");
444 assert_eq!(issues[0].line, 7);
445 }
446
447 #[test]
448 fn typed_codeclimate_issues_extract_like_json_codeclimate() {
449 let severities = [
450 (CodeClimateSeverity::Info, "info"),
451 (CodeClimateSeverity::Minor, "minor"),
452 (CodeClimateSeverity::Major, "major"),
453 (CodeClimateSeverity::Critical, "critical"),
454 (CodeClimateSeverity::Blocker, "blocker"),
455 ];
456 let typed = severities
457 .iter()
458 .enumerate()
459 .map(|(index, (severity, _))| CodeClimateIssue {
460 kind: CodeClimateIssueKind::Issue,
461 check_name: format!("fallow/rule-{index}"),
462 description: format!("Finding {index}"),
463 categories: vec!["Complexity".to_owned()],
464 severity: *severity,
465 fingerprint: format!("fp-{index}"),
466 location: CodeClimateLocation {
467 path: format!("src/{index}.ts"),
468 lines: CodeClimateLines {
469 begin: u32::try_from(index + 1).expect("small fixture index"),
470 },
471 },
472 owner: None,
473 group: None,
474 })
475 .collect::<Vec<_>>();
476 let value = serde_json::to_value(&typed).expect("typed fixture serializes");
477
478 assert_eq!(
479 issues_from_codeclimate_issues(&typed),
480 issues_from_codeclimate(&value)
481 );
482 let typed_labels = issues_from_codeclimate_issues(&typed)
483 .into_iter()
484 .map(|issue| issue.severity)
485 .collect::<Vec<_>>();
486 let expected_labels = severities
487 .iter()
488 .map(|(_, label)| (*label).to_owned())
489 .collect::<Vec<_>>();
490 assert_eq!(typed_labels, expected_labels);
491 }
492
493 #[test]
494 fn sticky_marker_id_default_when_nothing_set() {
495 let body = render_pr_comment("check", Provider::Github, &[]);
496 assert!(body.contains("<!-- fallow-id: fallow-results"));
497 assert!(body.contains("No GitHub PR/MR findings."));
498 }
499
500 #[test]
501 fn short_hex_hash_is_deterministic_and_six_chars() {
502 let a = short_hex_hash("api,worker");
503 assert_eq!(a.len(), 6);
504 assert_eq!(a, short_hex_hash("api,worker"));
505 assert_ne!(a, short_hex_hash("admin,web"));
506 }
507
508 #[test]
509 fn sanitize_marker_segment_collapses_unsafe_chars_to_dashes() {
510 assert_eq!(sanitize_marker_segment("@fallow/runtime"), "fallow-runtime");
511 assert_eq!(
512 sanitize_marker_segment("packages/web ui"),
513 "packages-web-ui"
514 );
515 assert_eq!(sanitize_marker_segment("plain"), "plain");
516 assert_eq!(
517 sanitize_marker_segment("--leading-trailing--"),
518 "leading-trailing"
519 );
520 }
521
522 #[test]
523 fn escape_md_escapes_inline_commonmark_specials() {
524 let raw = "foo*bar_baz [a](u) `c` <h> #x !i ~s | p";
525 let escaped = escape_md(raw);
526 for ch in [
527 '*', '_', '[', ']', '(', ')', '`', '<', '>', '#', '!', '~', '|',
528 ] {
529 let raw_count = raw.chars().filter(|c| c == &ch).count();
530 let escaped_count = escaped.matches(&format!("\\{ch}")).count();
531 assert_eq!(
532 raw_count, escaped_count,
533 "char {ch:?}: raw {raw_count} occurrences, escaped {escaped_count} in {escaped:?}"
534 );
535 }
536 }
537
538 #[test]
539 fn escape_md_escapes_ampersand_to_block_numeric_entity_bypass() {
540 let raw = "value *suspicious* here";
541 let escaped = escape_md(raw);
542 assert!(escaped.contains(r"\&"), "got: {escaped}");
543 assert!(escaped.contains(r"\#"), "got: {escaped}");
544 assert!(!escaped.contains(" *suspicious"), "got: {escaped}");
545 }
546
547 #[test]
548 fn summary_label_foreshadows_truncation() {
549 assert_eq!(
550 fallow_output::summary_label("Duplication", 160, 50),
551 "Duplication (160, showing 50)"
552 );
553 assert_eq!(
554 fallow_output::summary_label("Health", 12, 50),
555 "Health (12)"
556 );
557 assert_eq!(
558 fallow_output::summary_label("Dependencies", 50, 50),
559 "Dependencies (50)"
560 );
561 }
562
563 #[test]
564 fn escape_md_does_not_escape_block_only_markers() {
565 let raw = "fallow/test-only-dependency package.json:12";
566 let escaped = escape_md(raw);
567 assert!(!escaped.contains("\\-"), "should not escape `-`");
568 assert!(!escaped.contains("\\."), "should not escape `.`");
569 assert_eq!(escaped, raw);
570 }
571
572 #[test]
573 fn escape_md_collapses_newlines_to_spaces() {
574 let raw = "first\nsecond\nthird";
575 assert_eq!(escape_md(raw), "first second third");
576 }
577
578 #[test]
579 fn escape_md_leaves_safe_chars_unchanged() {
580 let raw = "Export 'helperFn' is never imported by other modules";
581 assert_eq!(
582 escape_md(raw),
583 r"Export 'helperFn' is never imported by other modules"
584 );
585 }
586
587 #[test]
588 fn is_project_level_rule_covers_config_anchored_dependency_findings() {
589 for rule_id in fallow_output::PROJECT_LEVEL_RULE_IDS {
590 assert!(
591 is_project_level_rule(rule_id),
592 "{rule_id} must be project-level"
593 );
594 }
595 for rule_id in [
596 "fallow/unused-file",
597 "fallow/unused-export",
598 "fallow/unused-type",
599 "fallow/unused-enum-member",
600 "fallow/unused-class-member",
601 "fallow/unused-store-member",
602 "fallow/unresolved-import",
603 "fallow/unlisted-dependency",
604 "fallow/duplicate-export",
605 "fallow/circular-dependency",
606 "fallow/re-export-cycle",
607 "fallow/boundary-violation",
608 "fallow/stale-suppression",
609 "fallow/private-type-leak",
610 "fallow/high-complexity",
611 "fallow/high-crap-score",
612 ] {
613 assert!(
614 !is_project_level_rule(rule_id),
615 "{rule_id} must NOT be project-level"
616 );
617 }
618 }
619
620 #[test]
621 fn decision_surface_preserves_blocking_conclusion_for_issue_output() {
622 let issues = [CiIssue {
623 path: "src/app.ts".to_owned(),
624 line: 12,
625 rule_id: "fallow/high-crap-score".to_owned(),
626 description: "Function is hard to safely change.".to_owned(),
627 severity: "minor".to_owned(),
628 fingerprint: "abc".to_owned(),
629 }];
630 let envelope = PrCommentEnvelope {
631 marker_id: "fallow-results".to_owned(),
632 body: "body".to_owned(),
633 is_clean: false,
634 details_url: None,
635 check_summary: Some("fail".to_owned()),
636 truncation: PrCommentTruncation {
637 truncated: false,
638 shown_findings: 1,
639 total_findings: 1,
640 },
641 };
642
643 let decision = build_issue_decision_surface(
644 "audit",
645 &issues,
646 &envelope,
647 PrDecisionConclusion::Failure,
648 Some(crate::report::ci::TYPE_AWARE_INCOMPLETE_MESSAGE),
649 );
650
651 assert_eq!(decision.conclusion, PrDecisionConclusion::Failure);
652 assert_eq!(decision.gates[0].status, PrDecisionConclusion::Failure);
653 assert!(decision.details.summary_markdown.contains("incomplete"));
654 assert!(
655 decision
656 .details
657 .summary_markdown
658 .contains("quality gates failed")
659 );
660 }
661
662 #[test]
663 fn project_level_rule_ids_each_register_in_explain_registry() {
664 for rule_id in fallow_output::PROJECT_LEVEL_RULE_IDS {
665 assert!(
666 crate::explain::rule_by_id(rule_id).is_some(),
667 "{rule_id} listed in PROJECT_LEVEL_RULE_IDS but not in explain registry"
668 );
669 }
670 }
671
672 #[test]
673 fn escape_md_double_apply_is_safe() {
674 let raw = "code with `backticks` and *stars*";
675 let once = escape_md(raw);
676 let twice = escape_md(&once);
677 assert!(twice.contains(r"\\"));
678 }
679}