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