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