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