Skip to main content

fallow_output/
ci_output.rs

1//! Shared CI comment output contracts for CLI and programmatic consumers.
2
3use std::borrow::Cow;
4use std::fmt::Write as _;
5
6use crate::review_envelopes::{default_marker_regex, default_marker_regex_flags};
7use crate::{
8    CodeClimateIssue, CodeClimateSeverity, DiffIndex, GitHubReviewComment, GitHubReviewSide,
9    GitLabReviewComment, GitLabReviewPosition, GitLabReviewPositionType, ReviewCheckConclusion,
10    ReviewComment, ReviewEnvelopeEvent, ReviewEnvelopeMeta, ReviewEnvelopeOutput,
11    ReviewEnvelopeSchema, ReviewEnvelopeSummary, ReviewId, ReviewProvider, review_id_marker,
12};
13use serde_json::Value;
14
15/// Supported CI review providers for generated comments.
16#[derive(Clone, Copy, Debug, PartialEq, Eq)]
17pub enum CiProvider {
18    /// GitHub pull requests and check runs.
19    Github,
20    /// GitLab merge requests and discussions.
21    Gitlab,
22}
23
24impl CiProvider {
25    /// Display name of the provider ("GitHub" / "GitLab").
26    #[must_use]
27    pub const fn name(self) -> &'static str {
28        match self {
29            Self::Github => "GitHub",
30            Self::Gitlab => "GitLab",
31        }
32    }
33}
34
35/// Prefix prepended to a rendered path so CI platforms, which address files
36/// from the repository root, can find it. Empty when the analysis root already
37/// is the repository root.
38///
39/// This is presentation only. Nothing looks a path up in a diff after it has
40/// been prefixed: matching happens on analysis-root-relative paths, which is
41/// the namespace `DiffIndex::key_for_root_relative` translates from.
42#[must_use]
43pub fn apply_path_prefix(prefix: &str, path: &str) -> String {
44    if prefix.is_empty() {
45        return path.to_owned();
46    }
47    format!("{prefix}/{path}")
48}
49
50/// Normalized CodeClimate issue used by CI comment renderers.
51#[derive(Clone, Debug, PartialEq, Eq)]
52pub struct CiIssue {
53    /// Fallow rule identifier, taken from the CodeClimate `check_name`.
54    pub rule_id: String,
55    /// Human-readable finding description.
56    pub description: String,
57    /// CodeClimate severity string, e.g. `minor` or `major`.
58    pub severity: String,
59    /// File path relative to the analysed root.
60    pub path: String,
61    /// 1-based line of the finding.
62    pub line: u64,
63    /// Inclusive 1-based end line for range findings.
64    pub end_line: Option<u64>,
65    /// Other source ranges that provide evidence for this finding.
66    pub other_locations: Vec<CiLocation>,
67    /// Stable finding fingerprint used for comment identity.
68    pub fingerprint: String,
69    /// The fingerprint an older Fallow release gave this finding, when it is
70    /// different from `fingerprint`. Review comments carry it for one
71    /// release, so a thread with the older marker still matches.
72    pub legacy_fingerprint: Option<String>,
73}
74
75/// Source range attached to a normalized CI finding as supporting evidence.
76#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
77pub struct CiLocation {
78    /// File path relative to the analysed root.
79    pub path: String,
80    /// Inclusive 1-based start line.
81    pub line: u64,
82    /// Inclusive 1-based end line.
83    pub end_line: u64,
84}
85
86/// Inputs for rendering a sticky PR/MR summary comment.
87pub struct PrCommentRenderInput<'a> {
88    /// Fallow command the comment reports on, e.g. `audit`.
89    pub command: &'a str,
90    /// CI provider whose comment conventions apply.
91    pub provider: CiProvider,
92    /// Findings to summarize, pre-sorted by severity and location.
93    pub issues: &'a [CiIssue],
94    /// Identity token embedded so reruns update the same sticky comment.
95    pub marker_id: String,
96    /// Maximum findings rendered in the comment body.
97    pub max_comments: usize,
98    /// Maps a rule id to its display category label.
99    pub category_for_rule: &'a dyn Fn(&str) -> &'static str,
100    /// A Markdown section after the findings and before the footer. The CLI
101    /// uses it for the unmatched config patterns.
102    pub trailing_section: Option<&'a str>,
103}
104
105/// GitLab diff refs for a review-envelope position.
106#[derive(Clone, Debug, PartialEq, Eq)]
107pub struct ReviewGitlabDiffRefs {
108    /// Merge-base SHA of the MR diff.
109    pub base_sha: String,
110    /// First commit SHA of the MR diff.
111    pub start_sha: String,
112    /// Head commit SHA of the MR diff.
113    pub head_sha: String,
114}
115
116/// Truncation signals produced while rendering a review envelope.
117#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
118pub struct ReviewEnvelopeTruncation {
119    /// A comment body hit [`MAX_COMMENT_BODY_BYTES`] and was truncated.
120    pub body: bool,
121    /// More findings existed than `max_comments` allowed.
122    pub comment_limit: bool,
123}
124
125/// Rendered review envelope plus side-channel signals for CLI telemetry.
126#[derive(Debug)]
127pub struct ReviewEnvelopeRenderResult {
128    /// Provider-ready review envelope.
129    pub envelope: ReviewEnvelopeOutput,
130    /// Truncation signals observed while rendering.
131    pub truncation: ReviewEnvelopeTruncation,
132}
133
134/// Inputs for rendering a GitHub/GitLab review envelope.
135pub struct ReviewEnvelopeRenderInput<'a> {
136    /// Fallow command the review reports on.
137    pub command: &'a str,
138    /// CI provider whose review API shapes the envelope.
139    pub provider: CiProvider,
140    /// Findings to turn into review comments.
141    pub issues: &'a [CiIssue],
142    /// Diff index used to keep comments on lines the diff actually added.
143    pub diff_index: Option<&'a DiffIndex>,
144    /// Prepended to every emitted path after diff lookups have run.
145    pub path_prefix: &'a str,
146    /// Maximum inline comments to emit.
147    pub max_comments: usize,
148    /// Required for GitLab positioned discussions; ignored for GitHub.
149    pub gitlab_diff_refs: Option<&'a ReviewGitlabDiffRefs>,
150    /// Whether to append per-finding guidance blocks to comment bodies.
151    pub include_guidance: bool,
152    /// Produces a provider-specific suggestion block for a finding, when one
153    /// applies.
154    pub suggestion_block: &'a dyn Fn(CiProvider, &CiIssue) -> Option<String>,
155    /// Produces a guidance block for a finding, when one applies.
156    pub guidance_block: &'a dyn Fn(&CiIssue) -> Option<String>,
157    /// A Markdown section in the summary body, after the inline-comment line
158    /// and before the markers. The CLI uses it for the unmatched config
159    /// patterns, as the sticky comment does.
160    pub trailing_section: Option<&'a str>,
161}
162
163/// Marker prefix appended to every review-comment body.
164///
165/// Version 3 carries the line-stable fingerprint of dead-code findings.
166pub const MARKER_PREFIX_V3: &str = "<!-- fallow-fingerprint:v3: ";
167
168/// Closing of the v3 marker, after the fingerprint string.
169pub const MARKER_SUFFIX_V3: &str = " -->";
170
171/// Hard cap on a single review-comment body, matching GitHub's 65 536-char
172/// comment limit; bodies at or over it are truncated with a marker suffix.
173pub const MAX_COMMENT_BODY_BYTES: usize = 65_536;
174const TRUNCATION_SUFFIX: &str = "\n\n<!-- fallow-truncated -->\n> Body truncated by fallow.";
175
176/// Extract normalized CI issues from a raw CodeClimate JSON array, sorted by
177/// severity then location.
178#[must_use]
179pub fn issues_from_codeclimate(value: &Value) -> Vec<CiIssue> {
180    let mut issues = value
181        .as_array()
182        .into_iter()
183        .flatten()
184        .filter_map(issue_from_codeclimate)
185        .collect::<Vec<_>>();
186    sort_ci_issues(&mut issues);
187    issues
188}
189
190/// Normalize typed CodeClimate issues into CI issues, sorted by severity then
191/// location.
192#[must_use]
193pub fn issues_from_codeclimate_issues(issues: &[CodeClimateIssue]) -> Vec<CiIssue> {
194    let mut issues = issues
195        .iter()
196        .map(issue_from_codeclimate_issue)
197        .collect::<Vec<_>>();
198    sort_ci_issues(&mut issues);
199    issues
200}
201
202fn issue_from_codeclimate(value: &Value) -> Option<CiIssue> {
203    let path = value.pointer("/location/path")?.as_str()?.to_string();
204    let line = value
205        .pointer("/location/lines/begin")
206        .and_then(Value::as_u64)
207        .unwrap_or(1);
208    let end_line = value.pointer("/location/lines/end").and_then(Value::as_u64);
209    let mut other_locations = value
210        .get("other_locations")
211        .and_then(Value::as_array)
212        .into_iter()
213        .flatten()
214        .filter_map(|location| {
215            let line = location.pointer("/lines/begin")?.as_u64()?;
216            let end_line = location
217                .pointer("/lines/end")
218                .and_then(Value::as_u64)
219                .filter(|end| *end >= line)
220                .unwrap_or(line);
221            Some(CiLocation {
222                path: location.get("path")?.as_str()?.to_owned(),
223                line,
224                end_line,
225            })
226        })
227        .collect::<Vec<_>>();
228    other_locations.sort();
229    Some(CiIssue {
230        rule_id: value
231            .get("check_name")
232            .and_then(Value::as_str)
233            .unwrap_or("fallow/finding")
234            .to_string(),
235        description: value
236            .get("description")
237            .and_then(Value::as_str)
238            .unwrap_or("Fallow finding")
239            .to_string(),
240        severity: value
241            .get("severity")
242            .and_then(Value::as_str)
243            .unwrap_or("minor")
244            .to_string(),
245        fingerprint: value
246            .get("fingerprint")
247            .and_then(Value::as_str)
248            .unwrap_or("")
249            .to_string(),
250        legacy_fingerprint: None,
251        path,
252        line,
253        end_line,
254        other_locations,
255    })
256}
257
258fn issue_from_codeclimate_issue(issue: &CodeClimateIssue) -> CiIssue {
259    let mut other_locations = issue
260        .other_locations
261        .iter()
262        .map(|location| CiLocation {
263            path: location.path.clone(),
264            line: u64::from(location.lines.begin),
265            end_line: u64::from(
266                location
267                    .lines
268                    .end
269                    .filter(|end| *end >= location.lines.begin)
270                    .unwrap_or(location.lines.begin),
271            ),
272        })
273        .collect::<Vec<_>>();
274    other_locations.sort();
275    CiIssue {
276        rule_id: issue.check_name.clone(),
277        description: issue.description.clone(),
278        severity: codeclimate_severity_label(issue.severity).to_owned(),
279        path: issue.location.path.clone(),
280        line: u64::from(issue.location.lines.begin),
281        end_line: issue.location.lines.end.map(u64::from),
282        other_locations,
283        fingerprint: issue.fingerprint.clone(),
284        legacy_fingerprint: issue.legacy_fingerprint.clone(),
285    }
286}
287
288const fn codeclimate_severity_label(severity: CodeClimateSeverity) -> &'static str {
289    match severity {
290        CodeClimateSeverity::Info => "info",
291        CodeClimateSeverity::Minor => "minor",
292        CodeClimateSeverity::Major => "major",
293        CodeClimateSeverity::Critical => "critical",
294        CodeClimateSeverity::Blocker => "blocker",
295    }
296}
297
298fn sort_ci_issues(issues: &mut [CiIssue]) {
299    issues
300        .sort_by(|a, b| (&a.path, a.line, &a.fingerprint).cmp(&(&b.path, b.line, &b.fingerprint)));
301}
302
303fn fingerprint_hash(parts: &[&str]) -> String {
304    crate::codeclimate_fingerprint_hash(parts)
305}
306
307/// Render the sticky summary comment body: identity marker, run verdict,
308/// headline count, and per-category findings tables.
309///
310/// The verdict is derived from the findings' severities. Use
311/// [`render_pr_comment_with_verdict`] to fold in a gate conclusion the caller
312/// already computed, such as a saved audit verdict.
313#[must_use]
314pub fn render_pr_comment(input: &PrCommentRenderInput<'_>) -> String {
315    render_pr_comment_with_verdict(input, None)
316}
317
318/// Render the sticky summary comment body with an explicit gate conclusion.
319///
320/// The rendered verdict is the more severe of `gate` and the severity-derived
321/// [`github_check_conclusion`], so a gate that only knows about thresholds
322/// cannot mask an error-severity finding, and a failing gate cannot be masked
323/// by findings that are all advisory.
324///
325/// This body is the surface a rerun edits in place. A provider review body is
326/// a point-in-time record that no later run can rewrite, which is why the run
327/// verdict lives here and not there.
328#[must_use]
329#[expect(clippy::expect_used, reason = "formatting into String is infallible")]
330pub fn render_pr_comment_with_verdict(
331    input: &PrCommentRenderInput<'_>,
332    gate: Option<ReviewCheckConclusion>,
333) -> String {
334    let marker = format!("<!-- fallow-id: {} -->", input.marker_id);
335    let title = command_title(input.command);
336    let count = input.issues.len();
337    let noun = if count == 1 { "finding" } else { "findings" };
338    let verdict = most_severe_conclusion(github_check_conclusion(input.issues), gate);
339
340    let mut out = String::new();
341    out.push_str(&marker);
342    out.push('\n');
343    write!(&mut out, "### Fallow {title}\n\n").expect("write to string");
344    write!(&mut out, "**{}**\n\n", pr_comment_verdict(verdict)).expect("write to string");
345    if count == 0 {
346        writeln!(
347            &mut out,
348            "No findings for this {}.",
349            change_noun(input.provider)
350        )
351        .expect("write to string");
352    } else {
353        let groups = group_by_category(input.issues, input.category_for_rule);
354        if groups.len() > 1 {
355            write!(
356                &mut out,
357                "Found **{count}** {noun}: {}.\n\n",
358                category_breakdown(&groups)
359            )
360            .expect("write to string");
361        } else {
362            write!(&mut out, "Found **{count}** {noun}.\n\n").expect("write to string");
363        }
364        for (category, group_issues) in &groups {
365            let summary_label = summary_label(category, group_issues.len(), input.max_comments);
366            render_findings_table(&mut out, group_issues, input.max_comments, &summary_label);
367        }
368    }
369    if let Some(section) = input
370        .trailing_section
371        .map(str::trim)
372        .filter(|section| !section.is_empty())
373    {
374        if !out.ends_with('\n') {
375            out.push('\n');
376        }
377        out.push('\n');
378        out.push_str(section);
379        out.push('\n');
380    }
381    out.push_str("\nGenerated by fallow.");
382    out
383}
384
385/// Verdict line rendered under the sticky comment heading.
386const fn pr_comment_verdict(conclusion: ReviewCheckConclusion) -> &'static str {
387    match conclusion {
388        ReviewCheckConclusion::Failure => "Quality gate failed",
389        ReviewCheckConclusion::Neutral => "Review needed",
390        ReviewCheckConclusion::Success => "Quality gate passed",
391    }
392}
393
394const fn conclusion_rank(conclusion: ReviewCheckConclusion) -> u8 {
395    match conclusion {
396        ReviewCheckConclusion::Success => 0,
397        ReviewCheckConclusion::Neutral => 1,
398        ReviewCheckConclusion::Failure => 2,
399    }
400}
401
402const fn most_severe_conclusion(
403    derived: ReviewCheckConclusion,
404    gate: Option<ReviewCheckConclusion>,
405) -> ReviewCheckConclusion {
406    match gate {
407        Some(gate) if conclusion_rank(gate) > conclusion_rank(derived) => gate,
408        _ => derived,
409    }
410}
411
412/// Rule ids whose findings describe project-wide config state rather than a
413/// change touching a specific source line.
414pub const PROJECT_LEVEL_RULE_IDS: &[&str] = &[
415    "fallow/unused-catalog-entry",
416    "fallow/empty-catalog-group",
417    "fallow/unresolved-catalog-reference",
418    "fallow/unused-dependency-override",
419    "fallow/misconfigured-dependency-override",
420    "fallow/unused-dependency",
421    "fallow/unused-dev-dependency",
422    "fallow/unused-optional-dependency",
423    "fallow/type-only-dependency",
424    "fallow/test-only-dependency",
425    "fallow/dev-dependency-in-production",
426];
427
428/// Whether findings for `rule_id` describe the whole project (e.g. dependency
429/// rules) rather than a specific file location.
430#[must_use]
431pub fn is_project_level_rule(rule_id: &str) -> bool {
432    PROJECT_LEVEL_RULE_IDS.contains(&rule_id)
433}
434
435/// Section order for the sticky comment. Every category the rule registry
436/// carries is listed, so a new bucket does not sort alphabetically into the
437/// middle of the report. "Other" collects rules a downstream consumer added
438/// without registering, and sorts last by construction.
439const CATEGORY_ORDER: [&str; 10] = [
440    "Dead code",
441    "Dependencies",
442    "Duplication",
443    "Health",
444    "Architecture",
445    "Policy",
446    "Security",
447    "Flags",
448    "Suppressions",
449    "Other",
450];
451
452fn group_by_category<'a>(
453    issues: &'a [CiIssue],
454    category_for_rule: &dyn Fn(&str) -> &'static str,
455) -> Vec<(&'static str, Vec<&'a CiIssue>)> {
456    let mut buckets: std::collections::BTreeMap<&'static str, Vec<&CiIssue>> =
457        std::collections::BTreeMap::new();
458    for issue in issues {
459        let category = category_for_rule(&issue.rule_id);
460        buckets.entry(category).or_default().push(issue);
461    }
462    let mut ordered: Vec<(&'static str, Vec<&CiIssue>)> = Vec::with_capacity(buckets.len());
463    for category in CATEGORY_ORDER {
464        if let Some(items) = buckets.remove(category) {
465            ordered.push((category, items));
466        }
467    }
468    for (category, items) in buckets {
469        ordered.push((category, items));
470    }
471    ordered
472}
473
474/// Table of contents for the collapsed sections below: "Dead code 13,
475/// Dependencies 12". Section order, so the subtitle reads in the order a
476/// reader scrolls.
477fn category_breakdown(groups: &[(&'static str, Vec<&CiIssue>)]) -> String {
478    groups
479        .iter()
480        .map(|(category, issues)| format!("{category} {}", issues.len()))
481        .collect::<Vec<_>>()
482        .join(", ")
483}
484
485/// Collapsible-section label for a findings category: appends "showing N"
486/// when the table is capped below the category's total.
487#[must_use]
488pub fn summary_label(category: &str, total: usize, max: usize) -> String {
489    if total > max {
490        format!("{category} ({total}, showing {max})")
491    } else {
492        format!("{category} ({total})")
493    }
494}
495
496#[expect(clippy::expect_used, reason = "formatting into String is infallible")]
497fn render_findings_table(out: &mut String, issues: &[&CiIssue], max: usize, summary: &str) {
498    writeln!(out, "<details>\n<summary>{summary}</summary>\n").expect("write to string");
499    out.push_str("| Severity | Rule | Location | Description |\n");
500    out.push_str("| --- | --- | --- | --- |\n");
501    for issue in issues.iter().take(max) {
502        writeln!(
503            out,
504            "| {} | `{}` | `{}`:{} | {} |",
505            escape_md(&issue.severity),
506            escape_md(&issue.rule_id),
507            escape_md(&issue.path),
508            issue.line,
509            escape_md(&issue.description),
510        )
511        .expect("write to string");
512    }
513    if issues.len() > max {
514        writeln!(
515            out,
516            "\nShowing {max} of {} findings. Run fallow locally or inspect the CI output for the full report.",
517            issues.len(),
518        )
519        .expect("write to string");
520    }
521    out.push_str("\n</details>\n\n");
522}
523
524/// Human-readable report title for a fallow command name, e.g. `dupes` maps
525/// to "duplication report".
526#[must_use]
527pub fn command_title(command: &str) -> &'static str {
528    match command {
529        "dead-code" | "check" => "codebase report",
530        "dupes" => "duplication report",
531        "health" => "health report",
532        "audit" => "audit report",
533        "security" => "security report",
534        "fix" => "fix report",
535        "" | "combined" => "combined report",
536        _ => "report",
537    }
538}
539
540/// How the provider names a change proposal ("pull request" / "merge request").
541const fn change_noun(provider: CiProvider) -> &'static str {
542    match provider {
543        CiProvider::Github => "pull request",
544        CiProvider::Gitlab => "merge request",
545    }
546}
547
548/// The provider's diff tab, where inline review comments are read.
549const fn changes_tab(provider: CiProvider) -> &'static str {
550    match provider {
551        CiProvider::Github => "Files changed",
552        CiProvider::Gitlab => "Changes",
553    }
554}
555
556/// Escape a string for inclusion in a Markdown table cell.
557#[must_use]
558pub fn escape_md(value: &str) -> String {
559    let value = value.trim();
560    // Collapse CRLF to one space; a bare CR is a CommonMark line ending and
561    // would otherwise split the table row.
562    let mut chars = value.chars().peekable();
563    let mut out = String::with_capacity(value.len());
564    while let Some(ch) = chars.next() {
565        let ch = match ch {
566            '\r' => {
567                if chars.peek() == Some(&'\n') {
568                    chars.next();
569                }
570                ' '
571            }
572            '\n' => ' ',
573            _ => ch,
574        };
575        if matches!(
576            ch,
577            '\\' | '`'
578                | '*'
579                | '_'
580                | '['
581                | ']'
582                | '('
583                | ')'
584                | '!'
585                | '<'
586                | '>'
587                | '#'
588                | '|'
589                | '~'
590                | '&'
591        ) {
592            out.push('\\');
593        }
594        out.push(ch);
595    }
596    out
597}
598
599/// Render a complete CommonMark code span around an untrusted value. The
600/// fence grows past the longest backtick run inside the value, so the span
601/// cannot be closed early from within.
602#[must_use]
603pub fn markdown_code_span(value: &str) -> String {
604    let longest_run = value
605        .split(|c| c != '`')
606        .map(str::len)
607        .max()
608        .unwrap_or_default();
609    let fence = "`".repeat(longest_run + 1);
610    let needs_padding = value.starts_with('`')
611        || value.ends_with('`')
612        || (value.starts_with(' ') && value.ends_with(' ') && !value.chars().all(|c| c == ' '));
613    if needs_padding {
614        format!("{fence} {value} {fence}")
615    } else {
616        format!("{fence}{value}{fence}")
617    }
618}
619
620/// [`markdown_code_span`] for a Markdown table cell: pipes are additionally
621/// escaped so the value cannot terminate the cell, and line endings collapse
622/// to spaces because any CommonMark line ending would split the table row.
623#[must_use]
624pub fn markdown_table_code_span(value: &str) -> String {
625    let collapsed = value.replace("\r\n", " ").replace(['\n', '\r'], " ");
626    markdown_code_span(&collapsed.replace('|', "\\|"))
627}
628
629/// Escape prose for a Markdown table cell while leaving intentional inline
630/// markup alone: pipes are escaped and line endings collapse to spaces.
631#[must_use]
632pub fn markdown_table_text(value: &str) -> String {
633    value
634        .replace("\r\n", " ")
635        .replace(['\n', '\r'], " ")
636        .replace('|', "\\|")
637}
638
639/// Render a provider-specific review envelope from typed CI issues.
640#[must_use]
641pub fn render_review_envelope(
642    input: &ReviewEnvelopeRenderInput<'_>,
643    status_message: Option<&str>,
644) -> ReviewEnvelopeRenderResult {
645    render_review_envelope_with_id(input, None, None, status_message)
646}
647
648/// Render a review envelope with an explicit gate conclusion and status.
649#[must_use]
650pub fn render_review_envelope_with_conclusion(
651    input: &ReviewEnvelopeRenderInput<'_>,
652    conclusion: ReviewCheckConclusion,
653    status_message: Option<&str>,
654) -> ReviewEnvelopeRenderResult {
655    render_review_envelope_with_id(input, None, Some(conclusion), status_message)
656}
657
658/// Render a review envelope whose bodies carry the supplied review scope.
659#[must_use]
660pub fn render_scoped_review_envelope(
661    input: &ReviewEnvelopeRenderInput<'_>,
662    review_id: &ReviewId,
663    status_message: Option<&str>,
664) -> ReviewEnvelopeRenderResult {
665    render_review_envelope_with_id(input, Some(review_id), None, status_message)
666}
667
668/// Render a scoped review envelope with an explicit gate conclusion and status.
669#[must_use]
670pub fn render_scoped_review_envelope_with_conclusion(
671    input: &ReviewEnvelopeRenderInput<'_>,
672    review_id: &ReviewId,
673    conclusion: ReviewCheckConclusion,
674    status_message: Option<&str>,
675) -> ReviewEnvelopeRenderResult {
676    render_review_envelope_with_id(input, Some(review_id), Some(conclusion), status_message)
677}
678
679fn render_review_envelope_with_id(
680    input: &ReviewEnvelopeRenderInput<'_>,
681    review_id: Option<&ReviewId>,
682    conclusion: Option<ReviewCheckConclusion>,
683    status_message: Option<&str>,
684) -> ReviewEnvelopeRenderResult {
685    let grouped = group_review_issues_by_path_line(input.issues, input.max_comments);
686
687    let comments: Vec<ReviewComment> = grouped
688        .groups
689        .iter()
690        .map(|group| {
691            render_review_comment_for_group_with_id(
692                &ReviewCommentRenderInput {
693                    provider: input.provider,
694                    group,
695                    gitlab_diff_refs: input.gitlab_diff_refs,
696                    diff_index: input.diff_index,
697                    path_prefix: input.path_prefix,
698                    include_guidance: input.include_guidance,
699                    suggestion_block: input.suggestion_block,
700                    guidance_block: input.guidance_block,
701                },
702                review_id,
703            )
704        })
705        .collect();
706
707    let conclusion = conclusion.unwrap_or_else(|| github_check_conclusion(input.issues));
708    let summary_text = review_summary_text(
709        input.command,
710        input.provider,
711        comments.len(),
712        status_message,
713        input.trailing_section,
714    );
715    let summary_fp = summary_fingerprint(&summary_text);
716    let summary_marker = review_markers(&summary_fp, review_id);
717    let body = format!("{summary_text}{summary_marker}");
718    let summary = ReviewEnvelopeSummary {
719        body: body.clone(),
720        fingerprint: summary_fp,
721    };
722
723    let truncation = ReviewEnvelopeTruncation {
724        body: comments.iter().any(review_comment_truncated),
725        comment_limit: grouped.truncated,
726    };
727
728    ReviewEnvelopeRenderResult {
729        envelope: build_review_envelope_output(input.provider, body, summary, comments, conclusion),
730        truncation,
731    }
732}
733
734/// Review body: what this review carries and where to read it.
735///
736/// No run verdict. A provider review is a point-in-time record that a later
737/// run cannot rewrite, so a verdict rendered here keeps asserting a state the
738/// editable sticky comment has already moved on from. The machine-readable
739/// gate result still travels on `meta.check_conclusion`.
740fn review_summary_text(
741    command: &str,
742    provider: CiProvider,
743    comment_count: usize,
744    status_message: Option<&str>,
745    trailing_section: Option<&str>,
746) -> String {
747    let status = status_message.map_or_else(String::new, |message| format!("\n\n> {message}"));
748    let section = trailing_section
749        .map(str::trim)
750        .filter(|section| !section.is_empty())
751        .map_or_else(String::new, |section| format!("\n\n{section}"));
752    format!(
753        "### Fallow {}{}\n\n{}{}\n\n<!-- fallow-review -->",
754        command_title(command),
755        status,
756        inline_comment_line(provider, comment_count),
757        section,
758    )
759}
760
761fn inline_comment_line(provider: CiProvider, count: usize) -> String {
762    if count == 0 {
763        return format!(
764            "No findings anchored to the changed lines in this {}.",
765            change_noun(provider)
766        );
767    }
768    format!(
769        "{count} inline comment{} on the changed lines. Open the {} tab to review.",
770        if count == 1 { "" } else { "s" },
771        changes_tab(provider),
772    )
773}
774
775/// Review issues grouped per `(path, line)` for one-comment-per-location
776/// rendering.
777#[derive(Debug, PartialEq, Eq)]
778pub struct GroupedReviewIssues<'a> {
779    /// One group per distinct location, in input order.
780    pub groups: Vec<Vec<&'a CiIssue>>,
781    /// True when the group cap cut off remaining issues.
782    pub truncated: bool,
783}
784
785/// Group consecutive same-(path, line) issues. Input is already sorted by
786/// `(path, line, fingerprint)` so a single linear pass collects runs.
787#[must_use]
788pub fn group_review_issues_by_path_line(
789    issues: &[CiIssue],
790    max_groups: usize,
791) -> GroupedReviewIssues<'_> {
792    if max_groups == 0 {
793        return GroupedReviewIssues {
794            groups: Vec::new(),
795            truncated: !issues.is_empty(),
796        };
797    }
798    let mut groups: Vec<Vec<&CiIssue>> = Vec::with_capacity(max_groups.min(issues.len()));
799    let mut current: Vec<&CiIssue> = Vec::new();
800    let mut current_key: Option<(&str, u64)> = None;
801    for issue in issues {
802        let key = (issue.path.as_str(), issue.line);
803        if Some(key) != current_key {
804            if !current.is_empty() {
805                groups.push(std::mem::take(&mut current));
806                if groups.len() == max_groups {
807                    return GroupedReviewIssues {
808                        groups,
809                        truncated: true,
810                    };
811                }
812            }
813            current_key = Some(key);
814        }
815        current.push(issue);
816    }
817    if !current.is_empty() && groups.len() < max_groups {
818        groups.push(current);
819    }
820    GroupedReviewIssues {
821        groups,
822        truncated: false,
823    }
824}
825
826fn review_comment_truncated(comment: &ReviewComment) -> bool {
827    match comment {
828        ReviewComment::GitHub(comment) => comment.truncated,
829        ReviewComment::GitLab(comment) => comment.truncated,
830    }
831}
832
833/// Inputs for rendering one inline review comment from a location group.
834pub struct ReviewCommentRenderInput<'a, 'group> {
835    /// CI provider whose comment shape to produce.
836    pub provider: CiProvider,
837    /// Issues sharing the same `(path, line)`; the first is the representative.
838    pub group: &'a [&'group CiIssue],
839    /// Required for GitLab positioned discussions; ignored for GitHub.
840    pub gitlab_diff_refs: Option<&'a ReviewGitlabDiffRefs>,
841    /// Diff index used to resolve renamed paths for GitLab positions.
842    pub diff_index: Option<&'a DiffIndex>,
843    /// Prepended to every emitted path after diff lookups have run.
844    pub path_prefix: &'a str,
845    /// Whether to append per-finding guidance blocks to the body.
846    pub include_guidance: bool,
847    /// Produces a provider-specific suggestion block for a finding, when one
848    /// applies.
849    pub suggestion_block: &'a dyn Fn(CiProvider, &CiIssue) -> Option<String>,
850    /// Produces a guidance block for a finding, when one applies.
851    pub guidance_block: &'a dyn Fn(&CiIssue) -> Option<String>,
852}
853
854/// Render one comment from a group of issues sharing the same `(path, line)`.
855#[must_use]
856pub fn render_review_comment_for_group(input: &ReviewCommentRenderInput<'_, '_>) -> ReviewComment {
857    render_review_comment_for_group_with_id(input, None)
858}
859
860fn render_review_comment_for_group_with_id(
861    input: &ReviewCommentRenderInput<'_, '_>,
862    review_id: Option<&ReviewId>,
863) -> ReviewComment {
864    assert!(
865        !input.group.is_empty(),
866        "group_review_issues_by_path_line never yields empty"
867    );
868    let representative = input.group[0];
869    let fingerprint = if input.group.len() == 1 {
870        representative.fingerprint.clone()
871    } else {
872        let constituents: Vec<&str> = input.group.iter().map(|i| i.fingerprint.as_str()).collect();
873        composite_fingerprint(&constituents)
874    };
875    let legacy_fingerprint = group_legacy_fingerprint(input.group);
876
877    let content = build_merged_comment_content(input);
878    let marker_line = review_markers(&fingerprint, review_id);
879    let (body, truncated) = cap_body_with_marker(&content, &marker_line);
880
881    build_review_comment(ReviewCommentInput {
882        provider: input.provider,
883        representative,
884        gitlab_diff_refs: input.gitlab_diff_refs,
885        diff_index: input.diff_index,
886        path_prefix: input.path_prefix,
887        body,
888        fingerprint,
889        legacy_fingerprint,
890        truncated,
891    })
892}
893
894/// The fingerprint an older Fallow release gave a comment for `group`, or
895/// `None` when no issue in the group has a legacy fingerprint.
896///
897/// A merged comment hashes the legacy fingerprint of each constituent, or its
898/// fingerprint when the constituent has no legacy value. This is the value
899/// that the older release wrote into the merged marker.
900fn group_legacy_fingerprint(group: &[&CiIssue]) -> Option<String> {
901    if group.iter().all(|issue| issue.legacy_fingerprint.is_none()) {
902        return None;
903    }
904    if let [issue] = group {
905        return issue.legacy_fingerprint.clone();
906    }
907    let constituents: Vec<&str> = group
908        .iter()
909        .map(|issue| {
910            issue
911                .legacy_fingerprint
912                .as_deref()
913                .unwrap_or(issue.fingerprint.as_str())
914        })
915        .collect();
916    Some(composite_fingerprint(&constituents))
917}
918
919#[expect(clippy::expect_used, reason = "formatting into String is infallible")]
920fn build_merged_comment_content(input: &ReviewCommentRenderInput<'_, '_>) -> String {
921    let mut content = String::new();
922    for (index, issue) in input.group.iter().enumerate() {
923        let label = review_label_from_codeclimate(&issue.severity);
924        if index > 0 {
925            content.push_str("\n\n");
926        }
927        write!(
928            content,
929            "**{}** `{}`: {}",
930            label,
931            escape_md(&issue.rule_id),
932            escape_md(&issue.description)
933        )
934        .expect("write to String is infallible");
935        if !issue.other_locations.is_empty() {
936            content.push_str("\n\nOther locations: ");
937            let locations = issue
938                .other_locations
939                .iter()
940                .map(|location| {
941                    markdown_code_span(&format!(
942                        "{}:{}-{}",
943                        apply_path_prefix(input.path_prefix, &location.path),
944                        location.line,
945                        location.end_line
946                    ))
947                })
948                .collect::<Vec<_>>()
949                .join(", ");
950            content.push_str(&locations);
951        }
952        if let Some(suggestion) = (input.suggestion_block)(input.provider, issue) {
953            content.push_str(&suggestion);
954        }
955        if input.include_guidance
956            && let Some(guidance) = (input.guidance_block)(issue)
957        {
958            content.push_str(&guidance);
959        }
960    }
961    content
962}
963
964struct ReviewCommentInput<'a> {
965    provider: CiProvider,
966    representative: &'a CiIssue,
967    gitlab_diff_refs: Option<&'a ReviewGitlabDiffRefs>,
968    diff_index: Option<&'a DiffIndex>,
969    path_prefix: &'a str,
970    body: String,
971    fingerprint: String,
972    legacy_fingerprint: Option<String>,
973    truncated: bool,
974}
975
976fn build_review_comment(input: ReviewCommentInput<'_>) -> ReviewComment {
977    let ReviewCommentInput {
978        provider,
979        representative,
980        gitlab_diff_refs,
981        diff_index,
982        path_prefix,
983        body,
984        fingerprint,
985        legacy_fingerprint,
986        truncated,
987    } = input;
988    match provider {
989        CiProvider::Github => ReviewComment::GitHub(GitHubReviewComment {
990            path: apply_path_prefix(path_prefix, &representative.path),
991            line: u32::try_from(representative.line).unwrap_or(u32::MAX),
992            side: GitHubReviewSide::Right,
993            body,
994            fingerprint,
995            legacy_fingerprint,
996            truncated,
997        }),
998        CiProvider::Gitlab => {
999            // Renames resolve on the analysis-root-relative path, before the
1000            // presentation prefix goes on: the diff's keys never carry it.
1001            let old_rel = diff_index
1002                .and_then(|di| di.old_path_for_root_relative(&representative.path))
1003                .map_or_else(|| representative.path.clone(), Cow::into_owned);
1004            let new_path = apply_path_prefix(path_prefix, &representative.path);
1005            let old_path = apply_path_prefix(path_prefix, &old_rel);
1006            let position = GitLabReviewPosition {
1007                base_sha: gitlab_diff_refs.map(|r| r.base_sha.clone()),
1008                start_sha: gitlab_diff_refs.map(|r| r.start_sha.clone()),
1009                head_sha: gitlab_diff_refs.map(|r| r.head_sha.clone()),
1010                position_type: GitLabReviewPositionType::Text,
1011                old_path,
1012                new_path,
1013                new_line: u32::try_from(representative.line).unwrap_or(u32::MAX),
1014            };
1015            ReviewComment::GitLab(GitLabReviewComment {
1016                body,
1017                position,
1018                fingerprint,
1019                legacy_fingerprint,
1020                truncated,
1021            })
1022        }
1023    }
1024}
1025
1026/// Append `marker_line` to `content`, truncating `content` on a char boundary
1027/// so the whole body stays within [`MAX_COMMENT_BODY_BYTES`]. The marker is
1028/// never sacrificed. Returns the body and whether truncation happened.
1029#[must_use]
1030pub fn cap_body_with_marker(content: &str, marker_line: &str) -> (String, bool) {
1031    let intact_len = content.len() + marker_line.len();
1032    if intact_len <= MAX_COMMENT_BODY_BYTES {
1033        let mut out = String::with_capacity(intact_len);
1034        out.push_str(content);
1035        out.push_str(marker_line);
1036        return (out, false);
1037    }
1038    let reserved = marker_line.len() + TRUNCATION_SUFFIX.len();
1039    let budget = MAX_COMMENT_BODY_BYTES.saturating_sub(reserved);
1040    let mut cut = budget.min(content.len());
1041    while cut > 0 && !content.is_char_boundary(cut) {
1042        cut -= 1;
1043    }
1044    let mut out = String::with_capacity(MAX_COMMENT_BODY_BYTES);
1045    out.push_str(&content[..cut]);
1046    out.push_str(TRUNCATION_SUFFIX);
1047    out.push_str(marker_line);
1048    (out, true)
1049}
1050
1051/// Map a CodeClimate severity name to the review badge label: `error` for
1052/// major and above, `warn` otherwise.
1053#[must_use]
1054pub const fn review_label_from_codeclimate(severity_name: &str) -> &'static str {
1055    match severity_name.as_bytes() {
1056        b"major" | b"critical" | b"blocker" => "error",
1057        _ => "warn",
1058    }
1059}
1060
1061/// GitHub check conclusion for a set of findings: `Failure` when any is major
1062/// or above, `Success` when empty, `Neutral` otherwise.
1063#[must_use]
1064pub fn github_check_conclusion(issues: &[CiIssue]) -> ReviewCheckConclusion {
1065    if issues
1066        .iter()
1067        .any(|issue| matches!(issue.severity.as_str(), "major" | "critical" | "blocker"))
1068    {
1069        ReviewCheckConclusion::Failure
1070    } else if issues.is_empty() {
1071        ReviewCheckConclusion::Success
1072    } else {
1073        ReviewCheckConclusion::Neutral
1074    }
1075}
1076
1077fn build_review_envelope_output(
1078    provider: CiProvider,
1079    body: String,
1080    summary: ReviewEnvelopeSummary,
1081    comments: Vec<ReviewComment>,
1082    conclusion: ReviewCheckConclusion,
1083) -> ReviewEnvelopeOutput {
1084    match provider {
1085        CiProvider::Github => ReviewEnvelopeOutput {
1086            event: Some(ReviewEnvelopeEvent::Comment),
1087            body,
1088            summary,
1089            comments,
1090            marker_regex: default_marker_regex(),
1091            marker_regex_flags: default_marker_regex_flags(),
1092            meta: ReviewEnvelopeMeta {
1093                schema: ReviewEnvelopeSchema::V3,
1094                provider: ReviewProvider::Github,
1095                check_conclusion: Some(conclusion),
1096            },
1097        },
1098        CiProvider::Gitlab => ReviewEnvelopeOutput {
1099            event: None,
1100            body,
1101            summary,
1102            comments,
1103            marker_regex: default_marker_regex(),
1104            marker_regex_flags: default_marker_regex_flags(),
1105            meta: ReviewEnvelopeMeta {
1106                schema: ReviewEnvelopeSchema::V3,
1107                provider: ReviewProvider::Gitlab,
1108                check_conclusion: None,
1109            },
1110        },
1111    }
1112}
1113
1114fn review_markers(fingerprint: &str, review_id: Option<&ReviewId>) -> String {
1115    let fingerprint = format!("\n\n{MARKER_PREFIX_V3}{fingerprint}{MARKER_SUFFIX_V3}");
1116    match review_id {
1117        Some(review_id) => format!("{fingerprint}\n{}", review_id_marker(review_id)),
1118        None => fingerprint,
1119    }
1120}
1121
1122/// Stable fingerprint for a summary comment body.
1123#[must_use]
1124pub fn summary_fingerprint(body: &str) -> String {
1125    fingerprint_hash(&[body])
1126}
1127
1128/// Order-independent fingerprint for a comment merged from several findings:
1129/// constituents are sorted before hashing and the result carries a `merged:`
1130/// prefix.
1131#[must_use]
1132pub fn composite_fingerprint(constituents: &[&str]) -> String {
1133    let mut sorted: Vec<&str> = constituents.to_vec();
1134    sorted.sort_unstable();
1135    let joined = sorted.join(":");
1136    format!("merged:{}", fingerprint_hash(&[joined.as_str()]))
1137}
1138
1139#[cfg(test)]
1140mod tests {
1141    use super::*;
1142    use crate::{CodeClimateIssueKind, CodeClimateLines, CodeClimateLocation};
1143
1144    fn category_for_rule(rule_id: &str) -> &'static str {
1145        match rule_id {
1146            "fallow/code-duplication" => "Duplication",
1147            "fallow/high-complexity" => "Health",
1148            "fallow/unused-dependency" => "Dependencies",
1149            _ => "Dead code",
1150        }
1151    }
1152
1153    #[test]
1154    fn extracts_issues_from_codeclimate() {
1155        let value = serde_json::json!([{
1156            "check_name": "fallow/unused-export",
1157            "description": "Export x is never imported",
1158            "severity": "minor",
1159            "fingerprint": "abc",
1160            "location": { "path": "src/a.ts", "lines": { "begin": 7 } }
1161        }]);
1162        let issues = issues_from_codeclimate(&value);
1163        assert_eq!(issues.len(), 1);
1164        assert_eq!(issues[0].path, "src/a.ts");
1165        assert_eq!(issues[0].line, 7);
1166    }
1167
1168    #[test]
1169    fn typed_codeclimate_issues_extract_like_json_codeclimate() {
1170        let severities = [
1171            (CodeClimateSeverity::Info, "info"),
1172            (CodeClimateSeverity::Minor, "minor"),
1173            (CodeClimateSeverity::Major, "major"),
1174            (CodeClimateSeverity::Critical, "critical"),
1175            (CodeClimateSeverity::Blocker, "blocker"),
1176        ];
1177        let typed = severities
1178            .iter()
1179            .enumerate()
1180            .map(|(index, (severity, _))| CodeClimateIssue {
1181                kind: CodeClimateIssueKind::Issue,
1182                check_name: format!("fallow/rule-{index}"),
1183                description: format!("Finding {index}"),
1184                categories: vec!["Complexity".to_owned()],
1185                severity: *severity,
1186                fingerprint: format!("fp-{index}"),
1187                location: CodeClimateLocation {
1188                    path: format!("src/{index}.ts"),
1189                    lines: CodeClimateLines {
1190                        begin: u32::try_from(index + 1).expect("small fixture index"),
1191                        end: Some(u32::try_from(index + 3).expect("small fixture index")),
1192                    },
1193                },
1194                other_locations: vec![CodeClimateLocation {
1195                    path: format!("src/peer-{index}.ts"),
1196                    lines: CodeClimateLines {
1197                        begin: 20,
1198                        end: Some(24),
1199                    },
1200                }],
1201                owner: None,
1202                group: None,
1203                legacy_fingerprint: None,
1204            })
1205            .collect::<Vec<_>>();
1206        let value = serde_json::to_value(&typed).expect("typed fixture serializes");
1207
1208        assert_eq!(
1209            issues_from_codeclimate_issues(&typed),
1210            issues_from_codeclimate(&value)
1211        );
1212        let normalized = issues_from_codeclimate_issues(&typed);
1213        assert_eq!(normalized[0].end_line, Some(3));
1214        assert_eq!(normalized[0].other_locations[0].path, "src/peer-0.ts");
1215        assert_eq!(normalized[0].other_locations[0].line, 20);
1216        assert_eq!(normalized[0].other_locations[0].end_line, 24);
1217        let typed_labels = issues_from_codeclimate_issues(&typed)
1218            .into_iter()
1219            .map(|issue| issue.severity)
1220            .collect::<Vec<_>>();
1221        let expected_labels = severities
1222            .iter()
1223            .map(|(_, label)| (*label).to_owned())
1224            .collect::<Vec<_>>();
1225        assert_eq!(typed_labels, expected_labels);
1226    }
1227
1228    #[test]
1229    fn review_comment_renders_repository_prefixed_peer_ranges() {
1230        let issue = CiIssue {
1231            rule_id: "fallow/code-duplication".to_owned(),
1232            description: "Code clone dup:abcd1234 (11 lines, 2 instances)".to_owned(),
1233            severity: "minor".to_owned(),
1234            path: "src/a.ts".to_owned(),
1235            line: 5,
1236            end_line: Some(15),
1237            other_locations: vec![CiLocation {
1238                path: "src/b.ts".to_owned(),
1239                line: 30,
1240                end_line: 40,
1241            }],
1242            fingerprint: "instance-fingerprint".to_owned(),
1243            legacy_fingerprint: None,
1244        };
1245        let comment = render_review_comment_for_group(&ReviewCommentRenderInput {
1246            provider: CiProvider::Gitlab,
1247            group: &[&issue],
1248            gitlab_diff_refs: None,
1249            diff_index: None,
1250            path_prefix: "packages/app",
1251            include_guidance: false,
1252            suggestion_block: &|_, _| None,
1253            guidance_block: &|_| None,
1254        });
1255        let ReviewComment::GitLab(comment) = comment else {
1256            panic!("expected GitLab comment");
1257        };
1258
1259        assert_eq!(comment.position.new_path, "packages/app/src/a.ts");
1260        assert!(
1261            comment
1262                .body
1263                .contains("Other locations: `packages/app/src/b.ts:30-40`")
1264        );
1265    }
1266
1267    #[test]
1268    fn renders_default_empty_comment() {
1269        let body = render_pr_comment(&PrCommentRenderInput {
1270            command: "check",
1271            provider: CiProvider::Github,
1272            issues: &[],
1273            marker_id: "fallow-results".to_owned(),
1274            max_comments: 50,
1275            category_for_rule: &category_for_rule,
1276            trailing_section: None,
1277        });
1278        assert!(body.contains("<!-- fallow-id: fallow-results"));
1279        assert!(body.contains("No findings for this pull request."));
1280    }
1281
1282    fn pr_comment_issue(rule_id: &str, description: &str, severity: &str, path: &str) -> CiIssue {
1283        CiIssue {
1284            rule_id: rule_id.to_owned(),
1285            description: description.to_owned(),
1286            severity: severity.to_owned(),
1287            path: path.to_owned(),
1288            line: 3,
1289            end_line: None,
1290            other_locations: Vec::new(),
1291            fingerprint: path.to_owned(),
1292            legacy_fingerprint: None,
1293        }
1294    }
1295
1296    fn dead_code_issue(line: u64, fingerprint: &str, legacy: Option<&str>) -> CiIssue {
1297        CiIssue {
1298            rule_id: "fallow/unused-export".to_owned(),
1299            description: "Export 'helper' is never imported by other modules".to_owned(),
1300            severity: "major".to_owned(),
1301            path: "src/lib.ts".to_owned(),
1302            line,
1303            end_line: None,
1304            other_locations: Vec::new(),
1305            fingerprint: fingerprint.to_owned(),
1306            legacy_fingerprint: legacy.map(str::to_owned),
1307        }
1308    }
1309
1310    fn github_comment(group: &[&CiIssue]) -> GitHubReviewComment {
1311        let comment = render_review_comment_for_group(&ReviewCommentRenderInput {
1312            provider: CiProvider::Github,
1313            group,
1314            gitlab_diff_refs: None,
1315            diff_index: None,
1316            path_prefix: "",
1317            include_guidance: false,
1318            suggestion_block: &|_, _| None,
1319            guidance_block: &|_| None,
1320        });
1321        let ReviewComment::GitHub(comment) = comment else {
1322            panic!("expected GitHub comment");
1323        };
1324        comment
1325    }
1326
1327    /// A comment carries the stable fingerprint in a v3 marker, and the
1328    /// legacy fingerprint on the side, so a thread with the v2 marker of an
1329    /// older release still matches.
1330    #[test]
1331    fn review_comment_writes_a_v3_marker_and_keeps_the_legacy_fingerprint() {
1332        let issue = dead_code_issue(3, "0123456789abcdef", Some("fedcba9876543210"));
1333
1334        let comment = github_comment(&[&issue]);
1335
1336        assert!(
1337            comment
1338                .body
1339                .ends_with("<!-- fallow-fingerprint:v3: 0123456789abcdef -->"),
1340            "{}",
1341            comment.body
1342        );
1343        assert!(!comment.body.contains("fallow-fingerprint:v2:"));
1344        assert_eq!(comment.fingerprint, "0123456789abcdef");
1345        assert_eq!(
1346            comment.legacy_fingerprint.as_deref(),
1347            Some("fedcba9876543210")
1348        );
1349        let value = serde_json::to_value(&comment).expect("comment serializes");
1350        assert_eq!(value["legacy_fingerprint"], "fedcba9876543210");
1351    }
1352
1353    /// A finding whose fingerprint did not change carries no legacy value,
1354    /// and the wire shape has no `legacy_fingerprint` key.
1355    #[test]
1356    fn review_comment_omits_an_unchanged_legacy_fingerprint() {
1357        let issue = dead_code_issue(3, "0123456789abcdef", None);
1358
1359        let comment = github_comment(&[&issue]);
1360
1361        assert_eq!(comment.legacy_fingerprint, None);
1362        let value = serde_json::to_value(&comment).expect("comment serializes");
1363        assert!(value.get("legacy_fingerprint").is_none());
1364    }
1365
1366    /// A merged comment gets the composite that the older release computed:
1367    /// the legacy value of each constituent, or its fingerprint when it has
1368    /// no legacy value.
1369    #[test]
1370    fn merged_review_comment_rebuilds_the_legacy_composite() {
1371        let moved = dead_code_issue(3, "0123456789abcdef", Some("fedcba9876543210"));
1372        let kept = dead_code_issue(3, "1111111111111111", None);
1373
1374        let comment = github_comment(&[&moved, &kept]);
1375
1376        assert_eq!(
1377            comment.fingerprint,
1378            composite_fingerprint(&["0123456789abcdef", "1111111111111111"])
1379        );
1380        assert_eq!(
1381            comment.legacy_fingerprint,
1382            Some(composite_fingerprint(&[
1383                "fedcba9876543210",
1384                "1111111111111111"
1385            ]))
1386        );
1387    }
1388
1389    #[test]
1390    fn pr_comment_titles_by_content_and_names_its_only_category() {
1391        let issues = vec![
1392            pr_comment_issue(
1393                "fallow/unresolved-import",
1394                "Import './x' could not be resolved",
1395                "major",
1396                "src/a.ts",
1397            ),
1398            pr_comment_issue(
1399                "fallow/unresolved-import",
1400                "Import './y' could not be resolved",
1401                "major",
1402                "src/b.ts",
1403            ),
1404        ];
1405        let body = render_pr_comment(&PrCommentRenderInput {
1406            command: "dead-code",
1407            provider: CiProvider::Github,
1408            issues: &issues,
1409            marker_id: "fallow-results".to_owned(),
1410            max_comments: 50,
1411            category_for_rule: &category_for_rule,
1412            trailing_section: None,
1413        });
1414        assert!(body.contains("### Fallow codebase report"), "{body}");
1415        assert!(body.contains("**Quality gate failed**"), "{body}");
1416        assert!(body.contains("Found **2** findings."), "{body}");
1417        assert!(body.contains("<summary>Dead code (2)</summary>"), "{body}");
1418    }
1419
1420    #[test]
1421    fn pr_comment_breakdown_indexes_several_categories() {
1422        let issues = vec![
1423            pr_comment_issue(
1424                "fallow/unresolved-import",
1425                "Import './x' could not be resolved",
1426                "major",
1427                "src/a.ts",
1428            ),
1429            pr_comment_issue(
1430                "fallow/unused-dependency",
1431                "Package 'lodash' is never imported",
1432                "minor",
1433                "package.json",
1434            ),
1435        ];
1436        let body = render_pr_comment(&PrCommentRenderInput {
1437            command: "check",
1438            provider: CiProvider::Github,
1439            issues: &issues,
1440            marker_id: "fallow-results".to_owned(),
1441            max_comments: 50,
1442            category_for_rule: &category_for_rule,
1443            trailing_section: None,
1444        });
1445        assert!(
1446            body.contains("Found **2** findings: Dead code 1, Dependencies 1."),
1447            "{body}"
1448        );
1449    }
1450
1451    #[test]
1452    fn pr_comment_empty_state_speaks_the_provider_language() {
1453        let github = render_pr_comment(&PrCommentRenderInput {
1454            command: "dead-code",
1455            provider: CiProvider::Github,
1456            issues: &[],
1457            marker_id: "fallow-results".to_owned(),
1458            max_comments: 50,
1459            category_for_rule: &category_for_rule,
1460            trailing_section: None,
1461        });
1462        let gitlab = render_pr_comment(&PrCommentRenderInput {
1463            command: "dead-code",
1464            provider: CiProvider::Gitlab,
1465            issues: &[],
1466            marker_id: "fallow-results".to_owned(),
1467            max_comments: 50,
1468            category_for_rule: &category_for_rule,
1469            trailing_section: None,
1470        });
1471        assert!(
1472            github.contains("No findings for this pull request."),
1473            "{github}"
1474        );
1475        assert!(
1476            gitlab.contains("No findings for this merge request."),
1477            "{gitlab}"
1478        );
1479        assert!(
1480            github.starts_with("<!-- fallow-id: fallow-results -->\n"),
1481            "{github}"
1482        );
1483        assert!(github.contains("Generated by fallow."), "{github}");
1484    }
1485
1486    #[test]
1487    fn escape_md_escapes_inline_commonmark_specials() {
1488        let raw = "foo*bar_baz [a](u) `c` <h> #x !i ~s | p";
1489        let escaped = escape_md(raw);
1490        for ch in [
1491            '*', '_', '[', ']', '(', ')', '`', '<', '>', '#', '!', '~', '|',
1492        ] {
1493            let raw_count = raw.chars().filter(|c| c == &ch).count();
1494            let escaped_count = escaped.matches(&format!("\\{ch}")).count();
1495            assert_eq!(
1496                raw_count, escaped_count,
1497                "char {ch:?}: raw {raw_count} occurrences, escaped {escaped_count} in {escaped:?}"
1498            );
1499        }
1500    }
1501
1502    #[test]
1503    fn escape_md_escapes_ampersand_to_block_numeric_entity_bypass() {
1504        let raw = "value &#42;suspicious&#42; here";
1505        let escaped = escape_md(raw);
1506        assert!(escaped.contains(r"\&"), "got: {escaped}");
1507        assert!(escaped.contains(r"\#"), "got: {escaped}");
1508        assert!(!escaped.contains(" *suspicious"), "got: {escaped}");
1509    }
1510
1511    #[test]
1512    fn summary_label_foreshadows_truncation() {
1513        assert_eq!(
1514            summary_label("Duplication", 160, 50),
1515            "Duplication (160, showing 50)"
1516        );
1517        assert_eq!(summary_label("Health", 12, 50), "Health (12)");
1518        assert_eq!(summary_label("Dependencies", 50, 50), "Dependencies (50)");
1519    }
1520
1521    #[test]
1522    fn escape_md_does_not_escape_block_only_markers() {
1523        let raw = "fallow/test-only-dependency package.json:12";
1524        let escaped = escape_md(raw);
1525        assert!(!escaped.contains("\\-"), "should not escape `-`");
1526        assert!(!escaped.contains("\\."), "should not escape `.`");
1527        assert_eq!(escaped, raw);
1528    }
1529
1530    #[test]
1531    fn escape_md_collapses_newlines_to_spaces() {
1532        let raw = "first\nsecond\nthird";
1533        assert_eq!(escape_md(raw), "first second third");
1534    }
1535
1536    #[test]
1537    fn escape_md_collapses_carriage_returns_to_spaces() {
1538        assert_eq!(escape_md("first\r\nsecond\rthird"), "first second third");
1539    }
1540
1541    #[test]
1542    fn escape_md_trims_surrounding_whitespace() {
1543        assert_eq!(escape_md("  a\u{2003}\r\n"), "a");
1544        assert_eq!(escape_md(" \t\r\n\u{2003} "), "");
1545    }
1546
1547    #[test]
1548    fn escape_md_collapses_crlf_to_one_space() {
1549        let collapsed = escape_md("a\r\nb\rc\nd");
1550        assert_eq!(collapsed, "a b c d");
1551        assert_eq!(collapsed.len(), 7, "CRLF must not expand to two spaces");
1552    }
1553
1554    #[test]
1555    fn escape_md_preserves_interior_tabs_and_wide_spaces() {
1556        assert_eq!(escape_md("a\tb"), "a\tb");
1557        assert_eq!(escape_md("a\u{2003}b"), "a\u{2003}b");
1558    }
1559
1560    #[test]
1561    fn escape_md_passes_non_ascii_through_unchanged() {
1562        assert_eq!(escape_md("é🦀"), "é🦀");
1563    }
1564
1565    #[test]
1566    fn markdown_code_span_grows_fence_past_inner_backticks() {
1567        assert_eq!(markdown_code_span("plain"), "`plain`");
1568        assert_eq!(markdown_code_span("has`tick"), "``has`tick``");
1569        assert_eq!(markdown_code_span("`leading"), "`` `leading ``");
1570    }
1571
1572    #[test]
1573    fn markdown_table_code_span_escapes_pipes() {
1574        assert_eq!(markdown_table_code_span("a|b"), "`a\\|b`");
1575        assert_eq!(markdown_table_code_span("x`|y"), "``x`\\|y``");
1576    }
1577
1578    #[test]
1579    fn markdown_table_code_span_collapses_line_endings() {
1580        assert_eq!(markdown_table_code_span("a\r\nb\rc\nd"), "`a b c d`");
1581    }
1582
1583    #[test]
1584    fn markdown_table_text_neutralizes_pipes_and_line_endings() {
1585        assert_eq!(markdown_table_text("a|b"), "a\\|b");
1586        assert_eq!(markdown_table_text("a\r\nb\rc\nd"), "a b c d");
1587    }
1588
1589    #[test]
1590    fn escape_md_leaves_safe_chars_unchanged() {
1591        let raw = "Export 'helperFn' is never imported by other modules";
1592        assert_eq!(
1593            escape_md(raw),
1594            r"Export 'helperFn' is never imported by other modules"
1595        );
1596    }
1597
1598    #[test]
1599    fn is_project_level_rule_covers_config_anchored_dependency_findings() {
1600        for rule_id in PROJECT_LEVEL_RULE_IDS {
1601            assert!(
1602                is_project_level_rule(rule_id),
1603                "{rule_id} must be project-level"
1604            );
1605        }
1606        for rule_id in [
1607            "fallow/unused-file",
1608            "fallow/unused-export",
1609            "fallow/unused-type",
1610            "fallow/unused-enum-member",
1611            "fallow/unused-class-member",
1612            "fallow/unused-store-member",
1613            "fallow/unresolved-import",
1614            "fallow/unlisted-dependency",
1615            "fallow/duplicate-export",
1616            "fallow/circular-dependency",
1617            "fallow/re-export-cycle",
1618            "fallow/package-cycle",
1619            "fallow/boundary-violation",
1620            "fallow/stale-suppression",
1621            "fallow/private-type-leak",
1622            "fallow/high-complexity",
1623            "fallow/high-crap-score",
1624        ] {
1625            assert!(
1626                !is_project_level_rule(rule_id),
1627                "{rule_id} must NOT be project-level"
1628            );
1629        }
1630    }
1631
1632    #[test]
1633    fn escape_md_double_apply_is_safe() {
1634        let raw = "code with `backticks` and *stars*";
1635        let once = escape_md(raw);
1636        let twice = escape_md(&once);
1637        assert_eq!(once, r"code with \`backticks\` and \*stars\*");
1638        assert_eq!(twice, r"code with \\\`backticks\\\` and \\\*stars\\\*");
1639    }
1640
1641    #[test]
1642    fn pr_comment_trailing_section_renders_before_the_footer() {
1643        let body = render_pr_comment(&PrCommentRenderInput {
1644            command: "check",
1645            provider: CiProvider::Github,
1646            issues: &[],
1647            marker_id: "fallow-results".to_owned(),
1648            max_comments: 50,
1649            category_for_rule: &category_for_rule,
1650            trailing_section: Some("\n## Unmatched config patterns\n\n- `@typo/*`"),
1651        });
1652        assert!(
1653            body.ends_with(
1654                "\n\n## Unmatched config patterns\n\n- `@typo/*`\n\nGenerated by fallow."
1655            ),
1656            "{body}"
1657        );
1658    }
1659}