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