1use std::process::ExitCode;
2
3use fallow_output::CodeClimateIssue;
4
5use super::diff_filter::DiffIndex;
6use crate::report::emit_json;
7use fallow_output::PrDecisionConclusion;
8use fallow_output::{
9 CiIssue, CiProvider as Provider, ReviewCheckConclusion, ReviewEnvelopeOutput,
10 ReviewEnvelopeRenderInput, ReviewEnvelopeTruncation, ReviewGitlabDiffRefs as GitlabDiffRefs,
11 ReviewId, issues_from_codeclimate_issues,
12};
13
14#[derive(Clone, Copy, Default)]
22pub struct ReviewSummaryNotes<'a> {
23 pub message: Option<&'a str>,
26 pub config_patterns: &'a [fallow_config::WorkspaceDiagnostic],
29}
30
31impl<'a> From<super::pr_comment::PrCommentStatus<'a>> for ReviewSummaryNotes<'a> {
32 fn from(status: super::pr_comment::PrCommentStatus<'a>) -> Self {
33 Self {
34 message: status.message,
35 config_patterns: status.config_patterns,
36 }
37 }
38}
39
40#[must_use]
41pub fn render_review_envelope(
42 command: &str,
43 provider: Provider,
44 issues: &[CiIssue],
45) -> ReviewEnvelopeOutput {
46 render_review_envelope_with_diff(
47 command,
48 provider,
49 issues,
50 super::diff_filter::shared_diff_index(),
51 None,
52 None,
53 ReviewSummaryNotes::default(),
54 )
55}
56
57#[must_use]
62#[expect(
63 clippy::too_many_arguments,
64 reason = "the private render seam keeps test-injected diff and optional gate state explicit"
65)]
66fn render_review_envelope_with_diff(
67 command: &str,
68 provider: Provider,
69 issues: &[CiIssue],
70 diff_index: Option<&DiffIndex>,
71 review_id: Option<&ReviewId>,
72 conclusion: Option<ReviewCheckConclusion>,
73 notes: ReviewSummaryNotes<'_>,
74) -> ReviewEnvelopeOutput {
75 let max = std::env::var("FALLOW_MAX_COMMENTS")
76 .ok()
77 .and_then(|v| v.parse::<usize>().ok())
78 .unwrap_or(50);
79 let gitlab_diff_refs = (provider == Provider::Gitlab)
80 .then(gitlab_diff_refs_from_env)
81 .flatten();
82 let include_guidance = review_guidance_enabled();
83 let config_section =
84 crate::report::config_pattern_text::markdown_section(notes.config_patterns);
85 let status_message = notes.message;
86
87 let input = ReviewEnvelopeRenderInput {
88 command,
89 provider,
90 issues,
91 diff_index,
92 path_prefix: crate::report::github::report_prefix(),
93 max_comments: max,
94 gitlab_diff_refs: gitlab_diff_refs.as_ref(),
95 include_guidance,
96 suggestion_block: &super::suggestion::suggestion_block,
97 guidance_block: &review_guidance_block,
98 trailing_section: config_section.as_deref(),
99 };
100 let rendered = match (review_id, conclusion) {
101 (Some(review_id), Some(conclusion)) => {
102 fallow_output::render_scoped_review_envelope_with_conclusion(
103 &input,
104 review_id,
105 conclusion,
106 status_message,
107 )
108 }
109 (Some(review_id), None) => {
110 fallow_output::render_scoped_review_envelope(&input, review_id, status_message)
111 }
112 (None, Some(conclusion)) => fallow_output::render_review_envelope_with_conclusion(
113 &input,
114 conclusion,
115 status_message,
116 ),
117 (None, None) => fallow_output::render_review_envelope(&input, status_message),
118 };
119 note_review_truncation(rendered.truncation);
120 rendered.envelope
121}
122
123fn note_review_truncation(truncation: ReviewEnvelopeTruncation) {
125 if truncation.body {
126 crate::telemetry::note_report_truncation(
127 true,
128 crate::telemetry::TruncationReason::SizeLimit,
129 );
130 } else if truncation.comment_limit {
131 crate::telemetry::note_report_truncation(
132 true,
133 crate::telemetry::TruncationReason::CommentLimit,
134 );
135 } else {
136 crate::telemetry::note_report_truncation(
137 false,
138 crate::telemetry::TruncationReason::Unknown,
139 );
140 }
141}
142
143#[must_use]
144pub(crate) fn print_review_envelope_from_codeclimate_issues(
145 command: &str,
146 provider: Provider,
147 codeclimate: &[CodeClimateIssue],
148 notes: ReviewSummaryNotes<'_>,
149) -> ExitCode {
150 let issues =
151 super::diff_filter::filter_issues_from_env(issues_from_codeclimate_issues(codeclimate));
152 print_review_envelope_from_ci_issues(command, provider, &issues, None, notes)
153}
154
155#[must_use]
156pub(crate) fn print_review_envelope_from_codeclimate_issues_with_conclusion(
157 command: &str,
158 provider: Provider,
159 codeclimate: &[CodeClimateIssue],
160 conclusion: PrDecisionConclusion,
161 notes: ReviewSummaryNotes<'_>,
162) -> ExitCode {
163 let issues =
164 super::diff_filter::filter_issues_from_env(issues_from_codeclimate_issues(codeclimate));
165 print_review_envelope_from_ci_issues(
166 command,
167 provider,
168 &issues,
169 Some(review_conclusion(conclusion)),
170 notes,
171 )
172}
173
174#[must_use]
175fn print_review_envelope_from_ci_issues(
176 command: &str,
177 provider: Provider,
178 issues: &[CiIssue],
179 conclusion: Option<ReviewCheckConclusion>,
180 notes: ReviewSummaryNotes<'_>,
181) -> ExitCode {
182 let review_id = match review_id_from_env() {
183 Ok(review_id) => review_id,
184 Err(error) => {
185 eprintln!("Error: {error}");
186 return ExitCode::from(2);
187 }
188 };
189 let envelope = render_review_envelope_with_diff(
190 command,
191 provider,
192 issues,
193 super::diff_filter::shared_diff_index(),
194 review_id.as_ref(),
195 conclusion,
196 notes,
197 );
198 let analysis_run_id = crate::output_runtime::telemetry_analysis_run_id();
199 let value = match review_id.as_ref() {
200 Some(review_id) => fallow_output::serialize_scoped_review_envelope_json_output(
201 &envelope,
202 review_id,
203 analysis_run_id.as_deref(),
204 ),
205 None => fallow_output::serialize_review_envelope_json_output(
206 envelope,
207 analysis_run_id.as_deref(),
208 )
209 .map_err(|error| error.to_string()),
210 };
211 let value = match value {
212 Ok(value) => value,
213 Err(error) => {
214 eprintln!("Error: failed to serialize review envelope: {error}");
215 return ExitCode::from(2);
216 }
217 };
218 emit_json(&value, "review envelope")
219}
220
221pub(crate) const fn review_conclusion(conclusion: PrDecisionConclusion) -> ReviewCheckConclusion {
225 match conclusion {
226 PrDecisionConclusion::Success => ReviewCheckConclusion::Success,
227 PrDecisionConclusion::Failure => ReviewCheckConclusion::Failure,
228 PrDecisionConclusion::Neutral | PrDecisionConclusion::Skipped => {
229 ReviewCheckConclusion::Neutral
230 }
231 }
232}
233
234fn review_id_from_env() -> Result<Option<ReviewId>, String> {
235 let Some(value) = env_nonempty("FALLOW_REVIEW_ID") else {
236 return Ok(None);
237 };
238 ReviewId::parse(value)
239 .map(Some)
240 .map_err(|error| format!("invalid FALLOW_REVIEW_ID: {error}"))
241}
242
243fn gitlab_diff_refs_from_env() -> Option<GitlabDiffRefs> {
244 let base_sha = env_nonempty("FALLOW_GITLAB_BASE_SHA")
245 .or_else(|| env_nonempty("CI_MERGE_REQUEST_DIFF_BASE_SHA"))?;
246 let start_sha = env_nonempty("FALLOW_GITLAB_START_SHA").unwrap_or_else(|| base_sha.clone());
247 let head_sha =
248 env_nonempty("FALLOW_GITLAB_HEAD_SHA").or_else(|| env_nonempty("CI_COMMIT_SHA"))?;
249 Some(GitlabDiffRefs {
250 base_sha,
251 start_sha,
252 head_sha,
253 })
254}
255
256fn env_nonempty(name: &str) -> Option<String> {
257 std::env::var(name)
258 .ok()
259 .filter(|value| !value.trim().is_empty())
260}
261
262fn review_guidance_enabled() -> bool {
263 std::env::var("FALLOW_REVIEW_GUIDANCE").is_ok_and(|value| env_truthy(&value))
264}
265
266fn env_truthy(value: &str) -> bool {
267 matches!(
268 value.trim().to_ascii_lowercase().as_str(),
269 "1" | "true" | "yes" | "on"
270 )
271}
272
273fn review_guidance_block(issue: &CiIssue) -> Option<String> {
274 let rule = crate::explain::rule_by_id(&issue.rule_id)?;
275 let guide = crate::explain::rule_guide(rule);
276 let docs_url = crate::explain::rule_docs_url(rule);
277
278 Some(format!(
279 "\n\n<details><summary>What to do</summary>\n\n{}\n\n[Read the rule docs]({docs_url})\n\n</details>",
280 guide.how_to_fix
281 ))
282}
283
284#[cfg(test)]
285fn render_merged_comment(
286 provider: Provider,
287 group: &[&CiIssue],
288 gitlab_diff_refs: Option<&GitlabDiffRefs>,
289 diff_index: Option<&DiffIndex>,
290 include_guidance: bool,
291) -> fallow_output::ReviewComment {
292 fallow_output::render_review_comment_for_group(&fallow_output::ReviewCommentRenderInput {
293 provider,
294 group,
295 gitlab_diff_refs,
296 diff_index,
297 path_prefix: "",
298 include_guidance,
299 suggestion_block: &super::suggestion::suggestion_block,
300 guidance_block: &review_guidance_block,
301 })
302}
303
304#[cfg(test)]
305fn group_by_path_line(
306 issues: &[CiIssue],
307 max_groups: usize,
308) -> fallow_output::GroupedReviewIssues<'_> {
309 fallow_output::group_review_issues_by_path_line(issues, max_groups)
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315 use fallow_output::{MARKER_PREFIX_V3, MARKER_SUFFIX_V3, MAX_COMMENT_BODY_BYTES};
316 use fallow_output::{MARKER_REGEX_V3, ReviewComment};
317 use serde_json::Value;
318
319 fn to_value(envelope: &ReviewEnvelopeOutput) -> Value {
320 serde_json::to_value(envelope).expect("ReviewEnvelopeOutput serializes infallibly")
321 }
322
323 fn comment_to_value(comment: &ReviewComment) -> Value {
324 serde_json::to_value(comment).expect("ReviewComment serializes infallibly")
325 }
326
327 fn issue(rule: &str, sev: &str, path: &str, line: u64, fp: &str) -> CiIssue {
328 CiIssue {
329 rule_id: rule.into(),
330 description: "desc".into(),
331 severity: sev.into(),
332 path: path.into(),
333 line,
334 end_line: None,
335 other_locations: Vec::new(),
336 fingerprint: fp.into(),
337 legacy_fingerprint: None,
338 }
339 }
340
341 fn issue_with_desc(
342 rule: &str,
343 desc: impl Into<String>,
344 sev: &str,
345 path: &str,
346 line: u64,
347 fp: &str,
348 ) -> CiIssue {
349 CiIssue {
350 rule_id: rule.into(),
351 description: desc.into(),
352 severity: sev.into(),
353 path: path.into(),
354 line,
355 end_line: None,
356 other_locations: Vec::new(),
357 fingerprint: fp.into(),
358 legacy_fingerprint: None,
359 }
360 }
361
362 #[test]
363 fn scoped_renderer_uses_typed_meta_and_exact_markers() {
364 let issue = issue(
365 "fallow/unused-file",
366 "minor",
367 "src/a.ts",
368 1,
369 "abcdef0123456789",
370 );
371 let review_id = ReviewId::parse("frontend").unwrap();
372 let envelope = render_review_envelope_with_diff(
373 "check",
374 Provider::Github,
375 &[issue],
376 None,
377 Some(&review_id),
378 None,
379 ReviewSummaryNotes::default(),
380 );
381 let envelope = fallow_output::serialize_scoped_review_envelope_json_output(
382 &envelope, &review_id, None,
383 )
384 .unwrap();
385
386 assert_eq!(envelope["meta"]["review_id"], "frontend");
387 assert!(
388 envelope["body"]
389 .as_str()
390 .unwrap()
391 .ends_with("<!-- fallow-review-id: frontend -->")
392 );
393 assert!(
394 envelope["comments"][0]["body"]
395 .as_str()
396 .unwrap()
397 .ends_with("<!-- fallow-review-id: frontend -->")
398 );
399 }
400
401 #[test]
402 fn github_review_envelope_matches_api_shape() {
403 let issues = vec![issue(
404 "fallow/unused-file",
405 "minor",
406 "src/a.ts",
407 1,
408 "abc1234567890def",
409 )];
410 let envelope = to_value(&render_review_envelope("check", Provider::Github, &issues));
411 assert_eq!(envelope["event"], "COMMENT");
412 assert_eq!(envelope["meta"]["schema"], "fallow-review-envelope/v3");
413 assert_eq!(envelope["comments"][0]["path"], "src/a.ts");
414 assert!(
415 envelope["comments"][0]["body"]
416 .as_str()
417 .unwrap()
418 .contains("fallow-fingerprint:v3:")
419 );
420 }
421
422 #[test]
423 fn review_summary_body_points_at_the_inline_comments() {
424 let issues = vec![issue(
425 "fallow/unused-file",
426 "major",
427 "src/a.ts",
428 1,
429 "abc1234567890def",
430 )];
431 let envelope = to_value(&render_review_envelope(
432 "combined",
433 Provider::Github,
434 &issues,
435 ));
436 let body = envelope["body"].as_str().expect("body is string");
437
438 assert!(!body.contains("Quality gate"), "{body}");
439 assert!(
440 body.contains(
441 "1 inline comment on the changed lines. Open the Files changed tab to review."
442 ),
443 "{body}"
444 );
445 assert!(body.contains("<!-- fallow-review -->"), "{body}");
446 assert_eq!(
447 envelope["meta"]["check_conclusion"], "failure",
448 "{envelope}"
449 );
450 }
451
452 #[test]
453 fn review_summary_body_lists_unmatched_config_patterns_before_the_markers() {
454 let root = std::path::Path::new("/project");
455 let diagnostics = [fallow_config::WorkspaceDiagnostic::new(
456 root,
457 root.to_path_buf(),
458 fallow_config::WorkspaceDiagnosticKind::IgnoreDependenciesGlobUnmatched {
459 pattern: "@typo/*".to_owned(),
460 },
461 )];
462 let envelope = to_value(&render_review_envelope_with_diff(
463 "dead-code",
464 Provider::Gitlab,
465 &[],
466 None,
467 None,
468 None,
469 ReviewSummaryNotes {
470 message: Some("Baseline advisory."),
471 config_patterns: &diagnostics,
472 },
473 ));
474 let body = envelope["body"].as_str().expect("body is string");
475 let note = body.find("> Baseline advisory.").expect("status note");
476 let section = body
477 .find("## Unmatched config patterns")
478 .expect("config pattern section");
479 let marker = body.find("<!-- fallow-review -->").expect("marker");
480 assert!(note < section && section < marker, "{body}");
481 assert!(
482 body.contains("- `ignoreDependencies`: `@typo/*` matched nothing in this run"),
483 "{body}"
484 );
485 assert_eq!(envelope["summary"]["body"], envelope["body"]);
486 }
487
488 #[test]
489 fn github_comments_target_current_state_side() {
490 let issue = issue("fallow/unused-file", "minor", "src/a.ts", 1, "abc");
491 let comment = comment_to_value(&render_merged_comment(
492 Provider::Github,
493 &[&issue],
494 None,
495 None,
496 false,
497 ));
498 assert_eq!(comment["side"], "RIGHT");
499 }
500
501 #[test]
502 fn labels_major_issues_as_errors() {
503 let issue = issue("fallow/unused-file", "major", "src/a.ts", 1, "abc");
504 let comment = comment_to_value(&render_merged_comment(
505 Provider::Github,
506 &[&issue],
507 None,
508 None,
509 false,
510 ));
511 assert!(comment["body"].as_str().unwrap().starts_with("**error**"));
512 }
513
514 #[test]
515 fn gitlab_comment_accepts_diff_refs() {
516 let issue = issue("fallow/unused-file", "minor", "src/a.ts", 1, "abc");
517 let refs = GitlabDiffRefs {
518 base_sha: "base".into(),
519 start_sha: "start".into(),
520 head_sha: "head".into(),
521 };
522 let comment = comment_to_value(&render_merged_comment(
523 Provider::Gitlab,
524 &[&issue],
525 Some(&refs),
526 None,
527 false,
528 ));
529 assert_eq!(comment["position"]["position_type"], "text");
530 assert_eq!(comment["position"]["base_sha"], "base");
531 assert_eq!(comment["position"]["start_sha"], "start");
532 assert_eq!(comment["position"]["head_sha"], "head");
533 }
534
535 #[test]
536 fn guidance_toggle_accepts_common_truthy_values() {
537 for value in ["1", "true", "TRUE", "yes", "on", " On "] {
538 assert!(env_truthy(value), "{value:?} should enable guidance");
539 }
540 for value in ["", "0", "false", "no", "off", "enabled"] {
541 assert!(!env_truthy(value), "{value:?} should not enable guidance");
542 }
543 }
544
545 #[test]
546 fn guidance_disabled_omits_details_block() {
547 let issue = issue(
548 "fallow/high-complexity",
549 "major",
550 "src/a.ts",
551 10,
552 "abc1234567890def",
553 );
554 let comment = comment_to_value(&render_merged_comment(
555 Provider::Github,
556 &[&issue],
557 None,
558 None,
559 false,
560 ));
561 let body = comment["body"].as_str().unwrap();
562 assert!(!body.contains("<details><summary>What to do</summary>"));
563 assert!(!body.contains("For function findings"));
564 }
565
566 #[test]
567 fn guidance_enabled_appends_rule_guide_details() {
568 let issue = issue(
569 "fallow/high-complexity",
570 "major",
571 "src/a.ts",
572 10,
573 "abc1234567890def",
574 );
575 let comment = comment_to_value(&render_merged_comment(
576 Provider::Github,
577 &[&issue],
578 None,
579 None,
580 true,
581 ));
582 let body = comment["body"].as_str().unwrap();
583 assert!(body.contains("<details><summary>What to do</summary>"));
584 assert!(body.contains("For function findings"));
585 assert!(body.contains("[Read the rule docs]("));
586 assert!(
587 body.find("</details>").unwrap() < body.find("fallow-fingerprint:v3:").unwrap(),
588 "guidance should render before the marker"
589 );
590 }
591
592 #[test]
593 fn guidance_attaches_to_each_merged_finding() {
594 let complexity = issue("fallow/high-complexity", "major", "src/foo.ts", 42, "fp_a");
595 let duplication = issue("fallow/code-duplication", "minor", "src/foo.ts", 42, "fp_b");
596 let comment = comment_to_value(&render_merged_comment(
597 Provider::Github,
598 &[&complexity, &duplication],
599 None,
600 None,
601 true,
602 ));
603 let body = comment["body"].as_str().unwrap();
604 assert_eq!(
605 body.matches("<details><summary>What to do</summary>")
606 .count(),
607 2
608 );
609 assert!(body.contains("For function findings"));
610 assert!(body.contains("Extract the shared logic"));
611 }
612
613 #[test]
614 fn envelope_emits_marker_regex_field_at_root() {
615 let issues = vec![issue("fallow/unused-file", "minor", "src/a.ts", 1, "abc")];
616 let env = to_value(&render_review_envelope("check", Provider::Github, &issues));
617 let regex = env["marker_regex"].as_str().expect("marker_regex present");
618 assert_eq!(regex, MARKER_REGEX_V3);
619 assert!(regex.contains("[0-9a-f]{16}"));
620 assert!(regex.starts_with('^'));
621 assert!(regex.ends_with("\\s*$"));
622 assert!(!regex.contains("(?m)"));
623 assert!(regex.contains("((?:[a-z]+:)?[0-9a-f]{16})"));
624 let flags = env["marker_regex_flags"]
625 .as_str()
626 .expect("marker_regex_flags present");
627 assert_eq!(flags, "m");
628 }
629
630 #[test]
631 fn envelope_emits_summary_block_with_fingerprint() {
632 let issues = vec![issue("fallow/unused-file", "minor", "src/a.ts", 1, "abc")];
633 let env = to_value(&render_review_envelope("check", Provider::Github, &issues));
634 assert_eq!(env["summary"]["body"], env["body"]);
635 let summary_fp = env["summary"]["fingerprint"].as_str().expect("fingerprint");
636 assert_eq!(summary_fp.len(), 16);
637 assert!(summary_fp.chars().all(|c| c.is_ascii_hexdigit()));
638 let body_str = env["body"].as_str().unwrap();
639 let marker_line = format!("{MARKER_PREFIX_V3}{summary_fp}{MARKER_SUFFIX_V3}");
640 assert!(
641 body_str.contains(&marker_line),
642 "body must carry summary marker:\nbody={body_str}\nmarker={marker_line}"
643 );
644 }
645
646 #[test]
647 fn same_line_findings_merge_into_one_comment_with_composite_fingerprint() {
648 let a = issue("fallow/unused-export", "minor", "src/foo.ts", 42, "fp_a");
649 let b = issue("fallow/duplicate-export", "minor", "src/foo.ts", 42, "fp_b");
650 let env = to_value(&render_review_envelope("check", Provider::Github, &[a, b]));
651 assert_eq!(
652 env["comments"].as_array().unwrap().len(),
653 1,
654 "two same-line findings must collapse to one comment"
655 );
656 let merged = &env["comments"][0];
657 let fp = merged["fingerprint"].as_str().unwrap();
658 assert!(
659 fp.starts_with("merged:"),
660 "merged comment fingerprint must start with merged:, got {fp}"
661 );
662 assert_eq!(fp.len(), 23);
663 let body = merged["body"].as_str().unwrap();
664 assert!(body.contains("fallow/unused-export"));
665 assert!(body.contains("fallow/duplicate-export"));
666 assert_eq!(
667 body.matches("fallow-fingerprint:v3:").count(),
668 1,
669 "merged body must carry exactly one fingerprint marker"
670 );
671 assert!(
672 merged.get("constituent_fingerprints").is_none(),
673 "v2 hashed-composite design does not emit constituent_fingerprints"
674 );
675 }
676
677 #[test]
678 fn group_by_path_line_respects_max_groups_without_splitting_same_line_findings() {
679 let a = issue("fallow/unused-export", "minor", "src/foo.ts", 42, "fp_a");
680 let b = issue("fallow/duplicate-export", "minor", "src/foo.ts", 42, "fp_b");
681 let c = issue("fallow/unused-type", "minor", "src/z.ts", 7, "fp_c");
682 let issues = vec![a, b, c];
683
684 let max_zero = group_by_path_line(&issues, 0);
685 assert!(max_zero.groups.is_empty());
686 assert!(max_zero.truncated);
687
688 let max_one = group_by_path_line(&issues, 1);
689 assert_eq!(max_one.groups.len(), 1);
690 assert!(max_one.truncated);
691 assert_eq!(max_one.groups[0].len(), 2);
692 assert_eq!(max_one.groups[0][0].path, "src/foo.ts");
693 assert_eq!(max_one.groups[0][0].line, 42);
694
695 let max_two = group_by_path_line(&issues, 2);
696 assert_eq!(max_two.groups.len(), 2);
697 assert!(!max_two.truncated);
698 assert_eq!(max_two.groups[0].len(), 2);
699 assert_eq!(max_two.groups[1].len(), 1);
700 assert_eq!(
701 max_two.groups[0]
702 .iter()
703 .map(|issue| issue.fingerprint.as_str())
704 .collect::<Vec<_>>(),
705 ["fp_a", "fp_b"]
706 );
707 }
708
709 #[test]
710 fn single_finding_keeps_v1_fingerprint_shape() {
711 let issues = vec![issue(
712 "fallow/unused-file",
713 "minor",
714 "src/a.ts",
715 1,
716 "abc1234567890def",
717 )];
718 let env = to_value(&render_review_envelope("check", Provider::Github, &issues));
719 let comment = &env["comments"][0];
720 assert_eq!(comment["fingerprint"], "abc1234567890def");
721 assert!(
722 comment.get("constituent_fingerprints").is_none(),
723 "single-finding comment must NOT emit constituent_fingerprints"
724 );
725 assert!(
726 comment.get("truncated").is_none(),
727 "non-truncated comment must NOT emit truncated"
728 );
729 }
730
731 #[test]
732 fn composite_fingerprint_shifts_when_constituents_change() {
733 let a = issue("fallow/unused-export", "minor", "src/foo.ts", 42, "fp_a");
734 let b = issue("fallow/duplicate-export", "minor", "src/foo.ts", 42, "fp_b");
735 let c = issue("fallow/unused-type", "minor", "src/foo.ts", 42, "fp_c");
736 let run1 = to_value(&render_review_envelope(
737 "check",
738 Provider::Github,
739 &[a.clone(), b, c.clone()],
740 ));
741 let run2_drop_b = to_value(&render_review_envelope("check", Provider::Github, &[a, c]));
742 assert_ne!(
743 run1["comments"][0]["fingerprint"], run2_drop_b["comments"][0]["fingerprint"],
744 "primary fingerprint must shift when a constituent drops"
745 );
746 }
747
748 #[test]
749 fn gitlab_old_path_pulls_from_diff_rename_map() {
750 let rename_diff = "\
751diff --git a/src/old.ts b/src/new.ts
752similarity index 90%
753rename from src/old.ts
754rename to src/new.ts
755--- a/src/old.ts
756+++ b/src/new.ts
757@@ -1,2 +1,3 @@
758 keep
759+added
760 still
761";
762 let diff_index = DiffIndex::from_unified_diff(rename_diff);
763 let issue = issue("fallow/unused-export", "minor", "src/new.ts", 2, "abc");
764 let envelope = to_value(&render_review_envelope_with_diff(
765 "check",
766 Provider::Gitlab,
767 &[issue],
768 Some(&diff_index),
769 None,
770 None,
771 ReviewSummaryNotes::default(),
772 ));
773 let position = &envelope["comments"][0]["position"];
774 assert_eq!(position["old_path"], "src/old.ts");
775 assert_eq!(position["new_path"], "src/new.ts");
776 }
777
778 #[test]
779 fn gitlab_old_path_falls_back_to_new_path_without_rename() {
780 let issue = issue("fallow/unused-export", "minor", "src/edit.ts", 5, "abc");
781 let envelope = to_value(&render_review_envelope_with_diff(
782 "check",
783 Provider::Gitlab,
784 &[issue],
785 None,
786 None,
787 None,
788 ReviewSummaryNotes::default(),
789 ));
790 let position = &envelope["comments"][0]["position"];
791 assert_eq!(position["old_path"], "src/edit.ts");
792 assert_eq!(position["new_path"], "src/edit.ts");
793 }
794
795 #[test]
796 fn oversized_body_truncates_at_char_boundary_and_preserves_marker() {
797 let huge_desc = "x".repeat(MAX_COMMENT_BODY_BYTES * 2);
798 let issue = CiIssue {
799 rule_id: "fallow/unused-export".into(),
800 description: huge_desc,
801 severity: "minor".into(),
802 path: "src/a.ts".into(),
803 line: 1,
804 end_line: None,
805 other_locations: Vec::new(),
806 fingerprint: "abc1234567890def".into(),
807 legacy_fingerprint: None,
808 };
809 let comment = comment_to_value(&render_merged_comment(
810 Provider::Github,
811 &[&issue],
812 None,
813 None,
814 false,
815 ));
816 let body = comment["body"].as_str().unwrap();
817 assert!(
818 body.len() <= MAX_COMMENT_BODY_BYTES,
819 "body len {} must not exceed cap {MAX_COMMENT_BODY_BYTES}",
820 body.len()
821 );
822 assert!(
823 body.contains("fallow-fingerprint:v3:"),
824 "marker must be preserved under truncation"
825 );
826 assert!(body.contains("<!-- fallow-truncated -->"));
827 assert!(body.contains("> Body truncated by fallow."));
828 assert_eq!(comment["truncated"], true);
829 assert!(std::str::from_utf8(body.as_bytes()).is_ok());
830 }
831
832 #[test]
833 fn oversized_guidance_body_truncates_and_preserves_marker() {
834 let issue = issue_with_desc(
835 "fallow/high-complexity",
836 "x".repeat(MAX_COMMENT_BODY_BYTES * 2),
837 "major",
838 "src/a.ts",
839 1,
840 "abc1234567890def",
841 );
842 let comment = comment_to_value(&render_merged_comment(
843 Provider::Github,
844 &[&issue],
845 None,
846 None,
847 true,
848 ));
849 let body = comment["body"].as_str().unwrap();
850 assert!(body.len() <= MAX_COMMENT_BODY_BYTES);
851 assert!(body.contains("<!-- fallow-truncated -->"));
852 assert!(body.contains("fallow-fingerprint:v3:"));
853 assert_eq!(comment["truncated"], true);
854 }
855
856 #[test]
857 fn multibyte_body_truncates_at_char_boundary() {
858 let huge_desc = "あ".repeat(MAX_COMMENT_BODY_BYTES);
859 let issue = CiIssue {
860 rule_id: "fallow/unused-export".into(),
861 description: huge_desc,
862 severity: "minor".into(),
863 path: "src/a.ts".into(),
864 line: 1,
865 end_line: None,
866 other_locations: Vec::new(),
867 fingerprint: "abc1234567890def".into(),
868 legacy_fingerprint: None,
869 };
870 let comment = comment_to_value(&render_merged_comment(
871 Provider::Github,
872 &[&issue],
873 None,
874 None,
875 false,
876 ));
877 let body = comment["body"].as_str().unwrap();
878 assert!(std::str::from_utf8(body.as_bytes()).is_ok());
879 assert!(body.len() <= MAX_COMMENT_BODY_BYTES);
880 assert_eq!(comment["truncated"], true);
881 }
882}