1#[cfg(test)]
2mod tests {
3 use super::*;
4 use crate::backlog::{BacklogItem, ItemId, Status};
5 use crate::rank::Rank;
6 use crate::sprint::{Sprint, SprintId};
7 use chrono::Utc;
8
9 fn item() -> BacklogItem {
10 let mut item = BacklogItem::new(
11 ItemId::new("T", 1),
12 "Implement parser",
13 Status::new("in-progress"),
14 Rank::after(None),
15 Utc::now(),
16 )
17 .unwrap();
18 item.body = "acceptance: parser handles metadata".to_string();
19 item.labels = vec!["backend".to_string()];
20 item
21 }
22
23 fn sprint() -> Sprint {
24 let mut sprint =
25 Sprint::new(SprintId::new("S-1").unwrap(), "Release train", Utc::now()).unwrap();
26 sprint.goal = "Ship the parser".to_string();
27 sprint
28 }
29
30 #[test]
31 fn contains_search_matches_title_body_and_sprint_goal() {
32 let item = item();
33 let sprint = sprint();
34
35 assert!(
36 SearchFilter::new("parser", false)
37 .unwrap()
38 .matches(&item, Some(&sprint))
39 );
40 assert!(
41 SearchFilter::new("metadata", false)
42 .unwrap()
43 .matches(&item, Some(&sprint))
44 );
45 assert!(
46 SearchFilter::new("Ship the parser", false)
47 .unwrap()
48 .matches(&item, Some(&sprint))
49 );
50 }
51
52 #[test]
53 fn contains_search_matches_metadata_and_rejects_unrelated_items() {
54 let item = item();
55 let sprint = sprint();
56
57 assert!(
58 SearchFilter::new("in-progress", false)
59 .unwrap()
60 .matches(&item, Some(&sprint))
61 );
62 assert!(
63 !SearchFilter::new("unrelated", false)
64 .unwrap()
65 .matches(&item, Some(&sprint))
66 );
67 }
68
69 #[test]
70 fn regex_search_uses_the_requested_pattern() {
71 let item = item();
72
73 assert!(
74 SearchFilter::new(r"^T-\d+$", true)
75 .unwrap()
76 .matches(&item, None)
77 );
78 assert!(
79 !SearchFilter::new(r"^S-\d+$", true)
80 .unwrap()
81 .matches(&item, None)
82 );
83 }
84
85 #[test]
86 fn invalid_regex_is_reported_as_a_user_error() {
87 let error = SearchFilter::new("[", true).unwrap_err();
88 assert!(error.is_user_error());
89 assert!(error.to_string().contains("invalid search pattern"));
90 }
91}
92use crate::backlog::BacklogItem;
93use crate::error::{Error, Result};
94use crate::sprint::Sprint;
95use regex::Regex;
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum SearchMode {
100 Contains,
102 Regex,
104}
105
106#[derive(Debug, Clone)]
107enum Matcher {
108 Contains(String),
109 Regex(Regex),
110}
111
112#[derive(Debug, Clone)]
114pub struct SearchFilter {
115 pattern: String,
116 mode: SearchMode,
117 matcher: Matcher,
118}
119
120impl SearchFilter {
121 pub fn new(pattern: impl Into<String>, regex: bool) -> Result<Self> {
123 let pattern = pattern.into();
124 let mode = if regex {
125 SearchMode::Regex
126 } else {
127 SearchMode::Contains
128 };
129 let matcher = if regex {
130 Matcher::Regex(
131 Regex::new(&pattern)
132 .map_err(|error| Error::InvalidSearchPattern(error.to_string()))?,
133 )
134 } else {
135 Matcher::Contains(pattern.clone())
136 };
137 Ok(Self {
138 pattern,
139 mode,
140 matcher,
141 })
142 }
143
144 #[must_use]
146 pub fn pattern(&self) -> &str {
147 &self.pattern
148 }
149
150 #[must_use]
152 pub const fn mode(&self) -> SearchMode {
153 self.mode
154 }
155
156 #[must_use]
158 pub fn matches(&self, item: &BacklogItem, sprint: Option<&Sprint>) -> bool {
159 let fields = searchable_fields(item, sprint);
160 match &self.matcher {
161 Matcher::Contains(pattern) => fields.iter().any(|field| field.contains(pattern)),
162 Matcher::Regex(pattern) => fields.iter().any(|field| pattern.is_match(field)),
163 }
164 }
165}
166
167fn searchable_fields(item: &BacklogItem, sprint: Option<&Sprint>) -> Vec<String> {
169 let mut fields = vec![
170 item.id.to_string(),
171 item.title.clone(),
172 item.status.to_string(),
173 item.rank.to_string(),
174 item.points
175 .map(|value| value.to_string())
176 .unwrap_or_default(),
177 item.labels.join(" "),
178 item.assignee.clone().unwrap_or_default(),
179 item.sprint.clone().unwrap_or_default(),
180 item.parent
181 .as_ref()
182 .map(ToString::to_string)
183 .unwrap_or_default(),
184 item.depends_on
185 .iter()
186 .map(ToString::to_string)
187 .collect::<Vec<_>>()
188 .join(" "),
189 item.start_at
190 .map(|value| value.to_rfc3339())
191 .unwrap_or_default(),
192 item.done_at
193 .map(|value| value.to_rfc3339())
194 .unwrap_or_default(),
195 item.commits.join(" "),
196 item.created.to_rfc3339(),
197 item.updated.to_rfc3339(),
198 item.body.clone(),
199 ];
200 if let Some(sprint) = sprint {
201 fields.push(sprint.id.to_string());
202 fields.push(sprint.title.clone());
203 fields.push(sprint.goal.clone());
204 fields.push(sprint.state.to_string());
205 fields.push(
206 sprint
207 .start
208 .map(|value| value.to_rfc3339())
209 .unwrap_or_default(),
210 );
211 fields.push(
212 sprint
213 .end
214 .map(|value| value.to_rfc3339())
215 .unwrap_or_default(),
216 );
217 fields.push(
218 sprint
219 .daily_work_hours
220 .map(|value| value.to_string())
221 .unwrap_or_default(),
222 );
223 fields.push(
224 sprint
225 .holiday_days
226 .map(|value| value.to_string())
227 .unwrap_or_default(),
228 );
229 fields.push(
230 sprint
231 .deduction_factor
232 .map(|value| value.to_string())
233 .unwrap_or_default(),
234 );
235 fields.push(sprint.created.to_rfc3339());
236 fields.push(sprint.updated.to_rfc3339());
237 }
238 fields
239}