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