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") || 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#[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
60fn 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}