Skip to main content

fallow_cli/report/ci/
review.rs

1use std::process::ExitCode;
2
3use fallow_output::CodeClimateIssue;
4use serde_json::Value;
5
6use super::diff_filter::DiffIndex;
7use crate::report::emit_json;
8use fallow_output::PrDecisionConclusion;
9use fallow_output::{
10    CiIssue, CiProvider as Provider, ReviewCheckConclusion, ReviewEnvelopeOutput,
11    ReviewEnvelopeRenderInput, ReviewEnvelopeTruncation, ReviewGitlabDiffRefs as GitlabDiffRefs,
12    ReviewId, issues_from_codeclimate_issues,
13};
14
15#[must_use]
16pub fn render_review_envelope(
17    command: &str,
18    provider: Provider,
19    issues: &[CiIssue],
20) -> ReviewEnvelopeOutput {
21    render_review_envelope_with_diff(
22        command,
23        provider,
24        issues,
25        super::diff_filter::shared_diff_index(),
26        None,
27        None,
28        None,
29    )
30}
31
32/// Render path the print site uses. Exposed so unit tests can pass a
33/// hand-crafted `DiffIndex` without poking the process-wide `SHARED_DIFF`
34/// cache (which is `OnceLock`-bounded and not reentrant under cargo test's
35/// parallel runner).
36#[must_use]
37#[expect(
38    clippy::too_many_arguments,
39    reason = "the private render seam keeps test-injected diff and optional gate state explicit"
40)]
41fn render_review_envelope_with_diff(
42    command: &str,
43    provider: Provider,
44    issues: &[CiIssue],
45    diff_index: Option<&DiffIndex>,
46    review_id: Option<&ReviewId>,
47    conclusion: Option<ReviewCheckConclusion>,
48    status_message: Option<&str>,
49) -> ReviewEnvelopeOutput {
50    let max = std::env::var("FALLOW_MAX_COMMENTS")
51        .ok()
52        .and_then(|v| v.parse::<usize>().ok())
53        .unwrap_or(50);
54    let gitlab_diff_refs = (provider == Provider::Gitlab)
55        .then(gitlab_diff_refs_from_env)
56        .flatten();
57    let include_guidance = review_guidance_enabled();
58
59    let input = ReviewEnvelopeRenderInput {
60        command,
61        provider,
62        issues,
63        diff_index,
64        path_prefix: crate::report::github::report_prefix(),
65        max_comments: max,
66        gitlab_diff_refs: gitlab_diff_refs.as_ref(),
67        include_guidance,
68        suggestion_block: &super::suggestion::suggestion_block,
69        guidance_block: &review_guidance_block,
70    };
71    let rendered = match (review_id, conclusion) {
72        (Some(review_id), Some(conclusion)) => {
73            fallow_output::render_scoped_review_envelope_with_conclusion(
74                &input,
75                review_id,
76                conclusion,
77                status_message,
78            )
79        }
80        (Some(review_id), None) => fallow_output::render_scoped_review_envelope(&input, review_id),
81        (None, Some(conclusion)) => fallow_output::render_review_envelope_with_conclusion(
82            &input,
83            conclusion,
84            status_message,
85        ),
86        (None, None) => fallow_output::render_review_envelope(&input),
87    };
88    note_review_truncation(rendered.truncation);
89    rendered.envelope
90}
91
92/// Record telemetry for body-size or comment-count truncation of the review.
93fn note_review_truncation(truncation: ReviewEnvelopeTruncation) {
94    if truncation.body {
95        crate::telemetry::note_report_truncation(
96            true,
97            crate::telemetry::TruncationReason::SizeLimit,
98        );
99    } else if truncation.comment_limit {
100        crate::telemetry::note_report_truncation(
101            true,
102            crate::telemetry::TruncationReason::CommentLimit,
103        );
104    } else {
105        crate::telemetry::note_report_truncation(
106            false,
107            crate::telemetry::TruncationReason::Unknown,
108        );
109    }
110}
111
112#[must_use]
113pub(crate) fn print_review_envelope(
114    command: &str,
115    provider: Provider,
116    codeclimate: &Value,
117) -> ExitCode {
118    let issues = super::diff_filter::filter_issues_from_env(
119        super::pr_comment::issues_from_codeclimate(codeclimate),
120    );
121    print_review_envelope_from_ci_issues(command, provider, &issues, None, None)
122}
123
124#[must_use]
125pub(crate) fn print_review_envelope_with_conclusion(
126    command: &str,
127    provider: Provider,
128    codeclimate: &Value,
129    conclusion: PrDecisionConclusion,
130    status_message: Option<&str>,
131) -> ExitCode {
132    let issues = super::diff_filter::filter_issues_from_env(
133        super::pr_comment::issues_from_codeclimate(codeclimate),
134    );
135    print_review_envelope_from_ci_issues(
136        command,
137        provider,
138        &issues,
139        Some(review_conclusion(conclusion)),
140        status_message,
141    )
142}
143
144#[must_use]
145pub(crate) fn print_review_envelope_from_codeclimate_issues(
146    command: &str,
147    provider: Provider,
148    codeclimate: &[CodeClimateIssue],
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, None)
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    status_message: Option<&str>,
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        status_message,
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    status_message: Option<&str>,
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        status_message,
197    );
198    let mode = crate::output_runtime::current_root_envelope_mode();
199    let analysis_run_id = crate::output_runtime::telemetry_analysis_run_id();
200    let value = match review_id.as_ref() {
201        Some(review_id) => fallow_output::serialize_scoped_review_envelope_json_output(
202            &envelope,
203            review_id,
204            mode,
205            analysis_run_id.as_deref(),
206        ),
207        None => fallow_output::serialize_review_envelope_json_output(
208            envelope,
209            mode,
210            analysis_run_id.as_deref(),
211        )
212        .map_err(|error| error.to_string()),
213    };
214    let value = match value {
215        Ok(value) => value,
216        Err(error) => {
217            eprintln!("Error: failed to serialize review envelope: {error}");
218            return ExitCode::from(2);
219        }
220    };
221    emit_json(&value, "review envelope")
222}
223
224const 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_V2, MARKER_SUFFIX_V2, MAX_COMMENT_BODY_BYTES};
316    use fallow_output::{MARKER_REGEX_V2, ReviewComment};
317
318    fn to_value(envelope: &ReviewEnvelopeOutput) -> Value {
319        serde_json::to_value(envelope).expect("ReviewEnvelopeOutput serializes infallibly")
320    }
321
322    fn comment_to_value(comment: &ReviewComment) -> Value {
323        serde_json::to_value(comment).expect("ReviewComment serializes infallibly")
324    }
325
326    fn issue(rule: &str, sev: &str, path: &str, line: u64, fp: &str) -> CiIssue {
327        CiIssue {
328            rule_id: rule.into(),
329            description: "desc".into(),
330            severity: sev.into(),
331            path: path.into(),
332            line,
333            end_line: None,
334            other_locations: Vec::new(),
335            fingerprint: fp.into(),
336        }
337    }
338
339    fn issue_with_desc(
340        rule: &str,
341        desc: impl Into<String>,
342        sev: &str,
343        path: &str,
344        line: u64,
345        fp: &str,
346    ) -> CiIssue {
347        CiIssue {
348            rule_id: rule.into(),
349            description: desc.into(),
350            severity: sev.into(),
351            path: path.into(),
352            line,
353            end_line: None,
354            other_locations: Vec::new(),
355            fingerprint: fp.into(),
356        }
357    }
358
359    #[test]
360    fn scoped_renderer_uses_typed_meta_and_exact_markers() {
361        let issue = issue(
362            "fallow/unused-file",
363            "minor",
364            "src/a.ts",
365            1,
366            "abcdef0123456789",
367        );
368        let review_id = ReviewId::parse("frontend").unwrap();
369        let envelope = render_review_envelope_with_diff(
370            "check",
371            Provider::Github,
372            &[issue],
373            None,
374            Some(&review_id),
375            None,
376            None,
377        );
378        let envelope = fallow_output::serialize_scoped_review_envelope_json_output(
379            &envelope,
380            &review_id,
381            crate::output_runtime::current_root_envelope_mode(),
382            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:v2:")
419        );
420    }
421
422    #[test]
423    fn review_summary_body_leads_with_decision() {
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 failed"), "{body}");
439        assert!(body.contains("1 inline finding selected"), "{body}");
440        assert!(body.contains("<!-- fallow-review -->"), "{body}");
441    }
442
443    #[test]
444    fn github_comments_target_current_state_side() {
445        let issue = issue("fallow/unused-file", "minor", "src/a.ts", 1, "abc");
446        let comment = comment_to_value(&render_merged_comment(
447            Provider::Github,
448            &[&issue],
449            None,
450            None,
451            false,
452        ));
453        assert_eq!(comment["side"], "RIGHT");
454    }
455
456    #[test]
457    fn labels_major_issues_as_errors() {
458        let issue = issue("fallow/unused-file", "major", "src/a.ts", 1, "abc");
459        let comment = comment_to_value(&render_merged_comment(
460            Provider::Github,
461            &[&issue],
462            None,
463            None,
464            false,
465        ));
466        assert!(comment["body"].as_str().unwrap().starts_with("**error**"));
467    }
468
469    #[test]
470    fn gitlab_comment_accepts_diff_refs() {
471        let issue = issue("fallow/unused-file", "minor", "src/a.ts", 1, "abc");
472        let refs = GitlabDiffRefs {
473            base_sha: "base".into(),
474            start_sha: "start".into(),
475            head_sha: "head".into(),
476        };
477        let comment = comment_to_value(&render_merged_comment(
478            Provider::Gitlab,
479            &[&issue],
480            Some(&refs),
481            None,
482            false,
483        ));
484        assert_eq!(comment["position"]["position_type"], "text");
485        assert_eq!(comment["position"]["base_sha"], "base");
486        assert_eq!(comment["position"]["start_sha"], "start");
487        assert_eq!(comment["position"]["head_sha"], "head");
488    }
489
490    #[test]
491    fn guidance_toggle_accepts_common_truthy_values() {
492        for value in ["1", "true", "TRUE", "yes", "on", " On "] {
493            assert!(env_truthy(value), "{value:?} should enable guidance");
494        }
495        for value in ["", "0", "false", "no", "off", "enabled"] {
496            assert!(!env_truthy(value), "{value:?} should not enable guidance");
497        }
498    }
499
500    #[test]
501    fn guidance_disabled_omits_details_block() {
502        let issue = issue(
503            "fallow/high-complexity",
504            "major",
505            "src/a.ts",
506            10,
507            "abc1234567890def",
508        );
509        let comment = comment_to_value(&render_merged_comment(
510            Provider::Github,
511            &[&issue],
512            None,
513            None,
514            false,
515        ));
516        let body = comment["body"].as_str().unwrap();
517        assert!(!body.contains("<details><summary>What to do</summary>"));
518        assert!(!body.contains("For function findings"));
519    }
520
521    #[test]
522    fn guidance_enabled_appends_rule_guide_details() {
523        let issue = issue(
524            "fallow/high-complexity",
525            "major",
526            "src/a.ts",
527            10,
528            "abc1234567890def",
529        );
530        let comment = comment_to_value(&render_merged_comment(
531            Provider::Github,
532            &[&issue],
533            None,
534            None,
535            true,
536        ));
537        let body = comment["body"].as_str().unwrap();
538        assert!(body.contains("<details><summary>What to do</summary>"));
539        assert!(body.contains("For function findings"));
540        assert!(body.contains("[Read the rule docs]("));
541        assert!(
542            body.find("</details>").unwrap() < body.find("fallow-fingerprint:v2:").unwrap(),
543            "guidance should render before the marker"
544        );
545    }
546
547    #[test]
548    fn guidance_attaches_to_each_merged_finding() {
549        let complexity = issue("fallow/high-complexity", "major", "src/foo.ts", 42, "fp_a");
550        let duplication = issue("fallow/code-duplication", "minor", "src/foo.ts", 42, "fp_b");
551        let comment = comment_to_value(&render_merged_comment(
552            Provider::Github,
553            &[&complexity, &duplication],
554            None,
555            None,
556            true,
557        ));
558        let body = comment["body"].as_str().unwrap();
559        assert_eq!(
560            body.matches("<details><summary>What to do</summary>")
561                .count(),
562            2
563        );
564        assert!(body.contains("For function findings"));
565        assert!(body.contains("Extract the shared logic"));
566    }
567
568    #[test]
569    fn envelope_emits_marker_regex_field_at_root() {
570        let issues = vec![issue("fallow/unused-file", "minor", "src/a.ts", 1, "abc")];
571        let env = to_value(&render_review_envelope("check", Provider::Github, &issues));
572        let regex = env["marker_regex"].as_str().expect("marker_regex present");
573        assert_eq!(regex, MARKER_REGEX_V2);
574        assert!(regex.contains("[0-9a-f]{16}"));
575        assert!(regex.starts_with('^'));
576        assert!(regex.ends_with("\\s*$"));
577        assert!(!regex.contains("(?m)"));
578        assert!(regex.contains("((?:[a-z]+:)?[0-9a-f]{16})"));
579        let flags = env["marker_regex_flags"]
580            .as_str()
581            .expect("marker_regex_flags present");
582        assert_eq!(flags, "m");
583    }
584
585    #[test]
586    fn envelope_emits_summary_block_with_fingerprint() {
587        let issues = vec![issue("fallow/unused-file", "minor", "src/a.ts", 1, "abc")];
588        let env = to_value(&render_review_envelope("check", Provider::Github, &issues));
589        assert_eq!(env["summary"]["body"], env["body"]);
590        let summary_fp = env["summary"]["fingerprint"].as_str().expect("fingerprint");
591        assert_eq!(summary_fp.len(), 16);
592        assert!(summary_fp.chars().all(|c| c.is_ascii_hexdigit()));
593        let body_str = env["body"].as_str().unwrap();
594        let marker_line = format!("{MARKER_PREFIX_V2}{summary_fp}{MARKER_SUFFIX_V2}");
595        assert!(
596            body_str.contains(&marker_line),
597            "body must carry summary marker:\nbody={body_str}\nmarker={marker_line}"
598        );
599    }
600
601    #[test]
602    fn same_line_findings_merge_into_one_comment_with_composite_fingerprint() {
603        let a = issue("fallow/unused-export", "minor", "src/foo.ts", 42, "fp_a");
604        let b = issue("fallow/duplicate-export", "minor", "src/foo.ts", 42, "fp_b");
605        let env = to_value(&render_review_envelope("check", Provider::Github, &[a, b]));
606        assert_eq!(
607            env["comments"].as_array().unwrap().len(),
608            1,
609            "two same-line findings must collapse to one comment"
610        );
611        let merged = &env["comments"][0];
612        let fp = merged["fingerprint"].as_str().unwrap();
613        assert!(
614            fp.starts_with("merged:"),
615            "merged comment fingerprint must start with merged:, got {fp}"
616        );
617        assert_eq!(fp.len(), 23);
618        let body = merged["body"].as_str().unwrap();
619        assert!(body.contains("fallow/unused-export"));
620        assert!(body.contains("fallow/duplicate-export"));
621        assert_eq!(
622            body.matches("fallow-fingerprint:v2:").count(),
623            1,
624            "merged body must carry exactly one fingerprint marker"
625        );
626        assert!(
627            merged.get("constituent_fingerprints").is_none(),
628            "v2 hashed-composite design does not emit constituent_fingerprints"
629        );
630    }
631
632    #[test]
633    fn group_by_path_line_respects_max_groups_without_splitting_same_line_findings() {
634        let a = issue("fallow/unused-export", "minor", "src/foo.ts", 42, "fp_a");
635        let b = issue("fallow/duplicate-export", "minor", "src/foo.ts", 42, "fp_b");
636        let c = issue("fallow/unused-type", "minor", "src/z.ts", 7, "fp_c");
637        let issues = vec![a, b, c];
638
639        let max_zero = group_by_path_line(&issues, 0);
640        assert!(max_zero.groups.is_empty());
641        assert!(max_zero.truncated);
642
643        let max_one = group_by_path_line(&issues, 1);
644        assert_eq!(max_one.groups.len(), 1);
645        assert!(max_one.truncated);
646        assert_eq!(max_one.groups[0].len(), 2);
647        assert_eq!(max_one.groups[0][0].path, "src/foo.ts");
648        assert_eq!(max_one.groups[0][0].line, 42);
649
650        let max_two = group_by_path_line(&issues, 2);
651        assert_eq!(max_two.groups.len(), 2);
652        assert!(!max_two.truncated);
653        assert_eq!(max_two.groups[0].len(), 2);
654        assert_eq!(max_two.groups[1].len(), 1);
655        assert_eq!(
656            max_two.groups[0]
657                .iter()
658                .map(|issue| issue.fingerprint.as_str())
659                .collect::<Vec<_>>(),
660            ["fp_a", "fp_b"]
661        );
662    }
663
664    #[test]
665    fn single_finding_keeps_v1_fingerprint_shape() {
666        let issues = vec![issue(
667            "fallow/unused-file",
668            "minor",
669            "src/a.ts",
670            1,
671            "abc1234567890def",
672        )];
673        let env = to_value(&render_review_envelope("check", Provider::Github, &issues));
674        let comment = &env["comments"][0];
675        assert_eq!(comment["fingerprint"], "abc1234567890def");
676        assert!(
677            comment.get("constituent_fingerprints").is_none(),
678            "single-finding comment must NOT emit constituent_fingerprints"
679        );
680        assert!(
681            comment.get("truncated").is_none(),
682            "non-truncated comment must NOT emit truncated"
683        );
684    }
685
686    #[test]
687    fn composite_fingerprint_shifts_when_constituents_change() {
688        let a = issue("fallow/unused-export", "minor", "src/foo.ts", 42, "fp_a");
689        let b = issue("fallow/duplicate-export", "minor", "src/foo.ts", 42, "fp_b");
690        let c = issue("fallow/unused-type", "minor", "src/foo.ts", 42, "fp_c");
691        let run1 = to_value(&render_review_envelope(
692            "check",
693            Provider::Github,
694            &[a.clone(), b, c.clone()],
695        ));
696        let run2_drop_b = to_value(&render_review_envelope("check", Provider::Github, &[a, c]));
697        assert_ne!(
698            run1["comments"][0]["fingerprint"], run2_drop_b["comments"][0]["fingerprint"],
699            "primary fingerprint must shift when a constituent drops"
700        );
701    }
702
703    #[test]
704    fn gitlab_old_path_pulls_from_diff_rename_map() {
705        let rename_diff = "\
706diff --git a/src/old.ts b/src/new.ts
707similarity index 90%
708rename from src/old.ts
709rename to src/new.ts
710--- a/src/old.ts
711+++ b/src/new.ts
712@@ -1,2 +1,3 @@
713 keep
714+added
715 still
716";
717        let diff_index = DiffIndex::from_unified_diff(rename_diff);
718        let issue = issue("fallow/unused-export", "minor", "src/new.ts", 2, "abc");
719        let envelope = to_value(&render_review_envelope_with_diff(
720            "check",
721            Provider::Gitlab,
722            &[issue],
723            Some(&diff_index),
724            None,
725            None,
726            None,
727        ));
728        let position = &envelope["comments"][0]["position"];
729        assert_eq!(position["old_path"], "src/old.ts");
730        assert_eq!(position["new_path"], "src/new.ts");
731    }
732
733    #[test]
734    fn gitlab_old_path_falls_back_to_new_path_without_rename() {
735        let issue = issue("fallow/unused-export", "minor", "src/edit.ts", 5, "abc");
736        let envelope = to_value(&render_review_envelope_with_diff(
737            "check",
738            Provider::Gitlab,
739            &[issue],
740            None,
741            None,
742            None,
743            None,
744        ));
745        let position = &envelope["comments"][0]["position"];
746        assert_eq!(position["old_path"], "src/edit.ts");
747        assert_eq!(position["new_path"], "src/edit.ts");
748    }
749
750    #[test]
751    fn oversized_body_truncates_at_char_boundary_and_preserves_marker() {
752        let huge_desc = "x".repeat(MAX_COMMENT_BODY_BYTES * 2);
753        let issue = CiIssue {
754            rule_id: "fallow/unused-export".into(),
755            description: huge_desc,
756            severity: "minor".into(),
757            path: "src/a.ts".into(),
758            line: 1,
759            end_line: None,
760            other_locations: Vec::new(),
761            fingerprint: "abc1234567890def".into(),
762        };
763        let comment = comment_to_value(&render_merged_comment(
764            Provider::Github,
765            &[&issue],
766            None,
767            None,
768            false,
769        ));
770        let body = comment["body"].as_str().unwrap();
771        assert!(
772            body.len() <= MAX_COMMENT_BODY_BYTES,
773            "body len {} must not exceed cap {MAX_COMMENT_BODY_BYTES}",
774            body.len()
775        );
776        assert!(
777            body.contains("fallow-fingerprint:v2:"),
778            "marker must be preserved under truncation"
779        );
780        assert!(body.contains("<!-- fallow-truncated -->"));
781        assert!(body.contains("> Body truncated by fallow."));
782        assert_eq!(comment["truncated"], true);
783        assert!(std::str::from_utf8(body.as_bytes()).is_ok());
784    }
785
786    #[test]
787    fn oversized_guidance_body_truncates_and_preserves_marker() {
788        let issue = issue_with_desc(
789            "fallow/high-complexity",
790            "x".repeat(MAX_COMMENT_BODY_BYTES * 2),
791            "major",
792            "src/a.ts",
793            1,
794            "abc1234567890def",
795        );
796        let comment = comment_to_value(&render_merged_comment(
797            Provider::Github,
798            &[&issue],
799            None,
800            None,
801            true,
802        ));
803        let body = comment["body"].as_str().unwrap();
804        assert!(body.len() <= MAX_COMMENT_BODY_BYTES);
805        assert!(body.contains("<!-- fallow-truncated -->"));
806        assert!(body.contains("fallow-fingerprint:v2:"));
807        assert_eq!(comment["truncated"], true);
808    }
809
810    #[test]
811    fn multibyte_body_truncates_at_char_boundary() {
812        let huge_desc: String = "あ".repeat(MAX_COMMENT_BODY_BYTES);
813        let issue = CiIssue {
814            rule_id: "fallow/unused-export".into(),
815            description: huge_desc,
816            severity: "minor".into(),
817            path: "src/a.ts".into(),
818            line: 1,
819            end_line: None,
820            other_locations: Vec::new(),
821            fingerprint: "abc1234567890def".into(),
822        };
823        let comment = comment_to_value(&render_merged_comment(
824            Provider::Github,
825            &[&issue],
826            None,
827            None,
828            false,
829        ));
830        let body = comment["body"].as_str().unwrap();
831        assert!(std::str::from_utf8(body.as_bytes()).is_ok());
832        assert!(body.len() <= MAX_COMMENT_BODY_BYTES);
833        assert_eq!(comment["truncated"], true);
834    }
835}