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