Skip to main content

fallow_cli/report/ci/
suggestion.rs

1use 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    // Unused-file rules don't have a per-line target on the diff; we surface
8    // a one-line text hint instead of a `suggestion` block (GitHub doesn't
9    // support file-deletion suggestions).
10    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    // Order matters: more-specific rule names first.
31    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/// One-line text hint for `unused-file` findings. Not a `suggestion` block:
44/// neither GitHub nor GitLab supports applying a file-scope deletion through
45/// the review-comment API, so we surface guidance for the human reader.
46#[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
65/// Suggestion that deletes the matched line entirely. Used for unused enum
66/// members and unused class members where the finding points at exactly the
67/// line that should disappear.
68///
69/// Both GitHub and GitLab render an empty `suggestion` block as "apply this
70/// to delete the line". The GitLab variant uses the line-offset-aware
71/// `:-0+0` suffix per their docs.
72fn 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        // Enum member line typically reads `  Deprecated,` or `  Foo = "foo",`.
238        // The fix is "delete this line" => empty suggestion block.
239        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        // GitHub's review-comment API has no file-deletion suggestion shape;
275        // GitLab same. We surface a one-liner hint instead of a misleading
276        // suggestion block that would not apply.
277        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        // Edge case: if the source line is empty, deleting it again is a no-op.
293        assert_eq!(
294            suggestion_block_for_issue_line(Provider::Github, "fallow/unused-enum-member", "   "),
295            None
296        );
297    }
298}