use serde::{Deserialize, Serialize};
use crate::error::Result;
use crate::model::Issue;
use crate::storage::Store;
pub struct Corpus {
pub issues: Vec<Issue>,
pub texts: Vec<String>,
}
impl Corpus {
pub fn build(store: &Store) -> Result<Corpus> {
let issues = store.list()?;
let texts = issues
.iter()
.map(|issue| issue.to_file_contents())
.collect();
Ok(Corpus { issues, texts })
}
pub fn by_number(&self, n: u32) -> Option<usize> {
self.issues.iter().position(|issue| issue.number == n)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SearchHit {
pub issue: Issue,
pub matched_lines: Vec<String>,
}
pub fn search_substring(corpus: &Corpus, query: &str) -> Vec<SearchHit> {
if query.is_empty() {
return Vec::new();
}
let needle = query.to_lowercase();
let mut hits: Vec<SearchHit> = Vec::new();
for (issue, text) in corpus.issues.iter().zip(corpus.texts.iter()) {
let hay = text.to_lowercase();
if !hay.contains(&needle) {
continue;
}
let matched_lines: Vec<String> = text
.lines()
.filter(|line| line.to_lowercase().contains(&needle))
.map(str::to_string)
.collect();
hits.push(SearchHit {
issue: issue.clone(),
matched_lines,
});
}
hits.sort_by_key(|hit| hit.issue.number);
hits
}
pub fn fuzzy_score(query: &str, haystack: &str) -> Option<u32> {
let q = query.to_lowercase();
let h = haystack.to_lowercase();
if q.is_empty() {
return Some(0);
}
if q == h {
return Some(EXACT_MATCH_SCORE);
}
let positions = greedy_subsequence_positions(&q, &h)?;
let mut score = 0u32;
let mut prev: Option<usize> = None;
for pos in &positions {
score += 100;
score += (1000 - (*pos).min(1000)) as u32;
if prev.is_some_and(|p| *pos == p + 1) {
score += RUN_BONUS;
}
prev = Some(*pos);
}
if positions.first() == Some(&0) {
score += PREFIX_BONUS;
}
Some(score)
}
const RUN_BONUS: u32 = 150;
const PREFIX_BONUS: u32 = 500;
const EXACT_MATCH_SCORE: u32 = 50_000;
fn greedy_subsequence_positions(query: &str, haystack: &str) -> Option<Vec<usize>> {
let q: Vec<char> = query.chars().collect();
if q.is_empty() {
return Some(Vec::new());
}
let h: Vec<char> = haystack.chars().collect();
let mut positions = Vec::with_capacity(q.len());
let mut hi = 0usize;
for qc in &q {
let found = h[hi..]
.iter()
.position(|hc| *hc == *qc)
.map(|off| off + hi)?;
positions.push(found);
hi = found + 1;
}
Some(positions)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FilterField {
Assignee,
Label,
Status,
Priority,
}
impl FilterField {
pub const ALL: [FilterField; 4] = [
FilterField::Assignee,
FilterField::Label,
FilterField::Status,
FilterField::Priority,
];
pub fn label(self) -> &'static str {
match self {
FilterField::Assignee => "Assignee",
FilterField::Label => "Label",
FilterField::Status => "Status",
FilterField::Priority => "Priority",
}
}
pub fn key(self) -> &'static str {
match self {
FilterField::Assignee => "assignee",
FilterField::Label => "labels",
FilterField::Status => "status",
FilterField::Priority => "priority",
}
}
pub fn row(self) -> usize {
FilterField::ALL
.iter()
.position(|field| *field == self)
.expect("FilterField variants are all in ALL")
}
pub fn next(self) -> Self {
let all = FilterField::ALL;
all[(self.row() + 1) % all.len()]
}
pub fn prev(self) -> Self {
let all = FilterField::ALL;
all[(self.row() + all.len() - 1) % all.len()]
}
pub fn value(self, filter: &StructuredFilter) -> Option<&str> {
let value = match self {
FilterField::Assignee => filter.assignee.as_deref(),
FilterField::Label => filter.label.as_deref(),
FilterField::Status => filter.status.as_deref(),
FilterField::Priority => filter.priority.as_deref(),
};
value.filter(|s| !s.is_empty())
}
pub fn value_mut(self, filter: &mut StructuredFilter) -> &mut String {
match self {
FilterField::Assignee => filter.assignee.get_or_insert_default(),
FilterField::Label => filter.label.get_or_insert_default(),
FilterField::Status => filter.status.get_or_insert_default(),
FilterField::Priority => filter.priority.get_or_insert_default(),
}
}
pub fn unset(self, filter: &mut StructuredFilter) {
match self {
FilterField::Assignee => filter.assignee = None,
FilterField::Label => filter.label = None,
FilterField::Status => filter.status = None,
FilterField::Priority => filter.priority = None,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct StructuredFilter {
pub assignee: Option<String>,
pub label: Option<String>,
pub status: Option<String>,
pub priority: Option<String>,
}
impl StructuredFilter {
pub fn criteria(&self) -> impl Iterator<Item = (FilterField, &str)> {
FilterField::ALL
.into_iter()
.filter_map(move |field| field.value(self).map(|value| (field, value)))
}
pub fn has_active_criteria(&self) -> bool {
self.criteria().next().is_some()
}
pub fn evaluate(&self, haystack: &str) -> bool {
let hay = haystack.to_lowercase();
for (_, value) in self.criteria() {
if !hay.contains(&value.to_lowercase()) {
return false;
}
}
true
}
pub fn normalize(&mut self) {
for field in FilterField::ALL {
let empty = field.value_mut(self).trim().is_empty();
if empty {
field.unset(self);
} else {
let value = field.value_mut(self);
let trimmed = value.trim().to_string();
*value = trimmed;
}
}
}
}
impl std::fmt::Display for StructuredFilter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut criteria = self.criteria();
let Some((field, value)) = criteria.next() else {
return write!(f, "(no active criteria)");
};
write!(f, "{}:{value}", field.key())?;
for (field, value) in criteria {
write!(f, ", {}:{value}", field.key())?;
}
Ok(())
}
}
pub fn matches(query: &str, filter: &StructuredFilter, haystack: &str) -> bool {
let text_matches = query.is_empty() || fuzzy_score(query, haystack).is_some();
text_matches && filter.evaluate(haystack)
}
pub fn number_query(raw: &str) -> Option<u32> {
let trimmed = raw.trim();
if trimmed.is_empty() || !trimmed.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
trimmed.parse::<u32>().ok()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{Issue, Priority, Status};
use std::fs;
fn make_store() -> (tempfile::TempDir, Store) {
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(&dir).unwrap();
let config_path = tmp.path().join("tix.toml");
let config = crate::config::Config {
issues_dir: dir.clone(),
..crate::config::Config::default()
};
config.save(&config_path).unwrap();
let config = crate::config::Config::load_with_warning(&config_path, |_| {}).unwrap();
let store = Store {
issues_dir: dir,
config,
};
(tmp, store)
}
fn sample_issue(number: u32, title: &str, extra_body: &str) -> Issue {
let mut issue = Issue {
number,
title: title.to_string(),
status: Status::default(),
labels: vec!["urgent".to_string()],
priority: Priority::default(),
assignee: Some("alice".to_string()),
estimate: None,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
links: vec![],
files: vec![],
created: "2026-01-01".to_string(),
updated: "2026-01-01".to_string(),
filename: String::new(),
body: extra_body.to_string(),
};
issue.filename = format!("alice/{}-{}.md", "2026-01-01", crate::slug::slugify(title));
issue
}
fn write_issue(store: &Store, name: &str, issue: &Issue) {
fs::write(store.issues_dir.join(name), issue.to_file_contents()).unwrap();
}
#[test]
fn corpus_texts_match_issue_to_file_contents_byte_for_byte() {
let (_tmp, store) = make_store();
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
let a = sample_issue(1, "Login bug", "Need to reproduce the redirect loop.");
write_issue(&store, "alice/2026-01-01-login-bug.md", &a);
let b = sample_issue(
2,
"Export csv",
"## Comments\n\n### alice (2026-01-01)\n\nSort order is wrong.\n",
);
write_issue(&store, "alice/2026-01-01-export-csv.md", &b);
let corpus = Corpus::build(&store).unwrap();
assert_eq!(corpus.issues.len(), 2);
let idx_a = corpus.by_number(1).unwrap();
let idx_b = corpus.by_number(2).unwrap();
assert_eq!(corpus.texts[idx_a], a.to_file_contents());
assert_eq!(corpus.texts[idx_b], b.to_file_contents());
assert_eq!(corpus.by_number(99), None);
}
#[test]
fn corpus_never_indexes_asset_dirs() {
let (_tmp, store) = make_store();
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
let a = sample_issue(1, "Login bug", "body");
write_issue(&store, "alice/2026-01-01-login-bug.md", &a);
let assets = store.issues_dir.join("alice/login-bug-assets");
fs::create_dir_all(&assets).unwrap();
fs::write(assets.join("notes.md"), "# not an issue\n").unwrap();
fs::write(assets.join("diagram.png"), b"\x89PNG").unwrap();
let corpus = Corpus::build(&store).unwrap();
assert_eq!(corpus.issues.len(), 1);
let evil_found = corpus.texts.iter().any(|t| t.contains("not an issue"));
assert!(
!evil_found,
"asset-dir markdown must not leak into the corpus"
);
}
fn two_issue_corpus() -> Corpus {
let (_tmp, store) = make_store();
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
let a = sample_issue(1, "Login bug", "The redirect URL is broken.");
write_issue(&store, "alice/2026-01-01-login-bug.md", &a);
let b = sample_issue(
2,
"Export csv",
"## Comments\n\n### alice (2026-01-01)\n\nHandle empty rows.\n",
);
write_issue(&store, "alice/2026-01-01-export-csv.md", &b);
Corpus::build(&store).unwrap()
}
#[test]
fn substring_matches_frontmatter() {
let corpus = two_issue_corpus();
let hits = search_substring(&corpus, "Login bug");
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].issue.number, 1);
assert!(
hits[0]
.matched_lines
.iter()
.any(|l| l.contains("Login bug")),
"frontmatter title line must appear as context: {hits:?}"
);
}
#[test]
fn substring_matches_comment_text_only() {
let corpus = two_issue_corpus();
let hits = search_substring(&corpus, "empty rows");
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].issue.number, 2);
assert!(
hits[0]
.matched_lines
.iter()
.any(|l| l.contains("empty rows")),
"comment line must appear as context: {hits:?}"
);
}
#[test]
fn substring_matches_label() {
let corpus = two_issue_corpus();
let hits = search_substring(&corpus, "urgent");
assert_eq!(hits.len(), 2, "both issues carry the `urgent` label");
assert_eq!(
hits.iter().map(|h| h.issue.number).collect::<Vec<_>>(),
vec![1, 2],
"stable number-ascending order (NFR-007)"
);
}
#[test]
fn substring_is_case_insensitive() {
let corpus = two_issue_corpus();
assert_eq!(search_substring(&corpus, "LOGIN BUG").len(), 1);
assert_eq!(search_substring(&corpus, "login bug").len(), 1);
}
#[test]
fn substring_orders_by_number_ascending() {
let (_tmp, store) = make_store();
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
let hi = sample_issue(9, "Zed bug", "The redirect loop");
let lo = sample_issue(3, "Alpha", "The redirect loop, too");
write_issue(&store, "alice/2026-01-01-zed-bug.md", &hi);
write_issue(&store, "alice/2026-01-01-alpha.md", &lo);
let corpus = Corpus::build(&store).unwrap();
let hits = search_substring(&corpus, "redirect loop");
assert_eq!(
hits.iter().map(|h| h.issue.number).collect::<Vec<_>>(),
vec![3, 9],
"hits sorted by number regardless of file order"
);
}
#[test]
fn substring_zero_matches_is_empty() {
let corpus = two_issue_corpus();
assert!(search_substring(&corpus, "nope-nothing-here").is_empty());
}
#[test]
fn substring_empty_query_is_empty() {
let corpus = two_issue_corpus();
assert!(search_substring(&corpus, "").is_empty());
}
#[test]
fn fuzzy_exact_match_scores_higher_than_partial() {
let exact = fuzzy_score("login bug", "login bug").unwrap();
let partial = fuzzy_score("login bug", "debug the login bug report").unwrap();
assert!(exact > partial);
}
#[test]
fn fuzzy_contiguous_beats_spread() {
let contiguous = fuzzy_score("ab", "xyab").unwrap();
let spread = fuzzy_score("ab", "xayb").unwrap();
assert!(contiguous > spread);
}
#[test]
fn fuzzy_prefix_beats_mid_string() {
let prefix = fuzzy_score("ab", "abxy").unwrap();
let mid = fuzzy_score("ab", "xaby").unwrap();
assert!(prefix > mid);
}
#[test]
fn fuzzy_non_subsequence_is_none() {
assert_eq!(fuzzy_score("abc", "aac"), None);
assert_eq!(fuzzy_score("zzz", "haystack"), None);
}
#[test]
fn fuzzy_is_case_insensitive() {
assert_eq!(
fuzzy_score("LOGIN", "login").unwrap(),
fuzzy_score("login", "login").unwrap()
);
assert_eq!(
fuzzy_score("aBc", "xAbC").unwrap(),
fuzzy_score("abc", "xabc").unwrap()
);
}
#[test]
fn fuzzy_empty_query_scores_zero() {
assert_eq!(fuzzy_score("", "anything"), Some(0));
}
#[test]
fn fuzzy_earlier_positions_beat_later() {
let early = fuzzy_score("ab", "abcxyz").unwrap();
let late = fuzzy_score("ab", "xyzabc").unwrap();
assert!(early > late);
}
#[test]
fn fuzzy_perf_tripwire_500_issue_sweep_stays_fast() {
let issues: Vec<String> = (0..500u32)
.map(|i| {
format!(
"---\nnumber: {i}\ntitle: \"synthetic issue {i}\"\npriority: medium\n---\n\n\
A long body about feature {i} that mentions refunds, retries, idempotency keys, \
and a blocking redirect loop. The assigned developer needs to investigate the \
flaky test and prepare a hotfix for the staging deploy.\n\n\
## Comments\n\n### alice (2026-01-01)\n\nNotes for issue {i}.\n"
)
})
.collect();
let queries = [
"issue",
"transient race",
"payment",
"hotfix 3",
"aAa",
"zzz",
"issue 499",
"staging",
"deploy",
"ref",
];
let start = std::time::Instant::now();
for q in &queries {
for issue in &issues {
let _ = fuzzy_score(q, issue);
}
}
let elapsed = start.elapsed();
assert!(
elapsed < std::time::Duration::from_millis(500),
"500-issue corpus re-filter must stay under 500ms, took {elapsed:?}"
);
}
#[test]
fn filter_default_has_no_active_criteria() {
let filter = StructuredFilter::default();
assert!(!filter.has_active_criteria());
assert_eq!(filter.criteria().count(), 0);
assert_eq!(filter.to_string(), "(no active criteria)");
}
#[test]
fn filter_criteria_lists_only_active_in_all_order() {
let filter = StructuredFilter {
label: Some("urgent".to_string()),
assignee: Some(String::new()),
status: Some("todo".to_string()),
priority: None,
};
let criteria: Vec<(FilterField, &str)> = filter.criteria().collect();
assert_eq!(
criteria,
vec![
(FilterField::Label, "urgent"),
(FilterField::Status, "todo")
]
);
}
#[test]
fn filter_evaluate_is_case_insensitive_substring() {
let filter = StructuredFilter {
status: Some("TODO".to_string()),
..Default::default()
};
assert!(filter.evaluate("---\nstatus: todo\n---\n"));
assert!(filter.evaluate("---\nstatus: Todo\n---\n"));
assert!(!filter.evaluate("---\nstatus: done\n---\n"));
assert!(filter.evaluate("todo"));
}
#[test]
fn filter_evaluate_requires_all_active_criteria_and() {
let haystack = "---\nstatus: todo\nlabels: [urgent]\npriority: high\nassignee: alice\n/**/";
let all = StructuredFilter {
assignee: Some("alice".to_string()),
label: Some("urgent".to_string()),
status: Some("todo".to_string()),
priority: Some("high".to_string()),
};
assert!(all.evaluate(haystack), "all four present => match");
assert!(!all.evaluate("status: todo labels: [urgent] priority: high"));
assert!(
!all.evaluate(
"---\nstatus: todo\nlabels: [urgent]\npriority: medium\nassignee: alice\n"
)
);
assert!(
!all.evaluate("---\nstatus: todo\nlabels: [other]\npriority: high\nassignee: alice\n")
);
}
#[test]
fn filter_empty_string_criterion_counts_as_unset() {
let filter = StructuredFilter {
assignee: Some(String::new()),
label: Some("urgent".to_string()),
..Default::default()
};
assert!(
filter.evaluate("labels: [urgent]"),
"empty assignee excluded"
);
assert!(filter.has_active_criteria());
}
#[test]
fn filter_no_criteria_matches_everything() {
let filter = StructuredFilter::default();
assert!(filter.evaluate("anything at all"));
assert!(filter.evaluate(""));
}
#[test]
fn filter_display_renders_key_value_pairs() {
let filter = StructuredFilter {
assignee: Some("alice".to_string()),
status: Some("todo".to_string()),
..Default::default()
};
assert_eq!(filter.to_string(), "assignee:alice, status:todo");
}
#[test]
fn filter_normalize_trims_and_drops_empty() {
let mut filter = StructuredFilter {
assignee: Some(" ".to_string()),
label: Some(" urgent ".to_string()),
..Default::default()
};
filter.normalize();
assert_eq!(
filter,
StructuredFilter {
assignee: None,
label: Some("urgent".to_string()),
..Default::default()
}
);
}
#[test]
fn filter_round_trips_through_toml() {
let filter = StructuredFilter {
assignee: Some("alice".to_string()),
label: Some("urgent".to_string()),
status: Some("todo".to_string()),
priority: Some("high".to_string()),
};
let text = toml::to_string(&filter).unwrap();
let back: StructuredFilter = toml::from_str(&text).unwrap();
assert_eq!(back, filter);
}
#[test]
fn number_query_classifies_bare_numbers() {
assert_eq!(number_query("55"), Some(55));
assert_eq!(number_query(" 55 "), Some(55), "whitespace-padded trims");
assert_eq!(number_query("055"), Some(55), "leading zeros parse");
assert_eq!(number_query("0"), Some(0));
}
#[test]
fn number_query_rejects_non_number_inputs() {
assert_eq!(number_query(""), None, "empty");
assert_eq!(number_query(" "), None, "whitespace-only");
assert_eq!(number_query("login"), None, "non-digit");
assert_eq!(number_query("55bug"), None, "mixed digits + letters");
assert_eq!(
number_query("99999999999999999999"),
None,
"overflows u32 -> falls back to fuzzy"
);
assert_eq!(number_query("-55"), None, "sign not a bare number");
}
#[test]
fn number_query_accepts_u32_max() {
assert_eq!(number_query(&u32::MAX.to_string()), Some(u32::MAX));
}
#[test]
fn matches_composes_fuzzy_and_structured() {
let filter = StructuredFilter {
label: Some("urgent".to_string()),
..Default::default()
};
let haystack_1 = "---\ntitle: \"Login bug\"\nlabels: [urgent]\n---\n";
let haystack_2 = "---\ntitle: \"Export csv\"\nlabels: [urgent]\n---\n";
assert!(matches("Login", &filter, haystack_1));
assert!(
!matches("Login", &filter, haystack_2),
"label matches but text does not"
);
assert!(
!matches("zzz", &filter, haystack_1),
"text matches but label does not"
);
assert!(matches("", &filter, haystack_1));
assert!(!matches("", &filter, "labels: [critical]\n"));
let default = StructuredFilter::default();
assert!(matches("Login", &default, haystack_1));
assert!(!matches("zzz", &default, haystack_1));
assert!(matches("", &default, "anything"));
}
#[test]
fn matches_against_real_corpus() {
let corpus = two_issue_corpus();
let filter = StructuredFilter {
label: Some("urgent".to_string()),
status: Some("todo".to_string()),
..Default::default()
};
let matches_by = |query: &str| {
corpus
.issues
.iter()
.zip(&corpus.texts)
.filter(|(_, text)| matches(query, &filter, text))
.map(|(issue, _)| issue.number)
.collect::<std::collections::BTreeSet<u32>>()
.into_iter()
.collect::<Vec<_>>()
};
assert_eq!(matches_by(""), vec![1, 2]);
assert_eq!(matches_by("Login bug"), vec![1]);
assert_eq!(matches_by("empty rows"), vec![2]);
assert_eq!(matches_by("zzz-no-hit"), Vec::<u32>::new());
}
#[test]
fn filter_evaluate_perf_tripwire_500_issue_sweep_stays_fast() {
let filter = StructuredFilter {
assignee: Some("alice".to_string()),
label: Some("urgent".to_string()),
status: Some("todo".to_string()),
priority: Some("medium".to_string()),
};
let issues: Vec<String> = (0..500u32)
.map(|i| {
format!(
"---\nnumber: {i}\ntitle: \"synthetic issue {i}\"\nstatus: todo\nlabels: [urgent]\npriority: medium\nassignee: alice\n---\n\n\
A long body about feature {i}.\n"
)
})
.collect();
let start = std::time::Instant::now();
for haystack in &issues {
assert!(filter.evaluate(haystack));
}
let elapsed = start.elapsed();
assert!(
elapsed < std::time::Duration::from_millis(500),
"500-issue structured re-filter must stay under 500ms, took {elapsed:?}"
);
}
}