Skip to main content

fallow_cli/report/ci/
pr_comment.rs

1use crate::report::sink::outln;
2use fallow_output::CodeClimateIssue;
3use std::process::ExitCode;
4use std::sync::OnceLock;
5
6use serde_json::Value;
7
8pub use fallow_output::{
9    CiIssue, CiProvider as Provider, issues_from_codeclimate, issues_from_codeclimate_issues,
10};
11#[cfg(test)]
12use fallow_output::{escape_md, is_project_level_rule};
13
14/// Workspace name, set once by `main()` when the binary is invoked with
15/// `--workspace <name>`. Read by `sticky_marker_id` to auto-suffix the
16/// sticky-comment marker per workspace, which keeps parallel per-workspace
17/// jobs from racing each other's sticky body on the same PR/MR.
18///
19/// `OnceLock` gives us safe cross-function read-after-set without env-var
20/// indirection. Only main writes; readers always observe the post-CLI-parse
21/// state.
22static WORKSPACE_MARKER: OnceLock<String> = OnceLock::new();
23
24/// Set the workspace marker from a `--workspace` selection list.
25///
26/// Single workspace -> the name itself, sanitised for marker grammar.
27/// N>1 workspaces -> a stable 6-char hex hash of the sorted, comma-joined
28/// list, prefixed with `w-`. Sort + join is deterministic so the same
29/// selection produces the same suffix across runs; two jobs with disjoint
30/// selections get distinct markers and don't race.
31#[allow(
32    dead_code,
33    reason = "called from main.rs bin target; lib target sees no caller"
34)]
35pub fn set_workspace_marker_from_list(values: &[String]) {
36    let trimmed: Vec<&str> = values
37        .iter()
38        .map(|value| value.trim())
39        .filter(|value| !value.is_empty())
40        .collect();
41    if trimmed.is_empty() {
42        return;
43    }
44    let marker = if let [single] = trimmed.as_slice() {
45        (*single).to_owned()
46    } else {
47        let mut sorted = trimmed.iter().map(|s| (*s).to_owned()).collect::<Vec<_>>();
48        sorted.sort();
49        let joined = sorted.join(",");
50        format!("w-{}", short_hex_hash(&joined))
51    };
52    let _ = WORKSPACE_MARKER.set(marker);
53}
54
55/// 6-char FNV-1a hex digest. Stable across Rust versions (FNV is content-
56/// determined), short enough for a marker suffix, wide enough that the
57/// chance of two real-world workspace selections colliding is ~1/16M.
58fn short_hex_hash(value: &str) -> String {
59    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
60    for byte in value.bytes() {
61        hash ^= u64::from(byte);
62        hash = hash.wrapping_mul(0x0100_0000_01b3);
63    }
64    format!("{:06x}", (hash & 0x00ff_ffff) as u32)
65}
66
67#[must_use]
68pub fn render_pr_comment(command: &str, provider: Provider, issues: &[CiIssue]) -> String {
69    fallow_output::render_pr_comment(&fallow_output::PrCommentRenderInput {
70        command,
71        provider,
72        issues,
73        marker_id: sticky_marker_id(),
74        max_comments: max_comments(),
75        category_for_rule: &category_for_rule,
76    })
77}
78
79/// Map a fallow rule id to its category for sticky-comment grouping.
80///
81/// Single source of truth lives on `RuleDef::category` in `explain.rs`. This
82/// helper does the lookup so callers don't need to know about the registry;
83/// the look-up-then-fallback shape also keeps the renderer working for
84/// rules a downstream consumer added without registering (rare; produces
85/// the conservative "Dead code" default).
86#[must_use]
87pub fn category_for_rule(rule_id: &str) -> &'static str {
88    crate::explain::rule_by_id(rule_id).map_or("Dead code", |def| def.category)
89}
90
91fn max_comments() -> usize {
92    std::env::var("FALLOW_MAX_COMMENTS")
93        .ok()
94        .and_then(|value| value.parse::<usize>().ok())
95        .unwrap_or(50)
96}
97
98/// Compute the sticky-comment marker id. Precedence (highest first):
99///
100/// 1. `FALLOW_COMMENT_ID` set by the user explicitly: use as-is.
101/// 2. `WORKSPACE_MARKER` populated by `main()` from `--workspace <name>`:
102///    suffix the default to avoid colliding with a sibling per-workspace
103///    job's sticky on the same PR/MR.
104/// 3. Plain `fallow-results`.
105///
106/// The collision case (2) is the common monorepo shape: parallel jobs each
107/// run fallow scoped to one workspace package and post their own sticky.
108/// Without a per-workspace suffix every job edits the same marker, racing
109/// each other's bodies on every CI re-run.
110fn sticky_marker_id() -> String {
111    if let Ok(value) = std::env::var("FALLOW_COMMENT_ID")
112        && !value.trim().is_empty()
113    {
114        return value;
115    }
116    let suffix = WORKSPACE_MARKER
117        .get()
118        .map(|value| value.trim())
119        .filter(|value| !value.is_empty())
120        .map(sanitize_marker_segment);
121    match suffix {
122        Some(workspace) => format!("fallow-results-{workspace}"),
123        None => "fallow-results".to_owned(),
124    }
125}
126
127/// Strip characters that would break the HTML-comment marker. The marker
128/// shape is `<!-- fallow-id: <id> -->`; `<`, `>`, and `--` are reserved by
129/// the HTML comment grammar, and whitespace would split the id when the
130/// reader scans for it.
131fn sanitize_marker_segment(value: &str) -> String {
132    value
133        .chars()
134        .map(|ch| {
135            if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.' {
136                ch
137            } else {
138                '-'
139            }
140        })
141        .collect::<String>()
142        .trim_matches('-')
143        .to_owned()
144}
145
146#[must_use]
147pub fn print_pr_comment(command: &str, provider: Provider, codeclimate: &Value) -> ExitCode {
148    let issues =
149        super::diff_filter::filter_issues_for_summary(issues_from_codeclimate(codeclimate));
150    print_pr_comment_from_ci_issues(command, provider, &issues)
151}
152
153#[must_use]
154pub fn print_pr_comment_from_codeclimate_issues(
155    command: &str,
156    provider: Provider,
157    codeclimate: &[CodeClimateIssue],
158) -> ExitCode {
159    let issues =
160        super::diff_filter::filter_issues_for_summary(issues_from_codeclimate_issues(codeclimate));
161    print_pr_comment_from_ci_issues(command, provider, &issues)
162}
163
164#[must_use]
165fn print_pr_comment_from_ci_issues(
166    command: &str,
167    provider: Provider,
168    issues: &[CiIssue],
169) -> ExitCode {
170    outln!("{}", render_pr_comment(command, provider, issues));
171    ExitCode::SUCCESS
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use fallow_output::{
178        CodeClimateIssueKind, CodeClimateLines, CodeClimateLocation, CodeClimateSeverity,
179    };
180
181    #[test]
182    fn extracts_issues_from_codeclimate() {
183        let value = serde_json::json!([{
184            "check_name": "fallow/unused-export",
185            "description": "Export x is never imported",
186            "severity": "minor",
187            "fingerprint": "abc",
188            "location": { "path": "src/a.ts", "lines": { "begin": 7 } }
189        }]);
190        let issues = issues_from_codeclimate(&value);
191        assert_eq!(issues.len(), 1);
192        assert_eq!(issues[0].path, "src/a.ts");
193        assert_eq!(issues[0].line, 7);
194    }
195
196    #[test]
197    fn typed_codeclimate_issues_extract_like_json_codeclimate() {
198        let severities = [
199            (CodeClimateSeverity::Info, "info"),
200            (CodeClimateSeverity::Minor, "minor"),
201            (CodeClimateSeverity::Major, "major"),
202            (CodeClimateSeverity::Critical, "critical"),
203            (CodeClimateSeverity::Blocker, "blocker"),
204        ];
205        let typed = severities
206            .iter()
207            .enumerate()
208            .map(|(index, (severity, _))| CodeClimateIssue {
209                kind: CodeClimateIssueKind::Issue,
210                check_name: format!("fallow/rule-{index}"),
211                description: format!("Finding {index}"),
212                categories: vec!["Complexity".to_owned()],
213                severity: *severity,
214                fingerprint: format!("fp-{index}"),
215                location: CodeClimateLocation {
216                    path: format!("src/{index}.ts"),
217                    lines: CodeClimateLines {
218                        begin: u32::try_from(index + 1).expect("small fixture index"),
219                    },
220                },
221                owner: None,
222                group: None,
223            })
224            .collect::<Vec<_>>();
225        let value = serde_json::to_value(&typed).expect("typed fixture serializes");
226
227        assert_eq!(
228            issues_from_codeclimate_issues(&typed),
229            issues_from_codeclimate(&value)
230        );
231        let typed_labels = issues_from_codeclimate_issues(&typed)
232            .into_iter()
233            .map(|issue| issue.severity)
234            .collect::<Vec<_>>();
235        let expected_labels = severities
236            .iter()
237            .map(|(_, label)| (*label).to_owned())
238            .collect::<Vec<_>>();
239        assert_eq!(typed_labels, expected_labels);
240    }
241
242    #[test]
243    fn sticky_marker_id_default_when_nothing_set() {
244        let body = render_pr_comment("check", Provider::Github, &[]);
245        assert!(body.contains("<!-- fallow-id: fallow-results"));
246        assert!(body.contains("No GitHub PR/MR findings."));
247    }
248
249    #[test]
250    fn short_hex_hash_is_deterministic_and_six_chars() {
251        let a = short_hex_hash("api,worker");
252        assert_eq!(a.len(), 6);
253        assert_eq!(a, short_hex_hash("api,worker"));
254        assert_ne!(a, short_hex_hash("admin,web"));
255    }
256
257    #[test]
258    fn sanitize_marker_segment_collapses_unsafe_chars_to_dashes() {
259        assert_eq!(sanitize_marker_segment("@fallow/runtime"), "fallow-runtime");
260        assert_eq!(
261            sanitize_marker_segment("packages/web ui"),
262            "packages-web-ui"
263        );
264        assert_eq!(sanitize_marker_segment("plain"), "plain");
265        assert_eq!(
266            sanitize_marker_segment("--leading-trailing--"),
267            "leading-trailing"
268        );
269    }
270
271    #[test]
272    fn escape_md_escapes_inline_commonmark_specials() {
273        let raw = "foo*bar_baz [a](u) `c` <h> #x !i ~s | p";
274        let escaped = escape_md(raw);
275        for ch in [
276            '*', '_', '[', ']', '(', ')', '`', '<', '>', '#', '!', '~', '|',
277        ] {
278            let raw_count = raw.chars().filter(|c| c == &ch).count();
279            let escaped_count = escaped.matches(&format!("\\{ch}")).count();
280            assert_eq!(
281                raw_count, escaped_count,
282                "char {ch:?}: raw {raw_count} occurrences, escaped {escaped_count} in {escaped:?}"
283            );
284        }
285    }
286
287    #[test]
288    fn escape_md_escapes_ampersand_to_block_numeric_entity_bypass() {
289        let raw = "value &#42;suspicious&#42; here";
290        let escaped = escape_md(raw);
291        assert!(escaped.contains(r"\&"), "got: {escaped}");
292        assert!(escaped.contains(r"\#"), "got: {escaped}");
293        assert!(!escaped.contains(" *suspicious"), "got: {escaped}");
294    }
295
296    #[test]
297    fn summary_label_foreshadows_truncation() {
298        assert_eq!(
299            fallow_output::summary_label("Duplication", 160, 50),
300            "Duplication (160, showing 50)"
301        );
302        assert_eq!(
303            fallow_output::summary_label("Health", 12, 50),
304            "Health (12)"
305        );
306        assert_eq!(
307            fallow_output::summary_label("Dependencies", 50, 50),
308            "Dependencies (50)"
309        );
310    }
311
312    #[test]
313    fn escape_md_does_not_escape_block_only_markers() {
314        let raw = "fallow/test-only-dependency package.json:12";
315        let escaped = escape_md(raw);
316        assert!(!escaped.contains("\\-"), "should not escape `-`");
317        assert!(!escaped.contains("\\."), "should not escape `.`");
318        assert_eq!(escaped, raw);
319    }
320
321    #[test]
322    fn escape_md_collapses_newlines_to_spaces() {
323        let raw = "first\nsecond\nthird";
324        assert_eq!(escape_md(raw), "first second third");
325    }
326
327    #[test]
328    fn escape_md_leaves_safe_chars_unchanged() {
329        let raw = "Export 'helperFn' is never imported by other modules";
330        assert_eq!(
331            escape_md(raw),
332            r"Export 'helperFn' is never imported by other modules"
333        );
334    }
335
336    #[test]
337    fn is_project_level_rule_covers_config_anchored_dependency_findings() {
338        for rule_id in fallow_output::PROJECT_LEVEL_RULE_IDS {
339            assert!(
340                is_project_level_rule(rule_id),
341                "{rule_id} must be project-level"
342            );
343        }
344        for rule_id in [
345            "fallow/unused-file",
346            "fallow/unused-export",
347            "fallow/unused-type",
348            "fallow/unused-enum-member",
349            "fallow/unused-class-member",
350            "fallow/unused-store-member",
351            "fallow/unresolved-import",
352            "fallow/unlisted-dependency",
353            "fallow/duplicate-export",
354            "fallow/circular-dependency",
355            "fallow/re-export-cycle",
356            "fallow/boundary-violation",
357            "fallow/stale-suppression",
358            "fallow/private-type-leak",
359            "fallow/high-complexity",
360            "fallow/high-crap-score",
361        ] {
362            assert!(
363                !is_project_level_rule(rule_id),
364                "{rule_id} must NOT be project-level"
365            );
366        }
367    }
368
369    #[test]
370    fn project_level_rule_ids_each_register_in_explain_registry() {
371        for rule_id in fallow_output::PROJECT_LEVEL_RULE_IDS {
372            assert!(
373                crate::explain::rule_by_id(rule_id).is_some(),
374                "{rule_id} listed in PROJECT_LEVEL_RULE_IDS but not in explain registry"
375            );
376        }
377    }
378
379    #[test]
380    fn escape_md_double_apply_is_safe() {
381        let raw = "code with `backticks` and *stars*";
382        let once = escape_md(raw);
383        let twice = escape_md(&once);
384        assert!(twice.contains(r"\\"));
385    }
386}