Skip to main content

pinto/service/
search.rs

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/// Search interpretation used by list, board, and Kanban filters.
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum SearchMode {
100    /// Match a literal substring.
101    Contains,
102    /// Match a regular expression.
103    Regex,
104}
105
106#[derive(Debug, Clone)]
107enum Matcher {
108    Contains(String),
109    Regex(Regex),
110}
111
112/// A validated search filter over a PBI and its assigned sprint metadata.
113#[derive(Debug, Clone)]
114pub struct SearchFilter {
115    pattern: String,
116    mode: SearchMode,
117    matcher: Matcher,
118}
119
120impl SearchFilter {
121    /// Create a search filter. `regex = false` performs literal substring matching.
122    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    /// Return the original user-supplied pattern.
145    #[must_use]
146    pub fn pattern(&self) -> &str {
147        &self.pattern
148    }
149
150    /// Return the selected search mode.
151    #[must_use]
152    pub const fn mode(&self) -> SearchMode {
153        self.mode
154    }
155
156    /// Return whether the item or its assigned sprint matches this filter.
157    #[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
167/// Build searchable fields from every persisted PBI field plus the sprint name and goal.
168fn 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}