1use crate::report::sink::outln;
2use std::process::ExitCode;
3use std::sync::OnceLock;
4
5use serde_json::Value;
6
7pub use fallow_output::{
8 CiIssue, CiProvider as Provider, PR_DECISION_SCHEMA, PR_DETAILS_SCHEMA, PrCommentEnvelope,
9 PrCommentLayout, PrCommentTruncation, PrDecisionAnnotation, PrDecisionAnnotationLevel,
10 PrDecisionConclusion, PrDecisionDetails, PrDecisionGate, PrDecisionSurface, PrDetailsArtifact,
11 PrDetailsRow, PrDetailsSection, command_title, issues_from_codeclimate,
12};
13#[cfg(test)]
14use fallow_output::{
15 CodeClimateIssue, escape_md, is_project_level_rule, issues_from_codeclimate_issues,
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]
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]
91pub fn 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 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 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 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 fn print_pr_comment(command: &str, provider: Provider, codeclimate: &Value) -> ExitCode {
162 let issues =
163 super::diff_filter::filter_issues_for_summary(issues_from_codeclimate(codeclimate));
164 let conclusion = issue_decision_conclusion(issues.is_empty());
165 print_pr_comment_from_ci_issues(command, provider, &issues, conclusion)
166}
167
168#[must_use]
169pub fn print_pr_comment_with_conclusion(
170 command: &str,
171 provider: Provider,
172 codeclimate: &Value,
173 conclusion: PrDecisionConclusion,
174) -> ExitCode {
175 let issues =
176 super::diff_filter::filter_issues_for_summary(issues_from_codeclimate(codeclimate));
177 print_pr_comment_from_ci_issues(command, provider, &issues, conclusion)
178}
179
180#[must_use]
181fn print_pr_comment_from_ci_issues(
182 command: &str,
183 provider: Provider,
184 issues: &[CiIssue],
185 conclusion: PrDecisionConclusion,
186) -> ExitCode {
187 let body = render_pr_comment(command, provider, issues);
188 let max_comments = max_comments();
189 let envelope = PrCommentEnvelope {
190 marker_id: sticky_marker_id(),
191 body,
192 is_clean: issues.is_empty(),
193 details_url: None,
194 check_summary: Some(decision_summary_label(conclusion).to_owned()),
195 truncation: PrCommentTruncation {
196 truncated: issues.len() > max_comments,
197 shown_findings: issues.len().min(max_comments),
198 total_findings: issues.len(),
199 },
200 };
201 let decision = build_issue_decision_surface(command, issues, &envelope, conclusion);
202 let details = build_pr_details_artifact(command, issues);
203 write_pr_comment_envelope_sidecar(&envelope);
204 write_pr_decision_sidecar(&decision);
205 write_pr_details_sidecar(&details);
206 outln!("{}", envelope.body());
207 ExitCode::SUCCESS
208}
209
210#[must_use]
211pub fn build_issue_decision_surface(
212 command: &str,
213 issues: &[CiIssue],
214 envelope: &PrCommentEnvelope,
215 conclusion: PrDecisionConclusion,
216) -> PrDecisionSurface {
217 let effective_conclusion = if issues.is_empty() {
218 PrDecisionConclusion::Success
219 } else {
220 conclusion
221 };
222 PrDecisionSurface {
223 schema: PR_DECISION_SCHEMA.to_owned(),
224 title: "Fallow".to_owned(),
225 conclusion: effective_conclusion,
226 gates: vec![PrDecisionGate {
227 id: command.to_owned(),
228 label: command_title(command).to_owned(),
229 status: effective_conclusion,
230 observed: count_label(issues.len(), "finding", "findings"),
231 threshold: None,
232 scope: "new code".to_owned(),
233 }],
234 annotations: issues
235 .iter()
236 .take(max_comments())
237 .map(decision_annotation_from_issue)
238 .collect(),
239 details: PrDecisionDetails {
240 summary_markdown: decision_summary_markdown(effective_conclusion, issues.len()),
241 full_report_path: None,
242 details_url: envelope.details_url.clone(),
243 },
244 }
245}
246
247fn issue_decision_conclusion(is_clean: bool) -> PrDecisionConclusion {
248 if is_clean {
249 PrDecisionConclusion::Success
250 } else {
251 PrDecisionConclusion::Neutral
252 }
253}
254
255fn decision_summary_label(conclusion: PrDecisionConclusion) -> &'static str {
256 match conclusion {
257 PrDecisionConclusion::Success => "pass",
258 PrDecisionConclusion::Failure => "fail",
259 PrDecisionConclusion::Neutral => "warn",
260 PrDecisionConclusion::Skipped => "skipped",
261 }
262}
263
264fn decision_summary_markdown(conclusion: PrDecisionConclusion, issue_count: usize) -> String {
265 if issue_count == 0 {
266 return "Fallow found no actionable PR findings.".to_owned();
267 }
268 let findings = count_label(issue_count, "finding", "findings");
269 match conclusion {
270 PrDecisionConclusion::Failure => format!("Fallow quality gates failed with {findings}."),
271 PrDecisionConclusion::Neutral => format!("Fallow found {findings} for review."),
272 PrDecisionConclusion::Success | PrDecisionConclusion::Skipped => {
273 format!("Fallow found {findings}.")
274 }
275 }
276}
277
278#[must_use]
279pub fn build_pr_details_artifact(command: &str, issues: &[CiIssue]) -> PrDetailsArtifact {
280 PrDetailsArtifact {
281 schema: PR_DETAILS_SCHEMA.to_owned(),
282 title: format!("Fallow {}", command_title(command)),
283 sections: vec![PrDetailsSection {
284 id: "findings".to_owned(),
285 title: "Findings".to_owned(),
286 rows: issues.iter().map(pr_details_row_from_issue).collect(),
287 }],
288 }
289}
290
291fn pr_details_row_from_issue(issue: &CiIssue) -> PrDetailsRow {
292 PrDetailsRow {
293 location: format!("{}:{}", issue.path, issue.line),
294 rule: issue.rule_id.clone(),
295 description: issue.description.clone(),
296 fix: super::suggestion::fix_intent(issue).map(str::to_owned),
297 fingerprint: (!issue.fingerprint.trim().is_empty()).then(|| issue.fingerprint.clone()),
298 }
299}
300
301#[must_use]
302pub fn decision_annotation_from_issue(issue: &CiIssue) -> PrDecisionAnnotation {
303 PrDecisionAnnotation {
304 path: issue.path.clone(),
305 line: u32::try_from(issue.line).unwrap_or(u32::MAX),
306 level: decision_level_from_severity(&issue.severity),
307 title: issue.rule_id.clone(),
308 message: issue.description.clone(),
309 raw_details: super::suggestion::fix_intent(issue).map(str::to_owned),
310 }
311}
312
313fn decision_level_from_severity(severity: &str) -> PrDecisionAnnotationLevel {
314 match severity {
315 "blocker" | "critical" | "major" => PrDecisionAnnotationLevel::Failure,
316 "minor" => PrDecisionAnnotationLevel::Warning,
317 _ => PrDecisionAnnotationLevel::Notice,
318 }
319}
320
321fn count_label(count: usize, singular: &str, plural: &str) -> String {
322 let noun = if count == 1 { singular } else { plural };
323 format!("{count} {noun}")
324}
325
326pub fn write_pr_comment_envelope_sidecar(envelope: &PrCommentEnvelope) {
327 let Ok(path) = std::env::var("FALLOW_PR_COMMENT_ENVELOPE_FILE") else {
328 return;
329 };
330 if path.trim().is_empty() {
331 return;
332 }
333 match serde_json::to_string_pretty(envelope)
334 .map_err(|e| e.to_string())
335 .and_then(|json| std::fs::write(&path, json).map_err(|e| e.to_string()))
336 {
337 Ok(()) => {}
338 Err(e) => eprintln!("warning: failed to write PR comment envelope '{path}': {e}"),
339 }
340}
341
342pub fn write_pr_decision_sidecar(surface: &PrDecisionSurface) {
343 let Ok(path) = std::env::var("FALLOW_PR_DECISION_FILE") else {
344 return;
345 };
346 if path.trim().is_empty() {
347 return;
348 }
349 match serde_json::to_string_pretty(surface)
350 .map_err(|e| e.to_string())
351 .and_then(|json| std::fs::write(&path, json).map_err(|e| e.to_string()))
352 {
353 Ok(()) => {}
354 Err(e) => eprintln!("warning: failed to write PR decision '{path}': {e}"),
355 }
356}
357
358pub fn write_pr_details_sidecar(artifact: &PrDetailsArtifact) {
359 let Ok(path) = std::env::var("FALLOW_PR_DETAILS_FILE") else {
360 return;
361 };
362 if path.trim().is_empty() {
363 return;
364 }
365 match serde_json::to_string_pretty(artifact)
366 .map_err(|e| e.to_string())
367 .and_then(|json| std::fs::write(&path, json).map_err(|e| e.to_string()))
368 {
369 Ok(()) => {}
370 Err(e) => eprintln!("warning: failed to write PR details '{path}': {e}"),
371 }
372}
373
374#[cfg(test)]
375mod tests {
376 use super::*;
377 use fallow_output::{
378 CodeClimateIssueKind, CodeClimateLines, CodeClimateLocation, CodeClimateSeverity,
379 };
380
381 #[test]
382 fn extracts_issues_from_codeclimate() {
383 let value = serde_json::json!([{
384 "check_name": "fallow/unused-export",
385 "description": "Export x is never imported",
386 "severity": "minor",
387 "fingerprint": "abc",
388 "location": { "path": "src/a.ts", "lines": { "begin": 7 } }
389 }]);
390 let issues = issues_from_codeclimate(&value);
391 assert_eq!(issues.len(), 1);
392 assert_eq!(issues[0].path, "src/a.ts");
393 assert_eq!(issues[0].line, 7);
394 }
395
396 #[test]
397 fn typed_codeclimate_issues_extract_like_json_codeclimate() {
398 let severities = [
399 (CodeClimateSeverity::Info, "info"),
400 (CodeClimateSeverity::Minor, "minor"),
401 (CodeClimateSeverity::Major, "major"),
402 (CodeClimateSeverity::Critical, "critical"),
403 (CodeClimateSeverity::Blocker, "blocker"),
404 ];
405 let typed = severities
406 .iter()
407 .enumerate()
408 .map(|(index, (severity, _))| CodeClimateIssue {
409 kind: CodeClimateIssueKind::Issue,
410 check_name: format!("fallow/rule-{index}"),
411 description: format!("Finding {index}"),
412 categories: vec!["Complexity".to_owned()],
413 severity: *severity,
414 fingerprint: format!("fp-{index}"),
415 location: CodeClimateLocation {
416 path: format!("src/{index}.ts"),
417 lines: CodeClimateLines {
418 begin: u32::try_from(index + 1).expect("small fixture index"),
419 },
420 },
421 owner: None,
422 group: None,
423 })
424 .collect::<Vec<_>>();
425 let value = serde_json::to_value(&typed).expect("typed fixture serializes");
426
427 assert_eq!(
428 issues_from_codeclimate_issues(&typed),
429 issues_from_codeclimate(&value)
430 );
431 let typed_labels = issues_from_codeclimate_issues(&typed)
432 .into_iter()
433 .map(|issue| issue.severity)
434 .collect::<Vec<_>>();
435 let expected_labels = severities
436 .iter()
437 .map(|(_, label)| (*label).to_owned())
438 .collect::<Vec<_>>();
439 assert_eq!(typed_labels, expected_labels);
440 }
441
442 #[test]
443 fn sticky_marker_id_default_when_nothing_set() {
444 let body = render_pr_comment("check", Provider::Github, &[]);
445 assert!(body.contains("<!-- fallow-id: fallow-results"));
446 assert!(body.contains("No GitHub PR/MR findings."));
447 }
448
449 #[test]
450 fn short_hex_hash_is_deterministic_and_six_chars() {
451 let a = short_hex_hash("api,worker");
452 assert_eq!(a.len(), 6);
453 assert_eq!(a, short_hex_hash("api,worker"));
454 assert_ne!(a, short_hex_hash("admin,web"));
455 }
456
457 #[test]
458 fn sanitize_marker_segment_collapses_unsafe_chars_to_dashes() {
459 assert_eq!(sanitize_marker_segment("@fallow/runtime"), "fallow-runtime");
460 assert_eq!(
461 sanitize_marker_segment("packages/web ui"),
462 "packages-web-ui"
463 );
464 assert_eq!(sanitize_marker_segment("plain"), "plain");
465 assert_eq!(
466 sanitize_marker_segment("--leading-trailing--"),
467 "leading-trailing"
468 );
469 }
470
471 #[test]
472 fn escape_md_escapes_inline_commonmark_specials() {
473 let raw = "foo*bar_baz [a](u) `c` <h> #x !i ~s | p";
474 let escaped = escape_md(raw);
475 for ch in [
476 '*', '_', '[', ']', '(', ')', '`', '<', '>', '#', '!', '~', '|',
477 ] {
478 let raw_count = raw.chars().filter(|c| c == &ch).count();
479 let escaped_count = escaped.matches(&format!("\\{ch}")).count();
480 assert_eq!(
481 raw_count, escaped_count,
482 "char {ch:?}: raw {raw_count} occurrences, escaped {escaped_count} in {escaped:?}"
483 );
484 }
485 }
486
487 #[test]
488 fn escape_md_escapes_ampersand_to_block_numeric_entity_bypass() {
489 let raw = "value *suspicious* here";
490 let escaped = escape_md(raw);
491 assert!(escaped.contains(r"\&"), "got: {escaped}");
492 assert!(escaped.contains(r"\#"), "got: {escaped}");
493 assert!(!escaped.contains(" *suspicious"), "got: {escaped}");
494 }
495
496 #[test]
497 fn summary_label_foreshadows_truncation() {
498 assert_eq!(
499 fallow_output::summary_label("Duplication", 160, 50),
500 "Duplication (160, showing 50)"
501 );
502 assert_eq!(
503 fallow_output::summary_label("Health", 12, 50),
504 "Health (12)"
505 );
506 assert_eq!(
507 fallow_output::summary_label("Dependencies", 50, 50),
508 "Dependencies (50)"
509 );
510 }
511
512 #[test]
513 fn escape_md_does_not_escape_block_only_markers() {
514 let raw = "fallow/test-only-dependency package.json:12";
515 let escaped = escape_md(raw);
516 assert!(!escaped.contains("\\-"), "should not escape `-`");
517 assert!(!escaped.contains("\\."), "should not escape `.`");
518 assert_eq!(escaped, raw);
519 }
520
521 #[test]
522 fn escape_md_collapses_newlines_to_spaces() {
523 let raw = "first\nsecond\nthird";
524 assert_eq!(escape_md(raw), "first second third");
525 }
526
527 #[test]
528 fn escape_md_leaves_safe_chars_unchanged() {
529 let raw = "Export 'helperFn' is never imported by other modules";
530 assert_eq!(
531 escape_md(raw),
532 r"Export 'helperFn' is never imported by other modules"
533 );
534 }
535
536 #[test]
537 fn is_project_level_rule_covers_config_anchored_dependency_findings() {
538 for rule_id in fallow_output::PROJECT_LEVEL_RULE_IDS {
539 assert!(
540 is_project_level_rule(rule_id),
541 "{rule_id} must be project-level"
542 );
543 }
544 for rule_id in [
545 "fallow/unused-file",
546 "fallow/unused-export",
547 "fallow/unused-type",
548 "fallow/unused-enum-member",
549 "fallow/unused-class-member",
550 "fallow/unused-store-member",
551 "fallow/unresolved-import",
552 "fallow/unlisted-dependency",
553 "fallow/duplicate-export",
554 "fallow/circular-dependency",
555 "fallow/re-export-cycle",
556 "fallow/boundary-violation",
557 "fallow/stale-suppression",
558 "fallow/private-type-leak",
559 "fallow/high-complexity",
560 "fallow/high-crap-score",
561 ] {
562 assert!(
563 !is_project_level_rule(rule_id),
564 "{rule_id} must NOT be project-level"
565 );
566 }
567 }
568
569 #[test]
570 fn decision_surface_preserves_blocking_conclusion_for_issue_output() {
571 let issues = [CiIssue {
572 path: "src/app.ts".to_owned(),
573 line: 12,
574 rule_id: "fallow/high-crap-score".to_owned(),
575 description: "Function is hard to safely change.".to_owned(),
576 severity: "minor".to_owned(),
577 fingerprint: "abc".to_owned(),
578 }];
579 let envelope = PrCommentEnvelope {
580 marker_id: "fallow-results".to_owned(),
581 body: "body".to_owned(),
582 is_clean: false,
583 details_url: None,
584 check_summary: Some("fail".to_owned()),
585 truncation: PrCommentTruncation {
586 truncated: false,
587 shown_findings: 1,
588 total_findings: 1,
589 },
590 };
591
592 let decision = build_issue_decision_surface(
593 "audit",
594 &issues,
595 &envelope,
596 PrDecisionConclusion::Failure,
597 );
598
599 assert_eq!(decision.conclusion, PrDecisionConclusion::Failure);
600 assert_eq!(decision.gates[0].status, PrDecisionConclusion::Failure);
601 assert!(
602 decision
603 .details
604 .summary_markdown
605 .contains("quality gates failed")
606 );
607 }
608
609 #[test]
610 fn project_level_rule_ids_each_register_in_explain_registry() {
611 for rule_id in fallow_output::PROJECT_LEVEL_RULE_IDS {
612 assert!(
613 crate::explain::rule_by_id(rule_id).is_some(),
614 "{rule_id} listed in PROJECT_LEVEL_RULE_IDS but not in explain registry"
615 );
616 }
617 }
618
619 #[test]
620 fn escape_md_double_apply_is_safe() {
621 let raw = "code with `backticks` and *stars*";
622 let once = escape_md(raw);
623 let twice = escape_md(&once);
624 assert!(twice.contains(r"\\"));
625 }
626}