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