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