1use std::path::PathBuf;
2
3use fallow_output::{CiIssue, CiProvider as Provider};
4
5#[must_use]
6pub fn suggestion_block(provider: Provider, issue: &CiIssue) -> Option<String> {
7 let mut block = fix_intent(issue).map(fix_intent_block).unwrap_or_default();
8 if issue.rule_id.contains("unused-file") {
9 block.push_str(&unused_file_hint());
10 return Some(block);
11 }
12 if issue.line == 0 {
13 return (!block.is_empty()).then_some(block);
14 }
15
16 let root = std::env::var_os("FALLOW_ROOT").map_or_else(|| PathBuf::from("."), PathBuf::from);
17 let path = root.join(&issue.path);
18 let Some(source) = std::fs::read_to_string(path).ok() else {
19 return (!block.is_empty()).then_some(block);
20 };
21 let Some(line) = source.lines().nth(issue.line.saturating_sub(1) as usize) else {
22 return (!block.is_empty()).then_some(block);
23 };
24 if let Some(suggestion) = suggestion_block_for_issue_line(provider, &issue.rule_id, line) {
25 block.push_str(&suggestion);
26 }
27 (!block.is_empty()).then_some(block)
28}
29
30#[must_use]
31pub fn fix_intent(issue: &CiIssue) -> Option<&'static str> {
32 match issue.rule_id.as_str() {
33 "fallow/unused-file" => Some("Delete the file or add a real entry-point reference."),
34 "fallow/unused-export" => {
35 Some("Remove the export or mark it public if it is part of the API.")
36 }
37 "fallow/code-duplication" => Some("Extract the repeated block or centralize shared logic."),
38 "fallow/high-crap-score" | "fallow/high-complexity" => {
39 Some("Split branches or add focused tests around the risky path.")
40 }
41 "fallow/unresolved-import" => Some("Fix the import path or install the missing package."),
42 _ => None,
43 }
44}
45
46fn fix_intent_block(intent: &str) -> String {
47 format!("\n\n> Fix intent: {intent}")
48}
49
50#[must_use]
51pub fn suggestion_block_for_issue_line(
52 provider: Provider,
53 rule_id: &str,
54 line: &str,
55) -> Option<String> {
56 if rule_id.contains("unused-import") {
57 return unused_import_suggestion(provider, line);
58 }
59 if rule_id.contains("unused-enum-member")
60 || rule_id.contains("unused-class-member")
61 || rule_id.contains("unused-store-member")
62 {
63 return delete_line_suggestion(provider, line);
64 }
65 if rule_id.contains("unused-export") || rule_id.contains("unused-type") {
66 return unused_export_suggestion(provider, line);
67 }
68 None
69}
70
71#[must_use]
74fn unused_file_hint() -> String {
75 "\n\n> Run `fallow fix --files` or delete this file.".to_owned()
76}
77
78fn unused_export_suggestion(provider: Provider, line: &str) -> Option<String> {
79 let fixed = line
80 .strip_prefix("export default ")
81 .or_else(|| line.strip_prefix("export "))?;
82 if fixed == line {
83 return None;
84 }
85
86 match provider {
87 Provider::Github => Some(format!("\n\n```suggestion\n{fixed}\n```")),
88 Provider::Gitlab => Some(format!("\n\n```suggestion:-0+0\n{fixed}\n```")),
89 }
90}
91
92fn delete_line_suggestion(provider: Provider, line: &str) -> Option<String> {
94 if line.trim().is_empty() {
95 return None;
96 }
97 match provider {
98 Provider::Github => Some("\n\n```suggestion\n\n```".to_owned()),
99 Provider::Gitlab => Some("\n\n```suggestion:-0+0\n\n```".to_owned()),
100 }
101}
102
103fn unused_import_suggestion(provider: Provider, line: &str) -> Option<String> {
104 let trimmed = line.trim_start();
105 if !trimmed.starts_with("import ") {
106 return None;
107 }
108
109 let import_target = trimmed.strip_prefix("import ")?.trim_start();
110 if import_target.starts_with('"') || import_target.starts_with('\'') {
111 return None;
112 }
113
114 let (clause, _) = import_target.split_once(" from ")?;
115 let clause = clause
116 .trim()
117 .strip_prefix("type ")
118 .unwrap_or_else(|| clause.trim())
119 .trim();
120 if clause.contains(',') {
121 return None;
122 }
123 if let Some(named) = clause
124 .strip_prefix('{')
125 .and_then(|value| value.strip_suffix('}'))
126 {
127 let named = named.trim();
128 if named.is_empty() || named.contains(',') {
129 return None;
130 }
131 }
132
133 match provider {
134 Provider::Github => Some("\n\n```suggestion\n\n```".to_string()),
135 Provider::Gitlab => Some("\n\n```suggestion:-0+0\n\n```".to_string()),
136 }
137}
138
139#[cfg(test)]
140mod tests {
141 use super::*;
142
143 #[test]
144 fn renders_github_suggestion() {
145 assert_eq!(
146 suggestion_block_for_issue_line(
147 Provider::Github,
148 "fallow/unused-export",
149 "export const value = 1;"
150 )
151 .as_deref(),
152 Some("\n\n```suggestion\nconst value = 1;\n```")
153 );
154 }
155
156 #[test]
157 fn renders_gitlab_suggestion() {
158 assert_eq!(
159 suggestion_block_for_issue_line(
160 Provider::Gitlab,
161 "fallow/unused-export",
162 "export default thing;"
163 )
164 .as_deref(),
165 Some("\n\n```suggestion:-0+0\nthing;\n```")
166 );
167 }
168
169 #[test]
170 fn renders_unused_type_export_suggestion() {
171 assert_eq!(
172 suggestion_block_for_issue_line(
173 Provider::Github,
174 "fallow/unused-type",
175 "export type Legacy = { id: string };"
176 )
177 .as_deref(),
178 Some("\n\n```suggestion\ntype Legacy = { id: string };\n```")
179 );
180 assert_eq!(
181 suggestion_block_for_issue_line(
182 Provider::Gitlab,
183 "fallow/unused-type",
184 "export interface Legacy { id: string }"
185 )
186 .as_deref(),
187 Some("\n\n```suggestion:-0+0\ninterface Legacy { id: string }\n```")
188 );
189 }
190
191 #[test]
192 fn fix_intent_names_common_review_actions() {
193 let issue = CiIssue {
194 rule_id: "fallow/code-duplication".to_owned(),
195 description: "clone".to_owned(),
196 severity: "minor".to_owned(),
197 path: "src/a.ts".to_owned(),
198 line: 1,
199 fingerprint: "abc".to_owned(),
200 };
201
202 assert_eq!(
203 fix_intent(&issue),
204 Some("Extract the repeated block or centralize shared logic.")
205 );
206 }
207
208 #[test]
209 fn unused_type_suggestion_is_conservative() {
210 assert_eq!(
211 suggestion_block_for_issue_line(
212 Provider::Github,
213 "fallow/unused-type",
214 " export type Indented = string;"
215 ),
216 None
217 );
218 assert_eq!(
219 suggestion_block_for_issue_line(
220 Provider::Github,
221 "fallow/unused-type",
222 "type Local = string;"
223 ),
224 None
225 );
226 assert_eq!(
227 suggestion_block_for_issue_line(
228 Provider::Github,
229 "fallow/unused-type",
230 "const used = 1; export type Legacy = string;"
231 ),
232 None
233 );
234 }
235
236 #[test]
237 fn renders_unused_import_delete_suggestion() {
238 assert_eq!(
239 suggestion_block_for_issue_line(
240 Provider::Github,
241 "fallow/unused-import",
242 "import { unused } from './module';"
243 )
244 .as_deref(),
245 Some("\n\n```suggestion\n\n```")
246 );
247 }
248
249 #[test]
250 fn skips_side_effect_imports() {
251 assert_eq!(
252 suggestion_block_for_issue_line(
253 Provider::Github,
254 "fallow/unused-import",
255 "import './setup';"
256 ),
257 None
258 );
259 }
260
261 #[test]
262 fn skips_mixed_import_bindings() {
263 assert_eq!(
264 suggestion_block_for_issue_line(
265 Provider::Github,
266 "fallow/unused-import",
267 "import { used, unused } from './module';"
268 ),
269 None
270 );
271 }
272
273 #[test]
274 fn renders_unused_enum_member_delete_suggestion() {
275 assert_eq!(
276 suggestion_block_for_issue_line(
277 Provider::Github,
278 "fallow/unused-enum-member",
279 " Deprecated,"
280 )
281 .as_deref(),
282 Some("\n\n```suggestion\n\n```")
283 );
284 assert_eq!(
285 suggestion_block_for_issue_line(
286 Provider::Gitlab,
287 "fallow/unused-enum-member",
288 " Deprecated,"
289 )
290 .as_deref(),
291 Some("\n\n```suggestion:-0+0\n\n```")
292 );
293 }
294
295 #[test]
296 fn renders_unused_class_member_delete_suggestion() {
297 assert_eq!(
298 suggestion_block_for_issue_line(
299 Provider::Github,
300 "fallow/unused-class-member",
301 " legacyMethod() { return null; }"
302 )
303 .as_deref(),
304 Some("\n\n```suggestion\n\n```")
305 );
306 }
307
308 #[test]
309 fn unused_file_hint_uses_text_not_suggestion_block() {
310 let issue = CiIssue {
311 rule_id: "fallow/unused-file".to_owned(),
312 description: "File is not reachable".to_owned(),
313 severity: "major".to_owned(),
314 path: "src/dead.ts".to_owned(),
315 line: 1,
316 fingerprint: "abc".to_owned(),
317 };
318 let body = suggestion_block(Provider::Github, &issue).expect("hint");
319 assert!(!body.contains("```suggestion"), "must not be a code block");
320 assert!(body.contains("fallow fix --files"));
321 }
322
323 #[test]
324 fn delete_line_suggestion_skips_blank_lines() {
325 assert_eq!(
326 suggestion_block_for_issue_line(Provider::Github, "fallow/unused-enum-member", " "),
327 None
328 );
329 }
330}