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") {
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_import_delete_suggestion() {
150 assert_eq!(
151 suggestion_block_for_issue_line(
152 Provider::Github,
153 "fallow/unused-import",
154 "import { unused } from './module';"
155 )
156 .as_deref(),
157 Some("\n\n```suggestion\n\n```")
158 );
159 }
160
161 #[test]
162 fn skips_side_effect_imports() {
163 assert_eq!(
164 suggestion_block_for_issue_line(
165 Provider::Github,
166 "fallow/unused-import",
167 "import './setup';"
168 ),
169 None
170 );
171 }
172
173 #[test]
174 fn skips_mixed_import_bindings() {
175 assert_eq!(
176 suggestion_block_for_issue_line(
177 Provider::Github,
178 "fallow/unused-import",
179 "import { used, unused } from './module';"
180 ),
181 None
182 );
183 }
184
185 #[test]
186 fn renders_unused_enum_member_delete_suggestion() {
187 assert_eq!(
190 suggestion_block_for_issue_line(
191 Provider::Github,
192 "fallow/unused-enum-member",
193 " Deprecated,"
194 )
195 .as_deref(),
196 Some("\n\n```suggestion\n\n```")
197 );
198 assert_eq!(
199 suggestion_block_for_issue_line(
200 Provider::Gitlab,
201 "fallow/unused-enum-member",
202 " Deprecated,"
203 )
204 .as_deref(),
205 Some("\n\n```suggestion:-0+0\n\n```")
206 );
207 }
208
209 #[test]
210 fn renders_unused_class_member_delete_suggestion() {
211 assert_eq!(
212 suggestion_block_for_issue_line(
213 Provider::Github,
214 "fallow/unused-class-member",
215 " legacyMethod() { return null; }"
216 )
217 .as_deref(),
218 Some("\n\n```suggestion\n\n```")
219 );
220 }
221
222 #[test]
223 fn unused_file_hint_uses_text_not_suggestion_block() {
224 let issue = CiIssue {
228 rule_id: "fallow/unused-file".to_owned(),
229 description: "File is not reachable".to_owned(),
230 severity: "major".to_owned(),
231 path: "src/dead.ts".to_owned(),
232 line: 1,
233 fingerprint: "abc".to_owned(),
234 };
235 let body = suggestion_block(Provider::Github, &issue).expect("hint");
236 assert!(!body.contains("```suggestion"), "must not be a code block");
237 assert!(body.contains("fallow fix --files"));
238 }
239
240 #[test]
241 fn delete_line_suggestion_skips_blank_lines() {
242 assert_eq!(
244 suggestion_block_for_issue_line(Provider::Github, "fallow/unused-enum-member", " "),
245 None
246 );
247 }
248}