Skip to main content

fallow_cli/report/ci/
review.rs

1use std::process::ExitCode;
2
3use fallow_output::CodeClimateIssue;
4
5use super::diff_filter::DiffIndex;
6use crate::report::emit_json;
7use fallow_output::PrDecisionConclusion;
8use fallow_output::{
9    CiIssue, CiProvider as Provider, ReviewCheckConclusion, ReviewEnvelopeOutput,
10    ReviewEnvelopeRenderInput, ReviewEnvelopeTruncation, ReviewGitlabDiffRefs as GitlabDiffRefs,
11    ReviewId, issues_from_codeclimate_issues,
12};
13
14/// The text a review summary body carries beside its inline-comment line.
15///
16/// The same two parts the sticky comment carries, so a setup that posts
17/// only the review (`FALLOW_REVIEW=true`, `FALLOW_COMMENT=false`) sees them
18/// when it posts a review. The review does not carry the gate rows: a review
19/// is a point-in-time record, and the gate result travels on
20/// `meta.check_conclusion`.
21#[derive(Clone, Copy, Default)]
22pub struct ReviewSummaryNotes<'a> {
23    /// The blockquote note: the type-aware message, the baseline advisory,
24    /// the gate inventory, or any combination.
25    pub message: Option<&'a str>,
26    /// The run diagnostics. The unmatched config patterns among them become
27    /// a Markdown section, as in `--format markdown` and the sticky comment.
28    pub config_patterns: &'a [fallow_config::WorkspaceDiagnostic],
29}
30
31impl<'a> From<super::pr_comment::PrCommentStatus<'a>> for ReviewSummaryNotes<'a> {
32    fn from(status: super::pr_comment::PrCommentStatus<'a>) -> Self {
33        Self {
34            message: status.message,
35            config_patterns: status.config_patterns,
36        }
37    }
38}
39
40#[must_use]
41pub fn render_review_envelope(
42    command: &str,
43    provider: Provider,
44    issues: &[CiIssue],
45) -> ReviewEnvelopeOutput {
46    render_review_envelope_with_diff(
47        command,
48        provider,
49        issues,
50        super::diff_filter::shared_diff_index(),
51        None,
52        None,
53        ReviewSummaryNotes::default(),
54    )
55}
56
57/// Render path the print site uses. Exposed so unit tests can pass a
58/// hand-crafted `DiffIndex` without poking the process-wide `SHARED_DIFF`
59/// cache (which is `OnceLock`-bounded and not reentrant under cargo test's
60/// parallel runner).
61#[must_use]
62#[expect(
63    clippy::too_many_arguments,
64    reason = "the private render seam keeps test-injected diff and optional gate state explicit"
65)]
66fn render_review_envelope_with_diff(
67    command: &str,
68    provider: Provider,
69    issues: &[CiIssue],
70    diff_index: Option<&DiffIndex>,
71    review_id: Option<&ReviewId>,
72    conclusion: Option<ReviewCheckConclusion>,
73    notes: ReviewSummaryNotes<'_>,
74) -> ReviewEnvelopeOutput {
75    let max = std::env::var("FALLOW_MAX_COMMENTS")
76        .ok()
77        .and_then(|v| v.parse::<usize>().ok())
78        .unwrap_or(50);
79    let gitlab_diff_refs = (provider == Provider::Gitlab)
80        .then(gitlab_diff_refs_from_env)
81        .flatten();
82    let include_guidance = review_guidance_enabled();
83    let config_section =
84        crate::report::config_pattern_text::markdown_section(notes.config_patterns);
85    let status_message = notes.message;
86
87    let input = ReviewEnvelopeRenderInput {
88        command,
89        provider,
90        issues,
91        diff_index,
92        path_prefix: crate::report::github::report_prefix(),
93        max_comments: max,
94        gitlab_diff_refs: gitlab_diff_refs.as_ref(),
95        include_guidance,
96        suggestion_block: &super::suggestion::suggestion_block,
97        guidance_block: &review_guidance_block,
98        trailing_section: config_section.as_deref(),
99    };
100    let rendered = match (review_id, conclusion) {
101        (Some(review_id), Some(conclusion)) => {
102            fallow_output::render_scoped_review_envelope_with_conclusion(
103                &input,
104                review_id,
105                conclusion,
106                status_message,
107            )
108        }
109        (Some(review_id), None) => {
110            fallow_output::render_scoped_review_envelope(&input, review_id, status_message)
111        }
112        (None, Some(conclusion)) => fallow_output::render_review_envelope_with_conclusion(
113            &input,
114            conclusion,
115            status_message,
116        ),
117        (None, None) => fallow_output::render_review_envelope(&input, status_message),
118    };
119    note_review_truncation(rendered.truncation);
120    rendered.envelope
121}
122
123/// Record telemetry for body-size or comment-count truncation of the review.
124fn note_review_truncation(truncation: ReviewEnvelopeTruncation) {
125    if truncation.body {
126        crate::telemetry::note_report_truncation(
127            true,
128            crate::telemetry::TruncationReason::SizeLimit,
129        );
130    } else if truncation.comment_limit {
131        crate::telemetry::note_report_truncation(
132            true,
133            crate::telemetry::TruncationReason::CommentLimit,
134        );
135    } else {
136        crate::telemetry::note_report_truncation(
137            false,
138            crate::telemetry::TruncationReason::Unknown,
139        );
140    }
141}
142
143#[must_use]
144pub(crate) fn print_review_envelope_from_codeclimate_issues(
145    command: &str,
146    provider: Provider,
147    codeclimate: &[CodeClimateIssue],
148    notes: ReviewSummaryNotes<'_>,
149) -> ExitCode {
150    let issues =
151        super::diff_filter::filter_issues_from_env(issues_from_codeclimate_issues(codeclimate));
152    print_review_envelope_from_ci_issues(command, provider, &issues, None, notes)
153}
154
155#[must_use]
156pub(crate) fn print_review_envelope_from_codeclimate_issues_with_conclusion(
157    command: &str,
158    provider: Provider,
159    codeclimate: &[CodeClimateIssue],
160    conclusion: PrDecisionConclusion,
161    notes: ReviewSummaryNotes<'_>,
162) -> ExitCode {
163    let issues =
164        super::diff_filter::filter_issues_from_env(issues_from_codeclimate_issues(codeclimate));
165    print_review_envelope_from_ci_issues(
166        command,
167        provider,
168        &issues,
169        Some(review_conclusion(conclusion)),
170        notes,
171    )
172}
173
174#[must_use]
175fn print_review_envelope_from_ci_issues(
176    command: &str,
177    provider: Provider,
178    issues: &[CiIssue],
179    conclusion: Option<ReviewCheckConclusion>,
180    notes: ReviewSummaryNotes<'_>,
181) -> ExitCode {
182    let review_id = match review_id_from_env() {
183        Ok(review_id) => review_id,
184        Err(error) => {
185            eprintln!("Error: {error}");
186            return ExitCode::from(2);
187        }
188    };
189    let envelope = render_review_envelope_with_diff(
190        command,
191        provider,
192        issues,
193        super::diff_filter::shared_diff_index(),
194        review_id.as_ref(),
195        conclusion,
196        notes,
197    );
198    let analysis_run_id = crate::output_runtime::telemetry_analysis_run_id();
199    let value = match review_id.as_ref() {
200        Some(review_id) => fallow_output::serialize_scoped_review_envelope_json_output(
201            &envelope,
202            review_id,
203            analysis_run_id.as_deref(),
204        ),
205        None => fallow_output::serialize_review_envelope_json_output(
206            envelope,
207            analysis_run_id.as_deref(),
208        )
209        .map_err(|error| error.to_string()),
210    };
211    let value = match value {
212        Ok(value) => value,
213        Err(error) => {
214            eprintln!("Error: failed to serialize review envelope: {error}");
215            return ExitCode::from(2);
216        }
217    };
218    emit_json(&value, "review envelope")
219}
220
221/// Translate a gate conclusion into the review/check vocabulary. Shared with
222/// the sticky comment renderer so both surfaces phrase one gate outcome the
223/// same way.
224pub(crate) const fn review_conclusion(conclusion: PrDecisionConclusion) -> ReviewCheckConclusion {
225    match conclusion {
226        PrDecisionConclusion::Success => ReviewCheckConclusion::Success,
227        PrDecisionConclusion::Failure => ReviewCheckConclusion::Failure,
228        PrDecisionConclusion::Neutral | PrDecisionConclusion::Skipped => {
229            ReviewCheckConclusion::Neutral
230        }
231    }
232}
233
234fn review_id_from_env() -> Result<Option<ReviewId>, String> {
235    let Some(value) = env_nonempty("FALLOW_REVIEW_ID") else {
236        return Ok(None);
237    };
238    ReviewId::parse(value)
239        .map(Some)
240        .map_err(|error| format!("invalid FALLOW_REVIEW_ID: {error}"))
241}
242
243fn gitlab_diff_refs_from_env() -> Option<GitlabDiffRefs> {
244    let base_sha = env_nonempty("FALLOW_GITLAB_BASE_SHA")
245        .or_else(|| env_nonempty("CI_MERGE_REQUEST_DIFF_BASE_SHA"))?;
246    let start_sha = env_nonempty("FALLOW_GITLAB_START_SHA").unwrap_or_else(|| base_sha.clone());
247    let head_sha =
248        env_nonempty("FALLOW_GITLAB_HEAD_SHA").or_else(|| env_nonempty("CI_COMMIT_SHA"))?;
249    Some(GitlabDiffRefs {
250        base_sha,
251        start_sha,
252        head_sha,
253    })
254}
255
256fn env_nonempty(name: &str) -> Option<String> {
257    std::env::var(name)
258        .ok()
259        .filter(|value| !value.trim().is_empty())
260}
261
262fn review_guidance_enabled() -> bool {
263    std::env::var("FALLOW_REVIEW_GUIDANCE").is_ok_and(|value| env_truthy(&value))
264}
265
266fn env_truthy(value: &str) -> bool {
267    matches!(
268        value.trim().to_ascii_lowercase().as_str(),
269        "1" | "true" | "yes" | "on"
270    )
271}
272
273fn review_guidance_block(issue: &CiIssue) -> Option<String> {
274    let rule = crate::explain::rule_by_id(&issue.rule_id)?;
275    let guide = crate::explain::rule_guide(rule);
276    let docs_url = crate::explain::rule_docs_url(rule);
277
278    Some(format!(
279        "\n\n<details><summary>What to do</summary>\n\n{}\n\n[Read the rule docs]({docs_url})\n\n</details>",
280        guide.how_to_fix
281    ))
282}
283
284#[cfg(test)]
285fn render_merged_comment(
286    provider: Provider,
287    group: &[&CiIssue],
288    gitlab_diff_refs: Option<&GitlabDiffRefs>,
289    diff_index: Option<&DiffIndex>,
290    include_guidance: bool,
291) -> fallow_output::ReviewComment {
292    fallow_output::render_review_comment_for_group(&fallow_output::ReviewCommentRenderInput {
293        provider,
294        group,
295        gitlab_diff_refs,
296        diff_index,
297        path_prefix: "",
298        include_guidance,
299        suggestion_block: &super::suggestion::suggestion_block,
300        guidance_block: &review_guidance_block,
301    })
302}
303
304#[cfg(test)]
305fn group_by_path_line(
306    issues: &[CiIssue],
307    max_groups: usize,
308) -> fallow_output::GroupedReviewIssues<'_> {
309    fallow_output::group_review_issues_by_path_line(issues, max_groups)
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315    use fallow_output::{MARKER_PREFIX_V3, MARKER_SUFFIX_V3, MAX_COMMENT_BODY_BYTES};
316    use fallow_output::{MARKER_REGEX_V3, ReviewComment};
317    use serde_json::Value;
318
319    fn to_value(envelope: &ReviewEnvelopeOutput) -> Value {
320        serde_json::to_value(envelope).expect("ReviewEnvelopeOutput serializes infallibly")
321    }
322
323    fn comment_to_value(comment: &ReviewComment) -> Value {
324        serde_json::to_value(comment).expect("ReviewComment serializes infallibly")
325    }
326
327    fn issue(rule: &str, sev: &str, path: &str, line: u64, fp: &str) -> CiIssue {
328        CiIssue {
329            rule_id: rule.into(),
330            description: "desc".into(),
331            severity: sev.into(),
332            path: path.into(),
333            line,
334            end_line: None,
335            other_locations: Vec::new(),
336            fingerprint: fp.into(),
337            legacy_fingerprint: None,
338        }
339    }
340
341    fn issue_with_desc(
342        rule: &str,
343        desc: impl Into<String>,
344        sev: &str,
345        path: &str,
346        line: u64,
347        fp: &str,
348    ) -> CiIssue {
349        CiIssue {
350            rule_id: rule.into(),
351            description: desc.into(),
352            severity: sev.into(),
353            path: path.into(),
354            line,
355            end_line: None,
356            other_locations: Vec::new(),
357            fingerprint: fp.into(),
358            legacy_fingerprint: None,
359        }
360    }
361
362    #[test]
363    fn scoped_renderer_uses_typed_meta_and_exact_markers() {
364        let issue = issue(
365            "fallow/unused-file",
366            "minor",
367            "src/a.ts",
368            1,
369            "abcdef0123456789",
370        );
371        let review_id = ReviewId::parse("frontend").unwrap();
372        let envelope = render_review_envelope_with_diff(
373            "check",
374            Provider::Github,
375            &[issue],
376            None,
377            Some(&review_id),
378            None,
379            ReviewSummaryNotes::default(),
380        );
381        let envelope = fallow_output::serialize_scoped_review_envelope_json_output(
382            &envelope, &review_id, None,
383        )
384        .unwrap();
385
386        assert_eq!(envelope["meta"]["review_id"], "frontend");
387        assert!(
388            envelope["body"]
389                .as_str()
390                .unwrap()
391                .ends_with("<!-- fallow-review-id: frontend -->")
392        );
393        assert!(
394            envelope["comments"][0]["body"]
395                .as_str()
396                .unwrap()
397                .ends_with("<!-- fallow-review-id: frontend -->")
398        );
399    }
400
401    #[test]
402    fn github_review_envelope_matches_api_shape() {
403        let issues = vec![issue(
404            "fallow/unused-file",
405            "minor",
406            "src/a.ts",
407            1,
408            "abc1234567890def",
409        )];
410        let envelope = to_value(&render_review_envelope("check", Provider::Github, &issues));
411        assert_eq!(envelope["event"], "COMMENT");
412        assert_eq!(envelope["meta"]["schema"], "fallow-review-envelope/v3");
413        assert_eq!(envelope["comments"][0]["path"], "src/a.ts");
414        assert!(
415            envelope["comments"][0]["body"]
416                .as_str()
417                .unwrap()
418                .contains("fallow-fingerprint:v3:")
419        );
420    }
421
422    #[test]
423    fn review_summary_body_points_at_the_inline_comments() {
424        let issues = vec![issue(
425            "fallow/unused-file",
426            "major",
427            "src/a.ts",
428            1,
429            "abc1234567890def",
430        )];
431        let envelope = to_value(&render_review_envelope(
432            "combined",
433            Provider::Github,
434            &issues,
435        ));
436        let body = envelope["body"].as_str().expect("body is string");
437
438        assert!(!body.contains("Quality gate"), "{body}");
439        assert!(
440            body.contains(
441                "1 inline comment on the changed lines. Open the Files changed tab to review."
442            ),
443            "{body}"
444        );
445        assert!(body.contains("<!-- fallow-review -->"), "{body}");
446        assert_eq!(
447            envelope["meta"]["check_conclusion"], "failure",
448            "{envelope}"
449        );
450    }
451
452    #[test]
453    fn review_summary_body_lists_unmatched_config_patterns_before_the_markers() {
454        let root = std::path::Path::new("/project");
455        let diagnostics = [fallow_config::WorkspaceDiagnostic::new(
456            root,
457            root.to_path_buf(),
458            fallow_config::WorkspaceDiagnosticKind::IgnoreDependenciesGlobUnmatched {
459                pattern: "@typo/*".to_owned(),
460            },
461        )];
462        let envelope = to_value(&render_review_envelope_with_diff(
463            "dead-code",
464            Provider::Gitlab,
465            &[],
466            None,
467            None,
468            None,
469            ReviewSummaryNotes {
470                message: Some("Baseline advisory."),
471                config_patterns: &diagnostics,
472            },
473        ));
474        let body = envelope["body"].as_str().expect("body is string");
475        let note = body.find("> Baseline advisory.").expect("status note");
476        let section = body
477            .find("## Unmatched config patterns")
478            .expect("config pattern section");
479        let marker = body.find("<!-- fallow-review -->").expect("marker");
480        assert!(note < section && section < marker, "{body}");
481        assert!(
482            body.contains("- `ignoreDependencies`: `@typo/*` matched nothing in this run"),
483            "{body}"
484        );
485        assert_eq!(envelope["summary"]["body"], envelope["body"]);
486    }
487
488    #[test]
489    fn github_comments_target_current_state_side() {
490        let issue = issue("fallow/unused-file", "minor", "src/a.ts", 1, "abc");
491        let comment = comment_to_value(&render_merged_comment(
492            Provider::Github,
493            &[&issue],
494            None,
495            None,
496            false,
497        ));
498        assert_eq!(comment["side"], "RIGHT");
499    }
500
501    #[test]
502    fn labels_major_issues_as_errors() {
503        let issue = issue("fallow/unused-file", "major", "src/a.ts", 1, "abc");
504        let comment = comment_to_value(&render_merged_comment(
505            Provider::Github,
506            &[&issue],
507            None,
508            None,
509            false,
510        ));
511        assert!(comment["body"].as_str().unwrap().starts_with("**error**"));
512    }
513
514    #[test]
515    fn gitlab_comment_accepts_diff_refs() {
516        let issue = issue("fallow/unused-file", "minor", "src/a.ts", 1, "abc");
517        let refs = GitlabDiffRefs {
518            base_sha: "base".into(),
519            start_sha: "start".into(),
520            head_sha: "head".into(),
521        };
522        let comment = comment_to_value(&render_merged_comment(
523            Provider::Gitlab,
524            &[&issue],
525            Some(&refs),
526            None,
527            false,
528        ));
529        assert_eq!(comment["position"]["position_type"], "text");
530        assert_eq!(comment["position"]["base_sha"], "base");
531        assert_eq!(comment["position"]["start_sha"], "start");
532        assert_eq!(comment["position"]["head_sha"], "head");
533    }
534
535    #[test]
536    fn guidance_toggle_accepts_common_truthy_values() {
537        for value in ["1", "true", "TRUE", "yes", "on", " On "] {
538            assert!(env_truthy(value), "{value:?} should enable guidance");
539        }
540        for value in ["", "0", "false", "no", "off", "enabled"] {
541            assert!(!env_truthy(value), "{value:?} should not enable guidance");
542        }
543    }
544
545    #[test]
546    fn guidance_disabled_omits_details_block() {
547        let issue = issue(
548            "fallow/high-complexity",
549            "major",
550            "src/a.ts",
551            10,
552            "abc1234567890def",
553        );
554        let comment = comment_to_value(&render_merged_comment(
555            Provider::Github,
556            &[&issue],
557            None,
558            None,
559            false,
560        ));
561        let body = comment["body"].as_str().unwrap();
562        assert!(!body.contains("<details><summary>What to do</summary>"));
563        assert!(!body.contains("For function findings"));
564    }
565
566    #[test]
567    fn guidance_enabled_appends_rule_guide_details() {
568        let issue = issue(
569            "fallow/high-complexity",
570            "major",
571            "src/a.ts",
572            10,
573            "abc1234567890def",
574        );
575        let comment = comment_to_value(&render_merged_comment(
576            Provider::Github,
577            &[&issue],
578            None,
579            None,
580            true,
581        ));
582        let body = comment["body"].as_str().unwrap();
583        assert!(body.contains("<details><summary>What to do</summary>"));
584        assert!(body.contains("For function findings"));
585        assert!(body.contains("[Read the rule docs]("));
586        assert!(
587            body.find("</details>").unwrap() < body.find("fallow-fingerprint:v3:").unwrap(),
588            "guidance should render before the marker"
589        );
590    }
591
592    #[test]
593    fn guidance_attaches_to_each_merged_finding() {
594        let complexity = issue("fallow/high-complexity", "major", "src/foo.ts", 42, "fp_a");
595        let duplication = issue("fallow/code-duplication", "minor", "src/foo.ts", 42, "fp_b");
596        let comment = comment_to_value(&render_merged_comment(
597            Provider::Github,
598            &[&complexity, &duplication],
599            None,
600            None,
601            true,
602        ));
603        let body = comment["body"].as_str().unwrap();
604        assert_eq!(
605            body.matches("<details><summary>What to do</summary>")
606                .count(),
607            2
608        );
609        assert!(body.contains("For function findings"));
610        assert!(body.contains("Extract the shared logic"));
611    }
612
613    #[test]
614    fn envelope_emits_marker_regex_field_at_root() {
615        let issues = vec![issue("fallow/unused-file", "minor", "src/a.ts", 1, "abc")];
616        let env = to_value(&render_review_envelope("check", Provider::Github, &issues));
617        let regex = env["marker_regex"].as_str().expect("marker_regex present");
618        assert_eq!(regex, MARKER_REGEX_V3);
619        assert!(regex.contains("[0-9a-f]{16}"));
620        assert!(regex.starts_with('^'));
621        assert!(regex.ends_with("\\s*$"));
622        assert!(!regex.contains("(?m)"));
623        assert!(regex.contains("((?:[a-z]+:)?[0-9a-f]{16})"));
624        let flags = env["marker_regex_flags"]
625            .as_str()
626            .expect("marker_regex_flags present");
627        assert_eq!(flags, "m");
628    }
629
630    #[test]
631    fn envelope_emits_summary_block_with_fingerprint() {
632        let issues = vec![issue("fallow/unused-file", "minor", "src/a.ts", 1, "abc")];
633        let env = to_value(&render_review_envelope("check", Provider::Github, &issues));
634        assert_eq!(env["summary"]["body"], env["body"]);
635        let summary_fp = env["summary"]["fingerprint"].as_str().expect("fingerprint");
636        assert_eq!(summary_fp.len(), 16);
637        assert!(summary_fp.chars().all(|c| c.is_ascii_hexdigit()));
638        let body_str = env["body"].as_str().unwrap();
639        let marker_line = format!("{MARKER_PREFIX_V3}{summary_fp}{MARKER_SUFFIX_V3}");
640        assert!(
641            body_str.contains(&marker_line),
642            "body must carry summary marker:\nbody={body_str}\nmarker={marker_line}"
643        );
644    }
645
646    #[test]
647    fn same_line_findings_merge_into_one_comment_with_composite_fingerprint() {
648        let a = issue("fallow/unused-export", "minor", "src/foo.ts", 42, "fp_a");
649        let b = issue("fallow/duplicate-export", "minor", "src/foo.ts", 42, "fp_b");
650        let env = to_value(&render_review_envelope("check", Provider::Github, &[a, b]));
651        assert_eq!(
652            env["comments"].as_array().unwrap().len(),
653            1,
654            "two same-line findings must collapse to one comment"
655        );
656        let merged = &env["comments"][0];
657        let fp = merged["fingerprint"].as_str().unwrap();
658        assert!(
659            fp.starts_with("merged:"),
660            "merged comment fingerprint must start with merged:, got {fp}"
661        );
662        assert_eq!(fp.len(), 23);
663        let body = merged["body"].as_str().unwrap();
664        assert!(body.contains("fallow/unused-export"));
665        assert!(body.contains("fallow/duplicate-export"));
666        assert_eq!(
667            body.matches("fallow-fingerprint:v3:").count(),
668            1,
669            "merged body must carry exactly one fingerprint marker"
670        );
671        assert!(
672            merged.get("constituent_fingerprints").is_none(),
673            "v2 hashed-composite design does not emit constituent_fingerprints"
674        );
675    }
676
677    #[test]
678    fn group_by_path_line_respects_max_groups_without_splitting_same_line_findings() {
679        let a = issue("fallow/unused-export", "minor", "src/foo.ts", 42, "fp_a");
680        let b = issue("fallow/duplicate-export", "minor", "src/foo.ts", 42, "fp_b");
681        let c = issue("fallow/unused-type", "minor", "src/z.ts", 7, "fp_c");
682        let issues = vec![a, b, c];
683
684        let max_zero = group_by_path_line(&issues, 0);
685        assert!(max_zero.groups.is_empty());
686        assert!(max_zero.truncated);
687
688        let max_one = group_by_path_line(&issues, 1);
689        assert_eq!(max_one.groups.len(), 1);
690        assert!(max_one.truncated);
691        assert_eq!(max_one.groups[0].len(), 2);
692        assert_eq!(max_one.groups[0][0].path, "src/foo.ts");
693        assert_eq!(max_one.groups[0][0].line, 42);
694
695        let max_two = group_by_path_line(&issues, 2);
696        assert_eq!(max_two.groups.len(), 2);
697        assert!(!max_two.truncated);
698        assert_eq!(max_two.groups[0].len(), 2);
699        assert_eq!(max_two.groups[1].len(), 1);
700        assert_eq!(
701            max_two.groups[0]
702                .iter()
703                .map(|issue| issue.fingerprint.as_str())
704                .collect::<Vec<_>>(),
705            ["fp_a", "fp_b"]
706        );
707    }
708
709    #[test]
710    fn single_finding_keeps_v1_fingerprint_shape() {
711        let issues = vec![issue(
712            "fallow/unused-file",
713            "minor",
714            "src/a.ts",
715            1,
716            "abc1234567890def",
717        )];
718        let env = to_value(&render_review_envelope("check", Provider::Github, &issues));
719        let comment = &env["comments"][0];
720        assert_eq!(comment["fingerprint"], "abc1234567890def");
721        assert!(
722            comment.get("constituent_fingerprints").is_none(),
723            "single-finding comment must NOT emit constituent_fingerprints"
724        );
725        assert!(
726            comment.get("truncated").is_none(),
727            "non-truncated comment must NOT emit truncated"
728        );
729    }
730
731    #[test]
732    fn composite_fingerprint_shifts_when_constituents_change() {
733        let a = issue("fallow/unused-export", "minor", "src/foo.ts", 42, "fp_a");
734        let b = issue("fallow/duplicate-export", "minor", "src/foo.ts", 42, "fp_b");
735        let c = issue("fallow/unused-type", "minor", "src/foo.ts", 42, "fp_c");
736        let run1 = to_value(&render_review_envelope(
737            "check",
738            Provider::Github,
739            &[a.clone(), b, c.clone()],
740        ));
741        let run2_drop_b = to_value(&render_review_envelope("check", Provider::Github, &[a, c]));
742        assert_ne!(
743            run1["comments"][0]["fingerprint"], run2_drop_b["comments"][0]["fingerprint"],
744            "primary fingerprint must shift when a constituent drops"
745        );
746    }
747
748    #[test]
749    fn gitlab_old_path_pulls_from_diff_rename_map() {
750        let rename_diff = "\
751diff --git a/src/old.ts b/src/new.ts
752similarity index 90%
753rename from src/old.ts
754rename to src/new.ts
755--- a/src/old.ts
756+++ b/src/new.ts
757@@ -1,2 +1,3 @@
758 keep
759+added
760 still
761";
762        let diff_index = DiffIndex::from_unified_diff(rename_diff);
763        let issue = issue("fallow/unused-export", "minor", "src/new.ts", 2, "abc");
764        let envelope = to_value(&render_review_envelope_with_diff(
765            "check",
766            Provider::Gitlab,
767            &[issue],
768            Some(&diff_index),
769            None,
770            None,
771            ReviewSummaryNotes::default(),
772        ));
773        let position = &envelope["comments"][0]["position"];
774        assert_eq!(position["old_path"], "src/old.ts");
775        assert_eq!(position["new_path"], "src/new.ts");
776    }
777
778    #[test]
779    fn gitlab_old_path_falls_back_to_new_path_without_rename() {
780        let issue = issue("fallow/unused-export", "minor", "src/edit.ts", 5, "abc");
781        let envelope = to_value(&render_review_envelope_with_diff(
782            "check",
783            Provider::Gitlab,
784            &[issue],
785            None,
786            None,
787            None,
788            ReviewSummaryNotes::default(),
789        ));
790        let position = &envelope["comments"][0]["position"];
791        assert_eq!(position["old_path"], "src/edit.ts");
792        assert_eq!(position["new_path"], "src/edit.ts");
793    }
794
795    #[test]
796    fn oversized_body_truncates_at_char_boundary_and_preserves_marker() {
797        let huge_desc = "x".repeat(MAX_COMMENT_BODY_BYTES * 2);
798        let issue = CiIssue {
799            rule_id: "fallow/unused-export".into(),
800            description: huge_desc,
801            severity: "minor".into(),
802            path: "src/a.ts".into(),
803            line: 1,
804            end_line: None,
805            other_locations: Vec::new(),
806            fingerprint: "abc1234567890def".into(),
807            legacy_fingerprint: None,
808        };
809        let comment = comment_to_value(&render_merged_comment(
810            Provider::Github,
811            &[&issue],
812            None,
813            None,
814            false,
815        ));
816        let body = comment["body"].as_str().unwrap();
817        assert!(
818            body.len() <= MAX_COMMENT_BODY_BYTES,
819            "body len {} must not exceed cap {MAX_COMMENT_BODY_BYTES}",
820            body.len()
821        );
822        assert!(
823            body.contains("fallow-fingerprint:v3:"),
824            "marker must be preserved under truncation"
825        );
826        assert!(body.contains("<!-- fallow-truncated -->"));
827        assert!(body.contains("> Body truncated by fallow."));
828        assert_eq!(comment["truncated"], true);
829        assert!(std::str::from_utf8(body.as_bytes()).is_ok());
830    }
831
832    #[test]
833    fn oversized_guidance_body_truncates_and_preserves_marker() {
834        let issue = issue_with_desc(
835            "fallow/high-complexity",
836            "x".repeat(MAX_COMMENT_BODY_BYTES * 2),
837            "major",
838            "src/a.ts",
839            1,
840            "abc1234567890def",
841        );
842        let comment = comment_to_value(&render_merged_comment(
843            Provider::Github,
844            &[&issue],
845            None,
846            None,
847            true,
848        ));
849        let body = comment["body"].as_str().unwrap();
850        assert!(body.len() <= MAX_COMMENT_BODY_BYTES);
851        assert!(body.contains("<!-- fallow-truncated -->"));
852        assert!(body.contains("fallow-fingerprint:v3:"));
853        assert_eq!(comment["truncated"], true);
854    }
855
856    #[test]
857    fn multibyte_body_truncates_at_char_boundary() {
858        let huge_desc = "あ".repeat(MAX_COMMENT_BODY_BYTES);
859        let issue = CiIssue {
860            rule_id: "fallow/unused-export".into(),
861            description: huge_desc,
862            severity: "minor".into(),
863            path: "src/a.ts".into(),
864            line: 1,
865            end_line: None,
866            other_locations: Vec::new(),
867            fingerprint: "abc1234567890def".into(),
868            legacy_fingerprint: None,
869        };
870        let comment = comment_to_value(&render_merged_comment(
871            Provider::Github,
872            &[&issue],
873            None,
874            None,
875            false,
876        ));
877        let body = comment["body"].as_str().unwrap();
878        assert!(std::str::from_utf8(body.as_bytes()).is_ok());
879        assert!(body.len() <= MAX_COMMENT_BODY_BYTES);
880        assert_eq!(comment["truncated"], true);
881    }
882}