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(
615    input: &ReviewEnvelopeRenderInput<'_>,
616    status_message: Option<&str>,
617) -> ReviewEnvelopeRenderResult {
618    render_review_envelope_with_id(input, None, None, status_message)
619}
620
621/// Render a review envelope with an explicit gate conclusion and status.
622#[must_use]
623pub fn render_review_envelope_with_conclusion(
624    input: &ReviewEnvelopeRenderInput<'_>,
625    conclusion: ReviewCheckConclusion,
626    status_message: Option<&str>,
627) -> ReviewEnvelopeRenderResult {
628    render_review_envelope_with_id(input, None, Some(conclusion), status_message)
629}
630
631/// Render a review envelope whose bodies carry the supplied review scope.
632#[must_use]
633pub fn render_scoped_review_envelope(
634    input: &ReviewEnvelopeRenderInput<'_>,
635    review_id: &ReviewId,
636    status_message: Option<&str>,
637) -> ReviewEnvelopeRenderResult {
638    render_review_envelope_with_id(input, Some(review_id), None, status_message)
639}
640
641/// Render a scoped review envelope with an explicit gate conclusion and status.
642#[must_use]
643pub fn render_scoped_review_envelope_with_conclusion(
644    input: &ReviewEnvelopeRenderInput<'_>,
645    review_id: &ReviewId,
646    conclusion: ReviewCheckConclusion,
647    status_message: Option<&str>,
648) -> ReviewEnvelopeRenderResult {
649    render_review_envelope_with_id(input, Some(review_id), Some(conclusion), status_message)
650}
651
652fn render_review_envelope_with_id(
653    input: &ReviewEnvelopeRenderInput<'_>,
654    review_id: Option<&ReviewId>,
655    conclusion: Option<ReviewCheckConclusion>,
656    status_message: Option<&str>,
657) -> ReviewEnvelopeRenderResult {
658    let grouped = group_review_issues_by_path_line(input.issues, input.max_comments);
659
660    let comments: Vec<ReviewComment> = grouped
661        .groups
662        .iter()
663        .map(|group| {
664            render_review_comment_for_group_with_id(
665                &ReviewCommentRenderInput {
666                    provider: input.provider,
667                    group,
668                    gitlab_diff_refs: input.gitlab_diff_refs,
669                    diff_index: input.diff_index,
670                    path_prefix: input.path_prefix,
671                    include_guidance: input.include_guidance,
672                    suggestion_block: input.suggestion_block,
673                    guidance_block: input.guidance_block,
674                },
675                review_id,
676            )
677        })
678        .collect();
679
680    let conclusion = conclusion.unwrap_or_else(|| github_check_conclusion(input.issues));
681    let summary_text = review_summary_text(
682        input.command,
683        input.provider,
684        comments.len(),
685        status_message,
686    );
687    let summary_fp = summary_fingerprint(&summary_text);
688    let summary_marker = review_markers(&summary_fp, review_id);
689    let body = format!("{summary_text}{summary_marker}");
690    let summary = ReviewEnvelopeSummary {
691        body: body.clone(),
692        fingerprint: summary_fp,
693    };
694
695    let truncation = ReviewEnvelopeTruncation {
696        body: comments.iter().any(review_comment_truncated),
697        comment_limit: grouped.truncated,
698    };
699
700    ReviewEnvelopeRenderResult {
701        envelope: build_review_envelope_output(input.provider, body, summary, comments, conclusion),
702        truncation,
703    }
704}
705
706/// Review body: what this review carries and where to read it.
707///
708/// No run verdict. A provider review is a point-in-time record that a later
709/// run cannot rewrite, so a verdict rendered here keeps asserting a state the
710/// editable sticky comment has already moved on from. The machine-readable
711/// gate result still travels on `meta.check_conclusion`.
712fn review_summary_text(
713    command: &str,
714    provider: CiProvider,
715    comment_count: usize,
716    status_message: Option<&str>,
717) -> String {
718    let status = status_message.map_or_else(String::new, |message| format!("\n\n> {message}"));
719    format!(
720        "### Fallow {}{}\n\n{}\n\n<!-- fallow-review -->",
721        command_title(command),
722        status,
723        inline_comment_line(provider, comment_count),
724    )
725}
726
727fn inline_comment_line(provider: CiProvider, count: usize) -> String {
728    if count == 0 {
729        return format!(
730            "No findings anchored to the changed lines in this {}.",
731            change_noun(provider)
732        );
733    }
734    format!(
735        "{count} inline comment{} on the changed lines. Open the {} tab to review.",
736        if count == 1 { "" } else { "s" },
737        changes_tab(provider),
738    )
739}
740
741/// Review issues grouped per `(path, line)` for one-comment-per-location
742/// rendering.
743#[derive(Debug, PartialEq, Eq)]
744pub struct GroupedReviewIssues<'a> {
745    /// One group per distinct location, in input order.
746    pub groups: Vec<Vec<&'a CiIssue>>,
747    /// True when the group cap cut off remaining issues.
748    pub truncated: bool,
749}
750
751/// Group consecutive same-(path, line) issues. Input is already sorted by
752/// `(path, line, fingerprint)` so a single linear pass collects runs.
753#[must_use]
754pub fn group_review_issues_by_path_line(
755    issues: &[CiIssue],
756    max_groups: usize,
757) -> GroupedReviewIssues<'_> {
758    if max_groups == 0 {
759        return GroupedReviewIssues {
760            groups: Vec::new(),
761            truncated: !issues.is_empty(),
762        };
763    }
764    let mut groups: Vec<Vec<&CiIssue>> = Vec::with_capacity(max_groups.min(issues.len()));
765    let mut current: Vec<&CiIssue> = Vec::new();
766    let mut current_key: Option<(&str, u64)> = None;
767    for issue in issues {
768        let key = (issue.path.as_str(), issue.line);
769        if Some(key) != current_key {
770            if !current.is_empty() {
771                groups.push(std::mem::take(&mut current));
772                if groups.len() == max_groups {
773                    return GroupedReviewIssues {
774                        groups,
775                        truncated: true,
776                    };
777                }
778            }
779            current_key = Some(key);
780        }
781        current.push(issue);
782    }
783    if !current.is_empty() && groups.len() < max_groups {
784        groups.push(current);
785    }
786    GroupedReviewIssues {
787        groups,
788        truncated: false,
789    }
790}
791
792fn review_comment_truncated(comment: &ReviewComment) -> bool {
793    match comment {
794        ReviewComment::GitHub(comment) => comment.truncated,
795        ReviewComment::GitLab(comment) => comment.truncated,
796    }
797}
798
799/// Inputs for rendering one inline review comment from a location group.
800pub struct ReviewCommentRenderInput<'a, 'group> {
801    /// CI provider whose comment shape to produce.
802    pub provider: CiProvider,
803    /// Issues sharing the same `(path, line)`; the first is the representative.
804    pub group: &'a [&'group CiIssue],
805    /// Required for GitLab positioned discussions; ignored for GitHub.
806    pub gitlab_diff_refs: Option<&'a ReviewGitlabDiffRefs>,
807    /// Diff index used to resolve renamed paths for GitLab positions.
808    pub diff_index: Option<&'a DiffIndex>,
809    /// Prepended to every emitted path after diff lookups have run.
810    pub path_prefix: &'a str,
811    /// Whether to append per-finding guidance blocks to the body.
812    pub include_guidance: bool,
813    /// Produces a provider-specific suggestion block for a finding, when one
814    /// applies.
815    pub suggestion_block: &'a dyn Fn(CiProvider, &CiIssue) -> Option<String>,
816    /// Produces a guidance block for a finding, when one applies.
817    pub guidance_block: &'a dyn Fn(&CiIssue) -> Option<String>,
818}
819
820/// Render one comment from a group of issues sharing the same `(path, line)`.
821#[must_use]
822pub fn render_review_comment_for_group(input: &ReviewCommentRenderInput<'_, '_>) -> ReviewComment {
823    render_review_comment_for_group_with_id(input, None)
824}
825
826fn render_review_comment_for_group_with_id(
827    input: &ReviewCommentRenderInput<'_, '_>,
828    review_id: Option<&ReviewId>,
829) -> ReviewComment {
830    assert!(
831        !input.group.is_empty(),
832        "group_review_issues_by_path_line never yields empty"
833    );
834    let representative = input.group[0];
835    let fingerprint = if input.group.len() == 1 {
836        representative.fingerprint.clone()
837    } else {
838        let constituents: Vec<&str> = input.group.iter().map(|i| i.fingerprint.as_str()).collect();
839        composite_fingerprint(&constituents)
840    };
841
842    let content = build_merged_comment_content(input);
843    let marker_line = review_markers(&fingerprint, review_id);
844    let (body, truncated) = cap_body_with_marker(&content, &marker_line);
845
846    build_review_comment(ReviewCommentInput {
847        provider: input.provider,
848        representative,
849        gitlab_diff_refs: input.gitlab_diff_refs,
850        diff_index: input.diff_index,
851        path_prefix: input.path_prefix,
852        body,
853        fingerprint,
854        truncated,
855    })
856}
857
858#[expect(clippy::expect_used, reason = "formatting into String is infallible")]
859fn build_merged_comment_content(input: &ReviewCommentRenderInput<'_, '_>) -> String {
860    let mut content = String::new();
861    for (index, issue) in input.group.iter().enumerate() {
862        let label = review_label_from_codeclimate(&issue.severity);
863        if index > 0 {
864            content.push_str("\n\n");
865        }
866        write!(
867            content,
868            "**{}** `{}`: {}",
869            label,
870            escape_md(&issue.rule_id),
871            escape_md(&issue.description)
872        )
873        .expect("write to String is infallible");
874        if !issue.other_locations.is_empty() {
875            content.push_str("\n\nOther locations: ");
876            let locations = issue
877                .other_locations
878                .iter()
879                .map(|location| {
880                    markdown_code_span(&format!(
881                        "{}:{}-{}",
882                        apply_path_prefix(input.path_prefix, &location.path),
883                        location.line,
884                        location.end_line
885                    ))
886                })
887                .collect::<Vec<_>>()
888                .join(", ");
889            content.push_str(&locations);
890        }
891        if let Some(suggestion) = (input.suggestion_block)(input.provider, issue) {
892            content.push_str(&suggestion);
893        }
894        if input.include_guidance
895            && let Some(guidance) = (input.guidance_block)(issue)
896        {
897            content.push_str(&guidance);
898        }
899    }
900    content
901}
902
903struct ReviewCommentInput<'a> {
904    provider: CiProvider,
905    representative: &'a CiIssue,
906    gitlab_diff_refs: Option<&'a ReviewGitlabDiffRefs>,
907    diff_index: Option<&'a DiffIndex>,
908    path_prefix: &'a str,
909    body: String,
910    fingerprint: String,
911    truncated: bool,
912}
913
914fn build_review_comment(input: ReviewCommentInput<'_>) -> ReviewComment {
915    let ReviewCommentInput {
916        provider,
917        representative,
918        gitlab_diff_refs,
919        diff_index,
920        path_prefix,
921        body,
922        fingerprint,
923        truncated,
924    } = input;
925    match provider {
926        CiProvider::Github => ReviewComment::GitHub(GitHubReviewComment {
927            path: apply_path_prefix(path_prefix, &representative.path),
928            line: u32::try_from(representative.line).unwrap_or(u32::MAX),
929            side: GitHubReviewSide::Right,
930            body,
931            fingerprint,
932            truncated,
933        }),
934        CiProvider::Gitlab => {
935            // Renames resolve on the analysis-root-relative path, before the
936            // presentation prefix goes on: the diff's keys never carry it.
937            let old_rel = diff_index
938                .and_then(|di| di.old_path_for_root_relative(&representative.path))
939                .map_or_else(|| representative.path.clone(), Cow::into_owned);
940            let new_path = apply_path_prefix(path_prefix, &representative.path);
941            let old_path = apply_path_prefix(path_prefix, &old_rel);
942            let position = GitLabReviewPosition {
943                base_sha: gitlab_diff_refs.map(|r| r.base_sha.clone()),
944                start_sha: gitlab_diff_refs.map(|r| r.start_sha.clone()),
945                head_sha: gitlab_diff_refs.map(|r| r.head_sha.clone()),
946                position_type: GitLabReviewPositionType::Text,
947                old_path,
948                new_path,
949                new_line: u32::try_from(representative.line).unwrap_or(u32::MAX),
950            };
951            ReviewComment::GitLab(GitLabReviewComment {
952                body,
953                position,
954                fingerprint,
955                truncated,
956            })
957        }
958    }
959}
960
961/// Append `marker_line` to `content`, truncating `content` on a char boundary
962/// so the whole body stays within [`MAX_COMMENT_BODY_BYTES`]. The marker is
963/// never sacrificed. Returns the body and whether truncation happened.
964#[must_use]
965pub fn cap_body_with_marker(content: &str, marker_line: &str) -> (String, bool) {
966    let intact_len = content.len() + marker_line.len();
967    if intact_len <= MAX_COMMENT_BODY_BYTES {
968        let mut out = String::with_capacity(intact_len);
969        out.push_str(content);
970        out.push_str(marker_line);
971        return (out, false);
972    }
973    let reserved = marker_line.len() + TRUNCATION_SUFFIX.len();
974    let budget = MAX_COMMENT_BODY_BYTES.saturating_sub(reserved);
975    let mut cut = budget.min(content.len());
976    while cut > 0 && !content.is_char_boundary(cut) {
977        cut -= 1;
978    }
979    let mut out = String::with_capacity(MAX_COMMENT_BODY_BYTES);
980    out.push_str(&content[..cut]);
981    out.push_str(TRUNCATION_SUFFIX);
982    out.push_str(marker_line);
983    (out, true)
984}
985
986/// Map a CodeClimate severity name to the review badge label: `error` for
987/// major and above, `warn` otherwise.
988#[must_use]
989pub const fn review_label_from_codeclimate(severity_name: &str) -> &'static str {
990    match severity_name.as_bytes() {
991        b"major" | b"critical" | b"blocker" => "error",
992        _ => "warn",
993    }
994}
995
996/// GitHub check conclusion for a set of findings: `Failure` when any is major
997/// or above, `Success` when empty, `Neutral` otherwise.
998#[must_use]
999pub fn github_check_conclusion(issues: &[CiIssue]) -> ReviewCheckConclusion {
1000    if issues
1001        .iter()
1002        .any(|issue| matches!(issue.severity.as_str(), "major" | "critical" | "blocker"))
1003    {
1004        ReviewCheckConclusion::Failure
1005    } else if issues.is_empty() {
1006        ReviewCheckConclusion::Success
1007    } else {
1008        ReviewCheckConclusion::Neutral
1009    }
1010}
1011
1012fn build_review_envelope_output(
1013    provider: CiProvider,
1014    body: String,
1015    summary: ReviewEnvelopeSummary,
1016    comments: Vec<ReviewComment>,
1017    conclusion: ReviewCheckConclusion,
1018) -> ReviewEnvelopeOutput {
1019    match provider {
1020        CiProvider::Github => ReviewEnvelopeOutput {
1021            event: Some(ReviewEnvelopeEvent::Comment),
1022            body,
1023            summary,
1024            comments,
1025            marker_regex: default_marker_regex(),
1026            marker_regex_flags: default_marker_regex_flags(),
1027            meta: ReviewEnvelopeMeta {
1028                schema: ReviewEnvelopeSchema::V3,
1029                provider: ReviewProvider::Github,
1030                check_conclusion: Some(conclusion),
1031            },
1032        },
1033        CiProvider::Gitlab => ReviewEnvelopeOutput {
1034            event: None,
1035            body,
1036            summary,
1037            comments,
1038            marker_regex: default_marker_regex(),
1039            marker_regex_flags: default_marker_regex_flags(),
1040            meta: ReviewEnvelopeMeta {
1041                schema: ReviewEnvelopeSchema::V3,
1042                provider: ReviewProvider::Gitlab,
1043                check_conclusion: None,
1044            },
1045        },
1046    }
1047}
1048
1049fn review_markers(fingerprint: &str, review_id: Option<&ReviewId>) -> String {
1050    let fingerprint = format!("\n\n{MARKER_PREFIX_V2}{fingerprint}{MARKER_SUFFIX_V2}");
1051    match review_id {
1052        Some(review_id) => format!("{fingerprint}\n{}", review_id_marker(review_id)),
1053        None => fingerprint,
1054    }
1055}
1056
1057/// Stable fingerprint for a summary comment body.
1058#[must_use]
1059pub fn summary_fingerprint(body: &str) -> String {
1060    fingerprint_hash(&[body])
1061}
1062
1063/// Order-independent fingerprint for a comment merged from several findings:
1064/// constituents are sorted before hashing and the result carries a `merged:`
1065/// prefix.
1066#[must_use]
1067pub fn composite_fingerprint(constituents: &[&str]) -> String {
1068    let mut sorted: Vec<&str> = constituents.to_vec();
1069    sorted.sort_unstable();
1070    let joined = sorted.join(":");
1071    format!("merged:{}", fingerprint_hash(&[joined.as_str()]))
1072}
1073
1074#[cfg(test)]
1075mod tests {
1076    use super::*;
1077    use crate::{CodeClimateIssueKind, CodeClimateLines, CodeClimateLocation};
1078
1079    fn category_for_rule(rule_id: &str) -> &'static str {
1080        match rule_id {
1081            "fallow/code-duplication" => "Duplication",
1082            "fallow/high-complexity" => "Health",
1083            "fallow/unused-dependency" => "Dependencies",
1084            _ => "Dead code",
1085        }
1086    }
1087
1088    #[test]
1089    fn extracts_issues_from_codeclimate() {
1090        let value = serde_json::json!([{
1091            "check_name": "fallow/unused-export",
1092            "description": "Export x is never imported",
1093            "severity": "minor",
1094            "fingerprint": "abc",
1095            "location": { "path": "src/a.ts", "lines": { "begin": 7 } }
1096        }]);
1097        let issues = issues_from_codeclimate(&value);
1098        assert_eq!(issues.len(), 1);
1099        assert_eq!(issues[0].path, "src/a.ts");
1100        assert_eq!(issues[0].line, 7);
1101    }
1102
1103    #[test]
1104    fn typed_codeclimate_issues_extract_like_json_codeclimate() {
1105        let severities = [
1106            (CodeClimateSeverity::Info, "info"),
1107            (CodeClimateSeverity::Minor, "minor"),
1108            (CodeClimateSeverity::Major, "major"),
1109            (CodeClimateSeverity::Critical, "critical"),
1110            (CodeClimateSeverity::Blocker, "blocker"),
1111        ];
1112        let typed = severities
1113            .iter()
1114            .enumerate()
1115            .map(|(index, (severity, _))| CodeClimateIssue {
1116                kind: CodeClimateIssueKind::Issue,
1117                check_name: format!("fallow/rule-{index}"),
1118                description: format!("Finding {index}"),
1119                categories: vec!["Complexity".to_owned()],
1120                severity: *severity,
1121                fingerprint: format!("fp-{index}"),
1122                location: CodeClimateLocation {
1123                    path: format!("src/{index}.ts"),
1124                    lines: CodeClimateLines {
1125                        begin: u32::try_from(index + 1).expect("small fixture index"),
1126                        end: Some(u32::try_from(index + 3).expect("small fixture index")),
1127                    },
1128                },
1129                other_locations: vec![CodeClimateLocation {
1130                    path: format!("src/peer-{index}.ts"),
1131                    lines: CodeClimateLines {
1132                        begin: 20,
1133                        end: Some(24),
1134                    },
1135                }],
1136                owner: None,
1137                group: None,
1138            })
1139            .collect::<Vec<_>>();
1140        let value = serde_json::to_value(&typed).expect("typed fixture serializes");
1141
1142        assert_eq!(
1143            issues_from_codeclimate_issues(&typed),
1144            issues_from_codeclimate(&value)
1145        );
1146        let normalized = issues_from_codeclimate_issues(&typed);
1147        assert_eq!(normalized[0].end_line, Some(3));
1148        assert_eq!(normalized[0].other_locations[0].path, "src/peer-0.ts");
1149        assert_eq!(normalized[0].other_locations[0].line, 20);
1150        assert_eq!(normalized[0].other_locations[0].end_line, 24);
1151        let typed_labels = issues_from_codeclimate_issues(&typed)
1152            .into_iter()
1153            .map(|issue| issue.severity)
1154            .collect::<Vec<_>>();
1155        let expected_labels = severities
1156            .iter()
1157            .map(|(_, label)| (*label).to_owned())
1158            .collect::<Vec<_>>();
1159        assert_eq!(typed_labels, expected_labels);
1160    }
1161
1162    #[test]
1163    fn review_comment_renders_repository_prefixed_peer_ranges() {
1164        let issue = CiIssue {
1165            rule_id: "fallow/code-duplication".to_owned(),
1166            description: "Code clone dup:abcd1234 (11 lines, 2 instances)".to_owned(),
1167            severity: "minor".to_owned(),
1168            path: "src/a.ts".to_owned(),
1169            line: 5,
1170            end_line: Some(15),
1171            other_locations: vec![CiLocation {
1172                path: "src/b.ts".to_owned(),
1173                line: 30,
1174                end_line: 40,
1175            }],
1176            fingerprint: "instance-fingerprint".to_owned(),
1177        };
1178        let comment = render_review_comment_for_group(&ReviewCommentRenderInput {
1179            provider: CiProvider::Gitlab,
1180            group: &[&issue],
1181            gitlab_diff_refs: None,
1182            diff_index: None,
1183            path_prefix: "packages/app",
1184            include_guidance: false,
1185            suggestion_block: &|_, _| None,
1186            guidance_block: &|_| None,
1187        });
1188        let ReviewComment::GitLab(comment) = comment else {
1189            panic!("expected GitLab comment");
1190        };
1191
1192        assert_eq!(comment.position.new_path, "packages/app/src/a.ts");
1193        assert!(
1194            comment
1195                .body
1196                .contains("Other locations: `packages/app/src/b.ts:30-40`")
1197        );
1198    }
1199
1200    #[test]
1201    fn renders_default_empty_comment() {
1202        let body = render_pr_comment(&PrCommentRenderInput {
1203            command: "check",
1204            provider: CiProvider::Github,
1205            issues: &[],
1206            marker_id: "fallow-results".to_owned(),
1207            max_comments: 50,
1208            category_for_rule: &category_for_rule,
1209        });
1210        assert!(body.contains("<!-- fallow-id: fallow-results"));
1211        assert!(body.contains("No findings for this pull request."));
1212    }
1213
1214    fn pr_comment_issue(rule_id: &str, description: &str, severity: &str, path: &str) -> CiIssue {
1215        CiIssue {
1216            rule_id: rule_id.to_owned(),
1217            description: description.to_owned(),
1218            severity: severity.to_owned(),
1219            path: path.to_owned(),
1220            line: 3,
1221            end_line: None,
1222            other_locations: Vec::new(),
1223            fingerprint: path.to_owned(),
1224        }
1225    }
1226
1227    #[test]
1228    fn pr_comment_titles_by_content_and_names_its_only_category() {
1229        let issues = vec![
1230            pr_comment_issue(
1231                "fallow/unresolved-import",
1232                "Import './x' could not be resolved",
1233                "major",
1234                "src/a.ts",
1235            ),
1236            pr_comment_issue(
1237                "fallow/unresolved-import",
1238                "Import './y' could not be resolved",
1239                "major",
1240                "src/b.ts",
1241            ),
1242        ];
1243        let body = render_pr_comment(&PrCommentRenderInput {
1244            command: "dead-code",
1245            provider: CiProvider::Github,
1246            issues: &issues,
1247            marker_id: "fallow-results".to_owned(),
1248            max_comments: 50,
1249            category_for_rule: &category_for_rule,
1250        });
1251        assert!(body.contains("### Fallow codebase report"), "{body}");
1252        assert!(body.contains("**Quality gate failed**"), "{body}");
1253        assert!(body.contains("Found **2** findings."), "{body}");
1254        assert!(body.contains("<summary>Dead code (2)</summary>"), "{body}");
1255    }
1256
1257    #[test]
1258    fn pr_comment_breakdown_indexes_several_categories() {
1259        let issues = vec![
1260            pr_comment_issue(
1261                "fallow/unresolved-import",
1262                "Import './x' could not be resolved",
1263                "major",
1264                "src/a.ts",
1265            ),
1266            pr_comment_issue(
1267                "fallow/unused-dependency",
1268                "Package 'lodash' is never imported",
1269                "minor",
1270                "package.json",
1271            ),
1272        ];
1273        let body = render_pr_comment(&PrCommentRenderInput {
1274            command: "check",
1275            provider: CiProvider::Github,
1276            issues: &issues,
1277            marker_id: "fallow-results".to_owned(),
1278            max_comments: 50,
1279            category_for_rule: &category_for_rule,
1280        });
1281        assert!(
1282            body.contains("Found **2** findings: Dead code 1, Dependencies 1."),
1283            "{body}"
1284        );
1285    }
1286
1287    #[test]
1288    fn pr_comment_empty_state_speaks_the_provider_language() {
1289        let github = render_pr_comment(&PrCommentRenderInput {
1290            command: "dead-code",
1291            provider: CiProvider::Github,
1292            issues: &[],
1293            marker_id: "fallow-results".to_owned(),
1294            max_comments: 50,
1295            category_for_rule: &category_for_rule,
1296        });
1297        let gitlab = render_pr_comment(&PrCommentRenderInput {
1298            command: "dead-code",
1299            provider: CiProvider::Gitlab,
1300            issues: &[],
1301            marker_id: "fallow-results".to_owned(),
1302            max_comments: 50,
1303            category_for_rule: &category_for_rule,
1304        });
1305        assert!(
1306            github.contains("No findings for this pull request."),
1307            "{github}"
1308        );
1309        assert!(
1310            gitlab.contains("No findings for this merge request."),
1311            "{gitlab}"
1312        );
1313        assert!(
1314            github.starts_with("<!-- fallow-id: fallow-results -->\n"),
1315            "{github}"
1316        );
1317        assert!(github.contains("Generated by fallow."), "{github}");
1318    }
1319
1320    #[test]
1321    fn escape_md_escapes_inline_commonmark_specials() {
1322        let raw = "foo*bar_baz [a](u) `c` <h> #x !i ~s | p";
1323        let escaped = escape_md(raw);
1324        for ch in [
1325            '*', '_', '[', ']', '(', ')', '`', '<', '>', '#', '!', '~', '|',
1326        ] {
1327            let raw_count = raw.chars().filter(|c| c == &ch).count();
1328            let escaped_count = escaped.matches(&format!("\\{ch}")).count();
1329            assert_eq!(
1330                raw_count, escaped_count,
1331                "char {ch:?}: raw {raw_count} occurrences, escaped {escaped_count} in {escaped:?}"
1332            );
1333        }
1334    }
1335
1336    #[test]
1337    fn escape_md_escapes_ampersand_to_block_numeric_entity_bypass() {
1338        let raw = "value &#42;suspicious&#42; here";
1339        let escaped = escape_md(raw);
1340        assert!(escaped.contains(r"\&"), "got: {escaped}");
1341        assert!(escaped.contains(r"\#"), "got: {escaped}");
1342        assert!(!escaped.contains(" *suspicious"), "got: {escaped}");
1343    }
1344
1345    #[test]
1346    fn summary_label_foreshadows_truncation() {
1347        assert_eq!(
1348            summary_label("Duplication", 160, 50),
1349            "Duplication (160, showing 50)"
1350        );
1351        assert_eq!(summary_label("Health", 12, 50), "Health (12)");
1352        assert_eq!(summary_label("Dependencies", 50, 50), "Dependencies (50)");
1353    }
1354
1355    #[test]
1356    fn escape_md_does_not_escape_block_only_markers() {
1357        let raw = "fallow/test-only-dependency package.json:12";
1358        let escaped = escape_md(raw);
1359        assert!(!escaped.contains("\\-"), "should not escape `-`");
1360        assert!(!escaped.contains("\\."), "should not escape `.`");
1361        assert_eq!(escaped, raw);
1362    }
1363
1364    #[test]
1365    fn escape_md_collapses_newlines_to_spaces() {
1366        let raw = "first\nsecond\nthird";
1367        assert_eq!(escape_md(raw), "first second third");
1368    }
1369
1370    #[test]
1371    fn escape_md_collapses_carriage_returns_to_spaces() {
1372        assert_eq!(escape_md("first\r\nsecond\rthird"), "first second third");
1373    }
1374
1375    #[test]
1376    fn escape_md_trims_surrounding_whitespace() {
1377        assert_eq!(escape_md("  a\u{2003}\r\n"), "a");
1378        assert_eq!(escape_md(" \t\r\n\u{2003} "), "");
1379    }
1380
1381    #[test]
1382    fn escape_md_collapses_crlf_to_one_space() {
1383        let collapsed = escape_md("a\r\nb\rc\nd");
1384        assert_eq!(collapsed, "a b c d");
1385        assert_eq!(collapsed.len(), 7, "CRLF must not expand to two spaces");
1386    }
1387
1388    #[test]
1389    fn escape_md_preserves_interior_tabs_and_wide_spaces() {
1390        assert_eq!(escape_md("a\tb"), "a\tb");
1391        assert_eq!(escape_md("a\u{2003}b"), "a\u{2003}b");
1392    }
1393
1394    #[test]
1395    fn escape_md_passes_non_ascii_through_unchanged() {
1396        assert_eq!(escape_md("é🦀"), "é🦀");
1397    }
1398
1399    #[test]
1400    fn markdown_code_span_grows_fence_past_inner_backticks() {
1401        assert_eq!(markdown_code_span("plain"), "`plain`");
1402        assert_eq!(markdown_code_span("has`tick"), "``has`tick``");
1403        assert_eq!(markdown_code_span("`leading"), "`` `leading ``");
1404    }
1405
1406    #[test]
1407    fn markdown_table_code_span_escapes_pipes() {
1408        assert_eq!(markdown_table_code_span("a|b"), "`a\\|b`");
1409        assert_eq!(markdown_table_code_span("x`|y"), "``x`\\|y``");
1410    }
1411
1412    #[test]
1413    fn markdown_table_code_span_collapses_line_endings() {
1414        assert_eq!(markdown_table_code_span("a\r\nb\rc\nd"), "`a b c d`");
1415    }
1416
1417    #[test]
1418    fn markdown_table_text_neutralizes_pipes_and_line_endings() {
1419        assert_eq!(markdown_table_text("a|b"), "a\\|b");
1420        assert_eq!(markdown_table_text("a\r\nb\rc\nd"), "a b c d");
1421    }
1422
1423    #[test]
1424    fn escape_md_leaves_safe_chars_unchanged() {
1425        let raw = "Export 'helperFn' is never imported by other modules";
1426        assert_eq!(
1427            escape_md(raw),
1428            r"Export 'helperFn' is never imported by other modules"
1429        );
1430    }
1431
1432    #[test]
1433    fn is_project_level_rule_covers_config_anchored_dependency_findings() {
1434        for rule_id in PROJECT_LEVEL_RULE_IDS {
1435            assert!(
1436                is_project_level_rule(rule_id),
1437                "{rule_id} must be project-level"
1438            );
1439        }
1440        for rule_id in [
1441            "fallow/unused-file",
1442            "fallow/unused-export",
1443            "fallow/unused-type",
1444            "fallow/unused-enum-member",
1445            "fallow/unused-class-member",
1446            "fallow/unused-store-member",
1447            "fallow/unresolved-import",
1448            "fallow/unlisted-dependency",
1449            "fallow/duplicate-export",
1450            "fallow/circular-dependency",
1451            "fallow/re-export-cycle",
1452            "fallow/boundary-violation",
1453            "fallow/stale-suppression",
1454            "fallow/private-type-leak",
1455            "fallow/high-complexity",
1456            "fallow/high-crap-score",
1457        ] {
1458            assert!(
1459                !is_project_level_rule(rule_id),
1460                "{rule_id} must NOT be project-level"
1461            );
1462        }
1463    }
1464
1465    #[test]
1466    fn escape_md_double_apply_is_safe() {
1467        let raw = "code with `backticks` and *stars*";
1468        let once = escape_md(raw);
1469        let twice = escape_md(&once);
1470        assert_eq!(once, r"code with \`backticks\` and \*stars\*");
1471        assert_eq!(twice, r"code with \\\`backticks\\\` and \\\*stars\\\*");
1472    }
1473}