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