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    let include_guidance = review_guidance_enabled();
83
84    // Step 1: group consecutive same-(path, line) issues. Input is already
85    // sorted by `(path, line, fingerprint)` (see `pr_comment::issues_from_codeclimate`).
86    let merged_groups = group_by_path_line(issues);
87
88    // Step 2: cap to FALLOW_MAX_COMMENTS at the post-merge group count.
89    // A run that produces 50 comments where 10 collapse into 1 still gets
90    // 40 comments emitted, not 50 minus 9.
91    let comments: Vec<ReviewComment> = merged_groups
92        .iter()
93        .take(max)
94        .map(|group| {
95            render_merged_comment(
96                provider,
97                group,
98                gitlab_diff_refs.as_ref(),
99                diff_index,
100                include_guidance,
101            )
102        })
103        .collect();
104
105    let summary_text = format!(
106        "### Fallow {}\n\n{} inline finding{} selected for {} review.\n\n<!-- fallow-review -->",
107        command_title(command),
108        comments.len(),
109        if comments.len() == 1 { "" } else { "s" },
110        provider.name(),
111    );
112    let summary_fp = summary_fingerprint(&summary_text);
113    let summary_marker = format!("\n\n{MARKER_PREFIX_V2}{summary_fp}{MARKER_SUFFIX_V2}");
114    let body = format!("{summary_text}{summary_marker}");
115    let summary = ReviewEnvelopeSummary {
116        body: body.clone(),
117        fingerprint: summary_fp,
118    };
119
120    match provider {
121        Provider::Github => ReviewEnvelopeOutput {
122            event: Some(ReviewEnvelopeEvent::Comment),
123            body,
124            summary,
125            comments,
126            marker_regex: default_marker_regex(),
127            marker_regex_flags: default_marker_regex_flags(),
128            meta: ReviewEnvelopeMeta {
129                schema: ReviewEnvelopeSchema::V2,
130                provider: ReviewProvider::Github,
131                check_conclusion: Some(github_check_conclusion(issues)),
132            },
133        },
134        Provider::Gitlab => ReviewEnvelopeOutput {
135            event: None,
136            body,
137            summary,
138            comments,
139            marker_regex: default_marker_regex(),
140            marker_regex_flags: default_marker_regex_flags(),
141            meta: ReviewEnvelopeMeta {
142                schema: ReviewEnvelopeSchema::V2,
143                provider: ReviewProvider::Gitlab,
144                check_conclusion: None,
145            },
146        },
147    }
148}
149
150#[must_use]
151pub fn print_review_envelope(command: &str, provider: Provider, codeclimate: &Value) -> ExitCode {
152    let issues = super::diff_filter::filter_issues_from_env(
153        super::pr_comment::issues_from_codeclimate(codeclimate),
154    );
155    let envelope = render_review_envelope(command, provider, &issues);
156    let value =
157        serde_json::to_value(&envelope).expect("ReviewEnvelopeOutput serializes infallibly");
158    emit_json(&value, "review envelope")
159}
160
161#[derive(Clone, Debug, PartialEq, Eq)]
162#[expect(
163    clippy::struct_field_names,
164    reason = "GitLab API names these diff refs base_sha/start_sha/head_sha"
165)]
166struct GitlabDiffRefs {
167    base_sha: String,
168    start_sha: String,
169    head_sha: String,
170}
171
172fn gitlab_diff_refs_from_env() -> Option<GitlabDiffRefs> {
173    let base_sha = env_nonempty("FALLOW_GITLAB_BASE_SHA")
174        .or_else(|| env_nonempty("CI_MERGE_REQUEST_DIFF_BASE_SHA"))?;
175    let start_sha = env_nonempty("FALLOW_GITLAB_START_SHA").unwrap_or_else(|| base_sha.clone());
176    let head_sha =
177        env_nonempty("FALLOW_GITLAB_HEAD_SHA").or_else(|| env_nonempty("CI_COMMIT_SHA"))?;
178    Some(GitlabDiffRefs {
179        base_sha,
180        start_sha,
181        head_sha,
182    })
183}
184
185fn env_nonempty(name: &str) -> Option<String> {
186    std::env::var(name)
187        .ok()
188        .filter(|value| !value.trim().is_empty())
189}
190
191fn review_guidance_enabled() -> bool {
192    std::env::var("FALLOW_REVIEW_GUIDANCE").is_ok_and(|value| env_truthy(&value))
193}
194
195fn env_truthy(value: &str) -> bool {
196    matches!(
197        value.trim().to_ascii_lowercase().as_str(),
198        "1" | "true" | "yes" | "on"
199    )
200}
201
202/// Group consecutive same-(path, line) issues. Input is already sorted by
203/// `(path, line, fingerprint)` so a single linear pass collects runs.
204fn group_by_path_line(issues: &[CiIssue]) -> Vec<Vec<&CiIssue>> {
205    let mut groups: Vec<Vec<&CiIssue>> = Vec::new();
206    let mut current: Vec<&CiIssue> = Vec::new();
207    let mut current_key: Option<(&str, u64)> = None;
208    for issue in issues {
209        let key = (issue.path.as_str(), issue.line);
210        if Some(key) != current_key {
211            if !current.is_empty() {
212                groups.push(std::mem::take(&mut current));
213            }
214            current_key = Some(key);
215        }
216        current.push(issue);
217    }
218    if !current.is_empty() {
219        groups.push(current);
220    }
221    groups
222}
223
224/// Render one comment from a group of 1+ issues that share the same
225/// `(path, line)`. Single-element groups produce the v1-shaped body
226/// (modulo the `:v2:` marker shape); multi-element groups stack each
227/// finding's `**label** \`rule\`: desc` paragraph under a
228/// `merged:<16-char hash>` composite fingerprint over sorted constituent
229/// fingerprints. The composite identity shifts whenever the set of
230/// constituents changes, so consumers' skip-if-fingerprint-exists logic
231/// correctly re-posts on content change.
232fn render_merged_comment(
233    provider: Provider,
234    group: &[&CiIssue],
235    gitlab_diff_refs: Option<&GitlabDiffRefs>,
236    diff_index: Option<&DiffIndex>,
237    include_guidance: bool,
238) -> ReviewComment {
239    assert!(!group.is_empty(), "group_by_path_line never yields empty");
240    let representative = group[0];
241    let fingerprint = if group.len() == 1 {
242        representative.fingerprint.clone()
243    } else {
244        let constituents: Vec<&str> = group.iter().map(|i| i.fingerprint.as_str()).collect();
245        composite_fingerprint(&constituents)
246    };
247
248    // Build the rendered body content WITHOUT the trailing marker so the
249    // truncation logic can preserve the marker at the tail under cap.
250    use std::fmt::Write as _;
251    let mut content = String::new();
252    for (index, issue) in group.iter().enumerate() {
253        let label = review_label_from_codeclimate(&issue.severity);
254        if index > 0 {
255            content.push_str("\n\n");
256        }
257        write!(
258            content,
259            "**{}** `{}`: {}",
260            label,
261            escape_md(&issue.rule_id),
262            escape_md(&issue.description)
263        )
264        .expect("write to String is infallible");
265        if let Some(suggestion) = super::suggestion::suggestion_block(provider, issue) {
266            content.push_str(&suggestion);
267        }
268        if include_guidance && let Some(guidance) = review_guidance_block(issue) {
269            content.push_str(&guidance);
270        }
271    }
272
273    let marker_line = format!("\n\n{MARKER_PREFIX_V2}{fingerprint}{MARKER_SUFFIX_V2}");
274    let (body, truncated) = cap_body_with_marker(&content, &marker_line);
275
276    match provider {
277        // Fallow findings point at the current file state. GitHub deletion-side
278        // review comments are intentionally not modeled in this envelope yet.
279        Provider::Github => ReviewComment::GitHub(GitHubReviewComment {
280            path: representative.path.clone(),
281            // `CiIssue.line` is `u64` for legacy reasons but every callsite
282            // populates it from a `u32` line number (`begin_line: Option<u32>`
283            // in `cc_issue`); the typed envelope locks the wire to `u32`.
284            // Follow-up: narrow `CiIssue.line` to `u32` at construction time
285            // in `pr_comment.rs::issues_from_codeclimate` so this cast goes
286            // away entirely (out of scope for the #384 ladder migration).
287            line: u32::try_from(representative.line).unwrap_or(u32::MAX),
288            side: GitHubReviewSide::Right,
289            body,
290            fingerprint,
291            truncated,
292        }),
293        Provider::Gitlab => {
294            // Issue #528: GitLab's position API requires `old_path` to hold
295            // the base-side filename for renamed files. Without this, an
296            // inline comment on a renamed file fails to anchor and is
297            // rejected at POST time. Falls back to the head-side path when
298            // the diff index has no rename pair recorded (the common case:
299            // edits, additions, deletions).
300            let new_path = representative.path.clone();
301            let old_path = diff_index
302                .and_then(|di| di.old_path_for(&new_path))
303                .map_or_else(|| new_path.clone(), str::to_owned);
304            let position = GitLabReviewPosition {
305                base_sha: gitlab_diff_refs.map(|r| r.base_sha.clone()),
306                start_sha: gitlab_diff_refs.map(|r| r.start_sha.clone()),
307                head_sha: gitlab_diff_refs.map(|r| r.head_sha.clone()),
308                position_type: GitLabReviewPositionType::Text,
309                old_path,
310                new_path,
311                // Same `u64 -> u32` narrowing as the GitHub branch above;
312                // see the follow-up note there.
313                new_line: u32::try_from(representative.line).unwrap_or(u32::MAX),
314            };
315            ReviewComment::GitLab(GitLabReviewComment {
316                body,
317                position,
318                fingerprint,
319                truncated,
320            })
321        }
322    }
323}
324
325fn review_guidance_block(issue: &CiIssue) -> Option<String> {
326    let rule = crate::explain::rule_by_id(&issue.rule_id)?;
327    let guide = crate::explain::rule_guide(rule);
328    let docs_url = crate::explain::rule_docs_url(rule);
329
330    Some(format!(
331        "\n\n<details><summary>What to do</summary>\n\n{}\n\n[Read the rule docs]({docs_url})\n\n</details>",
332        guide.how_to_fix
333    ))
334}
335
336/// Truncate `content` if appending `marker_line` would exceed
337/// [`MAX_COMMENT_BODY_BYTES`], preserving the marker at the tail and
338/// inserting a [`TRUNCATION_SUFFIX`] breadcrumb. Truncation walks back to
339/// the nearest UTF-8 char boundary so multi-byte characters straddling the
340/// cut are not chopped mid-codepoint. Returns `(final_body, truncated)`.
341fn cap_body_with_marker(content: &str, marker_line: &str) -> (String, bool) {
342    let intact_len = content.len() + marker_line.len();
343    if intact_len <= MAX_COMMENT_BODY_BYTES {
344        let mut out = String::with_capacity(intact_len);
345        out.push_str(content);
346        out.push_str(marker_line);
347        return (out, false);
348    }
349    // Reserve space for the marker + truncation breadcrumb, then walk back
350    // from the budget to the nearest UTF-8 boundary. `MAX - reserved` may
351    // underflow on absurdly large markers, but the marker is bounded by
352    // `MARKER_PREFIX_V2` (28 bytes) + fingerprint kind prefix + 16 hex chars
353    // + suffix (4 bytes), well under 100 bytes; saturating_sub is defensive.
354    let reserved = marker_line.len() + TRUNCATION_SUFFIX.len();
355    let budget = MAX_COMMENT_BODY_BYTES.saturating_sub(reserved);
356    let mut cut = budget.min(content.len());
357    while cut > 0 && !content.is_char_boundary(cut) {
358        cut -= 1;
359    }
360    let mut out = String::with_capacity(MAX_COMMENT_BODY_BYTES);
361    out.push_str(&content[..cut]);
362    out.push_str(TRUNCATION_SUFFIX);
363    out.push_str(marker_line);
364    (out, true)
365}
366
367fn review_label_from_codeclimate(severity_name: &str) -> &'static str {
368    match severity_name {
369        "major" | "critical" | "blocker" => severity::review_label(fallow_config::Severity::Error),
370        _ => severity::review_label(fallow_config::Severity::Warn),
371    }
372}
373
374fn github_check_conclusion(issues: &[CiIssue]) -> ReviewCheckConclusion {
375    if issues
376        .iter()
377        .any(|issue| matches!(issue.severity.as_str(), "major" | "critical" | "blocker"))
378    {
379        ReviewCheckConclusion::Failure
380    } else if issues.is_empty() {
381        ReviewCheckConclusion::Success
382    } else {
383        ReviewCheckConclusion::Neutral
384    }
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390    use crate::output_envelope::MARKER_REGEX_V2;
391
392    fn to_value(envelope: &ReviewEnvelopeOutput) -> Value {
393        serde_json::to_value(envelope).expect("ReviewEnvelopeOutput serializes infallibly")
394    }
395
396    fn comment_to_value(comment: &ReviewComment) -> Value {
397        serde_json::to_value(comment).expect("ReviewComment serializes infallibly")
398    }
399
400    fn issue(rule: &str, sev: &str, path: &str, line: u64, fp: &str) -> CiIssue {
401        CiIssue {
402            rule_id: rule.into(),
403            description: "desc".into(),
404            severity: sev.into(),
405            path: path.into(),
406            line,
407            fingerprint: fp.into(),
408        }
409    }
410
411    fn issue_with_desc(
412        rule: &str,
413        desc: impl Into<String>,
414        sev: &str,
415        path: &str,
416        line: u64,
417        fp: &str,
418    ) -> CiIssue {
419        CiIssue {
420            rule_id: rule.into(),
421            description: desc.into(),
422            severity: sev.into(),
423            path: path.into(),
424            line,
425            fingerprint: fp.into(),
426        }
427    }
428
429    #[test]
430    fn github_review_envelope_matches_api_shape() {
431        let issues = vec![issue(
432            "fallow/unused-file",
433            "minor",
434            "src/a.ts",
435            1,
436            "abc1234567890def",
437        )];
438        let envelope = to_value(&render_review_envelope("check", Provider::Github, &issues));
439        assert_eq!(envelope["event"], "COMMENT");
440        assert_eq!(envelope["meta"]["schema"], "fallow-review-envelope/v2");
441        assert_eq!(envelope["comments"][0]["path"], "src/a.ts");
442        assert!(
443            envelope["comments"][0]["body"]
444                .as_str()
445                .unwrap()
446                .contains("fallow-fingerprint:v2:")
447        );
448    }
449
450    #[test]
451    fn github_comments_target_current_state_side() {
452        let issue = issue("fallow/unused-file", "minor", "src/a.ts", 1, "abc");
453        let comment = comment_to_value(&render_merged_comment(
454            Provider::Github,
455            &[&issue],
456            None,
457            None,
458            false,
459        ));
460        assert_eq!(comment["side"], "RIGHT");
461    }
462
463    #[test]
464    fn labels_major_issues_as_errors() {
465        let issue = issue("fallow/unused-file", "major", "src/a.ts", 1, "abc");
466        let comment = comment_to_value(&render_merged_comment(
467            Provider::Github,
468            &[&issue],
469            None,
470            None,
471            false,
472        ));
473        assert!(comment["body"].as_str().unwrap().starts_with("**error**"));
474    }
475
476    #[test]
477    fn gitlab_comment_accepts_diff_refs() {
478        let issue = issue("fallow/unused-file", "minor", "src/a.ts", 1, "abc");
479        let refs = GitlabDiffRefs {
480            base_sha: "base".into(),
481            start_sha: "start".into(),
482            head_sha: "head".into(),
483        };
484        let comment = comment_to_value(&render_merged_comment(
485            Provider::Gitlab,
486            &[&issue],
487            Some(&refs),
488            None,
489            false,
490        ));
491        assert_eq!(comment["position"]["position_type"], "text");
492        assert_eq!(comment["position"]["base_sha"], "base");
493        assert_eq!(comment["position"]["start_sha"], "start");
494        assert_eq!(comment["position"]["head_sha"], "head");
495    }
496
497    #[test]
498    fn guidance_toggle_accepts_common_truthy_values() {
499        for value in ["1", "true", "TRUE", "yes", "on", " On "] {
500            assert!(env_truthy(value), "{value:?} should enable guidance");
501        }
502        for value in ["", "0", "false", "no", "off", "enabled"] {
503            assert!(!env_truthy(value), "{value:?} should not enable guidance");
504        }
505    }
506
507    #[test]
508    fn guidance_disabled_omits_details_block() {
509        let issue = issue(
510            "fallow/high-complexity",
511            "major",
512            "src/a.ts",
513            10,
514            "abc1234567890def",
515        );
516        let comment = comment_to_value(&render_merged_comment(
517            Provider::Github,
518            &[&issue],
519            None,
520            None,
521            false,
522        ));
523        let body = comment["body"].as_str().unwrap();
524        assert!(!body.contains("<details><summary>What to do</summary>"));
525        assert!(!body.contains("For function findings"));
526    }
527
528    #[test]
529    fn guidance_enabled_appends_rule_guide_details() {
530        let issue = issue(
531            "fallow/high-complexity",
532            "major",
533            "src/a.ts",
534            10,
535            "abc1234567890def",
536        );
537        let comment = comment_to_value(&render_merged_comment(
538            Provider::Github,
539            &[&issue],
540            None,
541            None,
542            true,
543        ));
544        let body = comment["body"].as_str().unwrap();
545        assert!(body.contains("<details><summary>What to do</summary>"));
546        assert!(body.contains("For function findings"));
547        assert!(body.contains("[Read the rule docs]("));
548        assert!(
549            body.find("</details>").unwrap() < body.find("fallow-fingerprint:v2:").unwrap(),
550            "guidance should render before the marker"
551        );
552    }
553
554    #[test]
555    fn guidance_attaches_to_each_merged_finding() {
556        let complexity = issue("fallow/high-complexity", "major", "src/foo.ts", 42, "fp_a");
557        let duplication = issue("fallow/code-duplication", "minor", "src/foo.ts", 42, "fp_b");
558        let comment = comment_to_value(&render_merged_comment(
559            Provider::Github,
560            &[&complexity, &duplication],
561            None,
562            None,
563            true,
564        ));
565        let body = comment["body"].as_str().unwrap();
566        assert_eq!(
567            body.matches("<details><summary>What to do</summary>")
568                .count(),
569            2
570        );
571        assert!(body.contains("For function findings"));
572        assert!(body.contains("Extract the shared logic"));
573    }
574
575    #[test]
576    fn envelope_emits_marker_regex_field_at_root() {
577        let issues = vec![issue("fallow/unused-file", "minor", "src/a.ts", 1, "abc")];
578        let env = to_value(&render_review_envelope("check", Provider::Github, &issues));
579        let regex = env["marker_regex"].as_str().expect("marker_regex present");
580        assert_eq!(regex, MARKER_REGEX_V2);
581        // Pinned to exactly 16 hex chars (single hash form, optionally with
582        // a kind prefix like `merged:`).
583        assert!(regex.contains("[0-9a-f]{16}"));
584        // Anchored start-of-line + end-of-line. Consumers pass the flags
585        // field as the second arg to their regex engine so `^` / `$`
586        // match at line boundaries.
587        assert!(regex.starts_with('^'));
588        assert!(regex.ends_with("\\s*$"));
589        // No `(?m)` baked into the pattern; JavaScript RegExp rejects
590        // standalone inline flag groups with `Invalid group`.
591        assert!(!regex.contains("(?m)"));
592        // Capture group 1 is the fingerprint. Optional kind prefix:
593        // `(?:[a-z]+:)?` lets `merged:<hex>` or bare `<hex>` match in the
594        // same group.
595        assert!(regex.contains("((?:[a-z]+:)?[0-9a-f]{16})"));
596        // Flags field carries "m" so anchored `^` / `$` work per-line.
597        let flags = env["marker_regex_flags"]
598            .as_str()
599            .expect("marker_regex_flags present");
600        assert_eq!(flags, "m");
601    }
602
603    #[test]
604    fn envelope_emits_summary_block_with_fingerprint() {
605        let issues = vec![issue("fallow/unused-file", "minor", "src/a.ts", 1, "abc")];
606        let env = to_value(&render_review_envelope("check", Provider::Github, &issues));
607        assert_eq!(env["summary"]["body"], env["body"]);
608        let summary_fp = env["summary"]["fingerprint"].as_str().expect("fingerprint");
609        assert_eq!(summary_fp.len(), 16);
610        assert!(summary_fp.chars().all(|c| c.is_ascii_hexdigit()));
611        // The fingerprint should appear inside the body's marker.
612        let body_str = env["body"].as_str().unwrap();
613        let marker_line = format!("{MARKER_PREFIX_V2}{summary_fp}{MARKER_SUFFIX_V2}");
614        assert!(
615            body_str.contains(&marker_line),
616            "body must carry summary marker:\nbody={body_str}\nmarker={marker_line}"
617        );
618    }
619
620    #[test]
621    fn same_line_findings_merge_into_one_comment_with_composite_fingerprint() {
622        let a = issue("fallow/unused-export", "minor", "src/foo.ts", 42, "fp_a");
623        let b = issue("fallow/duplicate-export", "minor", "src/foo.ts", 42, "fp_b");
624        let env = to_value(&render_review_envelope("check", Provider::Github, &[a, b]));
625        assert_eq!(
626            env["comments"].as_array().unwrap().len(),
627            1,
628            "two same-line findings must collapse to one comment"
629        );
630        let merged = &env["comments"][0];
631        let fp = merged["fingerprint"].as_str().unwrap();
632        assert!(
633            fp.starts_with("merged:"),
634            "merged comment fingerprint must start with merged:, got {fp}"
635        );
636        // 7 chars prefix + 16 hex = 23 total.
637        assert_eq!(fp.len(), 23);
638        // Body carries both finding paragraphs and ONE marker.
639        let body = merged["body"].as_str().unwrap();
640        assert!(body.contains("fallow/unused-export"));
641        assert!(body.contains("fallow/duplicate-export"));
642        assert_eq!(
643            body.matches("fallow-fingerprint:v2:").count(),
644            1,
645            "merged body must carry exactly one fingerprint marker"
646        );
647        // Constituent fingerprints are NOT emitted on the wire; the
648        // composite hash is the only identity signal.
649        assert!(
650            merged.get("constituent_fingerprints").is_none(),
651            "v2 hashed-composite design does not emit constituent_fingerprints"
652        );
653    }
654
655    #[test]
656    fn single_finding_keeps_v1_fingerprint_shape() {
657        let issues = vec![issue(
658            "fallow/unused-file",
659            "minor",
660            "src/a.ts",
661            1,
662            "abc1234567890def",
663        )];
664        let env = to_value(&render_review_envelope("check", Provider::Github, &issues));
665        let comment = &env["comments"][0];
666        assert_eq!(comment["fingerprint"], "abc1234567890def");
667        assert!(
668            comment.get("constituent_fingerprints").is_none(),
669            "single-finding comment must NOT emit constituent_fingerprints"
670        );
671        assert!(
672            comment.get("truncated").is_none(),
673            "non-truncated comment must NOT emit truncated"
674        );
675    }
676
677    #[test]
678    fn composite_fingerprint_shifts_when_constituents_change() {
679        // Hashed-composite identity changes when constituents change, so
680        // the bundled wrappers' skip-if-fingerprint-exists logic correctly
681        // re-posts on content change. Idempotent on equal input.
682        let a = issue("fallow/unused-export", "minor", "src/foo.ts", 42, "fp_a");
683        let b = issue("fallow/duplicate-export", "minor", "src/foo.ts", 42, "fp_b");
684        let c = issue("fallow/unused-type", "minor", "src/foo.ts", 42, "fp_c");
685        let run1 = to_value(&render_review_envelope(
686            "check",
687            Provider::Github,
688            &[a.clone(), b, c.clone()],
689        ));
690        let run2_drop_b = to_value(&render_review_envelope("check", Provider::Github, &[a, c]));
691        assert_ne!(
692            run1["comments"][0]["fingerprint"], run2_drop_b["comments"][0]["fingerprint"],
693            "primary fingerprint must shift when a constituent drops"
694        );
695    }
696
697    #[test]
698    fn gitlab_old_path_pulls_from_diff_rename_map() {
699        let rename_diff = "\
700diff --git a/src/old.ts b/src/new.ts
701similarity index 90%
702rename from src/old.ts
703rename to src/new.ts
704--- a/src/old.ts
705+++ b/src/new.ts
706@@ -1,2 +1,3 @@
707 keep
708+added
709 still
710";
711        let diff_index = DiffIndex::from_unified_diff(rename_diff);
712        let issue = issue("fallow/unused-export", "minor", "src/new.ts", 2, "abc");
713        let envelope = to_value(&render_review_envelope_with_diff(
714            "check",
715            Provider::Gitlab,
716            &[issue],
717            Some(&diff_index),
718        ));
719        let position = &envelope["comments"][0]["position"];
720        assert_eq!(position["old_path"], "src/old.ts");
721        assert_eq!(position["new_path"], "src/new.ts");
722    }
723
724    #[test]
725    fn gitlab_old_path_falls_back_to_new_path_without_rename() {
726        let issue = issue("fallow/unused-export", "minor", "src/edit.ts", 5, "abc");
727        let envelope = to_value(&render_review_envelope_with_diff(
728            "check",
729            Provider::Gitlab,
730            &[issue],
731            None,
732        ));
733        let position = &envelope["comments"][0]["position"];
734        assert_eq!(position["old_path"], "src/edit.ts");
735        assert_eq!(position["new_path"], "src/edit.ts");
736    }
737
738    #[test]
739    fn oversized_body_truncates_at_char_boundary_and_preserves_marker() {
740        // Synthesize an issue whose description blows past the body cap.
741        let huge_desc = "x".repeat(MAX_COMMENT_BODY_BYTES * 2);
742        let issue = CiIssue {
743            rule_id: "fallow/unused-export".into(),
744            description: huge_desc,
745            severity: "minor".into(),
746            path: "src/a.ts".into(),
747            line: 1,
748            fingerprint: "abc1234567890def".into(),
749        };
750        let comment = comment_to_value(&render_merged_comment(
751            Provider::Github,
752            &[&issue],
753            None,
754            None,
755            false,
756        ));
757        let body = comment["body"].as_str().unwrap();
758        assert!(
759            body.len() <= MAX_COMMENT_BODY_BYTES,
760            "body len {} must not exceed cap {MAX_COMMENT_BODY_BYTES}",
761            body.len()
762        );
763        // The marker must survive truncation at the tail.
764        assert!(
765            body.contains("fallow-fingerprint:v2:"),
766            "marker must be preserved under truncation"
767        );
768        // Both the machine-detectable HTML marker and the human blockquote
769        // breadcrumb appear.
770        assert!(body.contains("<!-- fallow-truncated -->"));
771        assert!(body.contains("> Body truncated by fallow."));
772        // Typed boolean is set so consumers don't have to string-match.
773        assert_eq!(comment["truncated"], true);
774        // Body bytes are valid UTF-8 (char-boundary truncation).
775        assert!(std::str::from_utf8(body.as_bytes()).is_ok());
776    }
777
778    #[test]
779    fn oversized_guidance_body_truncates_and_preserves_marker() {
780        let issue = issue_with_desc(
781            "fallow/high-complexity",
782            "x".repeat(MAX_COMMENT_BODY_BYTES * 2),
783            "major",
784            "src/a.ts",
785            1,
786            "abc1234567890def",
787        );
788        let comment = comment_to_value(&render_merged_comment(
789            Provider::Github,
790            &[&issue],
791            None,
792            None,
793            true,
794        ));
795        let body = comment["body"].as_str().unwrap();
796        assert!(body.len() <= MAX_COMMENT_BODY_BYTES);
797        assert!(body.contains("<!-- fallow-truncated -->"));
798        assert!(body.contains("fallow-fingerprint:v2:"));
799        assert_eq!(comment["truncated"], true);
800    }
801
802    #[test]
803    fn multibyte_body_truncates_at_char_boundary() {
804        // Each Japanese char is 3 bytes in UTF-8. A byte-boundary truncation
805        // anywhere inside one would produce invalid UTF-8.
806        let huge_desc: String = "あ".repeat(MAX_COMMENT_BODY_BYTES);
807        let issue = CiIssue {
808            rule_id: "fallow/unused-export".into(),
809            description: huge_desc,
810            severity: "minor".into(),
811            path: "src/a.ts".into(),
812            line: 1,
813            fingerprint: "abc1234567890def".into(),
814        };
815        let comment = comment_to_value(&render_merged_comment(
816            Provider::Github,
817            &[&issue],
818            None,
819            None,
820            false,
821        ));
822        let body = comment["body"].as_str().unwrap();
823        // Cargo will fail to deserialize the snapshot if `body` is not
824        // valid UTF-8 (serde_json::Value::String requires it), so this
825        // assertion is somewhat tautological -- but the explicit decode
826        // pins the contract.
827        assert!(std::str::from_utf8(body.as_bytes()).is_ok());
828        assert!(body.len() <= MAX_COMMENT_BODY_BYTES);
829        assert_eq!(comment["truncated"], true);
830    }
831}