Skip to main content

fallow_cli/report/ci/
review.rs

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::{CiIssue, Provider, command_title, escape_md};
8use super::severity;
9use crate::output_envelope::{
10    GitHubReviewComment, GitHubReviewSide, GitLabReviewComment, GitLabReviewPosition,
11    GitLabReviewPositionType, ReviewCheckConclusion, ReviewComment, ReviewEnvelopeEvent,
12    ReviewEnvelopeMeta, ReviewEnvelopeOutput, ReviewEnvelopeSchema, ReviewEnvelopeSummary,
13    ReviewProvider, default_marker_regex, default_marker_regex_flags,
14};
15use crate::report::emit_json;
16
17/// Conservative body-size floor across the two supported review providers.
18/// GitLab accepts ~1,000,000 chars per `Note#note` validation (see
19/// <https://docs.gitlab.com/administration/instance_limits/>) and GitHub
20/// empirically enforces a 65,536-character cap on PR review comments
21/// (undocumented but reproducible: a 65,537-char body returns
22/// `Body is too long (maximum is 65536 characters)`). We pick 65,536 BYTES
23/// here so the cap is safe under either vendor regardless of whether the
24/// limit is enforced in bytes or chars, and regardless of multi-byte UTF-8
25/// expansion. Hardcoded for now; if a real consumer needs it tunable, expose
26/// a `FALLOW_REVIEW_MAX_BODY_BYTES` env var.
27const MAX_COMMENT_BODY_BYTES: usize = 65_536;
28
29/// Marker prefix appended to every v2 review-comment body. Mirrored by
30/// [`crate::output_envelope::MARKER_REGEX_V2`]; both must change together
31/// because consumers extract the fingerprint by running the regex over a
32/// body whose marker line uses this prefix. The `:v2:` namespace prevents
33/// collision with v1 historical markers and reduces user-paste spoofing
34/// risk (typing `:v2:` by accident is unlikely).
35pub const MARKER_PREFIX_V2: &str = "<!-- fallow-fingerprint:v2: ";
36
37/// Closing of the v2 marker, after the fingerprint string.
38const MARKER_SUFFIX_V2: &str = " -->";
39
40/// Human-readable truncation breadcrumb appended to the body when the
41/// rendered content exceeds [`MAX_COMMENT_BODY_BYTES`]. The HTML comment is
42/// machine-detectable; the blockquote that follows is a human-readable
43/// breadcrumb that reads as fallow speaking (matching the existing
44/// `> Run \`fallow fix --files\` or delete this file.` convention from the
45/// unused-file suggestion block). Three signals total (typed
46/// `truncated: bool` on the comment, this HTML marker, and the blockquote
47/// text) so consumers don't need to choose a primary detection channel.
48const TRUNCATION_SUFFIX: &str = "\n\n<!-- fallow-truncated -->\n> Body truncated by fallow.";
49
50#[must_use]
51pub fn render_review_envelope(
52    command: &str,
53    provider: Provider,
54    issues: &[CiIssue],
55) -> ReviewEnvelopeOutput {
56    render_review_envelope_with_diff(
57        command,
58        provider,
59        issues,
60        super::diff_filter::shared_diff_index(),
61    )
62}
63
64/// Render path the print site uses. Exposed so unit tests can pass a
65/// hand-crafted `DiffIndex` without poking the process-wide `SHARED_DIFF`
66/// cache (which is `OnceLock`-bounded and not reentrant under cargo test's
67/// parallel runner).
68#[must_use]
69pub fn render_review_envelope_with_diff(
70    command: &str,
71    provider: Provider,
72    issues: &[CiIssue],
73    diff_index: Option<&DiffIndex>,
74) -> ReviewEnvelopeOutput {
75    let max = std::env::var("FALLOW_MAX_COMMENTS")
76        .ok()
77        .and_then(|v| v.parse::<usize>().ok())
78        .unwrap_or(50);
79    let gitlab_diff_refs = (provider == Provider::Gitlab)
80        .then(gitlab_diff_refs_from_env)
81        .flatten();
82
83    // Step 1: group consecutive same-(path, line) issues. Input is already
84    // sorted by `(path, line, fingerprint)` (see `pr_comment::issues_from_codeclimate`).
85    let merged_groups = group_by_path_line(issues);
86
87    // Step 2: cap to FALLOW_MAX_COMMENTS at the post-merge group count.
88    // A run that produces 50 comments where 10 collapse into 1 still gets
89    // 40 comments emitted, not 50 minus 9.
90    let comments: Vec<ReviewComment> = merged_groups
91        .iter()
92        .take(max)
93        .map(|group| render_merged_comment(provider, group, gitlab_diff_refs.as_ref(), diff_index))
94        .collect();
95
96    let summary_text = format!(
97        "### Fallow {}\n\n{} inline finding{} selected for {} review.\n\n<!-- fallow-review -->",
98        command_title(command),
99        comments.len(),
100        if comments.len() == 1 { "" } else { "s" },
101        provider.name(),
102    );
103    let summary_fp = summary_fingerprint(&summary_text);
104    let summary_marker = format!("\n\n{MARKER_PREFIX_V2}{summary_fp}{MARKER_SUFFIX_V2}");
105    let body = format!("{summary_text}{summary_marker}");
106    let summary = ReviewEnvelopeSummary {
107        body: body.clone(),
108        fingerprint: summary_fp,
109    };
110
111    match provider {
112        Provider::Github => ReviewEnvelopeOutput {
113            event: Some(ReviewEnvelopeEvent::Comment),
114            body,
115            summary,
116            comments,
117            marker_regex: default_marker_regex(),
118            marker_regex_flags: default_marker_regex_flags(),
119            meta: ReviewEnvelopeMeta {
120                schema: ReviewEnvelopeSchema::V2,
121                provider: ReviewProvider::Github,
122                check_conclusion: Some(github_check_conclusion(issues)),
123            },
124        },
125        Provider::Gitlab => ReviewEnvelopeOutput {
126            event: None,
127            body,
128            summary,
129            comments,
130            marker_regex: default_marker_regex(),
131            marker_regex_flags: default_marker_regex_flags(),
132            meta: ReviewEnvelopeMeta {
133                schema: ReviewEnvelopeSchema::V2,
134                provider: ReviewProvider::Gitlab,
135                check_conclusion: None,
136            },
137        },
138    }
139}
140
141#[must_use]
142pub fn print_review_envelope(command: &str, provider: Provider, codeclimate: &Value) -> ExitCode {
143    let issues = super::diff_filter::filter_issues_from_env(
144        super::pr_comment::issues_from_codeclimate(codeclimate),
145    );
146    let envelope = render_review_envelope(command, provider, &issues);
147    let value =
148        serde_json::to_value(&envelope).expect("ReviewEnvelopeOutput serializes infallibly");
149    emit_json(&value, "review envelope")
150}
151
152#[derive(Clone, Debug, PartialEq, Eq)]
153#[expect(
154    clippy::struct_field_names,
155    reason = "GitLab API names these diff refs base_sha/start_sha/head_sha"
156)]
157struct GitlabDiffRefs {
158    base_sha: String,
159    start_sha: String,
160    head_sha: String,
161}
162
163fn gitlab_diff_refs_from_env() -> Option<GitlabDiffRefs> {
164    let base_sha = env_nonempty("FALLOW_GITLAB_BASE_SHA")
165        .or_else(|| env_nonempty("CI_MERGE_REQUEST_DIFF_BASE_SHA"))?;
166    let start_sha = env_nonempty("FALLOW_GITLAB_START_SHA").unwrap_or_else(|| base_sha.clone());
167    let head_sha =
168        env_nonempty("FALLOW_GITLAB_HEAD_SHA").or_else(|| env_nonempty("CI_COMMIT_SHA"))?;
169    Some(GitlabDiffRefs {
170        base_sha,
171        start_sha,
172        head_sha,
173    })
174}
175
176fn env_nonempty(name: &str) -> Option<String> {
177    std::env::var(name)
178        .ok()
179        .filter(|value| !value.trim().is_empty())
180}
181
182/// Group consecutive same-(path, line) issues. Input is already sorted by
183/// `(path, line, fingerprint)` so a single linear pass collects runs.
184fn group_by_path_line(issues: &[CiIssue]) -> Vec<Vec<&CiIssue>> {
185    let mut groups: Vec<Vec<&CiIssue>> = Vec::new();
186    let mut current: Vec<&CiIssue> = Vec::new();
187    let mut current_key: Option<(&str, u64)> = None;
188    for issue in issues {
189        let key = (issue.path.as_str(), issue.line);
190        if Some(key) != current_key {
191            if !current.is_empty() {
192                groups.push(std::mem::take(&mut current));
193            }
194            current_key = Some(key);
195        }
196        current.push(issue);
197    }
198    if !current.is_empty() {
199        groups.push(current);
200    }
201    groups
202}
203
204/// Render one comment from a group of 1+ issues that share the same
205/// `(path, line)`. Single-element groups produce the v1-shaped body
206/// (modulo the `:v2:` marker shape); multi-element groups stack each
207/// finding's `**label** \`rule\`: desc` paragraph under a
208/// `merged:<16-char hash>` composite fingerprint over sorted constituent
209/// fingerprints. The composite identity shifts whenever the set of
210/// constituents changes, so consumers' skip-if-fingerprint-exists logic
211/// correctly re-posts on content change.
212fn render_merged_comment(
213    provider: Provider,
214    group: &[&CiIssue],
215    gitlab_diff_refs: Option<&GitlabDiffRefs>,
216    diff_index: Option<&DiffIndex>,
217) -> ReviewComment {
218    assert!(!group.is_empty(), "group_by_path_line never yields empty");
219    let representative = group[0];
220    let fingerprint = if group.len() == 1 {
221        representative.fingerprint.clone()
222    } else {
223        let constituents: Vec<&str> = group.iter().map(|i| i.fingerprint.as_str()).collect();
224        composite_fingerprint(&constituents)
225    };
226
227    // Build the rendered body content WITHOUT the trailing marker so the
228    // truncation logic can preserve the marker at the tail under cap.
229    use std::fmt::Write as _;
230    let mut content = String::new();
231    for (index, issue) in group.iter().enumerate() {
232        let label = review_label_from_codeclimate(&issue.severity);
233        if index > 0 {
234            content.push_str("\n\n");
235        }
236        write!(
237            content,
238            "**{}** `{}`: {}",
239            label,
240            escape_md(&issue.rule_id),
241            escape_md(&issue.description)
242        )
243        .expect("write to String is infallible");
244        if let Some(suggestion) = super::suggestion::suggestion_block(provider, issue) {
245            content.push_str(&suggestion);
246        }
247    }
248
249    let marker_line = format!("\n\n{MARKER_PREFIX_V2}{fingerprint}{MARKER_SUFFIX_V2}");
250    let (body, truncated) = cap_body_with_marker(&content, &marker_line);
251
252    match provider {
253        // Fallow findings point at the current file state. GitHub deletion-side
254        // review comments are intentionally not modeled in this envelope yet.
255        Provider::Github => ReviewComment::GitHub(GitHubReviewComment {
256            path: representative.path.clone(),
257            // `CiIssue.line` is `u64` for legacy reasons but every callsite
258            // populates it from a `u32` line number (`begin_line: Option<u32>`
259            // in `cc_issue`); the typed envelope locks the wire to `u32`.
260            // Follow-up: narrow `CiIssue.line` to `u32` at construction time
261            // in `pr_comment.rs::issues_from_codeclimate` so this cast goes
262            // away entirely (out of scope for the #384 ladder migration).
263            line: u32::try_from(representative.line).unwrap_or(u32::MAX),
264            side: GitHubReviewSide::Right,
265            body,
266            fingerprint,
267            truncated,
268        }),
269        Provider::Gitlab => {
270            // Issue #528: GitLab's position API requires `old_path` to hold
271            // the base-side filename for renamed files. Without this, an
272            // inline comment on a renamed file fails to anchor and is
273            // rejected at POST time. Falls back to the head-side path when
274            // the diff index has no rename pair recorded (the common case:
275            // edits, additions, deletions).
276            let new_path = representative.path.clone();
277            let old_path = diff_index
278                .and_then(|di| di.old_path_for(&new_path))
279                .map_or_else(|| new_path.clone(), str::to_owned);
280            let position = GitLabReviewPosition {
281                base_sha: gitlab_diff_refs.map(|r| r.base_sha.clone()),
282                start_sha: gitlab_diff_refs.map(|r| r.start_sha.clone()),
283                head_sha: gitlab_diff_refs.map(|r| r.head_sha.clone()),
284                position_type: GitLabReviewPositionType::Text,
285                old_path,
286                new_path,
287                // Same `u64 -> u32` narrowing as the GitHub branch above;
288                // see the follow-up note there.
289                new_line: u32::try_from(representative.line).unwrap_or(u32::MAX),
290            };
291            ReviewComment::GitLab(GitLabReviewComment {
292                body,
293                position,
294                fingerprint,
295                truncated,
296            })
297        }
298    }
299}
300
301/// Truncate `content` if appending `marker_line` would exceed
302/// [`MAX_COMMENT_BODY_BYTES`], preserving the marker at the tail and
303/// inserting a [`TRUNCATION_SUFFIX`] breadcrumb. Truncation walks back to
304/// the nearest UTF-8 char boundary so multi-byte characters straddling the
305/// cut are not chopped mid-codepoint. Returns `(final_body, truncated)`.
306fn cap_body_with_marker(content: &str, marker_line: &str) -> (String, bool) {
307    let intact_len = content.len() + marker_line.len();
308    if intact_len <= MAX_COMMENT_BODY_BYTES {
309        let mut out = String::with_capacity(intact_len);
310        out.push_str(content);
311        out.push_str(marker_line);
312        return (out, false);
313    }
314    // Reserve space for the marker + truncation breadcrumb, then walk back
315    // from the budget to the nearest UTF-8 boundary. `MAX - reserved` may
316    // underflow on absurdly large markers, but the marker is bounded by
317    // `MARKER_PREFIX_V2` (28 bytes) + fingerprint kind prefix + 16 hex chars
318    // + suffix (4 bytes), well under 100 bytes; saturating_sub is defensive.
319    let reserved = marker_line.len() + TRUNCATION_SUFFIX.len();
320    let budget = MAX_COMMENT_BODY_BYTES.saturating_sub(reserved);
321    let mut cut = budget.min(content.len());
322    while cut > 0 && !content.is_char_boundary(cut) {
323        cut -= 1;
324    }
325    let mut out = String::with_capacity(MAX_COMMENT_BODY_BYTES);
326    out.push_str(&content[..cut]);
327    out.push_str(TRUNCATION_SUFFIX);
328    out.push_str(marker_line);
329    (out, true)
330}
331
332fn review_label_from_codeclimate(severity_name: &str) -> &'static str {
333    match severity_name {
334        "major" | "critical" | "blocker" => severity::review_label(fallow_config::Severity::Error),
335        _ => severity::review_label(fallow_config::Severity::Warn),
336    }
337}
338
339fn github_check_conclusion(issues: &[CiIssue]) -> ReviewCheckConclusion {
340    if issues
341        .iter()
342        .any(|issue| matches!(issue.severity.as_str(), "major" | "critical" | "blocker"))
343    {
344        ReviewCheckConclusion::Failure
345    } else if issues.is_empty() {
346        ReviewCheckConclusion::Success
347    } else {
348        ReviewCheckConclusion::Neutral
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355    use crate::output_envelope::MARKER_REGEX_V2;
356
357    fn to_value(envelope: &ReviewEnvelopeOutput) -> Value {
358        serde_json::to_value(envelope).expect("ReviewEnvelopeOutput serializes infallibly")
359    }
360
361    fn comment_to_value(comment: &ReviewComment) -> Value {
362        serde_json::to_value(comment).expect("ReviewComment serializes infallibly")
363    }
364
365    fn issue(rule: &str, sev: &str, path: &str, line: u64, fp: &str) -> CiIssue {
366        CiIssue {
367            rule_id: rule.into(),
368            description: "desc".into(),
369            severity: sev.into(),
370            path: path.into(),
371            line,
372            fingerprint: fp.into(),
373        }
374    }
375
376    #[test]
377    fn github_review_envelope_matches_api_shape() {
378        let issues = vec![issue(
379            "fallow/unused-file",
380            "minor",
381            "src/a.ts",
382            1,
383            "abc1234567890def",
384        )];
385        let envelope = to_value(&render_review_envelope("check", Provider::Github, &issues));
386        assert_eq!(envelope["event"], "COMMENT");
387        assert_eq!(envelope["meta"]["schema"], "fallow-review-envelope/v2");
388        assert_eq!(envelope["comments"][0]["path"], "src/a.ts");
389        assert!(
390            envelope["comments"][0]["body"]
391                .as_str()
392                .unwrap()
393                .contains("fallow-fingerprint:v2:")
394        );
395    }
396
397    #[test]
398    fn github_comments_target_current_state_side() {
399        let issue = issue("fallow/unused-file", "minor", "src/a.ts", 1, "abc");
400        let comment = comment_to_value(&render_merged_comment(
401            Provider::Github,
402            &[&issue],
403            None,
404            None,
405        ));
406        assert_eq!(comment["side"], "RIGHT");
407    }
408
409    #[test]
410    fn labels_major_issues_as_errors() {
411        let issue = issue("fallow/unused-file", "major", "src/a.ts", 1, "abc");
412        let comment = comment_to_value(&render_merged_comment(
413            Provider::Github,
414            &[&issue],
415            None,
416            None,
417        ));
418        assert!(comment["body"].as_str().unwrap().starts_with("**error**"));
419    }
420
421    #[test]
422    fn gitlab_comment_accepts_diff_refs() {
423        let issue = issue("fallow/unused-file", "minor", "src/a.ts", 1, "abc");
424        let refs = GitlabDiffRefs {
425            base_sha: "base".into(),
426            start_sha: "start".into(),
427            head_sha: "head".into(),
428        };
429        let comment = comment_to_value(&render_merged_comment(
430            Provider::Gitlab,
431            &[&issue],
432            Some(&refs),
433            None,
434        ));
435        assert_eq!(comment["position"]["position_type"], "text");
436        assert_eq!(comment["position"]["base_sha"], "base");
437        assert_eq!(comment["position"]["start_sha"], "start");
438        assert_eq!(comment["position"]["head_sha"], "head");
439    }
440
441    #[test]
442    fn envelope_emits_marker_regex_field_at_root() {
443        let issues = vec![issue("fallow/unused-file", "minor", "src/a.ts", 1, "abc")];
444        let env = to_value(&render_review_envelope("check", Provider::Github, &issues));
445        let regex = env["marker_regex"].as_str().expect("marker_regex present");
446        assert_eq!(regex, MARKER_REGEX_V2);
447        // Pinned to exactly 16 hex chars (single hash form, optionally with
448        // a kind prefix like `merged:`).
449        assert!(regex.contains("[0-9a-f]{16}"));
450        // Anchored start-of-line + end-of-line. Consumers pass the flags
451        // field as the second arg to their regex engine so `^` / `$`
452        // match at line boundaries.
453        assert!(regex.starts_with('^'));
454        assert!(regex.ends_with("\\s*$"));
455        // No `(?m)` baked into the pattern; JavaScript RegExp rejects
456        // standalone inline flag groups with `Invalid group`.
457        assert!(!regex.contains("(?m)"));
458        // Capture group 1 is the fingerprint. Optional kind prefix:
459        // `(?:[a-z]+:)?` lets `merged:<hex>` or bare `<hex>` match in the
460        // same group.
461        assert!(regex.contains("((?:[a-z]+:)?[0-9a-f]{16})"));
462        // Flags field carries "m" so anchored `^` / `$` work per-line.
463        let flags = env["marker_regex_flags"]
464            .as_str()
465            .expect("marker_regex_flags present");
466        assert_eq!(flags, "m");
467    }
468
469    #[test]
470    fn envelope_emits_summary_block_with_fingerprint() {
471        let issues = vec![issue("fallow/unused-file", "minor", "src/a.ts", 1, "abc")];
472        let env = to_value(&render_review_envelope("check", Provider::Github, &issues));
473        assert_eq!(env["summary"]["body"], env["body"]);
474        let summary_fp = env["summary"]["fingerprint"].as_str().expect("fingerprint");
475        assert_eq!(summary_fp.len(), 16);
476        assert!(summary_fp.chars().all(|c| c.is_ascii_hexdigit()));
477        // The fingerprint should appear inside the body's marker.
478        let body_str = env["body"].as_str().unwrap();
479        let marker_line = format!("{MARKER_PREFIX_V2}{summary_fp}{MARKER_SUFFIX_V2}");
480        assert!(
481            body_str.contains(&marker_line),
482            "body must carry summary marker:\nbody={body_str}\nmarker={marker_line}"
483        );
484    }
485
486    #[test]
487    fn same_line_findings_merge_into_one_comment_with_composite_fingerprint() {
488        let a = issue("fallow/unused-export", "minor", "src/foo.ts", 42, "fp_a");
489        let b = issue("fallow/duplicate-export", "minor", "src/foo.ts", 42, "fp_b");
490        let env = to_value(&render_review_envelope("check", Provider::Github, &[a, b]));
491        assert_eq!(
492            env["comments"].as_array().unwrap().len(),
493            1,
494            "two same-line findings must collapse to one comment"
495        );
496        let merged = &env["comments"][0];
497        let fp = merged["fingerprint"].as_str().unwrap();
498        assert!(
499            fp.starts_with("merged:"),
500            "merged comment fingerprint must start with merged:, got {fp}"
501        );
502        // 7 chars prefix + 16 hex = 23 total.
503        assert_eq!(fp.len(), 23);
504        // Body carries both finding paragraphs and ONE marker.
505        let body = merged["body"].as_str().unwrap();
506        assert!(body.contains("fallow/unused-export"));
507        assert!(body.contains("fallow/duplicate-export"));
508        assert_eq!(
509            body.matches("fallow-fingerprint:v2:").count(),
510            1,
511            "merged body must carry exactly one fingerprint marker"
512        );
513        // Constituent fingerprints are NOT emitted on the wire; the
514        // composite hash is the only identity signal.
515        assert!(
516            merged.get("constituent_fingerprints").is_none(),
517            "v2 hashed-composite design does not emit constituent_fingerprints"
518        );
519    }
520
521    #[test]
522    fn single_finding_keeps_v1_fingerprint_shape() {
523        let issues = vec![issue(
524            "fallow/unused-file",
525            "minor",
526            "src/a.ts",
527            1,
528            "abc1234567890def",
529        )];
530        let env = to_value(&render_review_envelope("check", Provider::Github, &issues));
531        let comment = &env["comments"][0];
532        assert_eq!(comment["fingerprint"], "abc1234567890def");
533        assert!(
534            comment.get("constituent_fingerprints").is_none(),
535            "single-finding comment must NOT emit constituent_fingerprints"
536        );
537        assert!(
538            comment.get("truncated").is_none(),
539            "non-truncated comment must NOT emit truncated"
540        );
541    }
542
543    #[test]
544    fn composite_fingerprint_shifts_when_constituents_change() {
545        // Hashed-composite identity changes when constituents change, so
546        // the bundled wrappers' skip-if-fingerprint-exists logic correctly
547        // re-posts on content change. Idempotent on equal input.
548        let a = issue("fallow/unused-export", "minor", "src/foo.ts", 42, "fp_a");
549        let b = issue("fallow/duplicate-export", "minor", "src/foo.ts", 42, "fp_b");
550        let c = issue("fallow/unused-type", "minor", "src/foo.ts", 42, "fp_c");
551        let run1 = to_value(&render_review_envelope(
552            "check",
553            Provider::Github,
554            &[a.clone(), b, c.clone()],
555        ));
556        let run2_drop_b = to_value(&render_review_envelope("check", Provider::Github, &[a, c]));
557        assert_ne!(
558            run1["comments"][0]["fingerprint"], run2_drop_b["comments"][0]["fingerprint"],
559            "primary fingerprint must shift when a constituent drops"
560        );
561    }
562
563    #[test]
564    fn gitlab_old_path_pulls_from_diff_rename_map() {
565        let rename_diff = "\
566diff --git a/src/old.ts b/src/new.ts
567similarity index 90%
568rename from src/old.ts
569rename to src/new.ts
570--- a/src/old.ts
571+++ b/src/new.ts
572@@ -1,2 +1,3 @@
573 keep
574+added
575 still
576";
577        let diff_index = DiffIndex::from_unified_diff(rename_diff);
578        let issue = issue("fallow/unused-export", "minor", "src/new.ts", 2, "abc");
579        let envelope = to_value(&render_review_envelope_with_diff(
580            "check",
581            Provider::Gitlab,
582            &[issue],
583            Some(&diff_index),
584        ));
585        let position = &envelope["comments"][0]["position"];
586        assert_eq!(position["old_path"], "src/old.ts");
587        assert_eq!(position["new_path"], "src/new.ts");
588    }
589
590    #[test]
591    fn gitlab_old_path_falls_back_to_new_path_without_rename() {
592        let issue = issue("fallow/unused-export", "minor", "src/edit.ts", 5, "abc");
593        let envelope = to_value(&render_review_envelope_with_diff(
594            "check",
595            Provider::Gitlab,
596            &[issue],
597            None,
598        ));
599        let position = &envelope["comments"][0]["position"];
600        assert_eq!(position["old_path"], "src/edit.ts");
601        assert_eq!(position["new_path"], "src/edit.ts");
602    }
603
604    #[test]
605    fn oversized_body_truncates_at_char_boundary_and_preserves_marker() {
606        // Synthesize an issue whose description blows past the body cap.
607        let huge_desc = "x".repeat(MAX_COMMENT_BODY_BYTES * 2);
608        let issue = CiIssue {
609            rule_id: "fallow/unused-export".into(),
610            description: huge_desc,
611            severity: "minor".into(),
612            path: "src/a.ts".into(),
613            line: 1,
614            fingerprint: "abc1234567890def".into(),
615        };
616        let comment = comment_to_value(&render_merged_comment(
617            Provider::Github,
618            &[&issue],
619            None,
620            None,
621        ));
622        let body = comment["body"].as_str().unwrap();
623        assert!(
624            body.len() <= MAX_COMMENT_BODY_BYTES,
625            "body len {} must not exceed cap {MAX_COMMENT_BODY_BYTES}",
626            body.len()
627        );
628        // The marker must survive truncation at the tail.
629        assert!(
630            body.contains("fallow-fingerprint:v2:"),
631            "marker must be preserved under truncation"
632        );
633        // Both the machine-detectable HTML marker and the human blockquote
634        // breadcrumb appear.
635        assert!(body.contains("<!-- fallow-truncated -->"));
636        assert!(body.contains("> Body truncated by fallow."));
637        // Typed boolean is set so consumers don't have to string-match.
638        assert_eq!(comment["truncated"], true);
639        // Body bytes are valid UTF-8 (char-boundary truncation).
640        assert!(std::str::from_utf8(body.as_bytes()).is_ok());
641    }
642
643    #[test]
644    fn multibyte_body_truncates_at_char_boundary() {
645        // Each Japanese char is 3 bytes in UTF-8. A byte-boundary truncation
646        // anywhere inside one would produce invalid UTF-8.
647        let huge_desc: String = "あ".repeat(MAX_COMMENT_BODY_BYTES);
648        let issue = CiIssue {
649            rule_id: "fallow/unused-export".into(),
650            description: huge_desc,
651            severity: "minor".into(),
652            path: "src/a.ts".into(),
653            line: 1,
654            fingerprint: "abc1234567890def".into(),
655        };
656        let comment = comment_to_value(&render_merged_comment(
657            Provider::Github,
658            &[&issue],
659            None,
660            None,
661        ));
662        let body = comment["body"].as_str().unwrap();
663        // Cargo will fail to deserialize the snapshot if `body` is not
664        // valid UTF-8 (serde_json::Value::String requires it), so this
665        // assertion is somewhat tautological -- but the explicit decode
666        // pins the contract.
667        assert!(std::str::from_utf8(body.as_bytes()).is_ok());
668        assert!(body.len() <= MAX_COMMENT_BODY_BYTES);
669        assert_eq!(comment["truncated"], true);
670    }
671}