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