Skip to main content

fallow_cli/report/ci/
suggestion.rs

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