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