1use crate::report::sink::outln;
2use std::process::ExitCode;
3use std::sync::OnceLock;
4
5#[cfg(test)]
6use fallow_output::is_project_level_rule;
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
16static WORKSPACE_MARKER: OnceLock<String> = OnceLock::new();
25
26#[allow(
34 dead_code,
35 reason = "called from main.rs bin target; lib target sees no caller"
36)]
37pub fn set_workspace_marker_from_list(values: &[String]) {
38 let trimmed: Vec<&str> = values
39 .iter()
40 .map(|value| value.trim())
41 .filter(|value| !value.is_empty())
42 .collect();
43 if trimmed.is_empty() {
44 return;
45 }
46 let marker = if let [single] = trimmed.as_slice() {
47 (*single).to_owned()
48 } else {
49 let mut sorted = trimmed.iter().map(|s| (*s).to_owned()).collect::<Vec<_>>();
50 sorted.sort();
51 let joined = sorted.join(",");
52 format!("w-{}", short_hex_hash(&joined))
53 };
54 let _ = WORKSPACE_MARKER.set(marker);
55}
56
57fn short_hex_hash(value: &str) -> String {
61 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
62 for byte in value.bytes() {
63 hash ^= u64::from(byte);
64 hash = hash.wrapping_mul(0x0100_0000_01b3);
65 }
66 format!("{:06x}", (hash & 0x00ff_ffff) as u32)
67}
68
69#[derive(Clone, Copy)]
76pub struct PrCommentStatus<'a> {
77 pub message: Option<&'a str>,
80 pub gates: &'a [PrDecisionGate],
83 pub config_patterns: &'a [fallow_config::WorkspaceDiagnostic],
86}
87
88#[must_use]
92pub fn render_pr_comment(
93 command: &str,
94 provider: Provider,
95 issues: &[CiIssue],
96 conclusion: Option<PrDecisionConclusion>,
97) -> String {
98 render_pr_comment_body(command, provider, issues, conclusion, None)
99}
100
101fn render_pr_comment_body(
102 command: &str,
103 provider: Provider,
104 issues: &[CiIssue],
105 conclusion: Option<PrDecisionConclusion>,
106 trailing_section: Option<&str>,
107) -> String {
108 fallow_output::render_pr_comment_with_verdict(
109 &fallow_output::PrCommentRenderInput {
110 command,
111 provider,
112 issues,
113 marker_id: sticky_marker_id(),
114 max_comments: max_comments(),
115 category_for_rule: &category_for_rule,
116 trailing_section,
117 },
118 conclusion.map(super::review::review_conclusion),
119 )
120}
121
122#[must_use]
129pub fn render_pr_comment_with_status_note(
130 command: &str,
131 provider: Provider,
132 issues: &[CiIssue],
133 conclusion: Option<PrDecisionConclusion>,
134 config_patterns: &[fallow_config::WorkspaceDiagnostic],
135 status_message: Option<&str>,
136) -> String {
137 let section = crate::report::config_pattern_text::markdown_section(config_patterns);
138 let mut body =
139 render_pr_comment_body(command, provider, issues, conclusion, section.as_deref());
140 if let Some(message) = status_message {
141 body.push_str("\n\n> ");
142 body.push_str(message);
143 }
144 body
145}
146
147#[must_use]
157fn category_for_rule(rule_id: &str) -> &'static str {
158 crate::explain::rule_by_id(rule_id).map_or("Other", |def| def.category)
159}
160
161pub(crate) fn max_comments() -> usize {
162 std::env::var("FALLOW_MAX_COMMENTS")
163 .ok()
164 .and_then(|value| value.parse::<usize>().ok())
165 .unwrap_or(50)
166}
167
168#[must_use]
169pub(crate) fn pr_comment_layout_from_env() -> PrCommentLayout {
170 match std::env::var("FALLOW_PR_COMMENT_LAYOUT").as_deref() {
171 Ok("compact") => PrCommentLayout::Compact,
172 Ok("gate-only") => PrCommentLayout::GateOnly,
173 Ok("details") => PrCommentLayout::Details,
174 _ => PrCommentLayout::Default,
175 }
176}
177
178pub(crate) fn sticky_marker_id() -> String {
191 if let Ok(value) = std::env::var("FALLOW_COMMENT_ID")
192 && !value.trim().is_empty()
193 {
194 return value;
195 }
196 let suffix = WORKSPACE_MARKER
197 .get()
198 .map(|value| value.trim())
199 .filter(|value| !value.is_empty())
200 .map(sanitize_marker_segment);
201 match suffix {
202 Some(workspace) => format!("fallow-results-{workspace}"),
203 None => "fallow-results".to_owned(),
204 }
205}
206
207fn sanitize_marker_segment(value: &str) -> String {
212 value
213 .chars()
214 .map(|ch| {
215 if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.' {
216 ch
217 } else {
218 '-'
219 }
220 })
221 .collect::<String>()
222 .trim_matches('-')
223 .to_owned()
224}
225
226#[must_use]
227pub(crate) fn print_pr_comment_from_codeclimate_issues(
228 command: &str,
229 provider: Provider,
230 codeclimate: &[CodeClimateIssue],
231 conclusion: Option<PrDecisionConclusion>,
232 status: PrCommentStatus<'_>,
233) -> ExitCode {
234 let issues = rebase_issue_paths(super::diff_filter::filter_issues_for_summary(
235 issues_from_codeclimate_issues(codeclimate),
236 ));
237 let conclusion = conclusion.unwrap_or_else(|| issue_decision_conclusion(issues.is_empty()));
238 print_pr_comment_from_ci_issues(command, provider, &issues, conclusion, status)
239}
240
241fn rebase_issue_paths(mut issues: Vec<CiIssue>) -> Vec<CiIssue> {
242 let prefix = crate::report::github::report_prefix();
243 if !prefix.is_empty() {
244 for issue in &mut issues {
245 issue.path = fallow_output::apply_path_prefix(prefix, &issue.path);
246 }
247 }
248 issues
249}
250
251#[must_use]
252fn print_pr_comment_from_ci_issues(
253 command: &str,
254 provider: Provider,
255 issues: &[CiIssue],
256 conclusion: PrDecisionConclusion,
257 status: PrCommentStatus<'_>,
258) -> ExitCode {
259 let body = render_pr_comment_with_status_note(
260 command,
261 provider,
262 issues,
263 Some(conclusion),
264 status.config_patterns,
265 status.message,
266 );
267 let max_comments = max_comments();
268 let envelope = PrCommentEnvelope {
269 marker_id: sticky_marker_id(),
270 body,
271 is_clean: issues.is_empty() && conclusion == PrDecisionConclusion::Success,
272 details_url: None,
273 check_summary: Some(decision_summary_label(conclusion).to_owned()),
274 truncation: PrCommentTruncation {
275 truncated: issues.len() > max_comments,
276 shown_findings: issues.len().min(max_comments),
277 total_findings: issues.len(),
278 },
279 };
280 let decision = build_issue_decision_surface(command, issues, &envelope, conclusion, status);
281 let details = build_pr_details_artifact(command, issues);
282 write_pr_comment_envelope_sidecar(&envelope);
283 write_pr_decision_sidecar(&decision);
284 write_pr_details_sidecar(&details);
285 outln!("{}", envelope.body());
286 ExitCode::SUCCESS
287}
288
289#[must_use]
290fn build_issue_decision_surface(
291 command: &str,
292 issues: &[CiIssue],
293 envelope: &PrCommentEnvelope,
294 conclusion: PrDecisionConclusion,
295 status: PrCommentStatus<'_>,
296) -> PrDecisionSurface {
297 let mut gates = vec![PrDecisionGate {
302 id: command.to_owned(),
303 label: command_title(command).to_owned(),
304 status: conclusion,
305 observed: count_label(issues.len(), "finding", "findings"),
306 threshold: None,
307 scope: "new code".to_owned(),
308 }];
309 gates.extend(status.gates.iter().cloned());
310 PrDecisionSurface {
311 schema: PR_DECISION_SCHEMA.to_owned(),
312 title: "Fallow".to_owned(),
313 conclusion,
314 gates,
315 annotations: issues
316 .iter()
317 .take(max_comments())
318 .map(decision_annotation_from_issue)
319 .collect(),
320 details: PrDecisionDetails {
321 summary_markdown: decision_summary_markdown(conclusion, issues.len(), status.message),
322 full_report_path: None,
323 details_url: envelope.details_url.clone(),
324 },
325 }
326}
327
328fn issue_decision_conclusion(is_clean: bool) -> PrDecisionConclusion {
335 if is_clean {
336 PrDecisionConclusion::Success
337 } else {
338 PrDecisionConclusion::Neutral
339 }
340}
341
342fn decision_summary_label(conclusion: PrDecisionConclusion) -> &'static str {
343 match conclusion {
344 PrDecisionConclusion::Success => "pass",
345 PrDecisionConclusion::Failure => "fail",
346 PrDecisionConclusion::Neutral => "warn",
347 PrDecisionConclusion::Skipped => "skipped",
348 }
349}
350
351fn decision_summary_markdown(
352 conclusion: PrDecisionConclusion,
353 issue_count: usize,
354 status_message: Option<&str>,
355) -> String {
356 let summary = if issue_count == 0 {
357 match conclusion {
358 PrDecisionConclusion::Failure => {
359 "Fallow quality gates failed without renderable findings.".to_owned()
360 }
361 PrDecisionConclusion::Neutral => {
362 "Fallow needs review without renderable findings.".to_owned()
363 }
364 PrDecisionConclusion::Success | PrDecisionConclusion::Skipped => {
365 "Fallow found no actionable PR findings.".to_owned()
366 }
367 }
368 } else {
369 let findings = count_label(issue_count, "finding", "findings");
370 match conclusion {
371 PrDecisionConclusion::Failure => {
372 format!("Fallow quality gates failed with {findings}.")
373 }
374 PrDecisionConclusion::Neutral => format!("Fallow found {findings} for review."),
375 PrDecisionConclusion::Success | PrDecisionConclusion::Skipped => {
376 format!("Fallow found {findings}.")
377 }
378 }
379 };
380 match status_message {
381 Some(message) => format!("{summary}\n\n> {message}"),
382 None => summary,
383 }
384}
385
386#[must_use]
387pub(crate) fn build_pr_details_artifact(command: &str, issues: &[CiIssue]) -> PrDetailsArtifact {
388 PrDetailsArtifact {
389 schema: PR_DETAILS_SCHEMA.to_owned(),
390 title: format!("Fallow {}", command_title(command)),
391 sections: vec![PrDetailsSection {
392 id: "findings".to_owned(),
393 title: "Findings".to_owned(),
394 rows: issues.iter().map(pr_details_row_from_issue).collect(),
395 }],
396 }
397}
398
399fn pr_details_row_from_issue(issue: &CiIssue) -> PrDetailsRow {
400 PrDetailsRow {
401 location: format!("{}:{}", issue.path, issue.line),
402 rule: issue.rule_id.clone(),
403 description: issue.description.clone(),
404 fix: super::suggestion::fix_intent(issue).map(str::to_owned),
405 fingerprint: (!issue.fingerprint.trim().is_empty()).then(|| issue.fingerprint.clone()),
406 }
407}
408
409#[must_use]
410pub(crate) fn decision_annotation_from_issue(issue: &CiIssue) -> PrDecisionAnnotation {
411 PrDecisionAnnotation {
412 path: issue.path.clone(),
413 line: u32::try_from(issue.line).unwrap_or(u32::MAX),
414 level: decision_level_from_severity(&issue.severity),
415 title: issue.rule_id.clone(),
416 message: issue.description.clone(),
417 raw_details: super::suggestion::fix_intent(issue).map(str::to_owned),
418 }
419}
420
421fn decision_level_from_severity(severity: &str) -> PrDecisionAnnotationLevel {
422 match severity {
423 "blocker" | "critical" | "major" => PrDecisionAnnotationLevel::Failure,
424 "minor" => PrDecisionAnnotationLevel::Warning,
425 _ => PrDecisionAnnotationLevel::Notice,
426 }
427}
428
429fn count_label(count: usize, singular: &str, plural: &str) -> String {
430 let noun = if count == 1 { singular } else { plural };
431 format!("{count} {noun}")
432}
433
434pub(crate) fn write_pr_comment_envelope_sidecar(envelope: &PrCommentEnvelope) {
435 let Ok(path) = std::env::var("FALLOW_PR_COMMENT_ENVELOPE_FILE") else {
436 return;
437 };
438 if path.trim().is_empty() {
439 return;
440 }
441 match serde_json::to_string_pretty(envelope)
442 .map_err(|e| e.to_string())
443 .and_then(|json| std::fs::write(&path, json).map_err(|e| e.to_string()))
444 {
445 Ok(()) => {}
446 Err(e) => eprintln!("warning: failed to write PR comment envelope '{path}': {e}"),
447 }
448}
449
450pub(crate) fn write_pr_decision_sidecar(surface: &PrDecisionSurface) {
451 let Ok(path) = std::env::var("FALLOW_PR_DECISION_FILE") else {
452 return;
453 };
454 if path.trim().is_empty() {
455 return;
456 }
457 match serde_json::to_string_pretty(surface)
458 .map_err(|e| e.to_string())
459 .and_then(|json| std::fs::write(&path, json).map_err(|e| e.to_string()))
460 {
461 Ok(()) => {}
462 Err(e) => eprintln!("warning: failed to write PR decision '{path}': {e}"),
463 }
464}
465
466pub(crate) fn write_pr_details_sidecar(artifact: &PrDetailsArtifact) {
467 let Ok(path) = std::env::var("FALLOW_PR_DETAILS_FILE") else {
468 return;
469 };
470 if path.trim().is_empty() {
471 return;
472 }
473 match serde_json::to_string_pretty(artifact)
474 .map_err(|e| e.to_string())
475 .and_then(|json| std::fs::write(&path, json).map_err(|e| e.to_string()))
476 {
477 Ok(()) => {}
478 Err(e) => eprintln!("warning: failed to write PR details '{path}': {e}"),
479 }
480}
481
482#[cfg(test)]
483mod tests {
484 use super::*;
485 use fallow_output::{
486 CodeClimateIssueKind, CodeClimateLines, CodeClimateLocation, CodeClimateSeverity,
487 };
488
489 #[test]
490 fn typed_codeclimate_issues_extract_like_json_codeclimate() {
491 let severities = [
492 (CodeClimateSeverity::Info, "info"),
493 (CodeClimateSeverity::Minor, "minor"),
494 (CodeClimateSeverity::Major, "major"),
495 (CodeClimateSeverity::Critical, "critical"),
496 (CodeClimateSeverity::Blocker, "blocker"),
497 ];
498 let typed = severities
499 .iter()
500 .enumerate()
501 .map(|(index, (severity, _))| CodeClimateIssue {
502 kind: CodeClimateIssueKind::Issue,
503 check_name: format!("fallow/rule-{index}"),
504 description: format!("Finding {index}"),
505 categories: vec!["Complexity".to_owned()],
506 severity: *severity,
507 fingerprint: format!("fp-{index}"),
508 location: CodeClimateLocation {
509 path: format!("src/{index}.ts"),
510 lines: CodeClimateLines {
511 begin: u32::try_from(index + 1).expect("small fixture index"),
512 end: None,
513 },
514 },
515 other_locations: Vec::new(),
516 owner: None,
517 group: None,
518 legacy_fingerprint: None,
519 })
520 .collect::<Vec<_>>();
521 let value = serde_json::to_value(&typed).expect("typed fixture serializes");
522
523 assert_eq!(
524 issues_from_codeclimate_issues(&typed),
525 issues_from_codeclimate(&value)
526 );
527 let typed_labels = issues_from_codeclimate_issues(&typed)
528 .into_iter()
529 .map(|issue| issue.severity)
530 .collect::<Vec<_>>();
531 let expected_labels = severities
532 .iter()
533 .map(|(_, label)| (*label).to_owned())
534 .collect::<Vec<_>>();
535 assert_eq!(typed_labels, expected_labels);
536 }
537
538 #[test]
539 fn sticky_marker_id_default_when_nothing_set() {
540 let body = render_pr_comment("check", Provider::Github, &[], None);
541 assert!(body.contains("<!-- fallow-id: fallow-results"));
542 assert!(body.contains("No findings for this pull request."));
543 }
544
545 #[test]
546 fn short_hex_hash_is_deterministic_and_six_chars() {
547 let a = short_hex_hash("api,worker");
548 assert_eq!(a.len(), 6);
549 assert_eq!(a, short_hex_hash("api,worker"));
550 assert_ne!(a, short_hex_hash("admin,web"));
551 }
552
553 #[test]
554 fn sanitize_marker_segment_collapses_unsafe_chars_to_dashes() {
555 assert_eq!(sanitize_marker_segment("@fallow/runtime"), "fallow-runtime");
556 assert_eq!(
557 sanitize_marker_segment("packages/web ui"),
558 "packages-web-ui"
559 );
560 assert_eq!(sanitize_marker_segment("plain"), "plain");
561 assert_eq!(
562 sanitize_marker_segment("--leading-trailing--"),
563 "leading-trailing"
564 );
565 }
566
567 #[test]
568 fn is_project_level_rule_covers_config_anchored_dependency_findings() {
569 for rule_id in fallow_output::PROJECT_LEVEL_RULE_IDS {
570 assert!(
571 is_project_level_rule(rule_id),
572 "{rule_id} must be project-level"
573 );
574 }
575 for rule_id in [
576 "fallow/unused-file",
577 "fallow/unused-export",
578 "fallow/unused-type",
579 "fallow/unused-enum-member",
580 "fallow/unused-class-member",
581 "fallow/unused-store-member",
582 "fallow/unresolved-import",
583 "fallow/unlisted-dependency",
584 "fallow/duplicate-export",
585 "fallow/circular-dependency",
586 "fallow/re-export-cycle",
587 "fallow/package-cycle",
588 "fallow/boundary-violation",
589 "fallow/stale-suppression",
590 "fallow/private-type-leak",
591 "fallow/high-complexity",
592 "fallow/high-crap-score",
593 ] {
594 assert!(
595 !is_project_level_rule(rule_id),
596 "{rule_id} must NOT be project-level"
597 );
598 }
599 }
600
601 #[test]
602 fn decision_surface_preserves_blocking_conclusion_for_issue_output() {
603 let issues = [CiIssue {
604 path: "src/app.ts".to_owned(),
605 line: 12,
606 end_line: None,
607 other_locations: Vec::new(),
608 rule_id: "fallow/high-crap-score".to_owned(),
609 description: "Function is hard to safely change.".to_owned(),
610 severity: "minor".to_owned(),
611 fingerprint: "abc".to_owned(),
612 legacy_fingerprint: None,
613 }];
614 let envelope = PrCommentEnvelope {
615 marker_id: "fallow-results".to_owned(),
616 body: "body".to_owned(),
617 is_clean: false,
618 details_url: None,
619 check_summary: Some("fail".to_owned()),
620 truncation: PrCommentTruncation {
621 truncated: false,
622 shown_findings: 1,
623 total_findings: 1,
624 },
625 };
626
627 let decision = build_issue_decision_surface(
628 "audit",
629 &issues,
630 &envelope,
631 PrDecisionConclusion::Failure,
632 PrCommentStatus {
633 message: Some(crate::report::ci::TYPE_AWARE_INCOMPLETE_MESSAGE),
634 gates: &[],
635 config_patterns: &[],
636 },
637 );
638
639 assert_eq!(decision.conclusion, PrDecisionConclusion::Failure);
640 assert_eq!(decision.gates[0].status, PrDecisionConclusion::Failure);
641 assert!(decision.details.summary_markdown.contains("incomplete"));
642 assert!(
643 decision
644 .details
645 .summary_markdown
646 .contains("quality gates failed")
647 );
648 }
649
650 #[test]
654 fn gate_rows_follow_the_command_row_without_moving_the_conclusion() {
655 let envelope = PrCommentEnvelope {
656 marker_id: "fallow-results".to_owned(),
657 body: "body".to_owned(),
658 is_clean: true,
659 details_url: None,
660 check_summary: Some("pass".to_owned()),
661 truncation: PrCommentTruncation {
662 truncated: false,
663 shown_findings: 0,
664 total_findings: 0,
665 },
666 };
667 let gates = [PrDecisionGate {
668 id: "stale-baseline".to_owned(),
669 label: "Stale baseline".to_owned(),
670 status: PrDecisionConclusion::Failure,
671 observed: "fail".to_owned(),
672 threshold: None,
673 scope: "this run".to_owned(),
674 }];
675
676 let decision = build_issue_decision_surface(
677 "dead-code",
678 &[],
679 &envelope,
680 PrDecisionConclusion::Success,
681 PrCommentStatus {
682 message: None,
683 gates: &gates,
684 config_patterns: &[],
685 },
686 );
687
688 assert_eq!(decision.conclusion, PrDecisionConclusion::Success);
689 assert_eq!(decision.gates[0].id, "dead-code");
690 assert_eq!(decision.gates[1].id, "stale-baseline");
691 assert_eq!(decision.gates[1].scope, "this run");
692 }
693
694 #[test]
695 fn a_body_without_a_note_is_the_bare_render() {
696 let issues: Vec<CiIssue> = Vec::new();
697 assert_eq!(
698 render_pr_comment_with_status_note("check", Provider::Github, &issues, None, &[], None),
699 render_pr_comment("check", Provider::Github, &issues, None)
700 );
701 }
702
703 #[test]
704 fn unmatched_config_patterns_follow_the_findings_and_precede_the_footer() {
705 let issues: Vec<CiIssue> = Vec::new();
706 let root = std::path::Path::new("/project");
707 let patterns = [fallow_config::WorkspaceDiagnostic::new(
708 root,
709 root.to_path_buf(),
710 fallow_config::WorkspaceDiagnosticKind::IgnoreDependenciesGlobUnmatched {
711 pattern: "@typo/*".to_owned(),
712 },
713 )];
714 let body = render_pr_comment_with_status_note(
715 "check",
716 Provider::Github,
717 &issues,
718 None,
719 &patterns,
720 Some("Gate outcomes: passed."),
721 );
722 let section = body.find("## Unmatched config patterns").expect("section");
723 let footer = body.find("Generated by fallow.").expect("footer");
724 let note = body.find("> Gate outcomes").expect("note");
725 assert!(section < footer && footer < note, "{body}");
726 assert!(
727 body.contains("- `ignoreDependencies`: `@typo/*` matched nothing in this run"),
728 "{body}"
729 );
730 }
731
732 #[test]
733 fn project_level_rule_ids_each_register_in_explain_registry() {
734 for rule_id in fallow_output::PROJECT_LEVEL_RULE_IDS {
735 assert!(
736 crate::explain::rule_by_id(rule_id).is_some(),
737 "{rule_id} listed in PROJECT_LEVEL_RULE_IDS but not in explain registry"
738 );
739 }
740 }
741}