Skip to main content

fallow_cli/report/ci/
suggestion.rs

1use std::path::PathBuf;
2
3use fallow_output::{CiIssue, CiProvider as Provider};
4
5#[must_use]
6pub fn suggestion_block(provider: Provider, issue: &CiIssue) -> Option<String> {
7    let mut block = fix_intent(issue).map(fix_intent_block).unwrap_or_default();
8    if issue.rule_id.contains("unused-file") {
9        block.push_str(&unused_file_hint());
10        return Some(block);
11    }
12    if issue.line == 0 {
13        return (!block.is_empty()).then_some(block);
14    }
15
16    let root = std::env::var_os("FALLOW_ROOT").map_or_else(|| PathBuf::from("."), PathBuf::from);
17    let path = root.join(&issue.path);
18    let Some(source) = std::fs::read_to_string(path).ok() else {
19        return (!block.is_empty()).then_some(block);
20    };
21    let Some(line) = source.lines().nth(issue.line.saturating_sub(1) as usize) else {
22        return (!block.is_empty()).then_some(block);
23    };
24    if let Some(suggestion) = suggestion_block_for_issue_line(provider, &issue.rule_id, line) {
25        block.push_str(&suggestion);
26    }
27    (!block.is_empty()).then_some(block)
28}
29
30#[must_use]
31pub fn fix_intent(issue: &CiIssue) -> Option<&'static str> {
32    match issue.rule_id.as_str() {
33        "fallow/unused-file" => Some("Delete the file or add a real entry-point reference."),
34        "fallow/unused-export" => {
35            Some("Remove the export or mark it public if it is part of the API.")
36        }
37        "fallow/code-duplication" => Some("Extract the repeated block or centralize shared logic."),
38        "fallow/high-crap-score" | "fallow/high-complexity" => {
39            Some("Split branches or add focused tests around the risky path.")
40        }
41        "fallow/unresolved-import" => Some("Fix the import path or install the missing package."),
42        _ => None,
43    }
44}
45
46fn fix_intent_block(intent: &str) -> String {
47    format!("\n\n> Fix intent: {intent}")
48}
49
50#[must_use]
51pub fn suggestion_block_for_issue_line(
52    provider: Provider,
53    rule_id: &str,
54    line: &str,
55) -> Option<String> {
56    if rule_id.contains("unused-import") {
57        return unused_import_suggestion(provider, line);
58    }
59    if rule_id.contains("unused-enum-member")
60        || rule_id.contains("unused-class-member")
61        || rule_id.contains("unused-store-member")
62    {
63        return delete_line_suggestion(provider, line);
64    }
65    if rule_id.contains("unused-export") || rule_id.contains("unused-type") {
66        return unused_export_suggestion(provider, line);
67    }
68    None
69}
70
71/// Text hint for `unused-file` findings. Neither GitHub nor GitLab supports
72/// file-scope deletion suggestions through the review-comment API.
73#[must_use]
74fn unused_file_hint() -> String {
75    "\n\n> Run `fallow fix --files` or delete this file.".to_owned()
76}
77
78fn unused_export_suggestion(provider: Provider, line: &str) -> Option<String> {
79    let fixed = line
80        .strip_prefix("export default ")
81        .or_else(|| line.strip_prefix("export "))?;
82    if fixed == line {
83        return None;
84    }
85
86    match provider {
87        Provider::Github => Some(format!("\n\n```suggestion\n{fixed}\n```")),
88        Provider::Gitlab => Some(format!("\n\n```suggestion:-0+0\n{fixed}\n```")),
89    }
90}
91
92/// Delete the matched line entirely for unused enum/class members.
93fn delete_line_suggestion(provider: Provider, line: &str) -> Option<String> {
94    if line.trim().is_empty() {
95        return None;
96    }
97    match provider {
98        Provider::Github => Some("\n\n```suggestion\n\n```".to_owned()),
99        Provider::Gitlab => Some("\n\n```suggestion:-0+0\n\n```".to_owned()),
100    }
101}
102
103fn unused_import_suggestion(provider: Provider, line: &str) -> Option<String> {
104    let trimmed = line.trim_start();
105    if !trimmed.starts_with("import ") {
106        return None;
107    }
108
109    let import_target = trimmed.strip_prefix("import ")?.trim_start();
110    if import_target.starts_with('"') || import_target.starts_with('\'') {
111        return None;
112    }
113
114    let (clause, _) = import_target.split_once(" from ")?;
115    let clause = clause
116        .trim()
117        .strip_prefix("type ")
118        .unwrap_or_else(|| clause.trim())
119        .trim();
120    if clause.contains(',') {
121        return None;
122    }
123    if let Some(named) = clause
124        .strip_prefix('{')
125        .and_then(|value| value.strip_suffix('}'))
126    {
127        let named = named.trim();
128        if named.is_empty() || named.contains(',') {
129            return None;
130        }
131    }
132
133    match provider {
134        Provider::Github => Some("\n\n```suggestion\n\n```".to_string()),
135        Provider::Gitlab => Some("\n\n```suggestion:-0+0\n\n```".to_string()),
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn renders_github_suggestion() {
145        assert_eq!(
146            suggestion_block_for_issue_line(
147                Provider::Github,
148                "fallow/unused-export",
149                "export const value = 1;"
150            )
151            .as_deref(),
152            Some("\n\n```suggestion\nconst value = 1;\n```")
153        );
154    }
155
156    #[test]
157    fn renders_gitlab_suggestion() {
158        assert_eq!(
159            suggestion_block_for_issue_line(
160                Provider::Gitlab,
161                "fallow/unused-export",
162                "export default thing;"
163            )
164            .as_deref(),
165            Some("\n\n```suggestion:-0+0\nthing;\n```")
166        );
167    }
168
169    #[test]
170    fn renders_unused_type_export_suggestion() {
171        assert_eq!(
172            suggestion_block_for_issue_line(
173                Provider::Github,
174                "fallow/unused-type",
175                "export type Legacy = { id: string };"
176            )
177            .as_deref(),
178            Some("\n\n```suggestion\ntype Legacy = { id: string };\n```")
179        );
180        assert_eq!(
181            suggestion_block_for_issue_line(
182                Provider::Gitlab,
183                "fallow/unused-type",
184                "export interface Legacy { id: string }"
185            )
186            .as_deref(),
187            Some("\n\n```suggestion:-0+0\ninterface Legacy { id: string }\n```")
188        );
189    }
190
191    #[test]
192    fn fix_intent_names_common_review_actions() {
193        let issue = CiIssue {
194            rule_id: "fallow/code-duplication".to_owned(),
195            description: "clone".to_owned(),
196            severity: "minor".to_owned(),
197            path: "src/a.ts".to_owned(),
198            line: 1,
199            fingerprint: "abc".to_owned(),
200        };
201
202        assert_eq!(
203            fix_intent(&issue),
204            Some("Extract the repeated block or centralize shared logic.")
205        );
206    }
207
208    #[test]
209    fn unused_type_suggestion_is_conservative() {
210        assert_eq!(
211            suggestion_block_for_issue_line(
212                Provider::Github,
213                "fallow/unused-type",
214                "  export type Indented = string;"
215            ),
216            None
217        );
218        assert_eq!(
219            suggestion_block_for_issue_line(
220                Provider::Github,
221                "fallow/unused-type",
222                "type Local = string;"
223            ),
224            None
225        );
226        assert_eq!(
227            suggestion_block_for_issue_line(
228                Provider::Github,
229                "fallow/unused-type",
230                "const used = 1; export type Legacy = string;"
231            ),
232            None
233        );
234    }
235
236    #[test]
237    fn renders_unused_import_delete_suggestion() {
238        assert_eq!(
239            suggestion_block_for_issue_line(
240                Provider::Github,
241                "fallow/unused-import",
242                "import { unused } from './module';"
243            )
244            .as_deref(),
245            Some("\n\n```suggestion\n\n```")
246        );
247    }
248
249    #[test]
250    fn skips_side_effect_imports() {
251        assert_eq!(
252            suggestion_block_for_issue_line(
253                Provider::Github,
254                "fallow/unused-import",
255                "import './setup';"
256            ),
257            None
258        );
259    }
260
261    #[test]
262    fn skips_mixed_import_bindings() {
263        assert_eq!(
264            suggestion_block_for_issue_line(
265                Provider::Github,
266                "fallow/unused-import",
267                "import { used, unused } from './module';"
268            ),
269            None
270        );
271    }
272
273    #[test]
274    fn renders_unused_enum_member_delete_suggestion() {
275        assert_eq!(
276            suggestion_block_for_issue_line(
277                Provider::Github,
278                "fallow/unused-enum-member",
279                "  Deprecated,"
280            )
281            .as_deref(),
282            Some("\n\n```suggestion\n\n```")
283        );
284        assert_eq!(
285            suggestion_block_for_issue_line(
286                Provider::Gitlab,
287                "fallow/unused-enum-member",
288                "  Deprecated,"
289            )
290            .as_deref(),
291            Some("\n\n```suggestion:-0+0\n\n```")
292        );
293    }
294
295    #[test]
296    fn renders_unused_class_member_delete_suggestion() {
297        assert_eq!(
298            suggestion_block_for_issue_line(
299                Provider::Github,
300                "fallow/unused-class-member",
301                "  legacyMethod() { return null; }"
302            )
303            .as_deref(),
304            Some("\n\n```suggestion\n\n```")
305        );
306    }
307
308    #[test]
309    fn unused_file_hint_uses_text_not_suggestion_block() {
310        let issue = CiIssue {
311            rule_id: "fallow/unused-file".to_owned(),
312            description: "File is not reachable".to_owned(),
313            severity: "major".to_owned(),
314            path: "src/dead.ts".to_owned(),
315            line: 1,
316            fingerprint: "abc".to_owned(),
317        };
318        let body = suggestion_block(Provider::Github, &issue).expect("hint");
319        assert!(!body.contains("```suggestion"), "must not be a code block");
320        assert!(body.contains("fallow fix --files"));
321    }
322
323    #[test]
324    fn delete_line_suggestion_skips_blank_lines() {
325        assert_eq!(
326            suggestion_block_for_issue_line(Provider::Github, "fallow/unused-enum-member", "   "),
327            None
328        );
329    }
330}