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