Skip to main content

fallow_output/
ci_output.rs

1//! Shared CI comment output contracts for CLI and programmatic consumers.
2
3use std::fmt::Write as _;
4
5use crate::{
6    CodeClimateIssue, CodeClimateSeverity, DiffIndex, GitHubReviewComment, GitHubReviewSide,
7    GitLabReviewComment, GitLabReviewPosition, GitLabReviewPositionType, ReviewCheckConclusion,
8    ReviewComment, ReviewEnvelopeEvent, ReviewEnvelopeMeta, ReviewEnvelopeOutput,
9    ReviewEnvelopeSchema, ReviewEnvelopeSummary, ReviewProvider, default_marker_regex,
10    default_marker_regex_flags,
11};
12use serde_json::Value;
13
14/// Supported CI review providers for generated comments.
15#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16pub enum CiProvider {
17    Github,
18    Gitlab,
19}
20
21impl CiProvider {
22    #[must_use]
23    pub const fn name(self) -> &'static str {
24        match self {
25            Self::Github => "GitHub",
26            Self::Gitlab => "GitLab",
27        }
28    }
29}
30
31/// Normalized CodeClimate issue used by CI comment renderers.
32#[derive(Clone, Debug, PartialEq, Eq)]
33pub struct CiIssue {
34    pub rule_id: String,
35    pub description: String,
36    pub severity: String,
37    pub path: String,
38    pub line: u64,
39    pub fingerprint: String,
40}
41
42/// Inputs for rendering a sticky PR/MR summary comment.
43pub struct PrCommentRenderInput<'a> {
44    pub command: &'a str,
45    pub provider: CiProvider,
46    pub issues: &'a [CiIssue],
47    pub marker_id: String,
48    pub max_comments: usize,
49    pub category_for_rule: &'a dyn Fn(&str) -> &'static str,
50}
51
52/// GitLab diff refs for a review-envelope position.
53#[derive(Clone, Debug, PartialEq, Eq)]
54pub struct ReviewGitlabDiffRefs {
55    pub base_sha: String,
56    pub start_sha: String,
57    pub head_sha: String,
58}
59
60/// Truncation signals produced while rendering a review envelope.
61#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
62pub struct ReviewEnvelopeTruncation {
63    pub body: bool,
64    pub comment_limit: bool,
65}
66
67/// Rendered review envelope plus side-channel signals for CLI telemetry.
68#[derive(Debug)]
69pub struct ReviewEnvelopeRenderResult {
70    pub envelope: ReviewEnvelopeOutput,
71    pub truncation: ReviewEnvelopeTruncation,
72}
73
74/// Inputs for rendering a GitHub/GitLab review envelope.
75pub struct ReviewEnvelopeRenderInput<'a> {
76    pub command: &'a str,
77    pub provider: CiProvider,
78    pub issues: &'a [CiIssue],
79    pub diff_index: Option<&'a DiffIndex>,
80    pub max_comments: usize,
81    pub gitlab_diff_refs: Option<&'a ReviewGitlabDiffRefs>,
82    pub include_guidance: bool,
83    pub suggestion_block: &'a dyn Fn(CiProvider, &CiIssue) -> Option<String>,
84    pub guidance_block: &'a dyn Fn(&CiIssue) -> Option<String>,
85}
86
87/// Marker prefix appended to every v2 review-comment body.
88pub const MARKER_PREFIX_V2: &str = "<!-- fallow-fingerprint:v2: ";
89
90/// Closing of the v2 marker, after the fingerprint string.
91pub const MARKER_SUFFIX_V2: &str = " -->";
92
93pub const MAX_COMMENT_BODY_BYTES: usize = 65_536;
94const TRUNCATION_SUFFIX: &str = "\n\n<!-- fallow-truncated -->\n> Body truncated by fallow.";
95
96#[must_use]
97pub fn issues_from_codeclimate(value: &Value) -> Vec<CiIssue> {
98    let mut issues = value
99        .as_array()
100        .into_iter()
101        .flatten()
102        .filter_map(issue_from_codeclimate)
103        .collect::<Vec<_>>();
104    sort_ci_issues(&mut issues);
105    issues
106}
107
108#[must_use]
109pub fn issues_from_codeclimate_issues(issues: &[CodeClimateIssue]) -> Vec<CiIssue> {
110    let mut issues = issues
111        .iter()
112        .map(issue_from_codeclimate_issue)
113        .collect::<Vec<_>>();
114    sort_ci_issues(&mut issues);
115    issues
116}
117
118fn issue_from_codeclimate(value: &Value) -> Option<CiIssue> {
119    let path = value.pointer("/location/path")?.as_str()?.to_string();
120    let line = value
121        .pointer("/location/lines/begin")
122        .and_then(Value::as_u64)
123        .unwrap_or(1);
124    Some(CiIssue {
125        rule_id: value
126            .get("check_name")
127            .and_then(Value::as_str)
128            .unwrap_or("fallow/finding")
129            .to_string(),
130        description: value
131            .get("description")
132            .and_then(Value::as_str)
133            .unwrap_or("Fallow finding")
134            .to_string(),
135        severity: value
136            .get("severity")
137            .and_then(Value::as_str)
138            .unwrap_or("minor")
139            .to_string(),
140        fingerprint: value
141            .get("fingerprint")
142            .and_then(Value::as_str)
143            .unwrap_or("")
144            .to_string(),
145        path,
146        line,
147    })
148}
149
150fn issue_from_codeclimate_issue(issue: &CodeClimateIssue) -> CiIssue {
151    CiIssue {
152        rule_id: issue.check_name.clone(),
153        description: issue.description.clone(),
154        severity: codeclimate_severity_label(issue.severity).to_owned(),
155        path: issue.location.path.clone(),
156        line: u64::from(issue.location.lines.begin),
157        fingerprint: issue.fingerprint.clone(),
158    }
159}
160
161const fn codeclimate_severity_label(severity: CodeClimateSeverity) -> &'static str {
162    match severity {
163        CodeClimateSeverity::Info => "info",
164        CodeClimateSeverity::Minor => "minor",
165        CodeClimateSeverity::Major => "major",
166        CodeClimateSeverity::Critical => "critical",
167        CodeClimateSeverity::Blocker => "blocker",
168    }
169}
170
171fn sort_ci_issues(issues: &mut [CiIssue]) {
172    issues
173        .sort_by(|a, b| (&a.path, a.line, &a.fingerprint).cmp(&(&b.path, b.line, &b.fingerprint)));
174}
175
176fn fingerprint_hash(parts: &[&str]) -> String {
177    crate::codeclimate_fingerprint_hash(parts)
178}
179
180#[must_use]
181#[expect(clippy::expect_used, reason = "formatting into String is infallible")]
182pub fn render_pr_comment(input: &PrCommentRenderInput<'_>) -> String {
183    let marker = format!("<!-- fallow-id: {} -->", input.marker_id);
184    let title = command_title(input.command);
185    let count = input.issues.len();
186    let noun = if count == 1 { "finding" } else { "findings" };
187
188    let mut out = String::new();
189    out.push_str(&marker);
190    out.push('\n');
191    write!(&mut out, "### Fallow {title}\n\n").expect("write to string");
192    if count == 0 {
193        writeln!(
194            &mut out,
195            "No {provider} PR/MR findings.",
196            provider = input.provider.name()
197        )
198        .expect("write to string");
199    } else {
200        write!(&mut out, "Found **{count}** {noun}.\n\n").expect("write to string");
201        let groups = group_by_category(input.issues, input.category_for_rule);
202        if let [(_, group_issues)] = groups.as_slice() {
203            render_findings_table(&mut out, group_issues, input.max_comments, "Details");
204        } else {
205            for (category, group_issues) in &groups {
206                let summary_label = summary_label(category, group_issues.len(), input.max_comments);
207                render_findings_table(&mut out, group_issues, input.max_comments, &summary_label);
208            }
209        }
210    }
211    out.push_str("\nGenerated by fallow.");
212    out
213}
214
215/// Rule ids whose findings describe project-wide config state rather than a
216/// change touching a specific source line.
217pub const PROJECT_LEVEL_RULE_IDS: &[&str] = &[
218    "fallow/unused-catalog-entry",
219    "fallow/empty-catalog-group",
220    "fallow/unresolved-catalog-reference",
221    "fallow/unused-dependency-override",
222    "fallow/misconfigured-dependency-override",
223    "fallow/unused-dependency",
224    "fallow/unused-dev-dependency",
225    "fallow/unused-optional-dependency",
226    "fallow/type-only-dependency",
227    "fallow/test-only-dependency",
228    "fallow/dev-dependency-in-production",
229];
230
231#[must_use]
232pub fn is_project_level_rule(rule_id: &str) -> bool {
233    PROJECT_LEVEL_RULE_IDS.contains(&rule_id)
234}
235
236const CATEGORY_ORDER: [&str; 6] = [
237    "Dead code",
238    "Dependencies",
239    "Duplication",
240    "Health",
241    "Architecture",
242    "Suppressions",
243];
244
245fn group_by_category<'a>(
246    issues: &'a [CiIssue],
247    category_for_rule: &dyn Fn(&str) -> &'static str,
248) -> Vec<(&'static str, Vec<&'a CiIssue>)> {
249    let mut buckets: std::collections::BTreeMap<&'static str, Vec<&CiIssue>> =
250        std::collections::BTreeMap::new();
251    for issue in issues {
252        let category = category_for_rule(&issue.rule_id);
253        buckets.entry(category).or_default().push(issue);
254    }
255    let mut ordered: Vec<(&'static str, Vec<&CiIssue>)> = Vec::with_capacity(buckets.len());
256    for category in CATEGORY_ORDER {
257        if let Some(items) = buckets.remove(category) {
258            ordered.push((category, items));
259        }
260    }
261    for (category, items) in buckets {
262        ordered.push((category, items));
263    }
264    ordered
265}
266
267#[must_use]
268pub fn summary_label(category: &str, total: usize, max: usize) -> String {
269    if total > max {
270        format!("{category} ({total}, showing {max})")
271    } else {
272        format!("{category} ({total})")
273    }
274}
275
276#[expect(clippy::expect_used, reason = "formatting into String is infallible")]
277fn render_findings_table(out: &mut String, issues: &[&CiIssue], max: usize, summary: &str) {
278    writeln!(out, "<details>\n<summary>{summary}</summary>\n").expect("write to string");
279    out.push_str("| Severity | Rule | Location | Description |\n");
280    out.push_str("| --- | --- | --- | --- |\n");
281    for issue in issues.iter().take(max) {
282        writeln!(
283            out,
284            "| {} | `{}` | `{}`:{} | {} |",
285            escape_md(&issue.severity),
286            escape_md(&issue.rule_id),
287            escape_md(&issue.path),
288            issue.line,
289            escape_md(&issue.description),
290        )
291        .expect("write to string");
292    }
293    if issues.len() > max {
294        writeln!(
295            out,
296            "\nShowing {max} of {} findings. Run fallow locally or inspect the CI output for the full report.",
297            issues.len(),
298        )
299        .expect("write to string");
300    }
301    out.push_str("\n</details>\n\n");
302}
303
304#[must_use]
305pub fn command_title(command: &str) -> &'static str {
306    match command {
307        "dead-code" | "check" => "dead-code report",
308        "dupes" => "duplication report",
309        "health" => "health report",
310        "audit" => "audit report",
311        "" | "combined" => "combined report",
312        _ => "report",
313    }
314}
315
316/// Escape a string for inclusion in a Markdown table cell.
317#[must_use]
318pub fn escape_md(value: &str) -> String {
319    let collapsed = value.replace('\n', " ");
320    let mut out = String::with_capacity(collapsed.len());
321    for ch in collapsed.chars() {
322        if matches!(
323            ch,
324            '\\' | '`'
325                | '*'
326                | '_'
327                | '['
328                | ']'
329                | '('
330                | ')'
331                | '!'
332                | '<'
333                | '>'
334                | '#'
335                | '|'
336                | '~'
337                | '&'
338        ) {
339            out.push('\\');
340        }
341        out.push(ch);
342    }
343    out.trim().to_owned()
344}
345
346/// Render a provider-specific review envelope from typed CI issues.
347#[must_use]
348pub fn render_review_envelope(input: &ReviewEnvelopeRenderInput<'_>) -> ReviewEnvelopeRenderResult {
349    let grouped = group_review_issues_by_path_line(input.issues, input.max_comments);
350
351    let comments: Vec<ReviewComment> = grouped
352        .groups
353        .iter()
354        .map(|group| {
355            render_review_comment_for_group(&ReviewCommentRenderInput {
356                provider: input.provider,
357                group,
358                gitlab_diff_refs: input.gitlab_diff_refs,
359                diff_index: input.diff_index,
360                include_guidance: input.include_guidance,
361                suggestion_block: input.suggestion_block,
362                guidance_block: input.guidance_block,
363            })
364        })
365        .collect();
366
367    let summary_text =
368        review_summary_text(input.command, input.provider, comments.len(), input.issues);
369    let summary_fp = summary_fingerprint(&summary_text);
370    let summary_marker = format!("\n\n{MARKER_PREFIX_V2}{summary_fp}{MARKER_SUFFIX_V2}");
371    let body = format!("{summary_text}{summary_marker}");
372    let summary = ReviewEnvelopeSummary {
373        body: body.clone(),
374        fingerprint: summary_fp,
375    };
376
377    let truncation = ReviewEnvelopeTruncation {
378        body: comments.iter().any(review_comment_truncated),
379        comment_limit: grouped.truncated,
380    };
381
382    ReviewEnvelopeRenderResult {
383        envelope: build_review_envelope_output(
384            input.provider,
385            body,
386            summary,
387            comments,
388            input.issues,
389        ),
390        truncation,
391    }
392}
393
394fn review_summary_text(
395    command: &str,
396    provider: CiProvider,
397    comment_count: usize,
398    issues: &[CiIssue],
399) -> String {
400    let verdict = review_summary_verdict(issues);
401    format!(
402        "### Fallow {}\n\n**{}**\n\n{} inline finding{} selected for {} review.\n\n<!-- fallow-review -->",
403        command_title(command),
404        verdict,
405        comment_count,
406        if comment_count == 1 { "" } else { "s" },
407        provider.name(),
408    )
409}
410
411fn review_summary_verdict(issues: &[CiIssue]) -> &'static str {
412    match github_check_conclusion(issues) {
413        ReviewCheckConclusion::Failure => "Quality gate failed",
414        ReviewCheckConclusion::Neutral => "Review needed",
415        ReviewCheckConclusion::Success => "Quality gate passed",
416    }
417}
418
419#[derive(Debug, PartialEq, Eq)]
420pub struct GroupedReviewIssues<'a> {
421    pub groups: Vec<Vec<&'a CiIssue>>,
422    pub truncated: bool,
423}
424
425/// Group consecutive same-(path, line) issues. Input is already sorted by
426/// `(path, line, fingerprint)` so a single linear pass collects runs.
427#[must_use]
428pub fn group_review_issues_by_path_line(
429    issues: &[CiIssue],
430    max_groups: usize,
431) -> GroupedReviewIssues<'_> {
432    if max_groups == 0 {
433        return GroupedReviewIssues {
434            groups: Vec::new(),
435            truncated: !issues.is_empty(),
436        };
437    }
438    let mut groups: Vec<Vec<&CiIssue>> = Vec::with_capacity(max_groups.min(issues.len()));
439    let mut current: Vec<&CiIssue> = Vec::new();
440    let mut current_key: Option<(&str, u64)> = None;
441    for issue in issues {
442        let key = (issue.path.as_str(), issue.line);
443        if Some(key) != current_key {
444            if !current.is_empty() {
445                groups.push(std::mem::take(&mut current));
446                if groups.len() == max_groups {
447                    return GroupedReviewIssues {
448                        groups,
449                        truncated: true,
450                    };
451                }
452            }
453            current_key = Some(key);
454        }
455        current.push(issue);
456    }
457    if !current.is_empty() && groups.len() < max_groups {
458        groups.push(current);
459    }
460    GroupedReviewIssues {
461        groups,
462        truncated: false,
463    }
464}
465
466fn review_comment_truncated(comment: &ReviewComment) -> bool {
467    match comment {
468        ReviewComment::GitHub(comment) => comment.truncated,
469        ReviewComment::GitLab(comment) => comment.truncated,
470    }
471}
472
473pub struct ReviewCommentRenderInput<'a, 'group> {
474    pub provider: CiProvider,
475    pub group: &'a [&'group CiIssue],
476    pub gitlab_diff_refs: Option<&'a ReviewGitlabDiffRefs>,
477    pub diff_index: Option<&'a DiffIndex>,
478    pub include_guidance: bool,
479    pub suggestion_block: &'a dyn Fn(CiProvider, &CiIssue) -> Option<String>,
480    pub guidance_block: &'a dyn Fn(&CiIssue) -> Option<String>,
481}
482
483/// Render one comment from a group of issues sharing the same `(path, line)`.
484#[must_use]
485pub fn render_review_comment_for_group(input: &ReviewCommentRenderInput<'_, '_>) -> ReviewComment {
486    assert!(
487        !input.group.is_empty(),
488        "group_review_issues_by_path_line never yields empty"
489    );
490    let representative = input.group[0];
491    let fingerprint = if input.group.len() == 1 {
492        representative.fingerprint.clone()
493    } else {
494        let constituents: Vec<&str> = input.group.iter().map(|i| i.fingerprint.as_str()).collect();
495        composite_fingerprint(&constituents)
496    };
497
498    let content = build_merged_comment_content(input);
499    let marker_line = format!("\n\n{MARKER_PREFIX_V2}{fingerprint}{MARKER_SUFFIX_V2}");
500    let (body, truncated) = cap_body_with_marker(&content, &marker_line);
501
502    build_review_comment(ReviewCommentInput {
503        provider: input.provider,
504        representative,
505        gitlab_diff_refs: input.gitlab_diff_refs,
506        diff_index: input.diff_index,
507        body,
508        fingerprint,
509        truncated,
510    })
511}
512
513#[expect(clippy::expect_used, reason = "formatting into String is infallible")]
514fn build_merged_comment_content(input: &ReviewCommentRenderInput<'_, '_>) -> String {
515    let mut content = String::new();
516    for (index, issue) in input.group.iter().enumerate() {
517        let label = review_label_from_codeclimate(&issue.severity);
518        if index > 0 {
519            content.push_str("\n\n");
520        }
521        write!(
522            content,
523            "**{}** `{}`: {}",
524            label,
525            escape_md(&issue.rule_id),
526            escape_md(&issue.description)
527        )
528        .expect("write to String is infallible");
529        if let Some(suggestion) = (input.suggestion_block)(input.provider, issue) {
530            content.push_str(&suggestion);
531        }
532        if input.include_guidance
533            && let Some(guidance) = (input.guidance_block)(issue)
534        {
535            content.push_str(&guidance);
536        }
537    }
538    content
539}
540
541struct ReviewCommentInput<'a> {
542    provider: CiProvider,
543    representative: &'a CiIssue,
544    gitlab_diff_refs: Option<&'a ReviewGitlabDiffRefs>,
545    diff_index: Option<&'a DiffIndex>,
546    body: String,
547    fingerprint: String,
548    truncated: bool,
549}
550
551fn build_review_comment(input: ReviewCommentInput<'_>) -> ReviewComment {
552    let ReviewCommentInput {
553        provider,
554        representative,
555        gitlab_diff_refs,
556        diff_index,
557        body,
558        fingerprint,
559        truncated,
560    } = input;
561    match provider {
562        CiProvider::Github => ReviewComment::GitHub(GitHubReviewComment {
563            path: representative.path.clone(),
564            line: u32::try_from(representative.line).unwrap_or(u32::MAX),
565            side: GitHubReviewSide::Right,
566            body,
567            fingerprint,
568            truncated,
569        }),
570        CiProvider::Gitlab => {
571            let new_path = representative.path.clone();
572            let old_path = diff_index
573                .and_then(|di| di.old_path_for(&new_path))
574                .map_or_else(|| new_path.clone(), str::to_owned);
575            let position = GitLabReviewPosition {
576                base_sha: gitlab_diff_refs.map(|r| r.base_sha.clone()),
577                start_sha: gitlab_diff_refs.map(|r| r.start_sha.clone()),
578                head_sha: gitlab_diff_refs.map(|r| r.head_sha.clone()),
579                position_type: GitLabReviewPositionType::Text,
580                old_path,
581                new_path,
582                new_line: u32::try_from(representative.line).unwrap_or(u32::MAX),
583            };
584            ReviewComment::GitLab(GitLabReviewComment {
585                body,
586                position,
587                fingerprint,
588                truncated,
589            })
590        }
591    }
592}
593
594#[must_use]
595pub fn cap_body_with_marker(content: &str, marker_line: &str) -> (String, bool) {
596    let intact_len = content.len() + marker_line.len();
597    if intact_len <= MAX_COMMENT_BODY_BYTES {
598        let mut out = String::with_capacity(intact_len);
599        out.push_str(content);
600        out.push_str(marker_line);
601        return (out, false);
602    }
603    let reserved = marker_line.len() + TRUNCATION_SUFFIX.len();
604    let budget = MAX_COMMENT_BODY_BYTES.saturating_sub(reserved);
605    let mut cut = budget.min(content.len());
606    while cut > 0 && !content.is_char_boundary(cut) {
607        cut -= 1;
608    }
609    let mut out = String::with_capacity(MAX_COMMENT_BODY_BYTES);
610    out.push_str(&content[..cut]);
611    out.push_str(TRUNCATION_SUFFIX);
612    out.push_str(marker_line);
613    (out, true)
614}
615
616#[must_use]
617pub const fn review_label_from_codeclimate(severity_name: &str) -> &'static str {
618    match severity_name.as_bytes() {
619        b"major" | b"critical" | b"blocker" => "error",
620        _ => "warn",
621    }
622}
623
624#[must_use]
625pub fn github_check_conclusion(issues: &[CiIssue]) -> ReviewCheckConclusion {
626    if issues
627        .iter()
628        .any(|issue| matches!(issue.severity.as_str(), "major" | "critical" | "blocker"))
629    {
630        ReviewCheckConclusion::Failure
631    } else if issues.is_empty() {
632        ReviewCheckConclusion::Success
633    } else {
634        ReviewCheckConclusion::Neutral
635    }
636}
637
638fn build_review_envelope_output(
639    provider: CiProvider,
640    body: String,
641    summary: ReviewEnvelopeSummary,
642    comments: Vec<ReviewComment>,
643    issues: &[CiIssue],
644) -> ReviewEnvelopeOutput {
645    match provider {
646        CiProvider::Github => ReviewEnvelopeOutput {
647            event: Some(ReviewEnvelopeEvent::Comment),
648            body,
649            summary,
650            comments,
651            marker_regex: default_marker_regex(),
652            marker_regex_flags: default_marker_regex_flags(),
653            meta: ReviewEnvelopeMeta {
654                schema: ReviewEnvelopeSchema::V2,
655                provider: ReviewProvider::Github,
656                check_conclusion: Some(github_check_conclusion(issues)),
657            },
658        },
659        CiProvider::Gitlab => ReviewEnvelopeOutput {
660            event: None,
661            body,
662            summary,
663            comments,
664            marker_regex: default_marker_regex(),
665            marker_regex_flags: default_marker_regex_flags(),
666            meta: ReviewEnvelopeMeta {
667                schema: ReviewEnvelopeSchema::V2,
668                provider: ReviewProvider::Gitlab,
669                check_conclusion: None,
670            },
671        },
672    }
673}
674
675#[must_use]
676pub fn summary_fingerprint(body: &str) -> String {
677    fingerprint_hash(&[body])
678}
679
680#[must_use]
681pub fn composite_fingerprint(constituents: &[&str]) -> String {
682    let mut sorted: Vec<&str> = constituents.to_vec();
683    sorted.sort_unstable();
684    let joined = sorted.join(":");
685    format!("merged:{}", fingerprint_hash(&[joined.as_str()]))
686}
687
688#[cfg(test)]
689mod tests {
690    use super::*;
691    use crate::{CodeClimateIssueKind, CodeClimateLines, CodeClimateLocation};
692
693    fn category_for_rule(rule_id: &str) -> &'static str {
694        match rule_id {
695            "fallow/code-duplication" => "Duplication",
696            "fallow/high-complexity" => "Health",
697            "fallow/unused-dependency" => "Dependencies",
698            _ => "Dead code",
699        }
700    }
701
702    #[test]
703    fn extracts_issues_from_codeclimate() {
704        let value = serde_json::json!([{
705            "check_name": "fallow/unused-export",
706            "description": "Export x is never imported",
707            "severity": "minor",
708            "fingerprint": "abc",
709            "location": { "path": "src/a.ts", "lines": { "begin": 7 } }
710        }]);
711        let issues = issues_from_codeclimate(&value);
712        assert_eq!(issues.len(), 1);
713        assert_eq!(issues[0].path, "src/a.ts");
714        assert_eq!(issues[0].line, 7);
715    }
716
717    #[test]
718    fn typed_codeclimate_issues_extract_like_json_codeclimate() {
719        let severities = [
720            (CodeClimateSeverity::Info, "info"),
721            (CodeClimateSeverity::Minor, "minor"),
722            (CodeClimateSeverity::Major, "major"),
723            (CodeClimateSeverity::Critical, "critical"),
724            (CodeClimateSeverity::Blocker, "blocker"),
725        ];
726        let typed = severities
727            .iter()
728            .enumerate()
729            .map(|(index, (severity, _))| CodeClimateIssue {
730                kind: CodeClimateIssueKind::Issue,
731                check_name: format!("fallow/rule-{index}"),
732                description: format!("Finding {index}"),
733                categories: vec!["Complexity".to_owned()],
734                severity: *severity,
735                fingerprint: format!("fp-{index}"),
736                location: CodeClimateLocation {
737                    path: format!("src/{index}.ts"),
738                    lines: CodeClimateLines {
739                        begin: u32::try_from(index + 1).expect("small fixture index"),
740                    },
741                },
742                owner: None,
743                group: None,
744            })
745            .collect::<Vec<_>>();
746        let value = serde_json::to_value(&typed).expect("typed fixture serializes");
747
748        assert_eq!(
749            issues_from_codeclimate_issues(&typed),
750            issues_from_codeclimate(&value)
751        );
752        let typed_labels = issues_from_codeclimate_issues(&typed)
753            .into_iter()
754            .map(|issue| issue.severity)
755            .collect::<Vec<_>>();
756        let expected_labels = severities
757            .iter()
758            .map(|(_, label)| (*label).to_owned())
759            .collect::<Vec<_>>();
760        assert_eq!(typed_labels, expected_labels);
761    }
762
763    #[test]
764    fn renders_default_empty_comment() {
765        let body = render_pr_comment(&PrCommentRenderInput {
766            command: "check",
767            provider: CiProvider::Github,
768            issues: &[],
769            marker_id: "fallow-results".to_owned(),
770            max_comments: 50,
771            category_for_rule: &category_for_rule,
772        });
773        assert!(body.contains("<!-- fallow-id: fallow-results"));
774        assert!(body.contains("No GitHub PR/MR findings."));
775    }
776
777    #[test]
778    fn escape_md_escapes_inline_commonmark_specials() {
779        let raw = "foo*bar_baz [a](u) `c` <h> #x !i ~s | p";
780        let escaped = escape_md(raw);
781        for ch in [
782            '*', '_', '[', ']', '(', ')', '`', '<', '>', '#', '!', '~', '|',
783        ] {
784            let raw_count = raw.chars().filter(|c| c == &ch).count();
785            let escaped_count = escaped.matches(&format!("\\{ch}")).count();
786            assert_eq!(
787                raw_count, escaped_count,
788                "char {ch:?}: raw {raw_count} occurrences, escaped {escaped_count} in {escaped:?}"
789            );
790        }
791    }
792
793    #[test]
794    fn escape_md_escapes_ampersand_to_block_numeric_entity_bypass() {
795        let raw = "value &#42;suspicious&#42; here";
796        let escaped = escape_md(raw);
797        assert!(escaped.contains(r"\&"), "got: {escaped}");
798        assert!(escaped.contains(r"\#"), "got: {escaped}");
799        assert!(!escaped.contains(" *suspicious"), "got: {escaped}");
800    }
801
802    #[test]
803    fn summary_label_foreshadows_truncation() {
804        assert_eq!(
805            summary_label("Duplication", 160, 50),
806            "Duplication (160, showing 50)"
807        );
808        assert_eq!(summary_label("Health", 12, 50), "Health (12)");
809        assert_eq!(summary_label("Dependencies", 50, 50), "Dependencies (50)");
810    }
811
812    #[test]
813    fn escape_md_does_not_escape_block_only_markers() {
814        let raw = "fallow/test-only-dependency package.json:12";
815        let escaped = escape_md(raw);
816        assert!(!escaped.contains("\\-"), "should not escape `-`");
817        assert!(!escaped.contains("\\."), "should not escape `.`");
818        assert_eq!(escaped, raw);
819    }
820
821    #[test]
822    fn escape_md_collapses_newlines_to_spaces() {
823        let raw = "first\nsecond\nthird";
824        assert_eq!(escape_md(raw), "first second third");
825    }
826
827    #[test]
828    fn escape_md_leaves_safe_chars_unchanged() {
829        let raw = "Export 'helperFn' is never imported by other modules";
830        assert_eq!(
831            escape_md(raw),
832            r"Export 'helperFn' is never imported by other modules"
833        );
834    }
835
836    #[test]
837    fn is_project_level_rule_covers_config_anchored_dependency_findings() {
838        for rule_id in PROJECT_LEVEL_RULE_IDS {
839            assert!(
840                is_project_level_rule(rule_id),
841                "{rule_id} must be project-level"
842            );
843        }
844        for rule_id in [
845            "fallow/unused-file",
846            "fallow/unused-export",
847            "fallow/unused-type",
848            "fallow/unused-enum-member",
849            "fallow/unused-class-member",
850            "fallow/unused-store-member",
851            "fallow/unresolved-import",
852            "fallow/unlisted-dependency",
853            "fallow/duplicate-export",
854            "fallow/circular-dependency",
855            "fallow/re-export-cycle",
856            "fallow/boundary-violation",
857            "fallow/stale-suppression",
858            "fallow/private-type-leak",
859            "fallow/high-complexity",
860            "fallow/high-crap-score",
861        ] {
862            assert!(
863                !is_project_level_rule(rule_id),
864                "{rule_id} must NOT be project-level"
865            );
866        }
867    }
868
869    #[test]
870    fn escape_md_double_apply_is_safe() {
871        let raw = "code with `backticks` and *stars*";
872        let once = escape_md(raw);
873        let twice = escape_md(&once);
874        assert!(twice.contains(r"\\"));
875    }
876}