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
370#[cfg(test)]
371mod tests {
372 use super::*;
373 use crate::model::{Issue, Priority, Status};
374 use std::fs;
375
376 fn make_store() -> (tempfile::TempDir, Store) {
381 let tmp = tempfile::TempDir::new().unwrap();
382 let dir = tmp.path().join("issues");
383 fs::create_dir_all(&dir).unwrap();
384 let config_path = tmp.path().join("tix.toml");
385 let config = crate::config::Config {
386 issues_dir: dir.clone(),
387 ..crate::config::Config::default()
388 };
389 config.save(&config_path).unwrap();
390 let config = crate::config::Config::load_with_warning(&config_path, |_| {}).unwrap();
391 let store = Store {
392 issues_dir: dir,
393 config,
394 };
395 (tmp, store)
396 }
397
398 fn sample_issue(number: u32, title: &str, extra_body: &str) -> Issue {
399 let mut issue = Issue {
400 number,
401 title: title.to_string(),
402 status: Status::default(),
403 labels: vec!["urgent".to_string()],
404 priority: Priority::default(),
405 assignee: Some("alice".to_string()),
406 estimate: None,
407 commit: None,
408 gitlab_id: None,
409 linear_id: None,
410 mission: None,
411 task: None,
412 created: "2026-01-01".to_string(),
413 updated: "2026-01-01".to_string(),
414 filename: String::new(),
415 body: extra_body.to_string(),
416 };
417 issue.filename = format!("alice/{}-{}.md", "2026-01-01", crate::slug::slugify(title));
420 issue
421 }
422
423 fn write_issue(store: &Store, name: &str, issue: &Issue) {
424 fs::write(store.issues_dir.join(name), issue.to_file_contents()).unwrap();
425 }
426
427 #[test]
428 fn corpus_texts_match_issue_to_file_contents_byte_for_byte() {
429 let (_tmp, store) = make_store();
430 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
431 let a = sample_issue(1, "Login bug", "Need to reproduce the redirect loop.");
432 write_issue(&store, "alice/2026-01-01-login-bug.md", &a);
433 let b = sample_issue(
434 2,
435 "Export csv",
436 "## Comments\n\n### alice (2026-01-01)\n\nSort order is wrong.\n",
437 );
438 write_issue(&store, "alice/2026-01-01-export-csv.md", &b);
439
440 let corpus = Corpus::build(&store).unwrap();
441 assert_eq!(corpus.issues.len(), 2);
442 let idx_a = corpus.by_number(1).unwrap();
445 let idx_b = corpus.by_number(2).unwrap();
446 assert_eq!(corpus.texts[idx_a], a.to_file_contents());
447 assert_eq!(corpus.texts[idx_b], b.to_file_contents());
448 assert_eq!(corpus.by_number(99), None);
449 }
450
451 #[test]
452 fn corpus_never_indexes_asset_dirs() {
453 let (_tmp, store) = make_store();
454 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
455 let a = sample_issue(1, "Login bug", "body");
456 write_issue(&store, "alice/2026-01-01-login-bug.md", &a);
457 let assets = store.issues_dir.join("alice/login-bug-assets");
459 fs::create_dir_all(&assets).unwrap();
460 fs::write(assets.join("notes.md"), "# not an issue\n").unwrap();
461 fs::write(assets.join("diagram.png"), b"\x89PNG").unwrap();
462
463 let corpus = Corpus::build(&store).unwrap();
464 assert_eq!(corpus.issues.len(), 1);
465 let evil_found = corpus.texts.iter().any(|t| t.contains("not an issue"));
466 assert!(
467 !evil_found,
468 "asset-dir markdown must not leak into the corpus"
469 );
470 }
471
472 fn two_issue_corpus() -> Corpus {
477 let (_tmp, store) = make_store();
478 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
479 let a = sample_issue(1, "Login bug", "The redirect URL is broken.");
480 write_issue(&store, "alice/2026-01-01-login-bug.md", &a);
481 let b = sample_issue(
482 2,
483 "Export csv",
484 "## Comments\n\n### alice (2026-01-01)\n\nHandle empty rows.\n",
485 );
486 write_issue(&store, "alice/2026-01-01-export-csv.md", &b);
487 Corpus::build(&store).unwrap()
488 }
489
490 #[test]
491 fn substring_matches_frontmatter() {
492 let corpus = two_issue_corpus();
493 let hits = search_substring(&corpus, "Login bug");
494 assert_eq!(hits.len(), 1);
495 assert_eq!(hits[0].issue.number, 1);
496 assert!(
497 hits[0]
498 .matched_lines
499 .iter()
500 .any(|l| l.contains("Login bug")),
501 "frontmatter title line must appear as context: {hits:?}"
502 );
503 }
504
505 #[test]
506 fn substring_matches_comment_text_only() {
507 let corpus = two_issue_corpus();
508 let hits = search_substring(&corpus, "empty rows");
509 assert_eq!(hits.len(), 1);
510 assert_eq!(hits[0].issue.number, 2);
511 assert!(
512 hits[0]
513 .matched_lines
514 .iter()
515 .any(|l| l.contains("empty rows")),
516 "comment line must appear as context: {hits:?}"
517 );
518 }
519
520 #[test]
521 fn substring_matches_label() {
522 let corpus = two_issue_corpus();
523 let hits = search_substring(&corpus, "urgent");
524 assert_eq!(hits.len(), 2, "both issues carry the `urgent` label");
525 assert_eq!(
526 hits.iter().map(|h| h.issue.number).collect::<Vec<_>>(),
527 vec![1, 2],
528 "stable number-ascending order (NFR-007)"
529 );
530 }
531
532 #[test]
533 fn substring_is_case_insensitive() {
534 let corpus = two_issue_corpus();
535 assert_eq!(search_substring(&corpus, "LOGIN BUG").len(), 1);
536 assert_eq!(search_substring(&corpus, "login bug").len(), 1);
537 }
538
539 #[test]
540 fn substring_orders_by_number_ascending() {
541 let (_tmp, store) = make_store();
542 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
543 let hi = sample_issue(9, "Zed bug", "The redirect loop");
544 let lo = sample_issue(3, "Alpha", "The redirect loop, too");
545 write_issue(&store, "alice/2026-01-01-zed-bug.md", &hi);
546 write_issue(&store, "alice/2026-01-01-alpha.md", &lo);
547 let corpus = Corpus::build(&store).unwrap();
548 let hits = search_substring(&corpus, "redirect loop");
549 assert_eq!(
550 hits.iter().map(|h| h.issue.number).collect::<Vec<_>>(),
551 vec![3, 9],
552 "hits sorted by number regardless of file order"
553 );
554 }
555
556 #[test]
557 fn substring_zero_matches_is_empty() {
558 let corpus = two_issue_corpus();
559 assert!(search_substring(&corpus, "nope-nothing-here").is_empty());
560 }
561
562 #[test]
563 fn substring_empty_query_is_empty() {
564 let corpus = two_issue_corpus();
565 assert!(search_substring(&corpus, "").is_empty());
566 }
567
568 #[test]
573 fn fuzzy_exact_match_scores_higher_than_partial() {
574 let exact = fuzzy_score("login bug", "login bug").unwrap();
575 let partial = fuzzy_score("login bug", "debug the login bug report").unwrap();
576 assert!(exact > partial);
577 }
578
579 #[test]
580 fn fuzzy_contiguous_beats_spread() {
581 let contiguous = fuzzy_score("ab", "xyab").unwrap();
582 let spread = fuzzy_score("ab", "xayb").unwrap();
583 assert!(contiguous > spread);
584 }
585
586 #[test]
587 fn fuzzy_prefix_beats_mid_string() {
588 let prefix = fuzzy_score("ab", "abxy").unwrap();
589 let mid = fuzzy_score("ab", "xaby").unwrap();
590 assert!(prefix > mid);
591 }
592
593 #[test]
594 fn fuzzy_non_subsequence_is_none() {
595 assert_eq!(fuzzy_score("abc", "aac"), None);
596 assert_eq!(fuzzy_score("zzz", "haystack"), None);
597 }
598
599 #[test]
600 fn fuzzy_is_case_insensitive() {
601 assert_eq!(
602 fuzzy_score("LOGIN", "login").unwrap(),
603 fuzzy_score("login", "login").unwrap()
604 );
605 assert_eq!(
606 fuzzy_score("aBc", "xAbC").unwrap(),
607 fuzzy_score("abc", "xabc").unwrap()
608 );
609 }
610
611 #[test]
612 fn fuzzy_empty_query_scores_zero() {
613 assert_eq!(fuzzy_score("", "anything"), Some(0));
614 }
615
616 #[test]
617 fn fuzzy_earlier_positions_beat_later() {
618 let early = fuzzy_score("ab", "abcxyz").unwrap();
619 let late = fuzzy_score("ab", "xyzabc").unwrap();
620 assert!(early > late);
621 }
622
623 #[test]
624 fn fuzzy_perf_tripwire_500_issue_sweep_stays_fast() {
625 let issues: Vec<String> = (0..500u32)
630 .map(|i| {
631 format!(
632 "---\nnumber: {i}\ntitle: \"synthetic issue {i}\"\npriority: medium\n---\n\n\
633 A long body about feature {i} that mentions refunds, retries, idempotency keys, \
634 and a blocking redirect loop. The assigned developer needs to investigate the \
635 flaky test and prepare a hotfix for the staging deploy.\n\n\
636 ## Comments\n\n### alice (2026-01-01)\n\nNotes for issue {i}.\n"
637 )
638 })
639 .collect();
640 let queries = [
643 "issue",
644 "transient race",
645 "payment",
646 "hotfix 3",
647 "aAa",
648 "zzz",
649 "issue 499",
650 "staging",
651 "deploy",
652 "ref",
653 ];
654 let start = std::time::Instant::now();
655 for q in &queries {
656 for issue in &issues {
657 let _ = fuzzy_score(q, issue);
658 }
659 }
660 let elapsed = start.elapsed();
661 assert!(
664 elapsed < std::time::Duration::from_millis(500),
665 "500-issue corpus re-filter must stay under 500ms, took {elapsed:?}"
666 );
667 }
668
669 #[test]
674 fn filter_default_has_no_active_criteria() {
675 let filter = StructuredFilter::default();
676 assert!(!filter.has_active_criteria());
677 assert_eq!(filter.criteria().count(), 0);
678 assert_eq!(filter.to_string(), "(no active criteria)");
679 }
680
681 #[test]
682 fn filter_criteria_lists_only_active_in_all_order() {
683 let filter = StructuredFilter {
684 label: Some("urgent".to_string()),
685 assignee: Some(String::new()),
686 status: Some("todo".to_string()),
687 priority: None,
688 };
689 let criteria: Vec<(FilterField, &str)> = filter.criteria().collect();
690 assert_eq!(
691 criteria,
692 vec![
693 (FilterField::Label, "urgent"),
694 (FilterField::Status, "todo")
695 ]
696 );
697 }
698
699 #[test]
700 fn filter_evaluate_is_case_insensitive_substring() {
701 let filter = StructuredFilter {
702 status: Some("TODO".to_string()),
703 ..Default::default()
704 };
705 assert!(filter.evaluate("---\nstatus: todo\n---\n"));
706 assert!(filter.evaluate("---\nstatus: Todo\n---\n"));
707 assert!(!filter.evaluate("---\nstatus: done\n---\n"));
708 assert!(filter.evaluate("todo"));
711 }
712
713 #[test]
714 fn filter_evaluate_requires_all_active_criteria_and() {
715 let haystack = "---\nstatus: todo\nlabels: [urgent]\npriority: high\nassignee: alice\n/**/";
716 let all = StructuredFilter {
717 assignee: Some("alice".to_string()),
718 label: Some("urgent".to_string()),
719 status: Some("todo".to_string()),
720 priority: Some("high".to_string()),
721 };
722 assert!(all.evaluate(haystack), "all four present => match");
723 assert!(!all.evaluate("status: todo labels: [urgent] priority: high"));
725 assert!(
726 !all.evaluate(
727 "---\nstatus: todo\nlabels: [urgent]\npriority: medium\nassignee: alice\n"
728 )
729 );
730 assert!(
731 !all.evaluate("---\nstatus: todo\nlabels: [other]\npriority: high\nassignee: alice\n")
732 );
733 }
734
735 #[test]
736 fn filter_empty_string_criterion_counts_as_unset() {
737 let filter = StructuredFilter {
738 assignee: Some(String::new()),
739 label: Some("urgent".to_string()),
740 ..Default::default()
741 };
742 assert!(
743 filter.evaluate("labels: [urgent]"),
744 "empty assignee excluded"
745 );
746 assert!(filter.has_active_criteria());
747 }
748
749 #[test]
750 fn filter_no_criteria_matches_everything() {
751 let filter = StructuredFilter::default();
752 assert!(filter.evaluate("anything at all"));
753 assert!(filter.evaluate(""));
754 }
755
756 #[test]
757 fn filter_display_renders_key_value_pairs() {
758 let filter = StructuredFilter {
759 assignee: Some("alice".to_string()),
760 status: Some("todo".to_string()),
761 ..Default::default()
762 };
763 assert_eq!(filter.to_string(), "assignee:alice, status:todo");
764 }
765
766 #[test]
767 fn filter_normalize_trims_and_drops_empty() {
768 let mut filter = StructuredFilter {
769 assignee: Some(" ".to_string()),
770 label: Some(" urgent ".to_string()),
771 ..Default::default()
772 };
773 filter.normalize();
774 assert_eq!(
775 filter,
776 StructuredFilter {
777 assignee: None,
778 label: Some("urgent".to_string()),
779 ..Default::default()
780 }
781 );
782 }
783
784 #[test]
785 fn filter_round_trips_through_toml() {
786 let filter = StructuredFilter {
787 assignee: Some("alice".to_string()),
788 label: Some("urgent".to_string()),
789 status: Some("todo".to_string()),
790 priority: Some("high".to_string()),
791 };
792 let text = toml::to_string(&filter).unwrap();
793 let back: StructuredFilter = toml::from_str(&text).unwrap();
794 assert_eq!(back, filter);
795 }
796
797 #[test]
798 fn matches_composes_fuzzy_and_structured() {
799 let filter = StructuredFilter {
800 label: Some("urgent".to_string()),
801 ..Default::default()
802 };
803 let haystack_1 = "---\ntitle: \"Login bug\"\nlabels: [urgent]\n---\n";
804 let haystack_2 = "---\ntitle: \"Export csv\"\nlabels: [urgent]\n---\n";
805
806 assert!(matches("Login", &filter, haystack_1));
808 assert!(
809 !matches("Login", &filter, haystack_2),
810 "label matches but text does not"
811 );
812 assert!(
813 !matches("zzz", &filter, haystack_1),
814 "text matches but label does not"
815 );
816
817 assert!(matches("", &filter, haystack_1));
819 assert!(!matches("", &filter, "labels: [critical]\n"));
820
821 let default = StructuredFilter::default();
823 assert!(matches("Login", &default, haystack_1));
824 assert!(!matches("zzz", &default, haystack_1));
825
826 assert!(matches("", &default, "anything"));
828 }
829
830 #[test]
831 fn matches_against_real_corpus() {
832 let corpus = two_issue_corpus();
833 let filter = StructuredFilter {
834 label: Some("urgent".to_string()),
835 status: Some("todo".to_string()),
836 ..Default::default()
837 };
838 let matches_by = |query: &str| {
839 corpus
840 .issues
841 .iter()
842 .zip(&corpus.texts)
843 .filter(|(_, text)| matches(query, &filter, text))
844 .map(|(issue, _)| issue.number)
845 .collect::<std::collections::BTreeSet<u32>>()
846 .into_iter()
847 .collect::<Vec<_>>()
848 };
849 assert_eq!(matches_by(""), vec![1, 2]);
853 assert_eq!(matches_by("Login bug"), vec![1]);
854 assert_eq!(matches_by("empty rows"), vec![2]);
855 assert_eq!(matches_by("zzz-no-hit"), Vec::<u32>::new());
856 }
857
858 #[test]
859 fn filter_evaluate_perf_tripwire_500_issue_sweep_stays_fast() {
860 let filter = StructuredFilter {
864 assignee: Some("alice".to_string()),
865 label: Some("urgent".to_string()),
866 status: Some("todo".to_string()),
867 priority: Some("medium".to_string()),
868 };
869 let issues: Vec<String> = (0..500u32)
870 .map(|i| {
871 format!(
872 "---\nnumber: {i}\ntitle: \"synthetic issue {i}\"\nstatus: todo\nlabels: [urgent]\npriority: medium\nassignee: alice\n---\n\n\
873 A long body about feature {i}.\n"
874 )
875 })
876 .collect();
877 let start = std::time::Instant::now();
878 for haystack in &issues {
879 assert!(filter.evaluate(haystack));
880 }
881 let elapsed = start.elapsed();
882 assert!(
883 elapsed < std::time::Duration::from_millis(500),
884 "500-issue structured re-filter must stay under 500ms, took {elapsed:?}"
885 );
886 }
887}