1use std::process::ExitCode;
2
3use fallow_output::CodeClimateIssue;
4use serde_json::Value;
5
6use super::diff_filter::DiffIndex;
7use crate::report::emit_json;
8use fallow_output::{
9 CiIssue, CiProvider as Provider, ReviewEnvelopeOutput, ReviewEnvelopeRenderInput,
10 ReviewEnvelopeTruncation, ReviewGitlabDiffRefs as GitlabDiffRefs,
11 issues_from_codeclimate_issues,
12};
13
14#[must_use]
15pub fn render_review_envelope(
16 command: &str,
17 provider: Provider,
18 issues: &[CiIssue],
19) -> ReviewEnvelopeOutput {
20 render_review_envelope_with_diff(
21 command,
22 provider,
23 issues,
24 super::diff_filter::shared_diff_index(),
25 )
26}
27
28#[must_use]
33fn render_review_envelope_with_diff(
34 command: &str,
35 provider: Provider,
36 issues: &[CiIssue],
37 diff_index: Option<&DiffIndex>,
38) -> ReviewEnvelopeOutput {
39 let max = std::env::var("FALLOW_MAX_COMMENTS")
40 .ok()
41 .and_then(|v| v.parse::<usize>().ok())
42 .unwrap_or(50);
43 let gitlab_diff_refs = (provider == Provider::Gitlab)
44 .then(gitlab_diff_refs_from_env)
45 .flatten();
46 let include_guidance = review_guidance_enabled();
47
48 let rendered = fallow_output::render_review_envelope(&ReviewEnvelopeRenderInput {
49 command,
50 provider,
51 issues,
52 diff_index,
53 path_prefix: crate::report::github::report_prefix(),
54 max_comments: max,
55 gitlab_diff_refs: gitlab_diff_refs.as_ref(),
56 include_guidance,
57 suggestion_block: &super::suggestion::suggestion_block,
58 guidance_block: &review_guidance_block,
59 });
60 note_review_truncation(rendered.truncation);
61 rendered.envelope
62}
63
64fn note_review_truncation(truncation: ReviewEnvelopeTruncation) {
66 if truncation.body {
67 crate::telemetry::note_report_truncation(
68 true,
69 crate::telemetry::TruncationReason::SizeLimit,
70 );
71 } else if truncation.comment_limit {
72 crate::telemetry::note_report_truncation(
73 true,
74 crate::telemetry::TruncationReason::CommentLimit,
75 );
76 } else {
77 crate::telemetry::note_report_truncation(
78 false,
79 crate::telemetry::TruncationReason::Unknown,
80 );
81 }
82}
83
84#[must_use]
85pub(crate) fn print_review_envelope(
86 command: &str,
87 provider: Provider,
88 codeclimate: &Value,
89) -> ExitCode {
90 let issues = super::diff_filter::filter_issues_from_env(
91 super::pr_comment::issues_from_codeclimate(codeclimate),
92 );
93 print_review_envelope_from_ci_issues(command, provider, &issues)
94}
95
96#[must_use]
97pub(crate) fn print_review_envelope_from_codeclimate_issues(
98 command: &str,
99 provider: Provider,
100 codeclimate: &[CodeClimateIssue],
101) -> ExitCode {
102 let issues =
103 super::diff_filter::filter_issues_from_env(issues_from_codeclimate_issues(codeclimate));
104 print_review_envelope_from_ci_issues(command, provider, &issues)
105}
106
107#[must_use]
108#[expect(
109 clippy::expect_used,
110 reason = "review envelope contains only infallibly serializable fields"
111)]
112fn print_review_envelope_from_ci_issues(
113 command: &str,
114 provider: Provider,
115 issues: &[CiIssue],
116) -> ExitCode {
117 let envelope = render_review_envelope(command, provider, issues);
118 let value = fallow_output::serialize_review_envelope_json_output(
119 envelope,
120 crate::output_runtime::current_root_envelope_mode(),
121 crate::output_runtime::telemetry_analysis_run_id().as_deref(),
122 )
123 .expect("ReviewEnvelopeOutput serializes infallibly");
124 emit_json(&value, "review envelope")
125}
126
127fn gitlab_diff_refs_from_env() -> Option<GitlabDiffRefs> {
128 let base_sha = env_nonempty("FALLOW_GITLAB_BASE_SHA")
129 .or_else(|| env_nonempty("CI_MERGE_REQUEST_DIFF_BASE_SHA"))?;
130 let start_sha = env_nonempty("FALLOW_GITLAB_START_SHA").unwrap_or_else(|| base_sha.clone());
131 let head_sha =
132 env_nonempty("FALLOW_GITLAB_HEAD_SHA").or_else(|| env_nonempty("CI_COMMIT_SHA"))?;
133 Some(GitlabDiffRefs {
134 base_sha,
135 start_sha,
136 head_sha,
137 })
138}
139
140fn env_nonempty(name: &str) -> Option<String> {
141 std::env::var(name)
142 .ok()
143 .filter(|value| !value.trim().is_empty())
144}
145
146fn review_guidance_enabled() -> bool {
147 std::env::var("FALLOW_REVIEW_GUIDANCE").is_ok_and(|value| env_truthy(&value))
148}
149
150fn env_truthy(value: &str) -> bool {
151 matches!(
152 value.trim().to_ascii_lowercase().as_str(),
153 "1" | "true" | "yes" | "on"
154 )
155}
156
157fn review_guidance_block(issue: &CiIssue) -> Option<String> {
158 let rule = crate::explain::rule_by_id(&issue.rule_id)?;
159 let guide = crate::explain::rule_guide(rule);
160 let docs_url = crate::explain::rule_docs_url(rule);
161
162 Some(format!(
163 "\n\n<details><summary>What to do</summary>\n\n{}\n\n[Read the rule docs]({docs_url})\n\n</details>",
164 guide.how_to_fix
165 ))
166}
167
168#[cfg(test)]
169fn render_merged_comment(
170 provider: Provider,
171 group: &[&CiIssue],
172 gitlab_diff_refs: Option<&GitlabDiffRefs>,
173 diff_index: Option<&DiffIndex>,
174 include_guidance: bool,
175) -> fallow_output::ReviewComment {
176 fallow_output::render_review_comment_for_group(&fallow_output::ReviewCommentRenderInput {
177 provider,
178 group,
179 gitlab_diff_refs,
180 diff_index,
181 path_prefix: "",
182 include_guidance,
183 suggestion_block: &super::suggestion::suggestion_block,
184 guidance_block: &review_guidance_block,
185 })
186}
187
188#[cfg(test)]
189fn group_by_path_line(
190 issues: &[CiIssue],
191 max_groups: usize,
192) -> fallow_output::GroupedReviewIssues<'_> {
193 fallow_output::group_review_issues_by_path_line(issues, max_groups)
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199 use fallow_output::{MARKER_PREFIX_V2, MARKER_SUFFIX_V2, MAX_COMMENT_BODY_BYTES};
200 use fallow_output::{MARKER_REGEX_V2, ReviewComment};
201
202 fn to_value(envelope: &ReviewEnvelopeOutput) -> Value {
203 serde_json::to_value(envelope).expect("ReviewEnvelopeOutput serializes infallibly")
204 }
205
206 fn comment_to_value(comment: &ReviewComment) -> Value {
207 serde_json::to_value(comment).expect("ReviewComment serializes infallibly")
208 }
209
210 fn issue(rule: &str, sev: &str, path: &str, line: u64, fp: &str) -> CiIssue {
211 CiIssue {
212 rule_id: rule.into(),
213 description: "desc".into(),
214 severity: sev.into(),
215 path: path.into(),
216 line,
217 fingerprint: fp.into(),
218 }
219 }
220
221 fn issue_with_desc(
222 rule: &str,
223 desc: impl Into<String>,
224 sev: &str,
225 path: &str,
226 line: u64,
227 fp: &str,
228 ) -> CiIssue {
229 CiIssue {
230 rule_id: rule.into(),
231 description: desc.into(),
232 severity: sev.into(),
233 path: path.into(),
234 line,
235 fingerprint: fp.into(),
236 }
237 }
238
239 #[test]
240 fn github_review_envelope_matches_api_shape() {
241 let issues = vec![issue(
242 "fallow/unused-file",
243 "minor",
244 "src/a.ts",
245 1,
246 "abc1234567890def",
247 )];
248 let envelope = to_value(&render_review_envelope("check", Provider::Github, &issues));
249 assert_eq!(envelope["event"], "COMMENT");
250 assert_eq!(envelope["meta"]["schema"], "fallow-review-envelope/v2");
251 assert_eq!(envelope["comments"][0]["path"], "src/a.ts");
252 assert!(
253 envelope["comments"][0]["body"]
254 .as_str()
255 .unwrap()
256 .contains("fallow-fingerprint:v2:")
257 );
258 }
259
260 #[test]
261 fn review_summary_body_leads_with_decision() {
262 let issues = vec![issue(
263 "fallow/unused-file",
264 "major",
265 "src/a.ts",
266 1,
267 "abc1234567890def",
268 )];
269 let envelope = to_value(&render_review_envelope(
270 "combined",
271 Provider::Github,
272 &issues,
273 ));
274 let body = envelope["body"].as_str().expect("body is string");
275
276 assert!(body.contains("Quality gate failed"), "{body}");
277 assert!(body.contains("1 inline finding selected"), "{body}");
278 assert!(body.contains("<!-- fallow-review -->"), "{body}");
279 }
280
281 #[test]
282 fn github_comments_target_current_state_side() {
283 let issue = issue("fallow/unused-file", "minor", "src/a.ts", 1, "abc");
284 let comment = comment_to_value(&render_merged_comment(
285 Provider::Github,
286 &[&issue],
287 None,
288 None,
289 false,
290 ));
291 assert_eq!(comment["side"], "RIGHT");
292 }
293
294 #[test]
295 fn labels_major_issues_as_errors() {
296 let issue = issue("fallow/unused-file", "major", "src/a.ts", 1, "abc");
297 let comment = comment_to_value(&render_merged_comment(
298 Provider::Github,
299 &[&issue],
300 None,
301 None,
302 false,
303 ));
304 assert!(comment["body"].as_str().unwrap().starts_with("**error**"));
305 }
306
307 #[test]
308 fn gitlab_comment_accepts_diff_refs() {
309 let issue = issue("fallow/unused-file", "minor", "src/a.ts", 1, "abc");
310 let refs = GitlabDiffRefs {
311 base_sha: "base".into(),
312 start_sha: "start".into(),
313 head_sha: "head".into(),
314 };
315 let comment = comment_to_value(&render_merged_comment(
316 Provider::Gitlab,
317 &[&issue],
318 Some(&refs),
319 None,
320 false,
321 ));
322 assert_eq!(comment["position"]["position_type"], "text");
323 assert_eq!(comment["position"]["base_sha"], "base");
324 assert_eq!(comment["position"]["start_sha"], "start");
325 assert_eq!(comment["position"]["head_sha"], "head");
326 }
327
328 #[test]
329 fn guidance_toggle_accepts_common_truthy_values() {
330 for value in ["1", "true", "TRUE", "yes", "on", " On "] {
331 assert!(env_truthy(value), "{value:?} should enable guidance");
332 }
333 for value in ["", "0", "false", "no", "off", "enabled"] {
334 assert!(!env_truthy(value), "{value:?} should not enable guidance");
335 }
336 }
337
338 #[test]
339 fn guidance_disabled_omits_details_block() {
340 let issue = issue(
341 "fallow/high-complexity",
342 "major",
343 "src/a.ts",
344 10,
345 "abc1234567890def",
346 );
347 let comment = comment_to_value(&render_merged_comment(
348 Provider::Github,
349 &[&issue],
350 None,
351 None,
352 false,
353 ));
354 let body = comment["body"].as_str().unwrap();
355 assert!(!body.contains("<details><summary>What to do</summary>"));
356 assert!(!body.contains("For function findings"));
357 }
358
359 #[test]
360 fn guidance_enabled_appends_rule_guide_details() {
361 let issue = issue(
362 "fallow/high-complexity",
363 "major",
364 "src/a.ts",
365 10,
366 "abc1234567890def",
367 );
368 let comment = comment_to_value(&render_merged_comment(
369 Provider::Github,
370 &[&issue],
371 None,
372 None,
373 true,
374 ));
375 let body = comment["body"].as_str().unwrap();
376 assert!(body.contains("<details><summary>What to do</summary>"));
377 assert!(body.contains("For function findings"));
378 assert!(body.contains("[Read the rule docs]("));
379 assert!(
380 body.find("</details>").unwrap() < body.find("fallow-fingerprint:v2:").unwrap(),
381 "guidance should render before the marker"
382 );
383 }
384
385 #[test]
386 fn guidance_attaches_to_each_merged_finding() {
387 let complexity = issue("fallow/high-complexity", "major", "src/foo.ts", 42, "fp_a");
388 let duplication = issue("fallow/code-duplication", "minor", "src/foo.ts", 42, "fp_b");
389 let comment = comment_to_value(&render_merged_comment(
390 Provider::Github,
391 &[&complexity, &duplication],
392 None,
393 None,
394 true,
395 ));
396 let body = comment["body"].as_str().unwrap();
397 assert_eq!(
398 body.matches("<details><summary>What to do</summary>")
399 .count(),
400 2
401 );
402 assert!(body.contains("For function findings"));
403 assert!(body.contains("Extract the shared logic"));
404 }
405
406 #[test]
407 fn envelope_emits_marker_regex_field_at_root() {
408 let issues = vec![issue("fallow/unused-file", "minor", "src/a.ts", 1, "abc")];
409 let env = to_value(&render_review_envelope("check", Provider::Github, &issues));
410 let regex = env["marker_regex"].as_str().expect("marker_regex present");
411 assert_eq!(regex, MARKER_REGEX_V2);
412 assert!(regex.contains("[0-9a-f]{16}"));
413 assert!(regex.starts_with('^'));
414 assert!(regex.ends_with("\\s*$"));
415 assert!(!regex.contains("(?m)"));
416 assert!(regex.contains("((?:[a-z]+:)?[0-9a-f]{16})"));
417 let flags = env["marker_regex_flags"]
418 .as_str()
419 .expect("marker_regex_flags present");
420 assert_eq!(flags, "m");
421 }
422
423 #[test]
424 fn envelope_emits_summary_block_with_fingerprint() {
425 let issues = vec![issue("fallow/unused-file", "minor", "src/a.ts", 1, "abc")];
426 let env = to_value(&render_review_envelope("check", Provider::Github, &issues));
427 assert_eq!(env["summary"]["body"], env["body"]);
428 let summary_fp = env["summary"]["fingerprint"].as_str().expect("fingerprint");
429 assert_eq!(summary_fp.len(), 16);
430 assert!(summary_fp.chars().all(|c| c.is_ascii_hexdigit()));
431 let body_str = env["body"].as_str().unwrap();
432 let marker_line = format!("{MARKER_PREFIX_V2}{summary_fp}{MARKER_SUFFIX_V2}");
433 assert!(
434 body_str.contains(&marker_line),
435 "body must carry summary marker:\nbody={body_str}\nmarker={marker_line}"
436 );
437 }
438
439 #[test]
440 fn same_line_findings_merge_into_one_comment_with_composite_fingerprint() {
441 let a = issue("fallow/unused-export", "minor", "src/foo.ts", 42, "fp_a");
442 let b = issue("fallow/duplicate-export", "minor", "src/foo.ts", 42, "fp_b");
443 let env = to_value(&render_review_envelope("check", Provider::Github, &[a, b]));
444 assert_eq!(
445 env["comments"].as_array().unwrap().len(),
446 1,
447 "two same-line findings must collapse to one comment"
448 );
449 let merged = &env["comments"][0];
450 let fp = merged["fingerprint"].as_str().unwrap();
451 assert!(
452 fp.starts_with("merged:"),
453 "merged comment fingerprint must start with merged:, got {fp}"
454 );
455 assert_eq!(fp.len(), 23);
456 let body = merged["body"].as_str().unwrap();
457 assert!(body.contains("fallow/unused-export"));
458 assert!(body.contains("fallow/duplicate-export"));
459 assert_eq!(
460 body.matches("fallow-fingerprint:v2:").count(),
461 1,
462 "merged body must carry exactly one fingerprint marker"
463 );
464 assert!(
465 merged.get("constituent_fingerprints").is_none(),
466 "v2 hashed-composite design does not emit constituent_fingerprints"
467 );
468 }
469
470 #[test]
471 fn group_by_path_line_respects_max_groups_without_splitting_same_line_findings() {
472 let a = issue("fallow/unused-export", "minor", "src/foo.ts", 42, "fp_a");
473 let b = issue("fallow/duplicate-export", "minor", "src/foo.ts", 42, "fp_b");
474 let c = issue("fallow/unused-type", "minor", "src/z.ts", 7, "fp_c");
475 let issues = vec![a, b, c];
476
477 let max_zero = group_by_path_line(&issues, 0);
478 assert!(max_zero.groups.is_empty());
479 assert!(max_zero.truncated);
480
481 let max_one = group_by_path_line(&issues, 1);
482 assert_eq!(max_one.groups.len(), 1);
483 assert!(max_one.truncated);
484 assert_eq!(max_one.groups[0].len(), 2);
485 assert_eq!(max_one.groups[0][0].path, "src/foo.ts");
486 assert_eq!(max_one.groups[0][0].line, 42);
487
488 let max_two = group_by_path_line(&issues, 2);
489 assert_eq!(max_two.groups.len(), 2);
490 assert!(!max_two.truncated);
491 assert_eq!(max_two.groups[0].len(), 2);
492 assert_eq!(max_two.groups[1].len(), 1);
493 assert_eq!(
494 max_two.groups[0]
495 .iter()
496 .map(|issue| issue.fingerprint.as_str())
497 .collect::<Vec<_>>(),
498 ["fp_a", "fp_b"]
499 );
500 }
501
502 #[test]
503 fn single_finding_keeps_v1_fingerprint_shape() {
504 let issues = vec![issue(
505 "fallow/unused-file",
506 "minor",
507 "src/a.ts",
508 1,
509 "abc1234567890def",
510 )];
511 let env = to_value(&render_review_envelope("check", Provider::Github, &issues));
512 let comment = &env["comments"][0];
513 assert_eq!(comment["fingerprint"], "abc1234567890def");
514 assert!(
515 comment.get("constituent_fingerprints").is_none(),
516 "single-finding comment must NOT emit constituent_fingerprints"
517 );
518 assert!(
519 comment.get("truncated").is_none(),
520 "non-truncated comment must NOT emit truncated"
521 );
522 }
523
524 #[test]
525 fn composite_fingerprint_shifts_when_constituents_change() {
526 let a = issue("fallow/unused-export", "minor", "src/foo.ts", 42, "fp_a");
527 let b = issue("fallow/duplicate-export", "minor", "src/foo.ts", 42, "fp_b");
528 let c = issue("fallow/unused-type", "minor", "src/foo.ts", 42, "fp_c");
529 let run1 = to_value(&render_review_envelope(
530 "check",
531 Provider::Github,
532 &[a.clone(), b, c.clone()],
533 ));
534 let run2_drop_b = to_value(&render_review_envelope("check", Provider::Github, &[a, c]));
535 assert_ne!(
536 run1["comments"][0]["fingerprint"], run2_drop_b["comments"][0]["fingerprint"],
537 "primary fingerprint must shift when a constituent drops"
538 );
539 }
540
541 #[test]
542 fn gitlab_old_path_pulls_from_diff_rename_map() {
543 let rename_diff = "\
544diff --git a/src/old.ts b/src/new.ts
545similarity index 90%
546rename from src/old.ts
547rename to src/new.ts
548--- a/src/old.ts
549+++ b/src/new.ts
550@@ -1,2 +1,3 @@
551 keep
552+added
553 still
554";
555 let diff_index = DiffIndex::from_unified_diff(rename_diff);
556 let issue = issue("fallow/unused-export", "minor", "src/new.ts", 2, "abc");
557 let envelope = to_value(&render_review_envelope_with_diff(
558 "check",
559 Provider::Gitlab,
560 &[issue],
561 Some(&diff_index),
562 ));
563 let position = &envelope["comments"][0]["position"];
564 assert_eq!(position["old_path"], "src/old.ts");
565 assert_eq!(position["new_path"], "src/new.ts");
566 }
567
568 #[test]
569 fn gitlab_old_path_falls_back_to_new_path_without_rename() {
570 let issue = issue("fallow/unused-export", "minor", "src/edit.ts", 5, "abc");
571 let envelope = to_value(&render_review_envelope_with_diff(
572 "check",
573 Provider::Gitlab,
574 &[issue],
575 None,
576 ));
577 let position = &envelope["comments"][0]["position"];
578 assert_eq!(position["old_path"], "src/edit.ts");
579 assert_eq!(position["new_path"], "src/edit.ts");
580 }
581
582 #[test]
583 fn oversized_body_truncates_at_char_boundary_and_preserves_marker() {
584 let huge_desc = "x".repeat(MAX_COMMENT_BODY_BYTES * 2);
585 let issue = CiIssue {
586 rule_id: "fallow/unused-export".into(),
587 description: huge_desc,
588 severity: "minor".into(),
589 path: "src/a.ts".into(),
590 line: 1,
591 fingerprint: "abc1234567890def".into(),
592 };
593 let comment = comment_to_value(&render_merged_comment(
594 Provider::Github,
595 &[&issue],
596 None,
597 None,
598 false,
599 ));
600 let body = comment["body"].as_str().unwrap();
601 assert!(
602 body.len() <= MAX_COMMENT_BODY_BYTES,
603 "body len {} must not exceed cap {MAX_COMMENT_BODY_BYTES}",
604 body.len()
605 );
606 assert!(
607 body.contains("fallow-fingerprint:v2:"),
608 "marker must be preserved under truncation"
609 );
610 assert!(body.contains("<!-- fallow-truncated -->"));
611 assert!(body.contains("> Body truncated by fallow."));
612 assert_eq!(comment["truncated"], true);
613 assert!(std::str::from_utf8(body.as_bytes()).is_ok());
614 }
615
616 #[test]
617 fn oversized_guidance_body_truncates_and_preserves_marker() {
618 let issue = issue_with_desc(
619 "fallow/high-complexity",
620 "x".repeat(MAX_COMMENT_BODY_BYTES * 2),
621 "major",
622 "src/a.ts",
623 1,
624 "abc1234567890def",
625 );
626 let comment = comment_to_value(&render_merged_comment(
627 Provider::Github,
628 &[&issue],
629 None,
630 None,
631 true,
632 ));
633 let body = comment["body"].as_str().unwrap();
634 assert!(body.len() <= MAX_COMMENT_BODY_BYTES);
635 assert!(body.contains("<!-- fallow-truncated -->"));
636 assert!(body.contains("fallow-fingerprint:v2:"));
637 assert_eq!(comment["truncated"], true);
638 }
639
640 #[test]
641 fn multibyte_body_truncates_at_char_boundary() {
642 let huge_desc: String = "あ".repeat(MAX_COMMENT_BODY_BYTES);
643 let issue = CiIssue {
644 rule_id: "fallow/unused-export".into(),
645 description: huge_desc,
646 severity: "minor".into(),
647 path: "src/a.ts".into(),
648 line: 1,
649 fingerprint: "abc1234567890def".into(),
650 };
651 let comment = comment_to_value(&render_merged_comment(
652 Provider::Github,
653 &[&issue],
654 None,
655 None,
656 false,
657 ));
658 let body = comment["body"].as_str().unwrap();
659 assert!(std::str::from_utf8(body.as_bytes()).is_ok());
660 assert!(body.len() <= MAX_COMMENT_BODY_BYTES);
661 assert_eq!(comment["truncated"], true);
662 }
663}