fallow_cli/report/ci/
suggestion.rs1use 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#[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
63fn 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}