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        "architecture" => "architecture report",
531        "dupes" => "duplication report",
532        "health" => "health report",
533        "audit" => "audit report",
534        "security" => "security report",
535        "fix" => "fix report",
536        "" | "combined" => "combined report",
537        _ => "report",
538    }
539}
540
541/// How the provider names a change proposal ("pull request" / "merge request").
542const fn change_noun(provider: CiProvider) -> &'static str {
543    match provider {
544        CiProvider::Github => "pull request",
545        CiProvider::Gitlab => "merge request",
546    }
547}
548
549/// The provider's diff tab, where inline review comments are read.
550const fn changes_tab(provider: CiProvider) -> &'static str {
551    match provider {
552        CiProvider::Github => "Files changed",
553        CiProvider::Gitlab => "Changes",
554    }
555}
556
557/// Escape a string for inclusion in a Markdown table cell.
558#[must_use]
559pub fn escape_md(value: &str) -> String {
560    let value = value.trim();
561    // Collapse CRLF to one space; a bare CR is a CommonMark line ending and
562    // would otherwise split the table row.
563    let mut chars = value.chars().peekable();
564    let mut out = String::with_capacity(value.len());
565    while let Some(ch) = chars.next() {
566        let ch = match ch {
567            '\r' => {
568                if chars.peek() == Some(&'\n') {
569                    chars.next();
570                }
571                ' '
572            }
573            '\n' => ' ',
574            _ => ch,
575        };
576        if matches!(
577            ch,
578            '\\' | '`'
579                | '*'
580                | '_'
581                | '['
582                | ']'
583                | '('
584                | ')'
585                | '!'
586                | '<'
587                | '>'
588                | '#'
589                | '|'
590                | '~'
591                | '&'
592        ) {
593            out.push('\\');
594        }
595        out.push(ch);
596    }
597    out
598}
599
600/// Render a complete CommonMark code span around an untrusted value. The
601/// fence grows past the longest backtick run inside the value, so the span
602/// cannot be closed early from within.
603#[must_use]
604pub fn markdown_code_span(value: &str) -> String {
605    let longest_run = value
606        .split(|c| c != '`')
607        .map(str::len)
608        .max()
609        .unwrap_or_default();
610    let fence = "`".repeat(longest_run + 1);
611    let needs_padding = value.starts_with('`')
612        || value.ends_with('`')
613        || (value.starts_with(' ') && value.ends_with(' ') && !value.chars().all(|c| c == ' '));
614    if needs_padding {
615        format!("{fence} {value} {fence}")
616    } else {
617        format!("{fence}{value}{fence}")
618    }
619}
620
621/// [`markdown_code_span`] for a Markdown table cell: pipes are additionally
622/// escaped so the value cannot terminate the cell, and line endings collapse
623/// to spaces because any CommonMark line ending would split the table row.
624#[must_use]
625pub fn markdown_table_code_span(value: &str) -> String {
626    let collapsed = value.replace("\r\n", " ").replace(['\n', '\r'], " ");
627    markdown_code_span(&collapsed.replace('|', "\\|"))
628}
629
630/// Escape prose for a Markdown table cell while leaving intentional inline
631/// markup alone: pipes are escaped and line endings collapse to spaces.
632#[must_use]
633pub fn markdown_table_text(value: &str) -> String {
634    value
635        .replace("\r\n", " ")
636        .replace(['\n', '\r'], " ")
637        .replace('|', "\\|")
638}
639
640/// Render a provider-specific review envelope from typed CI issues.
641#[must_use]
642pub fn render_review_envelope(
643    input: &ReviewEnvelopeRenderInput<'_>,
644    status_message: Option<&str>,
645) -> ReviewEnvelopeRenderResult {
646    render_review_envelope_with_id(input, None, None, status_message)
647}
648
649/// Render a review envelope with an explicit gate conclusion and status.
650#[must_use]
651pub fn render_review_envelope_with_conclusion(
652    input: &ReviewEnvelopeRenderInput<'_>,
653    conclusion: ReviewCheckConclusion,
654    status_message: Option<&str>,
655) -> ReviewEnvelopeRenderResult {
656    render_review_envelope_with_id(input, None, Some(conclusion), status_message)
657}
658
659/// Render a review envelope whose bodies carry the supplied review scope.
660#[must_use]
661pub fn render_scoped_review_envelope(
662    input: &ReviewEnvelopeRenderInput<'_>,
663    review_id: &ReviewId,
664    status_message: Option<&str>,
665) -> ReviewEnvelopeRenderResult {
666    render_review_envelope_with_id(input, Some(review_id), None, status_message)
667}
668
669/// Render a scoped review envelope with an explicit gate conclusion and status.
670#[must_use]
671pub fn render_scoped_review_envelope_with_conclusion(
672    input: &ReviewEnvelopeRenderInput<'_>,
673    review_id: &ReviewId,
674    conclusion: ReviewCheckConclusion,
675    status_message: Option<&str>,
676) -> ReviewEnvelopeRenderResult {
677    render_review_envelope_with_id(input, Some(review_id), Some(conclusion), status_message)
678}
679
680fn render_review_envelope_with_id(
681    input: &ReviewEnvelopeRenderInput<'_>,
682    review_id: Option<&ReviewId>,
683    conclusion: Option<ReviewCheckConclusion>,
684    status_message: Option<&str>,
685) -> ReviewEnvelopeRenderResult {
686    let grouped = group_review_issues_by_path_line(input.issues, input.max_comments);
687
688    let comments: Vec<ReviewComment> = grouped
689        .groups
690        .iter()
691        .map(|group| {
692            render_review_comment_for_group_with_id(
693                &ReviewCommentRenderInput {
694                    provider: input.provider,
695                    group,
696                    gitlab_diff_refs: input.gitlab_diff_refs,
697                    diff_index: input.diff_index,
698                    path_prefix: input.path_prefix,
699                    include_guidance: input.include_guidance,
700                    suggestion_block: input.suggestion_block,
701                    guidance_block: input.guidance_block,
702                },
703                review_id,
704            )
705        })
706        .collect();
707
708    let conclusion = conclusion.unwrap_or_else(|| github_check_conclusion(input.issues));
709    let summary_text = review_summary_text(
710        input.command,
711        input.provider,
712        comments.len(),
713        status_message,
714        input.trailing_section,
715    );
716    let summary_fp = summary_fingerprint(&summary_text);
717    let summary_marker = review_markers(&summary_fp, review_id);
718    let body = format!("{summary_text}{summary_marker}");
719    let summary = ReviewEnvelopeSummary {
720        body: body.clone(),
721        fingerprint: summary_fp,
722    };
723
724    let truncation = ReviewEnvelopeTruncation {
725        body: comments.iter().any(review_comment_truncated),
726        comment_limit: grouped.truncated,
727    };
728
729    ReviewEnvelopeRenderResult {
730        envelope: build_review_envelope_output(input.provider, body, summary, comments, conclusion),
731        truncation,
732    }
733}
734
735/// Review body: what this review carries and where to read it.
736///
737/// No run verdict. A provider review is a point-in-time record that a later
738/// run cannot rewrite, so a verdict rendered here keeps asserting a state the
739/// editable sticky comment has already moved on from. The machine-readable
740/// gate result still travels on `meta.check_conclusion`.
741fn review_summary_text(
742    command: &str,
743    provider: CiProvider,
744    comment_count: usize,
745    status_message: Option<&str>,
746    trailing_section: Option<&str>,
747) -> String {
748    let status = status_message.map_or_else(String::new, |message| format!("\n\n> {message}"));
749    let section = trailing_section
750        .map(str::trim)
751        .filter(|section| !section.is_empty())
752        .map_or_else(String::new, |section| format!("\n\n{section}"));
753    format!(
754        "### Fallow {}{}\n\n{}{}\n\n<!-- fallow-review -->",
755        command_title(command),
756        status,
757        inline_comment_line(provider, comment_count),
758        section,
759    )
760}
761
762fn inline_comment_line(provider: CiProvider, count: usize) -> String {
763    if count == 0 {
764        return format!(
765            "No findings anchored to the changed lines in this {}.",
766            change_noun(provider)
767        );
768    }
769    format!(
770        "{count} inline comment{} on the changed lines. Open the {} tab to review.",
771        if count == 1 { "" } else { "s" },
772        changes_tab(provider),
773    )
774}
775
776/// Review issues grouped per `(path, line)` for one-comment-per-location
777/// rendering.
778#[derive(Debug, PartialEq, Eq)]
779pub struct GroupedReviewIssues<'a> {
780    /// One group per distinct location, in input order.
781    pub groups: Vec<Vec<&'a CiIssue>>,
782    /// True when the group cap cut off remaining issues.
783    pub truncated: bool,
784}
785
786/// Group consecutive same-(path, line) issues. Input is already sorted by
787/// `(path, line, fingerprint)` so a single linear pass collects runs.
788#[must_use]
789pub fn group_review_issues_by_path_line(
790    issues: &[CiIssue],
791    max_groups: usize,
792) -> GroupedReviewIssues<'_> {
793    if max_groups == 0 {
794        return GroupedReviewIssues {
795            groups: Vec::new(),
796            truncated: !issues.is_empty(),
797        };
798    }
799    let mut groups: Vec<Vec<&CiIssue>> = Vec::with_capacity(max_groups.min(issues.len()));
800    let mut current: Vec<&CiIssue> = Vec::new();
801    let mut current_key: Option<(&str, u64)> = None;
802    for issue in issues {
803        let key = (issue.path.as_str(), issue.line);
804        if Some(key) != current_key {
805            if !current.is_empty() {
806                groups.push(std::mem::take(&mut current));
807                if groups.len() == max_groups {
808                    return GroupedReviewIssues {
809                        groups,
810                        truncated: true,
811                    };
812                }
813            }
814            current_key = Some(key);
815        }
816        current.push(issue);
817    }
818    if !current.is_empty() && groups.len() < max_groups {
819        groups.push(current);
820    }
821    GroupedReviewIssues {
822        groups,
823        truncated: false,
824    }
825}
826
827fn review_comment_truncated(comment: &ReviewComment) -> bool {
828    match comment {
829        ReviewComment::GitHub(comment) => comment.truncated,
830        ReviewComment::GitLab(comment) => comment.truncated,
831    }
832}
833
834/// Inputs for rendering one inline review comment from a location group.
835pub struct ReviewCommentRenderInput<'a, 'group> {
836    /// CI provider whose comment shape to produce.
837    pub provider: CiProvider,
838    /// Issues sharing the same `(path, line)`; the first is the representative.
839    pub group: &'a [&'group CiIssue],
840    /// Required for GitLab positioned discussions; ignored for GitHub.
841    pub gitlab_diff_refs: Option<&'a ReviewGitlabDiffRefs>,
842    /// Diff index used to resolve renamed paths for GitLab positions.
843    pub diff_index: Option<&'a DiffIndex>,
844    /// Prepended to every emitted path after diff lookups have run.
845    pub path_prefix: &'a str,
846    /// Whether to append per-finding guidance blocks to the body.
847    pub include_guidance: bool,
848    /// Produces a provider-specific suggestion block for a finding, when one
849    /// applies.
850    pub suggestion_block: &'a dyn Fn(CiProvider, &CiIssue) -> Option<String>,
851    /// Produces a guidance block for a finding, when one applies.
852    pub guidance_block: &'a dyn Fn(&CiIssue) -> Option<String>,
853}
854
855/// Render one comment from a group of issues sharing the same `(path, line)`.
856#[must_use]
857pub fn render_review_comment_for_group(input: &ReviewCommentRenderInput<'_, '_>) -> ReviewComment {
858    render_review_comment_for_group_with_id(input, None)
859}
860
861fn render_review_comment_for_group_with_id(
862    input: &ReviewCommentRenderInput<'_, '_>,
863    review_id: Option<&ReviewId>,
864) -> ReviewComment {
865    assert!(
866        !input.group.is_empty(),
867        "group_review_issues_by_path_line never yields empty"
868    );
869    let representative = input.group[0];
870    let fingerprint = if input.group.len() == 1 {
871        representative.fingerprint.clone()
872    } else {
873        let constituents: Vec<&str> = input.group.iter().map(|i| i.fingerprint.as_str()).collect();
874        composite_fingerprint(&constituents)
875    };
876    let legacy_fingerprint = group_legacy_fingerprint(input.group);
877
878    let content = build_merged_comment_content(input);
879    let marker_line = review_markers(&fingerprint, review_id);
880    let (body, truncated) = cap_body_with_marker(&content, &marker_line);
881
882    build_review_comment(ReviewCommentInput {
883        provider: input.provider,
884        representative,
885        gitlab_diff_refs: input.gitlab_diff_refs,
886        diff_index: input.diff_index,
887        path_prefix: input.path_prefix,
888        body,
889        fingerprint,
890        legacy_fingerprint,
891        truncated,
892    })
893}
894
895/// The fingerprint an older Fallow release gave a comment for `group`, or
896/// `None` when no issue in the group has a legacy fingerprint.
897///
898/// A merged comment hashes the legacy fingerprint of each constituent, or its
899/// fingerprint when the constituent has no legacy value. This is the value
900/// that the older release wrote into the merged marker.
901fn group_legacy_fingerprint(group: &[&CiIssue]) -> Option<String> {
902    if group.iter().all(|issue| issue.legacy_fingerprint.is_none()) {
903        return None;
904    }
905    if let [issue] = group {
906        return issue.legacy_fingerprint.clone();
907    }
908    let constituents: Vec<&str> = group
909        .iter()
910        .map(|issue| {
911            issue
912                .legacy_fingerprint
913                .as_deref()
914                .unwrap_or(issue.fingerprint.as_str())
915        })
916        .collect();
917    Some(composite_fingerprint(&constituents))
918}
919
920#[expect(clippy::expect_used, reason = "formatting into String is infallible")]
921fn build_merged_comment_content(input: &ReviewCommentRenderInput<'_, '_>) -> String {
922    let mut content = String::new();
923    for (index, issue) in input.group.iter().enumerate() {
924        let label = review_label_from_codeclimate(&issue.severity);
925        if index > 0 {
926            content.push_str("\n\n");
927        }
928        write!(
929            content,
930            "**{}** `{}`: {}",
931            label,
932            escape_md(&issue.rule_id),
933            escape_md(&issue.description)
934        )
935        .expect("write to String is infallible");
936        if !issue.other_locations.is_empty() {
937            content.push_str("\n\nOther locations: ");
938            let locations = issue
939                .other_locations
940                .iter()
941                .map(|location| {
942                    markdown_code_span(&format!(
943                        "{}:{}-{}",
944                        apply_path_prefix(input.path_prefix, &location.path),
945                        location.line,
946                        location.end_line
947                    ))
948                })
949                .collect::<Vec<_>>()
950                .join(", ");
951            content.push_str(&locations);
952        }
953        if let Some(suggestion) = (input.suggestion_block)(input.provider, issue) {
954            content.push_str(&suggestion);
955        }
956        if input.include_guidance
957            && let Some(guidance) = (input.guidance_block)(issue)
958        {
959            content.push_str(&guidance);
960        }
961    }
962    content
963}
964
965struct ReviewCommentInput<'a> {
966    provider: CiProvider,
967    representative: &'a CiIssue,
968    gitlab_diff_refs: Option<&'a ReviewGitlabDiffRefs>,
969    diff_index: Option<&'a DiffIndex>,
970    path_prefix: &'a str,
971    body: String,
972    fingerprint: String,
973    legacy_fingerprint: Option<String>,
974    truncated: bool,
975}
976
977fn build_review_comment(input: ReviewCommentInput<'_>) -> ReviewComment {
978    let ReviewCommentInput {
979        provider,
980        representative,
981        gitlab_diff_refs,
982        diff_index,
983        path_prefix,
984        body,
985        fingerprint,
986        legacy_fingerprint,
987        truncated,
988    } = input;
989    match provider {
990        CiProvider::Github => ReviewComment::GitHub(GitHubReviewComment {
991            path: apply_path_prefix(path_prefix, &representative.path),
992            line: u32::try_from(representative.line).unwrap_or(u32::MAX),
993            side: GitHubReviewSide::Right,
994            body,
995            fingerprint,
996            legacy_fingerprint,
997            truncated,
998        }),
999        CiProvider::Gitlab => {
1000            // Renames resolve on the analysis-root-relative path, before the
1001            // presentation prefix goes on: the diff's keys never carry it.
1002            let old_rel = diff_index
1003                .and_then(|di| di.old_path_for_root_relative(&representative.path))
1004                .map_or_else(|| representative.path.clone(), Cow::into_owned);
1005            let new_path = apply_path_prefix(path_prefix, &representative.path);
1006            let old_path = apply_path_prefix(path_prefix, &old_rel);
1007            let position = GitLabReviewPosition {
1008                base_sha: gitlab_diff_refs.map(|r| r.base_sha.clone()),
1009                start_sha: gitlab_diff_refs.map(|r| r.start_sha.clone()),
1010                head_sha: gitlab_diff_refs.map(|r| r.head_sha.clone()),
1011                position_type: GitLabReviewPositionType::Text,
1012                old_path,
1013                new_path,
1014                new_line: u32::try_from(representative.line).unwrap_or(u32::MAX),
1015            };
1016            ReviewComment::GitLab(GitLabReviewComment {
1017                body,
1018                position,
1019                fingerprint,
1020                legacy_fingerprint,
1021                truncated,
1022            })
1023        }
1024    }
1025}
1026
1027/// Append `marker_line` to `content`, truncating `content` on a char boundary
1028/// so the whole body stays within [`MAX_COMMENT_BODY_BYTES`]. The marker is
1029/// never sacrificed. Returns the body and whether truncation happened.
1030#[must_use]
1031pub fn cap_body_with_marker(content: &str, marker_line: &str) -> (String, bool) {
1032    let intact_len = content.len() + marker_line.len();
1033    if intact_len <= MAX_COMMENT_BODY_BYTES {
1034        let mut out = String::with_capacity(intact_len);
1035        out.push_str(content);
1036        out.push_str(marker_line);
1037        return (out, false);
1038    }
1039    let reserved = marker_line.len() + TRUNCATION_SUFFIX.len();
1040    let budget = MAX_COMMENT_BODY_BYTES.saturating_sub(reserved);
1041    let mut cut = budget.min(content.len());
1042    while cut > 0 && !content.is_char_boundary(cut) {
1043        cut -= 1;
1044    }
1045    let mut out = String::with_capacity(MAX_COMMENT_BODY_BYTES);
1046    out.push_str(&content[..cut]);
1047    out.push_str(TRUNCATION_SUFFIX);
1048    out.push_str(marker_line);
1049    (out, true)
1050}
1051
1052/// Map a CodeClimate severity name to the review badge label: `error` for
1053/// major and above, `warn` otherwise.
1054#[must_use]
1055pub const fn review_label_from_codeclimate(severity_name: &str) -> &'static str {
1056    match severity_name.as_bytes() {
1057        b"major" | b"critical" | b"blocker" => "error",
1058        _ => "warn",
1059    }
1060}
1061
1062/// GitHub check conclusion for a set of findings: `Failure` when any is major
1063/// or above, `Success` when empty, `Neutral` otherwise.
1064#[must_use]
1065pub fn github_check_conclusion(issues: &[CiIssue]) -> ReviewCheckConclusion {
1066    if issues
1067        .iter()
1068        .any(|issue| matches!(issue.severity.as_str(), "major" | "critical" | "blocker"))
1069    {
1070        ReviewCheckConclusion::Failure
1071    } else if issues.is_empty() {
1072        ReviewCheckConclusion::Success
1073    } else {
1074        ReviewCheckConclusion::Neutral
1075    }
1076}
1077
1078fn build_review_envelope_output(
1079    provider: CiProvider,
1080    body: String,
1081    summary: ReviewEnvelopeSummary,
1082    comments: Vec<ReviewComment>,
1083    conclusion: ReviewCheckConclusion,
1084) -> ReviewEnvelopeOutput {
1085    match provider {
1086        CiProvider::Github => ReviewEnvelopeOutput {
1087            event: Some(ReviewEnvelopeEvent::Comment),
1088            body,
1089            summary,
1090            comments,
1091            marker_regex: default_marker_regex(),
1092            marker_regex_flags: default_marker_regex_flags(),
1093            meta: ReviewEnvelopeMeta {
1094                schema: ReviewEnvelopeSchema::V3,
1095                provider: ReviewProvider::Github,
1096                check_conclusion: Some(conclusion),
1097            },
1098        },
1099        CiProvider::Gitlab => ReviewEnvelopeOutput {
1100            event: None,
1101            body,
1102            summary,
1103            comments,
1104            marker_regex: default_marker_regex(),
1105            marker_regex_flags: default_marker_regex_flags(),
1106            meta: ReviewEnvelopeMeta {
1107                schema: ReviewEnvelopeSchema::V3,
1108                provider: ReviewProvider::Gitlab,
1109                check_conclusion: None,
1110            },
1111        },
1112    }
1113}
1114
1115fn review_markers(fingerprint: &str, review_id: Option<&ReviewId>) -> String {
1116    let fingerprint = format!("\n\n{MARKER_PREFIX_V3}{fingerprint}{MARKER_SUFFIX_V3}");
1117    match review_id {
1118        Some(review_id) => format!("{fingerprint}\n{}", review_id_marker(review_id)),
1119        None => fingerprint,
1120    }
1121}
1122
1123/// Stable fingerprint for a summary comment body.
1124#[must_use]
1125pub fn summary_fingerprint(body: &str) -> String {
1126    fingerprint_hash(&[body])
1127}
1128
1129/// Order-independent fingerprint for a comment merged from several findings:
1130/// constituents are sorted before hashing and the result carries a `merged:`
1131/// prefix.
1132#[must_use]
1133pub fn composite_fingerprint(constituents: &[&str]) -> String {
1134    let mut sorted: Vec<&str> = constituents.to_vec();
1135    sorted.sort_unstable();
1136    let joined = sorted.join(":");
1137    format!("merged:{}", fingerprint_hash(&[joined.as_str()]))
1138}
1139
1140#[cfg(test)]
1141mod tests {
1142    use super::*;
1143    use crate::{CodeClimateIssueKind, CodeClimateLines, CodeClimateLocation};
1144
1145    fn category_for_rule(rule_id: &str) -> &'static str {
1146        match rule_id {
1147            "fallow/code-duplication" => "Duplication",
1148            "fallow/high-complexity" => "Health",
1149            "fallow/unused-dependency" => "Dependencies",
1150            _ => "Dead code",
1151        }
1152    }
1153
1154    #[test]
1155    fn command_title_names_each_analysis_command() {
1156        assert_eq!(command_title("dead-code"), "codebase report");
1157        assert_eq!(command_title("architecture"), "architecture report");
1158        assert_eq!(command_title("dupes"), "duplication report");
1159    }
1160
1161    #[test]
1162    fn extracts_issues_from_codeclimate() {
1163        let value = serde_json::json!([{
1164            "check_name": "fallow/unused-export",
1165            "description": "Export x is never imported",
1166            "severity": "minor",
1167            "fingerprint": "abc",
1168            "location": { "path": "src/a.ts", "lines": { "begin": 7 } }
1169        }]);
1170        let issues = issues_from_codeclimate(&value);
1171        assert_eq!(issues.len(), 1);
1172        assert_eq!(issues[0].path, "src/a.ts");
1173        assert_eq!(issues[0].line, 7);
1174    }
1175
1176    #[test]
1177    fn typed_codeclimate_issues_extract_like_json_codeclimate() {
1178        let severities = [
1179            (CodeClimateSeverity::Info, "info"),
1180            (CodeClimateSeverity::Minor, "minor"),
1181            (CodeClimateSeverity::Major, "major"),
1182            (CodeClimateSeverity::Critical, "critical"),
1183            (CodeClimateSeverity::Blocker, "blocker"),
1184        ];
1185        let typed = severities
1186            .iter()
1187            .enumerate()
1188            .map(|(index, (severity, _))| CodeClimateIssue {
1189                kind: CodeClimateIssueKind::Issue,
1190                check_name: format!("fallow/rule-{index}"),
1191                description: format!("Finding {index}"),
1192                categories: vec!["Complexity".to_owned()],
1193                severity: *severity,
1194                fingerprint: format!("fp-{index}"),
1195                location: CodeClimateLocation {
1196                    path: format!("src/{index}.ts"),
1197                    lines: CodeClimateLines {
1198                        begin: u32::try_from(index + 1).expect("small fixture index"),
1199                        end: Some(u32::try_from(index + 3).expect("small fixture index")),
1200                    },
1201                },
1202                other_locations: vec![CodeClimateLocation {
1203                    path: format!("src/peer-{index}.ts"),
1204                    lines: CodeClimateLines {
1205                        begin: 20,
1206                        end: Some(24),
1207                    },
1208                }],
1209                owner: None,
1210                group: None,
1211                legacy_fingerprint: None,
1212            })
1213            .collect::<Vec<_>>();
1214        let value = serde_json::to_value(&typed).expect("typed fixture serializes");
1215
1216        assert_eq!(
1217            issues_from_codeclimate_issues(&typed),
1218            issues_from_codeclimate(&value)
1219        );
1220        let normalized = issues_from_codeclimate_issues(&typed);
1221        assert_eq!(normalized[0].end_line, Some(3));
1222        assert_eq!(normalized[0].other_locations[0].path, "src/peer-0.ts");
1223        assert_eq!(normalized[0].other_locations[0].line, 20);
1224        assert_eq!(normalized[0].other_locations[0].end_line, 24);
1225        let typed_labels = issues_from_codeclimate_issues(&typed)
1226            .into_iter()
1227            .map(|issue| issue.severity)
1228            .collect::<Vec<_>>();
1229        let expected_labels = severities
1230            .iter()
1231            .map(|(_, label)| (*label).to_owned())
1232            .collect::<Vec<_>>();
1233        assert_eq!(typed_labels, expected_labels);
1234    }
1235
1236    #[test]
1237    fn review_comment_renders_repository_prefixed_peer_ranges() {
1238        let issue = CiIssue {
1239            rule_id: "fallow/code-duplication".to_owned(),
1240            description: "Code clone dup:abcd1234 (11 lines, 2 instances)".to_owned(),
1241            severity: "minor".to_owned(),
1242            path: "src/a.ts".to_owned(),
1243            line: 5,
1244            end_line: Some(15),
1245            other_locations: vec![CiLocation {
1246                path: "src/b.ts".to_owned(),
1247                line: 30,
1248                end_line: 40,
1249            }],
1250            fingerprint: "instance-fingerprint".to_owned(),
1251            legacy_fingerprint: None,
1252        };
1253        let comment = render_review_comment_for_group(&ReviewCommentRenderInput {
1254            provider: CiProvider::Gitlab,
1255            group: &[&issue],
1256            gitlab_diff_refs: None,
1257            diff_index: None,
1258            path_prefix: "packages/app",
1259            include_guidance: false,
1260            suggestion_block: &|_, _| None,
1261            guidance_block: &|_| None,
1262        });
1263        let ReviewComment::GitLab(comment) = comment else {
1264            panic!("expected GitLab comment");
1265        };
1266
1267        assert_eq!(comment.position.new_path, "packages/app/src/a.ts");
1268        assert!(
1269            comment
1270                .body
1271                .contains("Other locations: `packages/app/src/b.ts:30-40`")
1272        );
1273    }
1274
1275    #[test]
1276    fn renders_default_empty_comment() {
1277        let body = render_pr_comment(&PrCommentRenderInput {
1278            command: "check",
1279            provider: CiProvider::Github,
1280            issues: &[],
1281            marker_id: "fallow-results".to_owned(),
1282            max_comments: 50,
1283            category_for_rule: &category_for_rule,
1284            trailing_section: None,
1285        });
1286        assert!(body.contains("<!-- fallow-id: fallow-results"));
1287        assert!(body.contains("No findings for this pull request."));
1288    }
1289
1290    fn pr_comment_issue(rule_id: &str, description: &str, severity: &str, path: &str) -> CiIssue {
1291        CiIssue {
1292            rule_id: rule_id.to_owned(),
1293            description: description.to_owned(),
1294            severity: severity.to_owned(),
1295            path: path.to_owned(),
1296            line: 3,
1297            end_line: None,
1298            other_locations: Vec::new(),
1299            fingerprint: path.to_owned(),
1300            legacy_fingerprint: None,
1301        }
1302    }
1303
1304    fn dead_code_issue(line: u64, fingerprint: &str, legacy: Option<&str>) -> CiIssue {
1305        CiIssue {
1306            rule_id: "fallow/unused-export".to_owned(),
1307            description: "Export 'helper' is never imported by other modules".to_owned(),
1308            severity: "major".to_owned(),
1309            path: "src/lib.ts".to_owned(),
1310            line,
1311            end_line: None,
1312            other_locations: Vec::new(),
1313            fingerprint: fingerprint.to_owned(),
1314            legacy_fingerprint: legacy.map(str::to_owned),
1315        }
1316    }
1317
1318    fn github_comment(group: &[&CiIssue]) -> GitHubReviewComment {
1319        let comment = render_review_comment_for_group(&ReviewCommentRenderInput {
1320            provider: CiProvider::Github,
1321            group,
1322            gitlab_diff_refs: None,
1323            diff_index: None,
1324            path_prefix: "",
1325            include_guidance: false,
1326            suggestion_block: &|_, _| None,
1327            guidance_block: &|_| None,
1328        });
1329        let ReviewComment::GitHub(comment) = comment else {
1330            panic!("expected GitHub comment");
1331        };
1332        comment
1333    }
1334
1335    /// A comment carries the stable fingerprint in a v3 marker, and the
1336    /// legacy fingerprint on the side, so a thread with the v2 marker of an
1337    /// older release still matches.
1338    #[test]
1339    fn review_comment_writes_a_v3_marker_and_keeps_the_legacy_fingerprint() {
1340        let issue = dead_code_issue(3, "0123456789abcdef", Some("fedcba9876543210"));
1341
1342        let comment = github_comment(&[&issue]);
1343
1344        assert!(
1345            comment
1346                .body
1347                .ends_with("<!-- fallow-fingerprint:v3: 0123456789abcdef -->"),
1348            "{}",
1349            comment.body
1350        );
1351        assert!(!comment.body.contains("fallow-fingerprint:v2:"));
1352        assert_eq!(comment.fingerprint, "0123456789abcdef");
1353        assert_eq!(
1354            comment.legacy_fingerprint.as_deref(),
1355            Some("fedcba9876543210")
1356        );
1357        let value = serde_json::to_value(&comment).expect("comment serializes");
1358        assert_eq!(value["legacy_fingerprint"], "fedcba9876543210");
1359    }
1360
1361    /// A finding whose fingerprint did not change carries no legacy value,
1362    /// and the wire shape has no `legacy_fingerprint` key.
1363    #[test]
1364    fn review_comment_omits_an_unchanged_legacy_fingerprint() {
1365        let issue = dead_code_issue(3, "0123456789abcdef", None);
1366
1367        let comment = github_comment(&[&issue]);
1368
1369        assert_eq!(comment.legacy_fingerprint, None);
1370        let value = serde_json::to_value(&comment).expect("comment serializes");
1371        assert!(value.get("legacy_fingerprint").is_none());
1372    }
1373
1374    /// A merged comment gets the composite that the older release computed:
1375    /// the legacy value of each constituent, or its fingerprint when it has
1376    /// no legacy value.
1377    #[test]
1378    fn merged_review_comment_rebuilds_the_legacy_composite() {
1379        let moved = dead_code_issue(3, "0123456789abcdef", Some("fedcba9876543210"));
1380        let kept = dead_code_issue(3, "1111111111111111", None);
1381
1382        let comment = github_comment(&[&moved, &kept]);
1383
1384        assert_eq!(
1385            comment.fingerprint,
1386            composite_fingerprint(&["0123456789abcdef", "1111111111111111"])
1387        );
1388        assert_eq!(
1389            comment.legacy_fingerprint,
1390            Some(composite_fingerprint(&[
1391                "fedcba9876543210",
1392                "1111111111111111"
1393            ]))
1394        );
1395    }
1396
1397    #[test]
1398    fn pr_comment_titles_by_content_and_names_its_only_category() {
1399        let issues = vec![
1400            pr_comment_issue(
1401                "fallow/unresolved-import",
1402                "Import './x' could not be resolved",
1403                "major",
1404                "src/a.ts",
1405            ),
1406            pr_comment_issue(
1407                "fallow/unresolved-import",
1408                "Import './y' could not be resolved",
1409                "major",
1410                "src/b.ts",
1411            ),
1412        ];
1413        let body = render_pr_comment(&PrCommentRenderInput {
1414            command: "dead-code",
1415            provider: CiProvider::Github,
1416            issues: &issues,
1417            marker_id: "fallow-results".to_owned(),
1418            max_comments: 50,
1419            category_for_rule: &category_for_rule,
1420            trailing_section: None,
1421        });
1422        assert!(body.contains("### Fallow codebase report"), "{body}");
1423        assert!(body.contains("**Quality gate failed**"), "{body}");
1424        assert!(body.contains("Found **2** findings."), "{body}");
1425        assert!(body.contains("<summary>Dead code (2)</summary>"), "{body}");
1426    }
1427
1428    #[test]
1429    fn pr_comment_breakdown_indexes_several_categories() {
1430        let issues = vec![
1431            pr_comment_issue(
1432                "fallow/unresolved-import",
1433                "Import './x' could not be resolved",
1434                "major",
1435                "src/a.ts",
1436            ),
1437            pr_comment_issue(
1438                "fallow/unused-dependency",
1439                "Package 'lodash' is never imported",
1440                "minor",
1441                "package.json",
1442            ),
1443        ];
1444        let body = render_pr_comment(&PrCommentRenderInput {
1445            command: "check",
1446            provider: CiProvider::Github,
1447            issues: &issues,
1448            marker_id: "fallow-results".to_owned(),
1449            max_comments: 50,
1450            category_for_rule: &category_for_rule,
1451            trailing_section: None,
1452        });
1453        assert!(
1454            body.contains("Found **2** findings: Dead code 1, Dependencies 1."),
1455            "{body}"
1456        );
1457    }
1458
1459    #[test]
1460    fn pr_comment_empty_state_speaks_the_provider_language() {
1461        let github = render_pr_comment(&PrCommentRenderInput {
1462            command: "dead-code",
1463            provider: CiProvider::Github,
1464            issues: &[],
1465            marker_id: "fallow-results".to_owned(),
1466            max_comments: 50,
1467            category_for_rule: &category_for_rule,
1468            trailing_section: None,
1469        });
1470        let gitlab = render_pr_comment(&PrCommentRenderInput {
1471            command: "dead-code",
1472            provider: CiProvider::Gitlab,
1473            issues: &[],
1474            marker_id: "fallow-results".to_owned(),
1475            max_comments: 50,
1476            category_for_rule: &category_for_rule,
1477            trailing_section: None,
1478        });
1479        assert!(
1480            github.contains("No findings for this pull request."),
1481            "{github}"
1482        );
1483        assert!(
1484            gitlab.contains("No findings for this merge request."),
1485            "{gitlab}"
1486        );
1487        assert!(
1488            github.starts_with("<!-- fallow-id: fallow-results -->\n"),
1489            "{github}"
1490        );
1491        assert!(github.contains("Generated by fallow."), "{github}");
1492    }
1493
1494    #[test]
1495    fn escape_md_escapes_inline_commonmark_specials() {
1496        let raw = "foo*bar_baz [a](u) `c` <h> #x !i ~s | p";
1497        let escaped = escape_md(raw);
1498        for ch in [
1499            '*', '_', '[', ']', '(', ')', '`', '<', '>', '#', '!', '~', '|',
1500        ] {
1501            let raw_count = raw.chars().filter(|c| c == &ch).count();
1502            let escaped_count = escaped.matches(&format!("\\{ch}")).count();
1503            assert_eq!(
1504                raw_count, escaped_count,
1505                "char {ch:?}: raw {raw_count} occurrences, escaped {escaped_count} in {escaped:?}"
1506            );
1507        }
1508    }
1509
1510    #[test]
1511    fn escape_md_escapes_ampersand_to_block_numeric_entity_bypass() {
1512        let raw = "value &#42;suspicious&#42; here";
1513        let escaped = escape_md(raw);
1514        assert!(escaped.contains(r"\&"), "got: {escaped}");
1515        assert!(escaped.contains(r"\#"), "got: {escaped}");
1516        assert!(!escaped.contains(" *suspicious"), "got: {escaped}");
1517    }
1518
1519    #[test]
1520    fn summary_label_foreshadows_truncation() {
1521        assert_eq!(
1522            summary_label("Duplication", 160, 50),
1523            "Duplication (160, showing 50)"
1524        );
1525        assert_eq!(summary_label("Health", 12, 50), "Health (12)");
1526        assert_eq!(summary_label("Dependencies", 50, 50), "Dependencies (50)");
1527    }
1528
1529    #[test]
1530    fn escape_md_does_not_escape_block_only_markers() {
1531        let raw = "fallow/test-only-dependency package.json:12";
1532        let escaped = escape_md(raw);
1533        assert!(!escaped.contains("\\-"), "should not escape `-`");
1534        assert!(!escaped.contains("\\."), "should not escape `.`");
1535        assert_eq!(escaped, raw);
1536    }
1537
1538    #[test]
1539    fn escape_md_collapses_newlines_to_spaces() {
1540        let raw = "first\nsecond\nthird";
1541        assert_eq!(escape_md(raw), "first second third");
1542    }
1543
1544    #[test]
1545    fn escape_md_collapses_carriage_returns_to_spaces() {
1546        assert_eq!(escape_md("first\r\nsecond\rthird"), "first second third");
1547    }
1548
1549    #[test]
1550    fn escape_md_trims_surrounding_whitespace() {
1551        assert_eq!(escape_md("  a\u{2003}\r\n"), "a");
1552        assert_eq!(escape_md(" \t\r\n\u{2003} "), "");
1553    }
1554
1555    #[test]
1556    fn escape_md_collapses_crlf_to_one_space() {
1557        let collapsed = escape_md("a\r\nb\rc\nd");
1558        assert_eq!(collapsed, "a b c d");
1559        assert_eq!(collapsed.len(), 7, "CRLF must not expand to two spaces");
1560    }
1561
1562    #[test]
1563    fn escape_md_preserves_interior_tabs_and_wide_spaces() {
1564        assert_eq!(escape_md("a\tb"), "a\tb");
1565        assert_eq!(escape_md("a\u{2003}b"), "a\u{2003}b");
1566    }
1567
1568    #[test]
1569    fn escape_md_passes_non_ascii_through_unchanged() {
1570        assert_eq!(escape_md("é🦀"), "é🦀");
1571    }
1572
1573    #[test]
1574    fn markdown_code_span_grows_fence_past_inner_backticks() {
1575        assert_eq!(markdown_code_span("plain"), "`plain`");
1576        assert_eq!(markdown_code_span("has`tick"), "``has`tick``");
1577        assert_eq!(markdown_code_span("`leading"), "`` `leading ``");
1578    }
1579
1580    #[test]
1581    fn markdown_table_code_span_escapes_pipes() {
1582        assert_eq!(markdown_table_code_span("a|b"), "`a\\|b`");
1583        assert_eq!(markdown_table_code_span("x`|y"), "``x`\\|y``");
1584    }
1585
1586    #[test]
1587    fn markdown_table_code_span_collapses_line_endings() {
1588        assert_eq!(markdown_table_code_span("a\r\nb\rc\nd"), "`a b c d`");
1589    }
1590
1591    #[test]
1592    fn markdown_table_text_neutralizes_pipes_and_line_endings() {
1593        assert_eq!(markdown_table_text("a|b"), "a\\|b");
1594        assert_eq!(markdown_table_text("a\r\nb\rc\nd"), "a b c d");
1595    }
1596
1597    #[test]
1598    fn escape_md_leaves_safe_chars_unchanged() {
1599        let raw = "Export 'helperFn' is never imported by other modules";
1600        assert_eq!(
1601            escape_md(raw),
1602            r"Export 'helperFn' is never imported by other modules"
1603        );
1604    }
1605
1606    #[test]
1607    fn is_project_level_rule_covers_config_anchored_dependency_findings() {
1608        for rule_id in PROJECT_LEVEL_RULE_IDS {
1609            assert!(
1610                is_project_level_rule(rule_id),
1611                "{rule_id} must be project-level"
1612            );
1613        }
1614        for rule_id in [
1615            "fallow/unused-file",
1616            "fallow/unused-export",
1617            "fallow/unused-type",
1618            "fallow/unused-enum-member",
1619            "fallow/unused-class-member",
1620            "fallow/unused-store-member",
1621            "fallow/unresolved-import",
1622            "fallow/unlisted-dependency",
1623            "fallow/duplicate-export",
1624            "fallow/circular-dependency",
1625            "fallow/re-export-cycle",
1626            "fallow/package-cycle",
1627            "fallow/boundary-violation",
1628            "fallow/stale-suppression",
1629            "fallow/private-type-leak",
1630            "fallow/high-complexity",
1631            "fallow/high-crap-score",
1632        ] {
1633            assert!(
1634                !is_project_level_rule(rule_id),
1635                "{rule_id} must NOT be project-level"
1636            );
1637        }
1638    }
1639
1640    #[test]
1641    fn escape_md_double_apply_is_safe() {
1642        let raw = "code with `backticks` and *stars*";
1643        let once = escape_md(raw);
1644        let twice = escape_md(&once);
1645        assert_eq!(once, r"code with \`backticks\` and \*stars\*");
1646        assert_eq!(twice, r"code with \\\`backticks\\\` and \\\*stars\\\*");
1647    }
1648
1649    #[test]
1650    fn pr_comment_trailing_section_renders_before_the_footer() {
1651        let body = render_pr_comment(&PrCommentRenderInput {
1652            command: "check",
1653            provider: CiProvider::Github,
1654            issues: &[],
1655            marker_id: "fallow-results".to_owned(),
1656            max_comments: 50,
1657            category_for_rule: &category_for_rule,
1658            trailing_section: Some("\n## Unmatched config patterns\n\n- `@typo/*`"),
1659        });
1660        assert!(
1661            body.ends_with(
1662                "\n\n## Unmatched config patterns\n\n- `@typo/*`\n\nGenerated by fallow."
1663            ),
1664            "{body}"
1665        );
1666    }
1667}