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") {
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_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        // Enum member line typically reads `  Deprecated,` or `  Foo = "foo",`.
188        // The fix is "delete this line" => empty suggestion block.
189        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        // GitHub's review-comment API has no file-deletion suggestion shape;
225        // GitLab same. We surface a one-liner hint instead of a misleading
226        // suggestion block that would not apply.
227        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        // Edge case: if the source line is empty, deleting it again is a no-op.
243        assert_eq!(
244            suggestion_block_for_issue_line(Provider::Github, "fallow/unused-enum-member", "   "),
245            None
246        );
247    }
248}