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::{
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
19/// Conservative body-size floor across the two supported review providers.
20/// GitLab accepts ~1,000,000 chars per `Note#note` validation (see
21/// <https://docs.gitlab.com/administration/instance_limits/>) and GitHub
22/// empirically enforces a 65,536-character cap on PR review comments
23/// (undocumented but reproducible: a 65,537-char body returns
24/// `Body is too long (maximum is 65536 characters)`). We pick 65,536 BYTES
25/// here so the cap is safe under either vendor regardless of whether the
26/// limit is enforced in bytes or chars, and regardless of multi-byte UTF-8
27/// expansion. Hardcoded for now; if a real consumer needs it tunable, expose
28/// a `FALLOW_REVIEW_MAX_BODY_BYTES` env var.
29const MAX_COMMENT_BODY_BYTES: usize = 65_536;
30
31/// Marker prefix appended to every v2 review-comment body. Mirrored by
32/// [`crate::output_envelope::MARKER_REGEX_V2`]; both must change together
33/// because consumers extract the fingerprint by running the regex over a
34/// body whose marker line uses this prefix. The `:v2:` namespace prevents
35/// collision with v1 historical markers and reduces user-paste spoofing
36/// risk (typing `:v2:` by accident is unlikely).
37pub const MARKER_PREFIX_V2: &str = "<!-- fallow-fingerprint:v2: ";
38
39/// Closing of the v2 marker, after the fingerprint string.
40const MARKER_SUFFIX_V2: &str = " -->";
41
42/// Human-readable truncation breadcrumb appended to the body when the
43/// rendered content exceeds [`MAX_COMMENT_BODY_BYTES`]. The HTML comment is
44/// machine-detectable; the blockquote that follows is a human-readable
45/// breadcrumb that reads as fallow speaking (matching the existing
46/// `> Run \`fallow fix --files\` or delete this file.` convention from the
47/// unused-file suggestion block). Three signals total (typed
48/// `truncated: bool` on the comment, this HTML marker, and the blockquote
49/// text) so consumers don't need to choose a primary detection channel.
50const 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/// Render path the print site uses. Exposed so unit tests can pass a
67/// hand-crafted `DiffIndex` without poking the process-wide `SHARED_DIFF`
68/// cache (which is `OnceLock`-bounded and not reentrant under cargo test's
69/// parallel runner).
70#[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
224/// Group consecutive same-(path, line) issues. Input is already sorted by
225/// `(path, line, fingerprint)` so a single linear pass collects runs.
226fn 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/// Render one comment from a group of 1+ issues that share the same
253/// `(path, line)`. Single-element groups produce the v1-shaped body
254/// (modulo the `:v2:` marker shape); multi-element groups stack each
255/// finding's `**label** \`rule\`: desc` paragraph under a
256/// `merged:<16-char hash>` composite fingerprint over sorted constituent
257/// fingerprints. The composite identity shifts whenever the set of
258/// constituents changes, so consumers' skip-if-fingerprint-exists logic
259/// correctly re-posts on content change.
260#[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
347/// Truncate `content` if appending `marker_line` would exceed
348/// [`MAX_COMMENT_BODY_BYTES`], preserving the marker at the tail and
349/// inserting a [`TRUNCATION_SUFFIX`] breadcrumb. Truncation walks back to
350/// the nearest UTF-8 char boundary so multi-byte characters straddling the
351/// cut are not chopped mid-codepoint. Returns `(final_body, truncated)`.
352fn 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}