1use chrono::{DateTime, Utc};
2use regex::Regex;
3use serde::{Deserialize, Serialize};
4
5use crate::types::GitHubIssue;
6
7#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
8pub enum IssueState {
9 Open,
10 Closed,
11 All,
12}
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct IssueFilters {
16 pub state: IssueState,
17 pub include_labels: Vec<String>,
18 pub exclude_labels: Vec<String>,
19 pub rust_errors_only: bool,
20 pub code_blocks_only: bool,
21 pub min_body_length: Option<usize>,
22 pub date_range: Option<DateRange>,
23 pub include_pull_requests: bool,
24 pub min_comments: Option<u32>,
25 pub required_keywords: Vec<String>,
26 pub excluded_keywords: Vec<String>,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct DateRange {
31 pub start: Option<DateTime<Utc>>,
32 pub end: Option<DateTime<Utc>>,
33}
34
35impl Default for IssueFilters {
36 fn default() -> Self {
37 Self {
38 state: IssueState::All,
39 include_labels: vec![],
40 exclude_labels: vec![
41 "duplicate".to_string(),
42 "invalid".to_string(),
43 "wontfix".to_string(),
44 "question".to_string(),
45 ],
46 rust_errors_only: false,
47 code_blocks_only: false,
48 min_body_length: Some(50),
49 date_range: None,
50 include_pull_requests: false,
51 min_comments: None,
52 required_keywords: vec![],
53 excluded_keywords: vec![
54 "discussion".to_string(),
55 "RFC".to_string(),
56 "tracking".to_string(),
57 ],
58 }
59 }
60}
61
62impl IssueFilters {
63 pub fn rust_error_focused() -> Self {
64 Self {
65 state: IssueState::All,
66 include_labels: vec![
67 "E-help-wanted".to_string(),
68 "A-diagnostics".to_string(),
69 "A-borrowck".to_string(),
70 "E-easy".to_string(),
71 "E-medium".to_string(),
72 ],
73 rust_errors_only: true,
74 code_blocks_only: true,
75 min_body_length: Some(100),
76 ..Default::default()
77 }
78 }
79
80 pub fn matches(&self, issue: &GitHubIssue) -> bool {
81 match self.state {
82 IssueState::Open => {
83 if issue.state != "Open" {
84 return false;
85 }
86 }
87 IssueState::Closed => {
88 if issue.state != "Closed" {
89 return false;
90 }
91 }
92 IssueState::All => {}
93 }
94
95 if !self.include_pull_requests && issue.is_pull_request {
96 return false;
97 }
98
99 if !self.include_labels.is_empty() {
100 let has_included_label = issue.labels.iter().any(|label| {
101 self.include_labels
102 .iter()
103 .any(|include_label| include_label.to_lowercase() == label.name.to_lowercase())
104 });
105 if !has_included_label {
106 return false;
107 }
108 }
109
110 if issue.labels.iter().any(|label| {
111 self.exclude_labels
112 .iter()
113 .any(|exclude_label| exclude_label.to_lowercase() == label.name.to_lowercase())
114 }) {
115 return false;
116 }
117
118 if let Some(min_length) = self.min_body_length {
119 if issue.body.as_ref().map_or(0, |b| b.len()) < min_length {
120 return false;
121 }
122 }
123
124 if let Some(min_comments) = self.min_comments {
125 if issue.comments < min_comments {
126 return false;
127 }
128 }
129
130 if let Some(date_range) = &self.date_range {
131 if let Some(start) = date_range.start {
132 if issue.created_at < start {
133 return false;
134 }
135 }
136 if let Some(end) = date_range.end {
137 if issue.created_at > end {
138 return false;
139 }
140 }
141 }
142
143 let content =
144 format!("{} {}", issue.title, issue.body.as_deref().unwrap_or("")).to_lowercase();
145
146 if !self.required_keywords.is_empty() {
147 if !self
148 .required_keywords
149 .iter()
150 .any(|keyword| content.contains(&keyword.to_lowercase()))
151 {
152 return false;
153 }
154 }
155
156 if self
157 .excluded_keywords
158 .iter()
159 .any(|keyword| content.contains(&keyword.to_lowercase()))
160 {
161 return false;
162 }
163
164 if self.rust_errors_only {
165 if !has_rust_error_codes(&content) {
166 return false;
167 }
168 }
169
170 if self.code_blocks_only {
171 if !has_code_blocks(issue.body.as_deref().unwrap_or("")) {
172 return false;
173 }
174 }
175
176 true
177 }
178}
179
180pub fn has_rust_error_codes(text: &str) -> bool {
181 let error_regex = Regex::new(r"E0\d{3,4}").unwrap();
182 error_regex.is_match(text)
183}
184
185pub fn has_code_blocks(text: &str) -> bool {
186 if text.contains("```") {
187 return true;
188 }
189
190 text.lines()
191 .any(|line| line.starts_with(" ") && !line.trim().is_empty())
192}
193
194pub fn extract_error_codes(text: &str) -> Vec<String> {
195 let error_regex = Regex::new(r"E0\d{3,4}").unwrap();
196 error_regex
197 .find_iter(text)
198 .map(|m| m.as_str().to_string())
199 .collect::<std::collections::HashSet<_>>()
200 .into_iter()
201 .collect()
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207
208 #[test]
209 fn test_rust_error_detection() {
210 assert!(has_rust_error_codes("Error E0382: use of moved value"));
211 assert!(has_rust_error_codes("Getting E0502 and E0499 errors"));
212 assert!(!has_rust_error_codes("No errors here"));
213
214 let codes = extract_error_codes("E0382 and E0502 errors occurred");
215 assert_eq!(codes.len(), 2);
216 assert!(codes.contains(&"E0382".to_string()));
217 assert!(codes.contains(&"E0502".to_string()));
218 }
219
220 #[test]
221 fn test_code_block_detection() {
222 assert!(has_code_blocks("```rust\nfn main() {}\n```"));
223 assert!(has_code_blocks(" let x = 5;\n println!(\"{}\", x);"));
224 assert!(!has_code_blocks("Just regular text without code"));
225 }
226}