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