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}
70
71/// Source range attached to a normalized CI finding as supporting evidence.
72#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
73pub struct CiLocation {
74    /// File path relative to the analysed root.
75    pub path: String,
76    /// Inclusive 1-based start line.
77    pub line: u64,
78    /// Inclusive 1-based end line.
79    pub end_line: u64,
80}
81
82/// Inputs for rendering a sticky PR/MR summary comment.
83pub struct PrCommentRenderInput<'a> {
84    /// Fallow command the comment reports on, e.g. `audit`.
85    pub command: &'a str,
86    /// CI provider whose comment conventions apply.
87    pub provider: CiProvider,
88    /// Findings to summarize, pre-sorted by severity and location.
89    pub issues: &'a [CiIssue],
90    /// Identity token embedded so reruns update the same sticky comment.
91    pub marker_id: String,
92    /// Maximum findings rendered in the comment body.
93    pub max_comments: usize,
94    /// Maps a rule id to its display category label.
95    pub category_for_rule: &'a dyn Fn(&str) -> &'static str,
96}
97
98/// GitLab diff refs for a review-envelope position.
99#[derive(Clone, Debug, PartialEq, Eq)]
100pub struct ReviewGitlabDiffRefs {
101    /// Merge-base SHA of the MR diff.
102    pub base_sha: String,
103    /// First commit SHA of the MR diff.
104    pub start_sha: String,
105    /// Head commit SHA of the MR diff.
106    pub head_sha: String,
107}
108
109/// Truncation signals produced while rendering a review envelope.
110#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
111pub struct ReviewEnvelopeTruncation {
112    /// A comment body hit [`MAX_COMMENT_BODY_BYTES`] and was truncated.
113    pub body: bool,
114    /// More findings existed than `max_comments` allowed.
115    pub comment_limit: bool,
116}
117
118/// Rendered review envelope plus side-channel signals for CLI telemetry.
119#[derive(Debug)]
120pub struct ReviewEnvelopeRenderResult {
121    /// Provider-ready review envelope.
122    pub envelope: ReviewEnvelopeOutput,
123    /// Truncation signals observed while rendering.
124    pub truncation: ReviewEnvelopeTruncation,
125}
126
127/// Inputs for rendering a GitHub/GitLab review envelope.
128pub struct ReviewEnvelopeRenderInput<'a> {
129    /// Fallow command the review reports on.
130    pub command: &'a str,
131    /// CI provider whose review API shapes the envelope.
132    pub provider: CiProvider,
133    /// Findings to turn into review comments.
134    pub issues: &'a [CiIssue],
135    /// Diff index used to keep comments on lines the diff actually added.
136    pub diff_index: Option<&'a DiffIndex>,
137    /// Prepended to every emitted path after diff lookups have run.
138    pub path_prefix: &'a str,
139    /// Maximum inline comments to emit.
140    pub max_comments: usize,
141    /// Required for GitLab positioned discussions; ignored for GitHub.
142    pub gitlab_diff_refs: Option<&'a ReviewGitlabDiffRefs>,
143    /// Whether to append per-finding guidance blocks to comment bodies.
144    pub include_guidance: bool,
145    /// Produces a provider-specific suggestion block for a finding, when one
146    /// applies.
147    pub suggestion_block: &'a dyn Fn(CiProvider, &CiIssue) -> Option<String>,
148    /// Produces a guidance block for a finding, when one applies.
149    pub guidance_block: &'a dyn Fn(&CiIssue) -> Option<String>,
150}
151
152/// Marker prefix appended to every v2 review-comment body.
153pub const MARKER_PREFIX_V2: &str = "<!-- fallow-fingerprint:v2: ";
154
155/// Closing of the v2 marker, after the fingerprint string.
156pub const MARKER_SUFFIX_V2: &str = " -->";
157
158/// Hard cap on a single review-comment body, matching GitHub's 65 536-char
159/// comment limit; bodies at or over it are truncated with a marker suffix.
160pub const MAX_COMMENT_BODY_BYTES: usize = 65_536;
161const TRUNCATION_SUFFIX: &str = "\n\n<!-- fallow-truncated -->\n> Body truncated by fallow.";
162
163/// Extract normalized CI issues from a raw CodeClimate JSON array, sorted by
164/// severity then location.
165#[must_use]
166pub fn issues_from_codeclimate(value: &Value) -> Vec<CiIssue> {
167    let mut issues = value
168        .as_array()
169        .into_iter()
170        .flatten()
171        .filter_map(issue_from_codeclimate)
172        .collect::<Vec<_>>();
173    sort_ci_issues(&mut issues);
174    issues
175}
176
177/// Normalize typed CodeClimate issues into CI issues, sorted by severity then
178/// location.
179#[must_use]
180pub fn issues_from_codeclimate_issues(issues: &[CodeClimateIssue]) -> Vec<CiIssue> {
181    let mut issues = issues
182        .iter()
183        .map(issue_from_codeclimate_issue)
184        .collect::<Vec<_>>();
185    sort_ci_issues(&mut issues);
186    issues
187}
188
189fn issue_from_codeclimate(value: &Value) -> Option<CiIssue> {
190    let path = value.pointer("/location/path")?.as_str()?.to_string();
191    let line = value
192        .pointer("/location/lines/begin")
193        .and_then(Value::as_u64)
194        .unwrap_or(1);
195    let end_line = value.pointer("/location/lines/end").and_then(Value::as_u64);
196    let mut other_locations = value
197        .get("other_locations")
198        .and_then(Value::as_array)
199        .into_iter()
200        .flatten()
201        .filter_map(|location| {
202            let line = location.pointer("/lines/begin")?.as_u64()?;
203            let end_line = location
204                .pointer("/lines/end")
205                .and_then(Value::as_u64)
206                .filter(|end| *end >= line)
207                .unwrap_or(line);
208            Some(CiLocation {
209                path: location.get("path")?.as_str()?.to_owned(),
210                line,
211                end_line,
212            })
213        })
214        .collect::<Vec<_>>();
215    other_locations.sort();
216    Some(CiIssue {
217        rule_id: value
218            .get("check_name")
219            .and_then(Value::as_str)
220            .unwrap_or("fallow/finding")
221            .to_string(),
222        description: value
223            .get("description")
224            .and_then(Value::as_str)
225            .unwrap_or("Fallow finding")
226            .to_string(),
227        severity: value
228            .get("severity")
229            .and_then(Value::as_str)
230            .unwrap_or("minor")
231            .to_string(),
232        fingerprint: value
233            .get("fingerprint")
234            .and_then(Value::as_str)
235            .unwrap_or("")
236            .to_string(),
237        path,
238        line,
239        end_line,
240        other_locations,
241    })
242}
243
244fn issue_from_codeclimate_issue(issue: &CodeClimateIssue) -> CiIssue {
245    let mut other_locations = issue
246        .other_locations
247        .iter()
248        .map(|location| CiLocation {
249            path: location.path.clone(),
250            line: u64::from(location.lines.begin),
251            end_line: u64::from(
252                location
253                    .lines
254                    .end
255                    .filter(|end| *end >= location.lines.begin)
256                    .unwrap_or(location.lines.begin),
257            ),
258        })
259        .collect::<Vec<_>>();
260    other_locations.sort();
261    CiIssue {
262        rule_id: issue.check_name.clone(),
263        description: issue.description.clone(),
264        severity: codeclimate_severity_label(issue.severity).to_owned(),
265        path: issue.location.path.clone(),
266        line: u64::from(issue.location.lines.begin),
267        end_line: issue.location.lines.end.map(u64::from),
268        other_locations,
269        fingerprint: issue.fingerprint.clone(),
270    }
271}
272
273const fn codeclimate_severity_label(severity: CodeClimateSeverity) -> &'static str {
274    match severity {
275        CodeClimateSeverity::Info => "info",
276        CodeClimateSeverity::Minor => "minor",
277        CodeClimateSeverity::Major => "major",
278        CodeClimateSeverity::Critical => "critical",
279        CodeClimateSeverity::Blocker => "blocker",
280    }
281}
282
283fn sort_ci_issues(issues: &mut [CiIssue]) {
284    issues
285        .sort_by(|a, b| (&a.path, a.line, &a.fingerprint).cmp(&(&b.path, b.line, &b.fingerprint)));
286}
287
288fn fingerprint_hash(parts: &[&str]) -> String {
289    crate::codeclimate_fingerprint_hash(parts)
290}
291
292/// Render the sticky PR/MR summary comment body: identity marker, headline
293/// count, and per-category findings tables.
294#[must_use]
295#[expect(clippy::expect_used, reason = "formatting into String is infallible")]
296pub fn render_pr_comment(input: &PrCommentRenderInput<'_>) -> String {
297    let marker = format!("<!-- fallow-id: {} -->", input.marker_id);
298    let title = command_title(input.command);
299    let count = input.issues.len();
300    let noun = if count == 1 { "finding" } else { "findings" };
301
302    let mut out = String::new();
303    out.push_str(&marker);
304    out.push('\n');
305    write!(&mut out, "### Fallow {title}\n\n").expect("write to string");
306    if count == 0 {
307        writeln!(
308            &mut out,
309            "No {provider} PR/MR findings.",
310            provider = input.provider.name()
311        )
312        .expect("write to string");
313    } else {
314        write!(&mut out, "Found **{count}** {noun}.\n\n").expect("write to string");
315        let groups = group_by_category(input.issues, input.category_for_rule);
316        if let [(_, group_issues)] = groups.as_slice() {
317            render_findings_table(&mut out, group_issues, input.max_comments, "Details");
318        } else {
319            for (category, group_issues) in &groups {
320                let summary_label = summary_label(category, group_issues.len(), input.max_comments);
321                render_findings_table(&mut out, group_issues, input.max_comments, &summary_label);
322            }
323        }
324    }
325    out.push_str("\nGenerated by fallow.");
326    out
327}
328
329/// Rule ids whose findings describe project-wide config state rather than a
330/// change touching a specific source line.
331pub const PROJECT_LEVEL_RULE_IDS: &[&str] = &[
332    "fallow/unused-catalog-entry",
333    "fallow/empty-catalog-group",
334    "fallow/unresolved-catalog-reference",
335    "fallow/unused-dependency-override",
336    "fallow/misconfigured-dependency-override",
337    "fallow/unused-dependency",
338    "fallow/unused-dev-dependency",
339    "fallow/unused-optional-dependency",
340    "fallow/type-only-dependency",
341    "fallow/test-only-dependency",
342    "fallow/dev-dependency-in-production",
343];
344
345/// Whether findings for `rule_id` describe the whole project (e.g. dependency
346/// rules) rather than a specific file location.
347#[must_use]
348pub fn is_project_level_rule(rule_id: &str) -> bool {
349    PROJECT_LEVEL_RULE_IDS.contains(&rule_id)
350}
351
352const CATEGORY_ORDER: [&str; 6] = [
353    "Dead code",
354    "Dependencies",
355    "Duplication",
356    "Health",
357    "Architecture",
358    "Suppressions",
359];
360
361fn group_by_category<'a>(
362    issues: &'a [CiIssue],
363    category_for_rule: &dyn Fn(&str) -> &'static str,
364) -> Vec<(&'static str, Vec<&'a CiIssue>)> {
365    let mut buckets: std::collections::BTreeMap<&'static str, Vec<&CiIssue>> =
366        std::collections::BTreeMap::new();
367    for issue in issues {
368        let category = category_for_rule(&issue.rule_id);
369        buckets.entry(category).or_default().push(issue);
370    }
371    let mut ordered: Vec<(&'static str, Vec<&CiIssue>)> = Vec::with_capacity(buckets.len());
372    for category in CATEGORY_ORDER {
373        if let Some(items) = buckets.remove(category) {
374            ordered.push((category, items));
375        }
376    }
377    for (category, items) in buckets {
378        ordered.push((category, items));
379    }
380    ordered
381}
382
383/// Collapsible-section label for a findings category: appends "showing N"
384/// when the table is capped below the category's total.
385#[must_use]
386pub fn summary_label(category: &str, total: usize, max: usize) -> String {
387    if total > max {
388        format!("{category} ({total}, showing {max})")
389    } else {
390        format!("{category} ({total})")
391    }
392}
393
394#[expect(clippy::expect_used, reason = "formatting into String is infallible")]
395fn render_findings_table(out: &mut String, issues: &[&CiIssue], max: usize, summary: &str) {
396    writeln!(out, "<details>\n<summary>{summary}</summary>\n").expect("write to string");
397    out.push_str("| Severity | Rule | Location | Description |\n");
398    out.push_str("| --- | --- | --- | --- |\n");
399    for issue in issues.iter().take(max) {
400        writeln!(
401            out,
402            "| {} | `{}` | `{}`:{} | {} |",
403            escape_md(&issue.severity),
404            escape_md(&issue.rule_id),
405            escape_md(&issue.path),
406            issue.line,
407            escape_md(&issue.description),
408        )
409        .expect("write to string");
410    }
411    if issues.len() > max {
412        writeln!(
413            out,
414            "\nShowing {max} of {} findings. Run fallow locally or inspect the CI output for the full report.",
415            issues.len(),
416        )
417        .expect("write to string");
418    }
419    out.push_str("\n</details>\n\n");
420}
421
422/// Human-readable report title for a fallow command name, e.g. `dupes` maps
423/// to "duplication report".
424#[must_use]
425pub fn command_title(command: &str) -> &'static str {
426    match command {
427        "dead-code" | "check" => "dead-code report",
428        "dupes" => "duplication report",
429        "health" => "health report",
430        "audit" => "audit report",
431        "" | "combined" => "combined report",
432        _ => "report",
433    }
434}
435
436/// Escape a string for inclusion in a Markdown table cell.
437#[must_use]
438pub fn escape_md(value: &str) -> String {
439    let value = value.trim();
440    // Collapse CRLF to one space; a bare CR is a CommonMark line ending and
441    // would otherwise split the table row.
442    let mut chars = value.chars().peekable();
443    let mut out = String::with_capacity(value.len());
444    while let Some(ch) = chars.next() {
445        let ch = match ch {
446            '\r' => {
447                if chars.peek() == Some(&'\n') {
448                    chars.next();
449                }
450                ' '
451            }
452            '\n' => ' ',
453            _ => ch,
454        };
455        if matches!(
456            ch,
457            '\\' | '`'
458                | '*'
459                | '_'
460                | '['
461                | ']'
462                | '('
463                | ')'
464                | '!'
465                | '<'
466                | '>'
467                | '#'
468                | '|'
469                | '~'
470                | '&'
471        ) {
472            out.push('\\');
473        }
474        out.push(ch);
475    }
476    out
477}
478
479/// Render a complete CommonMark code span around an untrusted value. The
480/// fence grows past the longest backtick run inside the value, so the span
481/// cannot be closed early from within.
482#[must_use]
483pub fn markdown_code_span(value: &str) -> String {
484    let longest_run = value
485        .split(|c| c != '`')
486        .map(str::len)
487        .max()
488        .unwrap_or_default();
489    let fence = "`".repeat(longest_run + 1);
490    let needs_padding = value.starts_with('`')
491        || value.ends_with('`')
492        || (value.starts_with(' ') && value.ends_with(' ') && !value.chars().all(|c| c == ' '));
493    if needs_padding {
494        format!("{fence} {value} {fence}")
495    } else {
496        format!("{fence}{value}{fence}")
497    }
498}
499
500/// [`markdown_code_span`] for a Markdown table cell: pipes are additionally
501/// escaped so the value cannot terminate the cell, and line endings collapse
502/// to spaces because any CommonMark line ending would split the table row.
503#[must_use]
504pub fn markdown_table_code_span(value: &str) -> String {
505    let collapsed = value.replace("\r\n", " ").replace(['\n', '\r'], " ");
506    markdown_code_span(&collapsed.replace('|', "\\|"))
507}
508
509/// Escape prose for a Markdown table cell while leaving intentional inline
510/// markup alone: pipes are escaped and line endings collapse to spaces.
511#[must_use]
512pub fn markdown_table_text(value: &str) -> String {
513    value
514        .replace("\r\n", " ")
515        .replace(['\n', '\r'], " ")
516        .replace('|', "\\|")
517}
518
519/// Render a provider-specific review envelope from typed CI issues.
520#[must_use]
521pub fn render_review_envelope(input: &ReviewEnvelopeRenderInput<'_>) -> ReviewEnvelopeRenderResult {
522    render_review_envelope_with_id(input, None, None, None)
523}
524
525/// Render a review envelope with an explicit gate conclusion and status.
526#[must_use]
527pub fn render_review_envelope_with_conclusion(
528    input: &ReviewEnvelopeRenderInput<'_>,
529    conclusion: ReviewCheckConclusion,
530    status_message: Option<&str>,
531) -> ReviewEnvelopeRenderResult {
532    render_review_envelope_with_id(input, None, Some(conclusion), status_message)
533}
534
535/// Render a review envelope whose bodies carry the supplied review scope.
536#[must_use]
537pub fn render_scoped_review_envelope(
538    input: &ReviewEnvelopeRenderInput<'_>,
539    review_id: &ReviewId,
540) -> ReviewEnvelopeRenderResult {
541    render_review_envelope_with_id(input, Some(review_id), None, None)
542}
543
544/// Render a scoped review envelope with an explicit gate conclusion and status.
545#[must_use]
546pub fn render_scoped_review_envelope_with_conclusion(
547    input: &ReviewEnvelopeRenderInput<'_>,
548    review_id: &ReviewId,
549    conclusion: ReviewCheckConclusion,
550    status_message: Option<&str>,
551) -> ReviewEnvelopeRenderResult {
552    render_review_envelope_with_id(input, Some(review_id), Some(conclusion), status_message)
553}
554
555fn render_review_envelope_with_id(
556    input: &ReviewEnvelopeRenderInput<'_>,
557    review_id: Option<&ReviewId>,
558    conclusion: Option<ReviewCheckConclusion>,
559    status_message: Option<&str>,
560) -> ReviewEnvelopeRenderResult {
561    let grouped = group_review_issues_by_path_line(input.issues, input.max_comments);
562
563    let comments: Vec<ReviewComment> = grouped
564        .groups
565        .iter()
566        .map(|group| {
567            render_review_comment_for_group_with_id(
568                &ReviewCommentRenderInput {
569                    provider: input.provider,
570                    group,
571                    gitlab_diff_refs: input.gitlab_diff_refs,
572                    diff_index: input.diff_index,
573                    path_prefix: input.path_prefix,
574                    include_guidance: input.include_guidance,
575                    suggestion_block: input.suggestion_block,
576                    guidance_block: input.guidance_block,
577                },
578                review_id,
579            )
580        })
581        .collect();
582
583    let conclusion = conclusion.unwrap_or_else(|| github_check_conclusion(input.issues));
584    let summary_text = review_summary_text(
585        input.command,
586        input.provider,
587        comments.len(),
588        conclusion,
589        status_message,
590    );
591    let summary_fp = summary_fingerprint(&summary_text);
592    let summary_marker = review_markers(&summary_fp, review_id);
593    let body = format!("{summary_text}{summary_marker}");
594    let summary = ReviewEnvelopeSummary {
595        body: body.clone(),
596        fingerprint: summary_fp,
597    };
598
599    let truncation = ReviewEnvelopeTruncation {
600        body: comments.iter().any(review_comment_truncated),
601        comment_limit: grouped.truncated,
602    };
603
604    ReviewEnvelopeRenderResult {
605        envelope: build_review_envelope_output(input.provider, body, summary, comments, conclusion),
606        truncation,
607    }
608}
609
610fn review_summary_text(
611    command: &str,
612    provider: CiProvider,
613    comment_count: usize,
614    conclusion: ReviewCheckConclusion,
615    status_message: Option<&str>,
616) -> String {
617    let verdict = review_summary_verdict(conclusion);
618    let status = status_message.map_or_else(String::new, |message| format!("\n\n> {message}"));
619    format!(
620        "### Fallow {}\n\n**{}**{}\n\n{} inline finding{} selected for {} review.\n\n<!-- fallow-review -->",
621        command_title(command),
622        verdict,
623        status,
624        comment_count,
625        if comment_count == 1 { "" } else { "s" },
626        provider.name(),
627    )
628}
629
630fn review_summary_verdict(conclusion: ReviewCheckConclusion) -> &'static str {
631    match conclusion {
632        ReviewCheckConclusion::Failure => "Quality gate failed",
633        ReviewCheckConclusion::Neutral => "Review needed",
634        ReviewCheckConclusion::Success => "Quality gate passed",
635    }
636}
637
638/// Review issues grouped per `(path, line)` for one-comment-per-location
639/// rendering.
640#[derive(Debug, PartialEq, Eq)]
641pub struct GroupedReviewIssues<'a> {
642    /// One group per distinct location, in input order.
643    pub groups: Vec<Vec<&'a CiIssue>>,
644    /// True when the group cap cut off remaining issues.
645    pub truncated: bool,
646}
647
648/// Group consecutive same-(path, line) issues. Input is already sorted by
649/// `(path, line, fingerprint)` so a single linear pass collects runs.
650#[must_use]
651pub fn group_review_issues_by_path_line(
652    issues: &[CiIssue],
653    max_groups: usize,
654) -> GroupedReviewIssues<'_> {
655    if max_groups == 0 {
656        return GroupedReviewIssues {
657            groups: Vec::new(),
658            truncated: !issues.is_empty(),
659        };
660    }
661    let mut groups: Vec<Vec<&CiIssue>> = Vec::with_capacity(max_groups.min(issues.len()));
662    let mut current: Vec<&CiIssue> = Vec::new();
663    let mut current_key: Option<(&str, u64)> = None;
664    for issue in issues {
665        let key = (issue.path.as_str(), issue.line);
666        if Some(key) != current_key {
667            if !current.is_empty() {
668                groups.push(std::mem::take(&mut current));
669                if groups.len() == max_groups {
670                    return GroupedReviewIssues {
671                        groups,
672                        truncated: true,
673                    };
674                }
675            }
676            current_key = Some(key);
677        }
678        current.push(issue);
679    }
680    if !current.is_empty() && groups.len() < max_groups {
681        groups.push(current);
682    }
683    GroupedReviewIssues {
684        groups,
685        truncated: false,
686    }
687}
688
689fn review_comment_truncated(comment: &ReviewComment) -> bool {
690    match comment {
691        ReviewComment::GitHub(comment) => comment.truncated,
692        ReviewComment::GitLab(comment) => comment.truncated,
693    }
694}
695
696/// Inputs for rendering one inline review comment from a location group.
697pub struct ReviewCommentRenderInput<'a, 'group> {
698    /// CI provider whose comment shape to produce.
699    pub provider: CiProvider,
700    /// Issues sharing the same `(path, line)`; the first is the representative.
701    pub group: &'a [&'group CiIssue],
702    /// Required for GitLab positioned discussions; ignored for GitHub.
703    pub gitlab_diff_refs: Option<&'a ReviewGitlabDiffRefs>,
704    /// Diff index used to resolve renamed paths for GitLab positions.
705    pub diff_index: Option<&'a DiffIndex>,
706    /// Prepended to every emitted path after diff lookups have run.
707    pub path_prefix: &'a str,
708    /// Whether to append per-finding guidance blocks to the body.
709    pub include_guidance: bool,
710    /// Produces a provider-specific suggestion block for a finding, when one
711    /// applies.
712    pub suggestion_block: &'a dyn Fn(CiProvider, &CiIssue) -> Option<String>,
713    /// Produces a guidance block for a finding, when one applies.
714    pub guidance_block: &'a dyn Fn(&CiIssue) -> Option<String>,
715}
716
717/// Render one comment from a group of issues sharing the same `(path, line)`.
718#[must_use]
719pub fn render_review_comment_for_group(input: &ReviewCommentRenderInput<'_, '_>) -> ReviewComment {
720    render_review_comment_for_group_with_id(input, None)
721}
722
723fn render_review_comment_for_group_with_id(
724    input: &ReviewCommentRenderInput<'_, '_>,
725    review_id: Option<&ReviewId>,
726) -> ReviewComment {
727    assert!(
728        !input.group.is_empty(),
729        "group_review_issues_by_path_line never yields empty"
730    );
731    let representative = input.group[0];
732    let fingerprint = if input.group.len() == 1 {
733        representative.fingerprint.clone()
734    } else {
735        let constituents: Vec<&str> = input.group.iter().map(|i| i.fingerprint.as_str()).collect();
736        composite_fingerprint(&constituents)
737    };
738
739    let content = build_merged_comment_content(input);
740    let marker_line = review_markers(&fingerprint, review_id);
741    let (body, truncated) = cap_body_with_marker(&content, &marker_line);
742
743    build_review_comment(ReviewCommentInput {
744        provider: input.provider,
745        representative,
746        gitlab_diff_refs: input.gitlab_diff_refs,
747        diff_index: input.diff_index,
748        path_prefix: input.path_prefix,
749        body,
750        fingerprint,
751        truncated,
752    })
753}
754
755#[expect(clippy::expect_used, reason = "formatting into String is infallible")]
756fn build_merged_comment_content(input: &ReviewCommentRenderInput<'_, '_>) -> String {
757    let mut content = String::new();
758    for (index, issue) in input.group.iter().enumerate() {
759        let label = review_label_from_codeclimate(&issue.severity);
760        if index > 0 {
761            content.push_str("\n\n");
762        }
763        write!(
764            content,
765            "**{}** `{}`: {}",
766            label,
767            escape_md(&issue.rule_id),
768            escape_md(&issue.description)
769        )
770        .expect("write to String is infallible");
771        if !issue.other_locations.is_empty() {
772            content.push_str("\n\nOther locations: ");
773            let locations = issue
774                .other_locations
775                .iter()
776                .map(|location| {
777                    markdown_code_span(&format!(
778                        "{}:{}-{}",
779                        apply_path_prefix(input.path_prefix, &location.path),
780                        location.line,
781                        location.end_line
782                    ))
783                })
784                .collect::<Vec<_>>()
785                .join(", ");
786            content.push_str(&locations);
787        }
788        if let Some(suggestion) = (input.suggestion_block)(input.provider, issue) {
789            content.push_str(&suggestion);
790        }
791        if input.include_guidance
792            && let Some(guidance) = (input.guidance_block)(issue)
793        {
794            content.push_str(&guidance);
795        }
796    }
797    content
798}
799
800struct ReviewCommentInput<'a> {
801    provider: CiProvider,
802    representative: &'a CiIssue,
803    gitlab_diff_refs: Option<&'a ReviewGitlabDiffRefs>,
804    diff_index: Option<&'a DiffIndex>,
805    path_prefix: &'a str,
806    body: String,
807    fingerprint: String,
808    truncated: bool,
809}
810
811fn build_review_comment(input: ReviewCommentInput<'_>) -> ReviewComment {
812    let ReviewCommentInput {
813        provider,
814        representative,
815        gitlab_diff_refs,
816        diff_index,
817        path_prefix,
818        body,
819        fingerprint,
820        truncated,
821    } = input;
822    match provider {
823        CiProvider::Github => ReviewComment::GitHub(GitHubReviewComment {
824            path: apply_path_prefix(path_prefix, &representative.path),
825            line: u32::try_from(representative.line).unwrap_or(u32::MAX),
826            side: GitHubReviewSide::Right,
827            body,
828            fingerprint,
829            truncated,
830        }),
831        CiProvider::Gitlab => {
832            // Renames resolve on the analysis-root-relative path, before the
833            // presentation prefix goes on: the diff's keys never carry it.
834            let old_rel = diff_index
835                .and_then(|di| di.old_path_for_root_relative(&representative.path))
836                .map_or_else(|| representative.path.clone(), Cow::into_owned);
837            let new_path = apply_path_prefix(path_prefix, &representative.path);
838            let old_path = apply_path_prefix(path_prefix, &old_rel);
839            let position = GitLabReviewPosition {
840                base_sha: gitlab_diff_refs.map(|r| r.base_sha.clone()),
841                start_sha: gitlab_diff_refs.map(|r| r.start_sha.clone()),
842                head_sha: gitlab_diff_refs.map(|r| r.head_sha.clone()),
843                position_type: GitLabReviewPositionType::Text,
844                old_path,
845                new_path,
846                new_line: u32::try_from(representative.line).unwrap_or(u32::MAX),
847            };
848            ReviewComment::GitLab(GitLabReviewComment {
849                body,
850                position,
851                fingerprint,
852                truncated,
853            })
854        }
855    }
856}
857
858/// Append `marker_line` to `content`, truncating `content` on a char boundary
859/// so the whole body stays within [`MAX_COMMENT_BODY_BYTES`]. The marker is
860/// never sacrificed. Returns the body and whether truncation happened.
861#[must_use]
862pub fn cap_body_with_marker(content: &str, marker_line: &str) -> (String, bool) {
863    let intact_len = content.len() + marker_line.len();
864    if intact_len <= MAX_COMMENT_BODY_BYTES {
865        let mut out = String::with_capacity(intact_len);
866        out.push_str(content);
867        out.push_str(marker_line);
868        return (out, false);
869    }
870    let reserved = marker_line.len() + TRUNCATION_SUFFIX.len();
871    let budget = MAX_COMMENT_BODY_BYTES.saturating_sub(reserved);
872    let mut cut = budget.min(content.len());
873    while cut > 0 && !content.is_char_boundary(cut) {
874        cut -= 1;
875    }
876    let mut out = String::with_capacity(MAX_COMMENT_BODY_BYTES);
877    out.push_str(&content[..cut]);
878    out.push_str(TRUNCATION_SUFFIX);
879    out.push_str(marker_line);
880    (out, true)
881}
882
883/// Map a CodeClimate severity name to the review badge label: `error` for
884/// major and above, `warn` otherwise.
885#[must_use]
886pub const fn review_label_from_codeclimate(severity_name: &str) -> &'static str {
887    match severity_name.as_bytes() {
888        b"major" | b"critical" | b"blocker" => "error",
889        _ => "warn",
890    }
891}
892
893/// GitHub check conclusion for a set of findings: `Failure` when any is major
894/// or above, `Success` when empty, `Neutral` otherwise.
895#[must_use]
896pub fn github_check_conclusion(issues: &[CiIssue]) -> ReviewCheckConclusion {
897    if issues
898        .iter()
899        .any(|issue| matches!(issue.severity.as_str(), "major" | "critical" | "blocker"))
900    {
901        ReviewCheckConclusion::Failure
902    } else if issues.is_empty() {
903        ReviewCheckConclusion::Success
904    } else {
905        ReviewCheckConclusion::Neutral
906    }
907}
908
909fn build_review_envelope_output(
910    provider: CiProvider,
911    body: String,
912    summary: ReviewEnvelopeSummary,
913    comments: Vec<ReviewComment>,
914    conclusion: ReviewCheckConclusion,
915) -> ReviewEnvelopeOutput {
916    match provider {
917        CiProvider::Github => ReviewEnvelopeOutput {
918            event: Some(ReviewEnvelopeEvent::Comment),
919            body,
920            summary,
921            comments,
922            marker_regex: default_marker_regex(),
923            marker_regex_flags: default_marker_regex_flags(),
924            meta: ReviewEnvelopeMeta {
925                schema: ReviewEnvelopeSchema::V3,
926                provider: ReviewProvider::Github,
927                check_conclusion: Some(conclusion),
928            },
929        },
930        CiProvider::Gitlab => ReviewEnvelopeOutput {
931            event: None,
932            body,
933            summary,
934            comments,
935            marker_regex: default_marker_regex(),
936            marker_regex_flags: default_marker_regex_flags(),
937            meta: ReviewEnvelopeMeta {
938                schema: ReviewEnvelopeSchema::V3,
939                provider: ReviewProvider::Gitlab,
940                check_conclusion: None,
941            },
942        },
943    }
944}
945
946fn review_markers(fingerprint: &str, review_id: Option<&ReviewId>) -> String {
947    let fingerprint = format!("\n\n{MARKER_PREFIX_V2}{fingerprint}{MARKER_SUFFIX_V2}");
948    match review_id {
949        Some(review_id) => format!("{fingerprint}\n{}", review_id_marker(review_id)),
950        None => fingerprint,
951    }
952}
953
954/// Stable fingerprint for a summary comment body.
955#[must_use]
956pub fn summary_fingerprint(body: &str) -> String {
957    fingerprint_hash(&[body])
958}
959
960/// Order-independent fingerprint for a comment merged from several findings:
961/// constituents are sorted before hashing and the result carries a `merged:`
962/// prefix.
963#[must_use]
964pub fn composite_fingerprint(constituents: &[&str]) -> String {
965    let mut sorted: Vec<&str> = constituents.to_vec();
966    sorted.sort_unstable();
967    let joined = sorted.join(":");
968    format!("merged:{}", fingerprint_hash(&[joined.as_str()]))
969}
970
971#[cfg(test)]
972mod tests {
973    use super::*;
974    use crate::{CodeClimateIssueKind, CodeClimateLines, CodeClimateLocation};
975
976    fn escape_md_legacy(value: &str) -> String {
977        let collapsed = value.replace("\r\n", " ").replace(['\n', '\r'], " ");
978        let mut out = String::with_capacity(collapsed.len());
979        for ch in collapsed.chars() {
980            if matches!(
981                ch,
982                '\\' | '`'
983                    | '*'
984                    | '_'
985                    | '['
986                    | ']'
987                    | '('
988                    | ')'
989                    | '!'
990                    | '<'
991                    | '>'
992                    | '#'
993                    | '|'
994                    | '~'
995                    | '&'
996            ) {
997                out.push('\\');
998            }
999            out.push(ch);
1000        }
1001        out.trim().to_owned()
1002    }
1003
1004    fn category_for_rule(rule_id: &str) -> &'static str {
1005        match rule_id {
1006            "fallow/code-duplication" => "Duplication",
1007            "fallow/high-complexity" => "Health",
1008            "fallow/unused-dependency" => "Dependencies",
1009            _ => "Dead code",
1010        }
1011    }
1012
1013    #[test]
1014    fn extracts_issues_from_codeclimate() {
1015        let value = serde_json::json!([{
1016            "check_name": "fallow/unused-export",
1017            "description": "Export x is never imported",
1018            "severity": "minor",
1019            "fingerprint": "abc",
1020            "location": { "path": "src/a.ts", "lines": { "begin": 7 } }
1021        }]);
1022        let issues = issues_from_codeclimate(&value);
1023        assert_eq!(issues.len(), 1);
1024        assert_eq!(issues[0].path, "src/a.ts");
1025        assert_eq!(issues[0].line, 7);
1026    }
1027
1028    #[test]
1029    fn typed_codeclimate_issues_extract_like_json_codeclimate() {
1030        let severities = [
1031            (CodeClimateSeverity::Info, "info"),
1032            (CodeClimateSeverity::Minor, "minor"),
1033            (CodeClimateSeverity::Major, "major"),
1034            (CodeClimateSeverity::Critical, "critical"),
1035            (CodeClimateSeverity::Blocker, "blocker"),
1036        ];
1037        let typed = severities
1038            .iter()
1039            .enumerate()
1040            .map(|(index, (severity, _))| CodeClimateIssue {
1041                kind: CodeClimateIssueKind::Issue,
1042                check_name: format!("fallow/rule-{index}"),
1043                description: format!("Finding {index}"),
1044                categories: vec!["Complexity".to_owned()],
1045                severity: *severity,
1046                fingerprint: format!("fp-{index}"),
1047                location: CodeClimateLocation {
1048                    path: format!("src/{index}.ts"),
1049                    lines: CodeClimateLines {
1050                        begin: u32::try_from(index + 1).expect("small fixture index"),
1051                        end: Some(u32::try_from(index + 3).expect("small fixture index")),
1052                    },
1053                },
1054                other_locations: vec![CodeClimateLocation {
1055                    path: format!("src/peer-{index}.ts"),
1056                    lines: CodeClimateLines {
1057                        begin: 20,
1058                        end: Some(24),
1059                    },
1060                }],
1061                owner: None,
1062                group: None,
1063            })
1064            .collect::<Vec<_>>();
1065        let value = serde_json::to_value(&typed).expect("typed fixture serializes");
1066
1067        assert_eq!(
1068            issues_from_codeclimate_issues(&typed),
1069            issues_from_codeclimate(&value)
1070        );
1071        let normalized = issues_from_codeclimate_issues(&typed);
1072        assert_eq!(normalized[0].end_line, Some(3));
1073        assert_eq!(normalized[0].other_locations[0].path, "src/peer-0.ts");
1074        assert_eq!(normalized[0].other_locations[0].line, 20);
1075        assert_eq!(normalized[0].other_locations[0].end_line, 24);
1076        let typed_labels = issues_from_codeclimate_issues(&typed)
1077            .into_iter()
1078            .map(|issue| issue.severity)
1079            .collect::<Vec<_>>();
1080        let expected_labels = severities
1081            .iter()
1082            .map(|(_, label)| (*label).to_owned())
1083            .collect::<Vec<_>>();
1084        assert_eq!(typed_labels, expected_labels);
1085    }
1086
1087    #[test]
1088    fn review_comment_renders_repository_prefixed_peer_ranges() {
1089        let issue = CiIssue {
1090            rule_id: "fallow/code-duplication".to_owned(),
1091            description: "Code clone dup:abcd1234 (11 lines, 2 instances)".to_owned(),
1092            severity: "minor".to_owned(),
1093            path: "src/a.ts".to_owned(),
1094            line: 5,
1095            end_line: Some(15),
1096            other_locations: vec![CiLocation {
1097                path: "src/b.ts".to_owned(),
1098                line: 30,
1099                end_line: 40,
1100            }],
1101            fingerprint: "instance-fingerprint".to_owned(),
1102        };
1103        let comment = render_review_comment_for_group(&ReviewCommentRenderInput {
1104            provider: CiProvider::Gitlab,
1105            group: &[&issue],
1106            gitlab_diff_refs: None,
1107            diff_index: None,
1108            path_prefix: "packages/app",
1109            include_guidance: false,
1110            suggestion_block: &|_, _| None,
1111            guidance_block: &|_| None,
1112        });
1113        let ReviewComment::GitLab(comment) = comment else {
1114            panic!("expected GitLab comment");
1115        };
1116
1117        assert_eq!(comment.position.new_path, "packages/app/src/a.ts");
1118        assert!(
1119            comment
1120                .body
1121                .contains("Other locations: `packages/app/src/b.ts:30-40`")
1122        );
1123    }
1124
1125    #[test]
1126    fn renders_default_empty_comment() {
1127        let body = render_pr_comment(&PrCommentRenderInput {
1128            command: "check",
1129            provider: CiProvider::Github,
1130            issues: &[],
1131            marker_id: "fallow-results".to_owned(),
1132            max_comments: 50,
1133            category_for_rule: &category_for_rule,
1134        });
1135        assert!(body.contains("<!-- fallow-id: fallow-results"));
1136        assert!(body.contains("No GitHub PR/MR findings."));
1137    }
1138
1139    #[test]
1140    fn escape_md_escapes_inline_commonmark_specials() {
1141        let raw = "foo*bar_baz [a](u) `c` <h> #x !i ~s | p";
1142        let escaped = escape_md(raw);
1143        for ch in [
1144            '*', '_', '[', ']', '(', ')', '`', '<', '>', '#', '!', '~', '|',
1145        ] {
1146            let raw_count = raw.chars().filter(|c| c == &ch).count();
1147            let escaped_count = escaped.matches(&format!("\\{ch}")).count();
1148            assert_eq!(
1149                raw_count, escaped_count,
1150                "char {ch:?}: raw {raw_count} occurrences, escaped {escaped_count} in {escaped:?}"
1151            );
1152        }
1153    }
1154
1155    #[test]
1156    fn escape_md_escapes_ampersand_to_block_numeric_entity_bypass() {
1157        let raw = "value &#42;suspicious&#42; here";
1158        let escaped = escape_md(raw);
1159        assert!(escaped.contains(r"\&"), "got: {escaped}");
1160        assert!(escaped.contains(r"\#"), "got: {escaped}");
1161        assert!(!escaped.contains(" *suspicious"), "got: {escaped}");
1162    }
1163
1164    #[test]
1165    fn summary_label_foreshadows_truncation() {
1166        assert_eq!(
1167            summary_label("Duplication", 160, 50),
1168            "Duplication (160, showing 50)"
1169        );
1170        assert_eq!(summary_label("Health", 12, 50), "Health (12)");
1171        assert_eq!(summary_label("Dependencies", 50, 50), "Dependencies (50)");
1172    }
1173
1174    #[test]
1175    fn escape_md_does_not_escape_block_only_markers() {
1176        let raw = "fallow/test-only-dependency package.json:12";
1177        let escaped = escape_md(raw);
1178        assert!(!escaped.contains("\\-"), "should not escape `-`");
1179        assert!(!escaped.contains("\\."), "should not escape `.`");
1180        assert_eq!(escaped, raw);
1181    }
1182
1183    #[test]
1184    fn escape_md_collapses_newlines_to_spaces() {
1185        let raw = "first\nsecond\nthird";
1186        assert_eq!(escape_md(raw), "first second third");
1187    }
1188
1189    #[test]
1190    fn escape_md_collapses_carriage_returns_to_spaces() {
1191        assert_eq!(escape_md("first\r\nsecond\rthird"), "first second third");
1192    }
1193
1194    #[test]
1195    fn escape_md_matches_legacy_contract_corpus() {
1196        const ALPHABET: [char; 12] = [
1197            'a', ' ', '\t', '\r', '\n', '\\', '|', '`', '&', '\u{2003}', 'é', '🦀',
1198        ];
1199
1200        for length in 0..=4 {
1201            let case_count = ALPHABET.len().pow(length);
1202            for mut encoded in 0..case_count {
1203                let mut value = String::new();
1204                for _ in 0..length {
1205                    value.push(ALPHABET[encoded % ALPHABET.len()]);
1206                    encoded /= ALPHABET.len();
1207                }
1208                assert_eq!(
1209                    escape_md(&value),
1210                    escape_md_legacy(&value),
1211                    "contract mismatch for {value:?}"
1212                );
1213            }
1214        }
1215    }
1216
1217    #[test]
1218    fn markdown_code_span_grows_fence_past_inner_backticks() {
1219        assert_eq!(markdown_code_span("plain"), "`plain`");
1220        assert_eq!(markdown_code_span("has`tick"), "``has`tick``");
1221        assert_eq!(markdown_code_span("`leading"), "`` `leading ``");
1222    }
1223
1224    #[test]
1225    fn markdown_table_code_span_escapes_pipes() {
1226        assert_eq!(markdown_table_code_span("a|b"), "`a\\|b`");
1227        assert_eq!(markdown_table_code_span("x`|y"), "``x`\\|y``");
1228    }
1229
1230    #[test]
1231    fn markdown_table_code_span_collapses_line_endings() {
1232        assert_eq!(markdown_table_code_span("a\r\nb\rc\nd"), "`a b c d`");
1233    }
1234
1235    #[test]
1236    fn markdown_table_text_neutralizes_pipes_and_line_endings() {
1237        assert_eq!(markdown_table_text("a|b"), "a\\|b");
1238        assert_eq!(markdown_table_text("a\r\nb\rc\nd"), "a b c d");
1239    }
1240
1241    #[test]
1242    fn escape_md_leaves_safe_chars_unchanged() {
1243        let raw = "Export 'helperFn' is never imported by other modules";
1244        assert_eq!(
1245            escape_md(raw),
1246            r"Export 'helperFn' is never imported by other modules"
1247        );
1248    }
1249
1250    #[test]
1251    fn is_project_level_rule_covers_config_anchored_dependency_findings() {
1252        for rule_id in PROJECT_LEVEL_RULE_IDS {
1253            assert!(
1254                is_project_level_rule(rule_id),
1255                "{rule_id} must be project-level"
1256            );
1257        }
1258        for rule_id in [
1259            "fallow/unused-file",
1260            "fallow/unused-export",
1261            "fallow/unused-type",
1262            "fallow/unused-enum-member",
1263            "fallow/unused-class-member",
1264            "fallow/unused-store-member",
1265            "fallow/unresolved-import",
1266            "fallow/unlisted-dependency",
1267            "fallow/duplicate-export",
1268            "fallow/circular-dependency",
1269            "fallow/re-export-cycle",
1270            "fallow/boundary-violation",
1271            "fallow/stale-suppression",
1272            "fallow/private-type-leak",
1273            "fallow/high-complexity",
1274            "fallow/high-crap-score",
1275        ] {
1276            assert!(
1277                !is_project_level_rule(rule_id),
1278                "{rule_id} must NOT be project-level"
1279            );
1280        }
1281    }
1282
1283    #[test]
1284    fn escape_md_double_apply_is_safe() {
1285        let raw = "code with `backticks` and *stars*";
1286        let once = escape_md(raw);
1287        let twice = escape_md(&once);
1288        assert!(twice.contains(r"\\"));
1289    }
1290}