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