1use serde::{Deserialize, Serialize};
20
21use crate::error::Result;
22use crate::model::Issue;
23use crate::storage::Store;
24
25pub struct Corpus {
28 pub issues: Vec<Issue>,
29 pub texts: Vec<String>,
30}
31
32impl Corpus {
33 pub fn build(store: &Store) -> Result<Corpus> {
41 let issues = store.list()?;
42 let texts = issues
43 .iter()
44 .map(|issue| issue.to_file_contents())
45 .collect();
46 Ok(Corpus { issues, texts })
47 }
48
49 pub fn by_number(&self, n: u32) -> Option<usize> {
51 self.issues.iter().position(|issue| issue.number == n)
52 }
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct SearchHit {
59 pub issue: Issue,
60 pub matched_lines: Vec<String>,
61}
62
63pub fn search_substring(corpus: &Corpus, query: &str) -> Vec<SearchHit> {
73 if query.is_empty() {
74 return Vec::new();
75 }
76 let needle = query.to_lowercase();
77 let mut hits: Vec<SearchHit> = Vec::new();
78 for (issue, text) in corpus.issues.iter().zip(corpus.texts.iter()) {
79 let hay = text.to_lowercase();
80 if !hay.contains(&needle) {
81 continue;
82 }
83 let matched_lines: Vec<String> = text
84 .lines()
85 .filter(|line| line.to_lowercase().contains(&needle))
86 .map(str::to_string)
87 .collect();
88 hits.push(SearchHit {
89 issue: issue.clone(),
90 matched_lines,
91 });
92 }
93 hits.sort_by_key(|hit| hit.issue.number);
94 hits
95}
96
97pub fn fuzzy_score(query: &str, haystack: &str) -> Option<u32> {
114 let q = query.to_lowercase();
115 let h = haystack.to_lowercase();
116 if q.is_empty() {
117 return Some(0);
118 }
119 if q == h {
120 return Some(EXACT_MATCH_SCORE);
121 }
122 let positions = greedy_subsequence_positions(&q, &h)?;
123 let mut score = 0u32;
124 let mut prev: Option<usize> = None;
125 for pos in &positions {
126 score += 100;
127 score += (1000 - (*pos).min(1000)) as u32;
128 if prev.is_some_and(|p| *pos == p + 1) {
129 score += RUN_BONUS;
130 }
131 prev = Some(*pos);
132 }
133 if positions.first() == Some(&0) {
134 score += PREFIX_BONUS;
135 }
136 Some(score)
137}
138
139const RUN_BONUS: u32 = 150;
141const PREFIX_BONUS: u32 = 500;
143const EXACT_MATCH_SCORE: u32 = 50_000;
145
146fn greedy_subsequence_positions(query: &str, haystack: &str) -> Option<Vec<usize>> {
150 let q: Vec<char> = query.chars().collect();
151 if q.is_empty() {
152 return Some(Vec::new());
153 }
154 let h: Vec<char> = haystack.chars().collect();
155 let mut positions = Vec::with_capacity(q.len());
156 let mut hi = 0usize;
157 for qc in &q {
159 let found = h[hi..]
160 .iter()
161 .position(|hc| *hc == *qc)
162 .map(|off| off + hi)?;
163 positions.push(found);
164 hi = found + 1;
165 }
166 Some(positions)
167}
168
169#[derive(Debug, Clone, Copy, PartialEq, Eq)]
180pub enum FilterField {
181 Assignee,
182 Label,
183 Status,
184 Priority,
185}
186
187impl FilterField {
188 pub const ALL: [FilterField; 4] = [
190 FilterField::Assignee,
191 FilterField::Label,
192 FilterField::Status,
193 FilterField::Priority,
194 ];
195
196 pub fn label(self) -> &'static str {
197 match self {
198 FilterField::Assignee => "Assignee",
199 FilterField::Label => "Label",
200 FilterField::Status => "Status",
201 FilterField::Priority => "Priority",
202 }
203 }
204
205 pub fn key(self) -> &'static str {
208 match self {
209 FilterField::Assignee => "assignee",
210 FilterField::Label => "labels",
211 FilterField::Status => "status",
212 FilterField::Priority => "priority",
213 }
214 }
215
216 pub fn row(self) -> usize {
218 FilterField::ALL
219 .iter()
220 .position(|field| *field == self)
221 .expect("FilterField variants are all in ALL")
222 }
223
224 pub fn next(self) -> Self {
225 let all = FilterField::ALL;
226 all[(self.row() + 1) % all.len()]
227 }
228
229 pub fn prev(self) -> Self {
230 let all = FilterField::ALL;
231 all[(self.row() + all.len() - 1) % all.len()]
232 }
233
234 pub fn value(self, filter: &StructuredFilter) -> Option<&str> {
238 let value = match self {
239 FilterField::Assignee => filter.assignee.as_deref(),
240 FilterField::Label => filter.label.as_deref(),
241 FilterField::Status => filter.status.as_deref(),
242 FilterField::Priority => filter.priority.as_deref(),
243 };
244 value.filter(|s| !s.is_empty())
245 }
246
247 pub fn value_mut(self, filter: &mut StructuredFilter) -> &mut String {
251 match self {
252 FilterField::Assignee => filter.assignee.get_or_insert_default(),
253 FilterField::Label => filter.label.get_or_insert_default(),
254 FilterField::Status => filter.status.get_or_insert_default(),
255 FilterField::Priority => filter.priority.get_or_insert_default(),
256 }
257 }
258
259 pub fn unset(self, filter: &mut StructuredFilter) {
261 match self {
262 FilterField::Assignee => filter.assignee = None,
263 FilterField::Label => filter.label = None,
264 FilterField::Status => filter.status = None,
265 FilterField::Priority => filter.priority = None,
266 }
267 }
268}
269
270#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
279pub struct StructuredFilter {
280 pub assignee: Option<String>,
281 pub label: Option<String>,
282 pub status: Option<String>,
283 pub priority: Option<String>,
284}
285
286impl StructuredFilter {
287 pub fn criteria(&self) -> impl Iterator<Item = (FilterField, &str)> {
289 FilterField::ALL
290 .into_iter()
291 .filter_map(move |field| field.value(self).map(|value| (field, value)))
292 }
293
294 pub fn has_active_criteria(&self) -> bool {
297 self.criteria().next().is_some()
298 }
299
300 pub fn evaluate(&self, haystack: &str) -> bool {
315 let hay = haystack.to_lowercase();
316 for (_, value) in self.criteria() {
317 if !hay.contains(&value.to_lowercase()) {
318 return false;
319 }
320 }
321 true
322 }
323
324 pub fn normalize(&mut self) {
327 for field in FilterField::ALL {
328 let empty = field.value_mut(self).trim().is_empty();
329 if empty {
330 field.unset(self);
331 } else {
332 let value = field.value_mut(self);
333 let trimmed = value.trim().to_string();
334 *value = trimmed;
335 }
336 }
337 }
338}
339
340impl std::fmt::Display for StructuredFilter {
341 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
344 let mut criteria = self.criteria();
345 let Some((field, value)) = criteria.next() else {
346 return write!(f, "(no active criteria)");
347 };
348 write!(f, "{}:{value}", field.key())?;
349 for (field, value) in criteria {
350 write!(f, ", {}:{value}", field.key())?;
351 }
352 Ok(())
353 }
354}
355
356pub fn matches(query: &str, filter: &StructuredFilter, haystack: &str) -> bool {
366 let text_matches = query.is_empty() || fuzzy_score(query, haystack).is_some();
367 text_matches && filter.evaluate(haystack)
368}
369
370pub fn number_query(raw: &str) -> Option<u32> {
378 let trimmed = raw.trim();
379 if trimmed.is_empty() || !trimmed.bytes().all(|b| b.is_ascii_digit()) {
380 return None;
381 }
382 trimmed.parse::<u32>().ok()
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388 use crate::model::{Issue, Priority, Status};
389 use std::fs;
390
391 fn make_store() -> (tempfile::TempDir, Store) {
396 let tmp = tempfile::TempDir::new().unwrap();
397 let dir = tmp.path().join("issues");
398 fs::create_dir_all(&dir).unwrap();
399 let config_path = tmp.path().join("tix.toml");
400 let config = crate::config::Config {
401 issues_dir: dir.clone(),
402 ..crate::config::Config::default()
403 };
404 config.save(&config_path).unwrap();
405 let config = crate::config::Config::load_with_warning(&config_path, |_| {}).unwrap();
406 let store = Store {
407 issues_dir: dir,
408 config,
409 };
410 (tmp, store)
411 }
412
413 fn sample_issue(number: u32, title: &str, extra_body: &str) -> Issue {
414 let mut issue = Issue {
415 number,
416 title: title.to_string(),
417 status: Status::default(),
418 labels: vec!["urgent".to_string()],
419 priority: Priority::default(),
420 assignee: Some("alice".to_string()),
421 estimate: None,
422 commit: None,
423 gitlab_id: None,
424 linear_id: None,
425 mission: None,
426 task: None,
427 links: vec![],
428 files: vec![],
429 created: "2026-01-01".to_string(),
430 updated: "2026-01-01".to_string(),
431 filename: String::new(),
432 body: extra_body.to_string(),
433 };
434 issue.filename = format!("alice/{}-{}.md", "2026-01-01", crate::slug::slugify(title));
437 issue
438 }
439
440 fn write_issue(store: &Store, name: &str, issue: &Issue) {
441 fs::write(store.issues_dir.join(name), issue.to_file_contents()).unwrap();
442 }
443
444 #[test]
445 fn corpus_texts_match_issue_to_file_contents_byte_for_byte() {
446 let (_tmp, store) = make_store();
447 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
448 let a = sample_issue(1, "Login bug", "Need to reproduce the redirect loop.");
449 write_issue(&store, "alice/2026-01-01-login-bug.md", &a);
450 let b = sample_issue(
451 2,
452 "Export csv",
453 "## Comments\n\n### alice (2026-01-01)\n\nSort order is wrong.\n",
454 );
455 write_issue(&store, "alice/2026-01-01-export-csv.md", &b);
456
457 let corpus = Corpus::build(&store).unwrap();
458 assert_eq!(corpus.issues.len(), 2);
459 let idx_a = corpus.by_number(1).unwrap();
462 let idx_b = corpus.by_number(2).unwrap();
463 assert_eq!(corpus.texts[idx_a], a.to_file_contents());
464 assert_eq!(corpus.texts[idx_b], b.to_file_contents());
465 assert_eq!(corpus.by_number(99), None);
466 }
467
468 #[test]
469 fn corpus_never_indexes_asset_dirs() {
470 let (_tmp, store) = make_store();
471 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
472 let a = sample_issue(1, "Login bug", "body");
473 write_issue(&store, "alice/2026-01-01-login-bug.md", &a);
474 let assets = store.issues_dir.join("alice/login-bug-assets");
476 fs::create_dir_all(&assets).unwrap();
477 fs::write(assets.join("notes.md"), "# not an issue\n").unwrap();
478 fs::write(assets.join("diagram.png"), b"\x89PNG").unwrap();
479
480 let corpus = Corpus::build(&store).unwrap();
481 assert_eq!(corpus.issues.len(), 1);
482 let evil_found = corpus.texts.iter().any(|t| t.contains("not an issue"));
483 assert!(
484 !evil_found,
485 "asset-dir markdown must not leak into the corpus"
486 );
487 }
488
489 fn two_issue_corpus() -> Corpus {
494 let (_tmp, store) = make_store();
495 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
496 let a = sample_issue(1, "Login bug", "The redirect URL is broken.");
497 write_issue(&store, "alice/2026-01-01-login-bug.md", &a);
498 let b = sample_issue(
499 2,
500 "Export csv",
501 "## Comments\n\n### alice (2026-01-01)\n\nHandle empty rows.\n",
502 );
503 write_issue(&store, "alice/2026-01-01-export-csv.md", &b);
504 Corpus::build(&store).unwrap()
505 }
506
507 #[test]
508 fn substring_matches_frontmatter() {
509 let corpus = two_issue_corpus();
510 let hits = search_substring(&corpus, "Login bug");
511 assert_eq!(hits.len(), 1);
512 assert_eq!(hits[0].issue.number, 1);
513 assert!(
514 hits[0]
515 .matched_lines
516 .iter()
517 .any(|l| l.contains("Login bug")),
518 "frontmatter title line must appear as context: {hits:?}"
519 );
520 }
521
522 #[test]
523 fn substring_matches_comment_text_only() {
524 let corpus = two_issue_corpus();
525 let hits = search_substring(&corpus, "empty rows");
526 assert_eq!(hits.len(), 1);
527 assert_eq!(hits[0].issue.number, 2);
528 assert!(
529 hits[0]
530 .matched_lines
531 .iter()
532 .any(|l| l.contains("empty rows")),
533 "comment line must appear as context: {hits:?}"
534 );
535 }
536
537 #[test]
538 fn substring_matches_label() {
539 let corpus = two_issue_corpus();
540 let hits = search_substring(&corpus, "urgent");
541 assert_eq!(hits.len(), 2, "both issues carry the `urgent` label");
542 assert_eq!(
543 hits.iter().map(|h| h.issue.number).collect::<Vec<_>>(),
544 vec![1, 2],
545 "stable number-ascending order (NFR-007)"
546 );
547 }
548
549 #[test]
550 fn substring_is_case_insensitive() {
551 let corpus = two_issue_corpus();
552 assert_eq!(search_substring(&corpus, "LOGIN BUG").len(), 1);
553 assert_eq!(search_substring(&corpus, "login bug").len(), 1);
554 }
555
556 #[test]
557 fn substring_orders_by_number_ascending() {
558 let (_tmp, store) = make_store();
559 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
560 let hi = sample_issue(9, "Zed bug", "The redirect loop");
561 let lo = sample_issue(3, "Alpha", "The redirect loop, too");
562 write_issue(&store, "alice/2026-01-01-zed-bug.md", &hi);
563 write_issue(&store, "alice/2026-01-01-alpha.md", &lo);
564 let corpus = Corpus::build(&store).unwrap();
565 let hits = search_substring(&corpus, "redirect loop");
566 assert_eq!(
567 hits.iter().map(|h| h.issue.number).collect::<Vec<_>>(),
568 vec![3, 9],
569 "hits sorted by number regardless of file order"
570 );
571 }
572
573 #[test]
574 fn substring_zero_matches_is_empty() {
575 let corpus = two_issue_corpus();
576 assert!(search_substring(&corpus, "nope-nothing-here").is_empty());
577 }
578
579 #[test]
580 fn substring_empty_query_is_empty() {
581 let corpus = two_issue_corpus();
582 assert!(search_substring(&corpus, "").is_empty());
583 }
584
585 #[test]
590 fn fuzzy_exact_match_scores_higher_than_partial() {
591 let exact = fuzzy_score("login bug", "login bug").unwrap();
592 let partial = fuzzy_score("login bug", "debug the login bug report").unwrap();
593 assert!(exact > partial);
594 }
595
596 #[test]
597 fn fuzzy_contiguous_beats_spread() {
598 let contiguous = fuzzy_score("ab", "xyab").unwrap();
599 let spread = fuzzy_score("ab", "xayb").unwrap();
600 assert!(contiguous > spread);
601 }
602
603 #[test]
604 fn fuzzy_prefix_beats_mid_string() {
605 let prefix = fuzzy_score("ab", "abxy").unwrap();
606 let mid = fuzzy_score("ab", "xaby").unwrap();
607 assert!(prefix > mid);
608 }
609
610 #[test]
611 fn fuzzy_non_subsequence_is_none() {
612 assert_eq!(fuzzy_score("abc", "aac"), None);
613 assert_eq!(fuzzy_score("zzz", "haystack"), None);
614 }
615
616 #[test]
617 fn fuzzy_is_case_insensitive() {
618 assert_eq!(
619 fuzzy_score("LOGIN", "login").unwrap(),
620 fuzzy_score("login", "login").unwrap()
621 );
622 assert_eq!(
623 fuzzy_score("aBc", "xAbC").unwrap(),
624 fuzzy_score("abc", "xabc").unwrap()
625 );
626 }
627
628 #[test]
629 fn fuzzy_empty_query_scores_zero() {
630 assert_eq!(fuzzy_score("", "anything"), Some(0));
631 }
632
633 #[test]
634 fn fuzzy_earlier_positions_beat_later() {
635 let early = fuzzy_score("ab", "abcxyz").unwrap();
636 let late = fuzzy_score("ab", "xyzabc").unwrap();
637 assert!(early > late);
638 }
639
640 #[test]
641 fn fuzzy_perf_tripwire_500_issue_sweep_stays_fast() {
642 let issues: Vec<String> = (0..500u32)
647 .map(|i| {
648 format!(
649 "---\nnumber: {i}\ntitle: \"synthetic issue {i}\"\npriority: medium\n---\n\n\
650 A long body about feature {i} that mentions refunds, retries, idempotency keys, \
651 and a blocking redirect loop. The assigned developer needs to investigate the \
652 flaky test and prepare a hotfix for the staging deploy.\n\n\
653 ## Comments\n\n### alice (2026-01-01)\n\nNotes for issue {i}.\n"
654 )
655 })
656 .collect();
657 let queries = [
660 "issue",
661 "transient race",
662 "payment",
663 "hotfix 3",
664 "aAa",
665 "zzz",
666 "issue 499",
667 "staging",
668 "deploy",
669 "ref",
670 ];
671 let start = std::time::Instant::now();
672 for q in &queries {
673 for issue in &issues {
674 let _ = fuzzy_score(q, issue);
675 }
676 }
677 let elapsed = start.elapsed();
678 assert!(
681 elapsed < std::time::Duration::from_millis(500),
682 "500-issue corpus re-filter must stay under 500ms, took {elapsed:?}"
683 );
684 }
685
686 #[test]
691 fn filter_default_has_no_active_criteria() {
692 let filter = StructuredFilter::default();
693 assert!(!filter.has_active_criteria());
694 assert_eq!(filter.criteria().count(), 0);
695 assert_eq!(filter.to_string(), "(no active criteria)");
696 }
697
698 #[test]
699 fn filter_criteria_lists_only_active_in_all_order() {
700 let filter = StructuredFilter {
701 label: Some("urgent".to_string()),
702 assignee: Some(String::new()),
703 status: Some("todo".to_string()),
704 priority: None,
705 };
706 let criteria: Vec<(FilterField, &str)> = filter.criteria().collect();
707 assert_eq!(
708 criteria,
709 vec![
710 (FilterField::Label, "urgent"),
711 (FilterField::Status, "todo")
712 ]
713 );
714 }
715
716 #[test]
717 fn filter_evaluate_is_case_insensitive_substring() {
718 let filter = StructuredFilter {
719 status: Some("TODO".to_string()),
720 ..Default::default()
721 };
722 assert!(filter.evaluate("---\nstatus: todo\n---\n"));
723 assert!(filter.evaluate("---\nstatus: Todo\n---\n"));
724 assert!(!filter.evaluate("---\nstatus: done\n---\n"));
725 assert!(filter.evaluate("todo"));
728 }
729
730 #[test]
731 fn filter_evaluate_requires_all_active_criteria_and() {
732 let haystack = "---\nstatus: todo\nlabels: [urgent]\npriority: high\nassignee: alice\n/**/";
733 let all = StructuredFilter {
734 assignee: Some("alice".to_string()),
735 label: Some("urgent".to_string()),
736 status: Some("todo".to_string()),
737 priority: Some("high".to_string()),
738 };
739 assert!(all.evaluate(haystack), "all four present => match");
740 assert!(!all.evaluate("status: todo labels: [urgent] priority: high"));
742 assert!(
743 !all.evaluate(
744 "---\nstatus: todo\nlabels: [urgent]\npriority: medium\nassignee: alice\n"
745 )
746 );
747 assert!(
748 !all.evaluate("---\nstatus: todo\nlabels: [other]\npriority: high\nassignee: alice\n")
749 );
750 }
751
752 #[test]
753 fn filter_empty_string_criterion_counts_as_unset() {
754 let filter = StructuredFilter {
755 assignee: Some(String::new()),
756 label: Some("urgent".to_string()),
757 ..Default::default()
758 };
759 assert!(
760 filter.evaluate("labels: [urgent]"),
761 "empty assignee excluded"
762 );
763 assert!(filter.has_active_criteria());
764 }
765
766 #[test]
767 fn filter_no_criteria_matches_everything() {
768 let filter = StructuredFilter::default();
769 assert!(filter.evaluate("anything at all"));
770 assert!(filter.evaluate(""));
771 }
772
773 #[test]
774 fn filter_display_renders_key_value_pairs() {
775 let filter = StructuredFilter {
776 assignee: Some("alice".to_string()),
777 status: Some("todo".to_string()),
778 ..Default::default()
779 };
780 assert_eq!(filter.to_string(), "assignee:alice, status:todo");
781 }
782
783 #[test]
784 fn filter_normalize_trims_and_drops_empty() {
785 let mut filter = StructuredFilter {
786 assignee: Some(" ".to_string()),
787 label: Some(" urgent ".to_string()),
788 ..Default::default()
789 };
790 filter.normalize();
791 assert_eq!(
792 filter,
793 StructuredFilter {
794 assignee: None,
795 label: Some("urgent".to_string()),
796 ..Default::default()
797 }
798 );
799 }
800
801 #[test]
802 fn filter_round_trips_through_toml() {
803 let filter = StructuredFilter {
804 assignee: Some("alice".to_string()),
805 label: Some("urgent".to_string()),
806 status: Some("todo".to_string()),
807 priority: Some("high".to_string()),
808 };
809 let text = toml::to_string(&filter).unwrap();
810 let back: StructuredFilter = toml::from_str(&text).unwrap();
811 assert_eq!(back, filter);
812 }
813
814 #[test]
815 fn number_query_classifies_bare_numbers() {
816 assert_eq!(number_query("55"), Some(55));
817 assert_eq!(number_query(" 55 "), Some(55), "whitespace-padded trims");
818 assert_eq!(number_query("055"), Some(55), "leading zeros parse");
819 assert_eq!(number_query("0"), Some(0));
820 }
821
822 #[test]
823 fn number_query_rejects_non_number_inputs() {
824 assert_eq!(number_query(""), None, "empty");
825 assert_eq!(number_query(" "), None, "whitespace-only");
826 assert_eq!(number_query("login"), None, "non-digit");
827 assert_eq!(number_query("55bug"), None, "mixed digits + letters");
828 assert_eq!(
829 number_query("99999999999999999999"),
830 None,
831 "overflows u32 -> falls back to fuzzy"
832 );
833 assert_eq!(number_query("-55"), None, "sign not a bare number");
834 }
835
836 #[test]
837 fn number_query_accepts_u32_max() {
838 assert_eq!(number_query(&u32::MAX.to_string()), Some(u32::MAX));
839 }
840
841 #[test]
842 fn matches_composes_fuzzy_and_structured() {
843 let filter = StructuredFilter {
844 label: Some("urgent".to_string()),
845 ..Default::default()
846 };
847 let haystack_1 = "---\ntitle: \"Login bug\"\nlabels: [urgent]\n---\n";
848 let haystack_2 = "---\ntitle: \"Export csv\"\nlabels: [urgent]\n---\n";
849
850 assert!(matches("Login", &filter, haystack_1));
852 assert!(
853 !matches("Login", &filter, haystack_2),
854 "label matches but text does not"
855 );
856 assert!(
857 !matches("zzz", &filter, haystack_1),
858 "text matches but label does not"
859 );
860
861 assert!(matches("", &filter, haystack_1));
863 assert!(!matches("", &filter, "labels: [critical]\n"));
864
865 let default = StructuredFilter::default();
867 assert!(matches("Login", &default, haystack_1));
868 assert!(!matches("zzz", &default, haystack_1));
869
870 assert!(matches("", &default, "anything"));
872 }
873
874 #[test]
875 fn matches_against_real_corpus() {
876 let corpus = two_issue_corpus();
877 let filter = StructuredFilter {
878 label: Some("urgent".to_string()),
879 status: Some("todo".to_string()),
880 ..Default::default()
881 };
882 let matches_by = |query: &str| {
883 corpus
884 .issues
885 .iter()
886 .zip(&corpus.texts)
887 .filter(|(_, text)| matches(query, &filter, text))
888 .map(|(issue, _)| issue.number)
889 .collect::<std::collections::BTreeSet<u32>>()
890 .into_iter()
891 .collect::<Vec<_>>()
892 };
893 assert_eq!(matches_by(""), vec![1, 2]);
897 assert_eq!(matches_by("Login bug"), vec![1]);
898 assert_eq!(matches_by("empty rows"), vec![2]);
899 assert_eq!(matches_by("zzz-no-hit"), Vec::<u32>::new());
900 }
901
902 #[test]
903 fn filter_evaluate_perf_tripwire_500_issue_sweep_stays_fast() {
904 let filter = StructuredFilter {
908 assignee: Some("alice".to_string()),
909 label: Some("urgent".to_string()),
910 status: Some("todo".to_string()),
911 priority: Some("medium".to_string()),
912 };
913 let issues: Vec<String> = (0..500u32)
914 .map(|i| {
915 format!(
916 "---\nnumber: {i}\ntitle: \"synthetic issue {i}\"\nstatus: todo\nlabels: [urgent]\npriority: medium\nassignee: alice\n---\n\n\
917 A long body about feature {i}.\n"
918 )
919 })
920 .collect();
921 let start = std::time::Instant::now();
922 for haystack in &issues {
923 assert!(filter.evaluate(haystack));
924 }
925 let elapsed = start.elapsed();
926 assert!(
927 elapsed < std::time::Duration::from_millis(500),
928 "500-issue structured re-filter must stay under 500ms, took {elapsed:?}"
929 );
930 }
931}