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