use serde::{Deserialize, Serialize};
use crate::error::{Result, TixError};
use crate::model::Issue;
use crate::storage::Store;
use crate::watcher::FsDelta;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
pub struct Corpus {
pub issues: Vec<Issue>,
texts: std::cell::OnceCell<Vec<String>>,
}
impl Corpus {
pub fn build(store: &Store) -> Result<Corpus> {
Self::build_with(store, false)
}
pub fn build_with(store: &Store, with_archived: bool) -> Result<Corpus> {
let issues = if with_archived {
store.list_all()?
} else {
store.list()?
};
Ok(Self::from_issues(issues))
}
pub fn from_issues(issues: Vec<Issue>) -> Corpus {
Corpus {
issues,
texts: std::cell::OnceCell::new(),
}
}
pub fn texts(&self) -> &[String] {
self.texts.get_or_init(|| {
self.issues
.iter()
.map(|issue| issue.to_file_contents())
.collect()
})
}
#[cfg(test)]
pub fn with_texts(issues: Vec<Issue>, texts: Vec<String>) -> Corpus {
Corpus {
issues,
texts: std::cell::OnceCell::from(texts),
}
}
#[cfg(test)]
pub fn texts_initialized(&self) -> bool {
self.texts.get().is_some()
}
pub fn by_number(&self, n: u32) -> Option<usize> {
self.issues.iter().position(|issue| issue.number == n)
}
pub fn apply(&mut self, store: &Store, delta: &FsDelta) -> Result<()> {
if !delta
.changed
.iter()
.any(|p| crate::storage::is_issue_rel_path(p))
&& !delta
.removed
.iter()
.any(|p| crate::storage::is_issue_rel_path(p))
{
return Ok(());
}
let mut updates: Vec<Issue> = Vec::new();
for path in &delta.changed {
if !crate::storage::is_issue_rel_path(path) {
continue;
}
match store.read_issue_at(path) {
Ok(issue) => updates.push(issue),
Err(TixError::NotFound(_) | TixError::Io(_)) => {}
Err(e) => return Err(e),
}
}
let dropped: HashSet<&Path> = delta
.changed
.iter()
.chain(delta.removed.iter())
.filter(|path| crate::storage::is_issue_rel_path(path))
.map(PathBuf::as_path)
.collect();
let mut combined: Vec<Issue> = Vec::with_capacity(self.issues.len() + updates.len());
let issues = std::mem::take(&mut self.issues);
for issue in issues {
if !dropped.contains(Path::new(&issue.filename)) {
combined.push(issue);
}
}
combined.extend(updates);
combined.sort_by(|a, b| a.filename.cmp(&b.filename));
self.issues = combined;
self.texts = std::cell::OnceCell::new();
Ok(())
}
}
#[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,
Cycle,
Milestone,
Reviewer,
StartDate,
DueDate,
Parent,
Category,
Project,
Epic,
}
impl FilterField {
pub const ALL: [FilterField; 13] = [
FilterField::Assignee,
FilterField::Label,
FilterField::Status,
FilterField::Priority,
FilterField::Cycle,
FilterField::Milestone,
FilterField::Reviewer,
FilterField::StartDate,
FilterField::DueDate,
FilterField::Parent,
FilterField::Category,
FilterField::Project,
FilterField::Epic,
];
pub fn label(self) -> &'static str {
match self {
FilterField::Assignee => "Assignee",
FilterField::Label => "Label",
FilterField::Status => "Status",
FilterField::Priority => "Priority",
FilterField::Cycle => "Cycle",
FilterField::Milestone => "Milestone",
FilterField::Reviewer => "Reviewer",
FilterField::StartDate => "Start date",
FilterField::DueDate => "Due date",
FilterField::Parent => "Parent",
FilterField::Category => "Category",
FilterField::Project => "Project",
FilterField::Epic => "Epic",
}
}
pub fn key(self) -> &'static str {
match self {
FilterField::Assignee => "assignee",
FilterField::Label => "labels",
FilterField::Status => "status",
FilterField::Priority => "priority",
FilterField::Cycle => "cycle",
FilterField::Milestone => "milestone",
FilterField::Reviewer => "reviewer",
FilterField::StartDate => "start_date",
FilterField::DueDate => "due_date",
FilterField::Parent => "parent",
FilterField::Category => "category",
FilterField::Project => "projects",
FilterField::Epic => crate::epics::EPICS_KEY,
}
}
pub fn is_derived(self) -> bool {
matches!(self, FilterField::Category)
}
pub fn describe(self) -> String {
if self.is_derived() {
format!("{} (derived from status)", self.key())
} else {
self.key().to_string()
}
}
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(),
FilterField::Cycle => filter.cycle.as_deref(),
FilterField::Milestone => filter.milestone.as_deref(),
FilterField::Reviewer => filter.reviewer.as_deref(),
FilterField::StartDate => filter.start_date.as_deref(),
FilterField::DueDate => filter.due_date.as_deref(),
FilterField::Parent => filter.parent.as_deref(),
FilterField::Category => filter.category.as_deref(),
FilterField::Project => filter.project.as_deref(),
FilterField::Epic => filter.epic.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(),
FilterField::Cycle => filter.cycle.get_or_insert_default(),
FilterField::Milestone => filter.milestone.get_or_insert_default(),
FilterField::Reviewer => filter.reviewer.get_or_insert_default(),
FilterField::StartDate => filter.start_date.get_or_insert_default(),
FilterField::DueDate => filter.due_date.get_or_insert_default(),
FilterField::Parent => filter.parent.get_or_insert_default(),
FilterField::Category => filter.category.get_or_insert_default(),
FilterField::Project => filter.project.get_or_insert_default(),
FilterField::Epic => filter.epic.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,
FilterField::Cycle => filter.cycle = None,
FilterField::Milestone => filter.milestone = None,
FilterField::Reviewer => filter.reviewer = None,
FilterField::StartDate => filter.start_date = None,
FilterField::DueDate => filter.due_date = None,
FilterField::Parent => filter.parent = None,
FilterField::Category => filter.category = None,
FilterField::Project => filter.project = None,
FilterField::Epic => filter.epic = None,
}
}
pub fn is_reference(self) -> bool {
matches!(self, FilterField::Parent)
}
pub fn token(self) -> &'static str {
match self {
FilterField::Project => "project",
FilterField::Epic => "epic",
_ => self.key(),
}
}
pub fn is_project(self) -> bool {
matches!(self, FilterField::Project)
}
pub fn is_epic(self) -> bool {
matches!(self, FilterField::Epic)
}
pub fn is_date(self) -> bool {
matches!(self, FilterField::StartDate | FilterField::DueDate)
}
}
#[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>,
pub cycle: Option<String>,
pub milestone: Option<String>,
pub reviewer: Option<String>,
pub start_date: Option<String>,
pub due_date: Option<String>,
pub overdue: Option<bool>,
pub parent: Option<String>,
pub category: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub epic: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scope: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub archived: Option<bool>,
}
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() || self.scope.is_some() || self.archived.is_some()
}
pub fn evaluate(&self, haystack: &str) -> bool {
let hay = haystack.to_lowercase();
if let Some(number) = &self.parent
&& field_value(haystack, "parent") != Some(number.as_str())
{
return false;
}
for (field, value) in self.criteria() {
if field.is_reference() || field.is_derived() || field.is_project() || field.is_epic() {
continue;
}
if !hay.contains(&value.to_lowercase()) {
return false;
}
}
if let Some(cycle) = &self.cycle
&& !hay.contains(&cycle.to_lowercase())
{
return false;
}
if let Some(milestone) = &self.milestone
&& !hay.contains(&milestone.to_lowercase())
{
return false;
}
if let Some(reviewer) = &self.reviewer
&& !hay.contains(&reviewer.to_lowercase())
{
return false;
}
if let Some(start_date) = &self.start_date
&& !hay.contains(start_date)
{
return false;
}
if let Some(due_date) = &self.due_date
&& !hay.contains(due_date)
{
return false;
}
if self.overdue == Some(true) && !is_overdue_haystack(haystack) {
return false;
}
true
}
pub fn evaluate_issue(
&self,
issue: &Issue,
text: &str,
resolution: &crate::workflow::CategoryResolution,
) -> bool {
self.evaluate(text)
&& self.category_matches(issue, resolution)
&& self.scope_matches(issue)
&& self.archived_matches(issue)
&& self.project_matches(issue)
&& self.epic_matches(issue)
}
pub fn project_matches(&self, issue: &Issue) -> bool {
let Some(want) = self.project.as_deref().filter(|s| !s.is_empty()) else {
return true;
};
crate::projects::ProjectNode::project_ids(issue)
.iter()
.any(|id| id == want)
}
pub fn epic_matches(&self, issue: &Issue) -> bool {
let Some(want) = self.epic.as_deref().filter(|s| !s.is_empty()) else {
return true;
};
crate::epics::EpicNode::epic_ids(issue)
.iter()
.any(|id| id == want)
}
pub fn archived_matches(&self, issue: &Issue) -> bool {
match self.archived {
None => true,
Some(want) => issue.archived == want,
}
}
pub fn scope_matches(&self, issue: &Issue) -> bool {
match &self.scope {
None => true,
Some(columns) => columns.iter().any(|c| issue.status.is(c)),
}
}
pub fn category_matches(
&self,
issue: &Issue,
resolution: &crate::workflow::CategoryResolution,
) -> bool {
let Some(want) = self.category.as_deref().filter(|s| !s.is_empty()) else {
return true;
};
match resolution.resolve(issue.status.display()) {
Some(actual) => actual.key() == want,
None => false,
}
}
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 parts: Vec<String> = self
.criteria()
.map(|(field, value)| format!("{}:{value}", field.token()))
.collect();
if let Some(columns) = &self.scope {
parts.push(format!("scope:{}", columns.join(",")));
}
if let Some(archived) = self.archived {
parts.push(format!("archived:{archived}"));
}
if self.overdue == Some(true) {
parts.push("overdue:true".to_string());
}
if parts.is_empty() {
return write!(f, "(no active criteria)");
}
write!(f, "{}", parts.join(", "))
}
}
pub fn matches(query: &str, filter: &StructuredFilter, haystack: &str) -> bool {
let text_matches = query_matches(query, haystack);
text_matches && filter.evaluate(haystack)
}
pub fn matches_issue(
query: &str,
filter: &StructuredFilter,
issue: &Issue,
text: &str,
resolution: &crate::workflow::CategoryResolution,
) -> bool {
query_matches(query, text) && filter.evaluate_issue(issue, text, resolution)
}
pub fn resolve_parent_criterion(store: &crate::storage::Store, query: &str) -> Result<String> {
let mut out: Vec<String> = Vec::with_capacity(8);
let mut touched = false;
for token in query.split_whitespace() {
let Some((key, value)) = parse_token(token) else {
out.push(token.to_string());
continue;
};
if !key.eq_ignore_ascii_case("parent") {
out.push(token.to_string());
continue;
}
let target = crate::commands::parse_selector(store, value)?;
let number = store
.get(target)
.map_err(|_| {
crate::error::TixError::NotFound(format!(
"issue matching '{value}' by number, filename or title"
))
})?
.number;
out.push(format!("parent:{number}"));
touched = true;
}
if !touched {
return Ok(query.to_string());
}
Ok(out.join(" "))
}
fn lift_category_criterion(
config: &crate::config::Config,
query: &str,
) -> crate::error::Result<(String, Option<String>)> {
let mut out: Vec<String> = Vec::with_capacity(8);
let mut category = None;
for token in query.split_whitespace() {
let Some((key, value)) = parse_token(token) else {
out.push(token.to_string());
continue;
};
if !key.eq_ignore_ascii_case("category") {
out.push(token.to_string());
continue;
}
let lowered = value.to_lowercase();
if !config.categories.contains(&lowered) {
return Err(crate::error::TixError::InvalidInput(format!(
"unknown category '{value}' — this project's categories are: {}",
config.categories.order.join(", ")
)));
}
category = Some(
crate::workflow::WorkflowCategory::from_key(&lowered)
.expect("categories.order is validated against the global vocabulary at load")
.key()
.to_string(),
);
}
Ok((out.join(" "), category))
}
fn lift_project_criterion(config: &crate::config::Config, query: &str) -> (String, Option<String>) {
let mut out: Vec<String> = Vec::with_capacity(8);
let mut project = None;
for token in query.split_whitespace() {
let Some((key, value)) = parse_token(token) else {
out.push(token.to_string());
continue;
};
if !key.eq_ignore_ascii_case("project") {
out.push(token.to_string());
continue;
}
project = Some(
crate::projects::resolve_project_id(config, value)
.map(|id| id.to_string())
.unwrap_or_else(|_| value.to_string()),
);
}
(out.join(" "), project)
}
fn lift_epic_criterion(config: &crate::config::Config, query: &str) -> (String, Option<String>) {
let mut out: Vec<String> = Vec::with_capacity(8);
let mut epic = None;
for token in query.split_whitespace() {
let Some((key, value)) = parse_token(token) else {
out.push(token.to_string());
continue;
};
if !key.eq_ignore_ascii_case("epic") {
out.push(token.to_string());
continue;
}
epic = Some(crate::epics::dereference_needle(&config.epics, value));
}
(out.join(" "), epic)
}
pub fn epic_bucket_label(ids: &[String]) -> String {
match ids {
[] => NO_EPIC_BUCKET.to_string(),
[only] => only.clone(),
_ => MULTIPLE_EPICS_BUCKET.to_string(),
}
}
pub const NO_EPIC_BUCKET: &str = "(no epic)";
pub const MULTIPLE_EPICS_BUCKET: &str = "(multiple epics)";
fn lift_scope_criterion(
config: &crate::config::Config,
query: &str,
) -> crate::error::Result<(String, Option<Vec<String>>)> {
let mut out: Vec<String> = Vec::with_capacity(8);
let mut scope = None;
for token in query.split_whitespace() {
let Some((key, value)) = parse_token(token) else {
out.push(token.to_string());
continue;
};
if !key.eq_ignore_ascii_case("scope") {
out.push(token.to_string());
continue;
}
scope = Some(crate::triage::inbox::scope_columns(config, value)?);
}
Ok((out.join(" "), scope))
}
pub fn resolve_filter_criteria(
store: &crate::storage::Store,
query: &str,
) -> Result<(String, StructuredFilter)> {
let query = resolve_parent_criterion(store, query)?;
let (query, category) = lift_category_criterion(&store.config, &query)?;
let (query, scope) = lift_scope_criterion(&store.config, &query)?;
let (query, archived) = lift_archived_criterion(&query)?;
let (query, project) = lift_project_criterion(&store.config, &query);
let (query, epic) = lift_epic_criterion(&store.config, &query);
Ok((
query,
StructuredFilter {
category,
project,
epic,
scope,
archived,
..StructuredFilter::default()
},
))
}
fn lift_archived_criterion(query: &str) -> crate::error::Result<(String, Option<bool>)> {
let mut out: Vec<String> = Vec::with_capacity(8);
let mut archived = None;
for token in query.split_whitespace() {
let Some((key, value)) = parse_token(token) else {
out.push(token.to_string());
continue;
};
if !key.eq_ignore_ascii_case("archived") {
out.push(token.to_string());
continue;
}
archived = match value.to_ascii_lowercase().as_str() {
"true" => Some(true),
"false" => Some(false),
other => {
return Err(crate::error::TixError::InvalidInput(format!(
"invalid archived value '{other}' — use archived:true or archived:false"
)));
}
};
}
Ok((out.join(" "), archived))
}
fn parse_token(token: &str) -> Option<(&str, &str)> {
let (key, value) = token.split_once(':')?;
let value = value.trim();
if key.is_empty() || value.is_empty() {
return None;
}
Some((key, value))
}
pub fn control_token(query: &str, key: &str) -> Option<String> {
query.split_whitespace().find_map(|token| {
let (k, v) = token.split_once(':')?;
if k.eq_ignore_ascii_case(key) {
Some(v.to_ascii_lowercase())
} else {
None
}
})
}
pub fn query_matches(query: &str, haystack: &str) -> bool {
let mut bare_terms = Vec::new();
let hay = haystack.to_lowercase();
for token in query.split_whitespace() {
if token.eq_ignore_ascii_case("overdue") {
if !is_overdue_haystack(haystack) {
return false;
}
continue;
}
if let Some((key, value)) = parse_token(token) {
let value = value.to_lowercase();
let ok = match key.to_lowercase().as_str() {
"assignee" | "label" | "status" | "priority" | "cycle" | "milestone"
| "reviewer" => hay.contains(&value),
"start" | "due" => hay.contains(&value),
"parent" => field_value(haystack, "parent") == Some(value.as_str()),
"category" => false,
"scope" => false,
"archived" => false,
"project" => false,
"epic" => false,
"sort" => matches!(
value.as_str(),
"created"
| "priority"
| "status"
| "cycle"
| "milestone"
| "due"
| "start"
| "reviewer"
| "project"
| "epic"
),
"group" => matches!(value.as_str(), "cycle" | "milestone" | "project" | "epic"),
"overdue" if value == "true" => is_overdue_haystack(haystack),
_ => false,
};
if !ok {
return false;
}
} else {
bare_terms.push(token);
}
}
let bare = bare_terms.join(" ").to_lowercase();
bare.is_empty() || hay.contains(&bare)
}
fn field_value<'a>(haystack: &'a str, key: &str) -> Option<&'a str> {
for line in haystack.lines() {
let prefix = format!("{key}:");
if let Some(rest) = line.strip_prefix(&prefix) {
return Some(rest.trim().trim_matches('"'));
}
}
None
}
fn is_overdue_haystack(haystack: &str) -> bool {
let status = field_value(haystack, "status").unwrap_or_default();
let due = field_value(haystack, "due_date").unwrap_or_default();
!due.is_empty() && due < crate::model::today().as_str() && status != "done"
}
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 {
projects: None,
id: None,
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,
relationships: vec![],
related_issues: vec![],
files: vec![],
cycles: None,
milestones: None,
reviewer: None,
start_date: None,
due_date: None,
created: "2026-01-01".to_string(),
type_: None,
updated: "2026-01-01".to_string(),
filename: String::new(),
body: extra_body.to_string(),
history: crate::model::History::new(),
has_retired_links_key: false,
time_entries: vec![],
parent: None,
series: None,
health: None,
epics: None,
extra_frontmatter: std::collections::BTreeMap::new(),
archived: false,
entered_at: None,
source: None,
};
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 fs_delta(changed: &[&str], removed: &[&str]) -> FsDelta {
FsDelta {
changed: changed.iter().map(PathBuf::from).collect(),
removed: removed.iter().map(PathBuf::from).collect(),
}
}
fn shape(corpus: &Corpus) -> Vec<(u32, String, String)> {
corpus
.issues
.iter()
.map(|i| (i.number, i.title.clone(), i.filename.clone()))
.collect()
}
#[test]
fn apply_matches_a_full_rebuild_after_edits() {
let (_tmp, store) = make_store();
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
write_issue(
&store,
"alice/2026-01-01-login-bug.md",
&sample_issue(1, "Login bug", "one"),
);
write_issue(
&store,
"alice/2026-01-02-export-csv.md",
&sample_issue(2, "Export csv", "two"),
);
write_issue(
&store,
"2026-01-03-flat.md",
&sample_issue(3, "Flat", "three"),
);
let mut corpus = Corpus::build(&store).unwrap();
write_issue(
&store,
"alice/2026-01-01-login-bug.md",
&sample_issue(1, "Login bug fixed", "one v2"),
);
write_issue(
&store,
"alice/2026-01-04-new.md",
&sample_issue(4, "New", "four"),
);
fs::remove_file(store.issues_dir.join("2026-01-03-flat.md")).unwrap();
fs::write(store.issues_dir.join("notes.txt"), "not an issue").unwrap();
corpus
.apply(
&store,
&fs_delta(
&[
"alice/2026-01-01-login-bug.md",
"alice/2026-01-04-new.md",
"notes.txt",
],
&["2026-01-03-flat.md"],
),
)
.unwrap();
let rebuilt = Corpus::build(&store).unwrap();
assert_eq!(
shape(&corpus),
shape(&rebuilt),
"the incremental corpus is exactly a rebuild, in scan order"
);
assert_eq!(
corpus.texts(),
rebuilt.texts(),
"texts stay byte-identical to `to_file_contents`"
);
}
#[test]
fn apply_ignores_paths_scan_would_skip() {
let (_tmp, store) = make_store();
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
write_issue(
&store,
"alice/2026-01-01-a.md",
&sample_issue(1, "A", "body"),
);
let mut corpus = Corpus::build(&store).unwrap();
let before = shape(&corpus);
for (path, contents) in [
(".tix-tmp-x/a.md", "temp"),
("foo-assets/a.md", "asset"),
("notes.txt", "not an issue"),
] {
let full = store.issues_dir.join(path);
fs::create_dir_all(full.parent().unwrap()).unwrap();
fs::write(&full, contents).unwrap();
}
corpus
.apply(
&store,
&fs_delta(&["notes.txt", ".tix-tmp-x/a.md", "foo-assets/a.md"], &[]),
)
.unwrap();
assert_eq!(shape(&corpus), before, "nothing `scan` skips is indexed");
assert_eq!(corpus.texts(), Corpus::build(&store).unwrap().texts());
}
#[test]
fn from_issues_matches_a_fresh_walk() {
let (_tmp, store) = make_store();
write_issue(&store, "2026-01-01-a.md", &sample_issue(1, "A", "body"));
write_issue(
&store,
"2026-01-02-b.md",
&sample_issue(2, "B", "more body"),
);
let shared = Corpus::from_issues(store.list().unwrap());
let walked = Corpus::build(&store).unwrap();
assert_eq!(shared.issues.len(), walked.issues.len());
assert_eq!(
shared.texts(),
walked.texts(),
"same files, same serialized text"
);
}
#[test]
fn texts_are_serialized_lazily() {
let (_tmp, store) = make_store();
write_issue(&store, "2026-01-01-a.md", &sample_issue(1, "A", "body"));
let corpus = Corpus::build(&store).unwrap();
assert!(
!corpus.texts_initialized(),
"the walk must not serialize issues it may never search"
);
let texts = corpus.texts();
assert!(corpus.texts_initialized(), "first read builds the snapshot");
assert_eq!(texts.len(), 1);
}
#[test]
fn apply_treats_a_vanished_changed_file_as_removed() {
let (_tmp, store) = make_store();
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
write_issue(
&store,
"alice/2026-01-01-a.md",
&sample_issue(1, "A", "body"),
);
let mut corpus = Corpus::build(&store).unwrap();
fs::remove_file(store.issues_dir.join("alice/2026-01-01-a.md")).unwrap();
corpus
.apply(&store, &fs_delta(&["alice/2026-01-01-a.md"], &[]))
.unwrap();
assert_eq!(corpus.issues, [] as [crate::model::Issue; 0]);
assert_eq!(corpus.texts(), [] as [String; 0]);
}
#[test]
fn apply_keeps_the_corpus_on_a_parse_error() {
let (_tmp, store) = make_store();
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
write_issue(
&store,
"alice/2026-01-01-a.md",
&sample_issue(1, "A", "body"),
);
let mut corpus = Corpus::build(&store).unwrap();
let before = shape(&corpus);
let texts_before = corpus.texts().to_vec();
fs::write(
store.issues_dir.join("alice/2026-01-01-bad.md"),
"not frontmatter",
)
.unwrap();
let result = corpus.apply(&store, &fs_delta(&["alice/2026-01-01-bad.md"], &[]));
assert!(result.is_err(), "a malformed file is reported, not ignored");
assert_eq!(
shape(&corpus),
before,
"all-or-nothing: nothing was applied"
);
assert_eq!(corpus.texts(), texts_before);
}
#[test]
fn apply_indexes_an_archived_file_the_caller_passes() {
let (_tmp, store) = make_store();
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
let mut corpus = Corpus::build(&store).unwrap();
assert_eq!(corpus.issues, [] as [crate::model::Issue; 0]);
write_issue(
&store,
"alice/.2026-01-01-a.md",
&sample_issue(1, "Archived", "body"),
);
corpus
.apply(&store, &fs_delta(&["alice/.2026-01-01-a.md"], &[]))
.unwrap();
assert_eq!(corpus.issues.len(), 1);
assert!(corpus.issues[0].archived);
assert_eq!(
corpus.texts(),
Corpus::build_with(&store, true).unwrap().texts()
);
}
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_eq!(
search_substring(&corpus, "nope-nothing-here"),
[] as [crate::search::SearchHit; 0]
);
}
#[test]
fn substring_empty_query_is_empty() {
let corpus = two_issue_corpus();
assert_eq!(
search_substring(&corpus, ""),
[] as [crate::search::SearchHit; 0]
);
}
#[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,
cycle: None,
milestone: None,
reviewer: None,
start_date: None,
due_date: None,
overdue: None,
parent: None,
category: None,
scope: None,
archived: None,
project: None,
epic: None,
};
let criteria: Vec<(FilterField, &str)> = filter.criteria().collect();
assert_eq!(
criteria,
vec![
(FilterField::Label, "urgent"),
(FilterField::Status, "todo")
]
);
}
#[test]
fn archived_criterion_matches_the_issue_state() {
let mut archived = sample_issue(1, "Old", "");
archived.archived = true;
let active = sample_issue(2, "New", "");
let want_true = StructuredFilter {
archived: Some(true),
..Default::default()
};
assert!(want_true.archived_matches(&archived));
assert!(!want_true.archived_matches(&active));
let want_false = StructuredFilter {
archived: Some(false),
..Default::default()
};
assert!(want_false.archived_matches(&active));
assert!(!want_false.archived_matches(&archived));
assert!(StructuredFilter::default().archived_matches(&archived));
}
#[test]
fn archived_token_is_lifted_out_of_the_query() {
let (_tmp, store) = make_store();
let (query, filter) = resolve_filter_criteria(&store, "archived:true status:todo").unwrap();
assert_eq!(filter.archived, Some(true));
assert!(
!query.contains("archived"),
"the token is lifted, not left for the store-free matcher: {query:?}"
);
assert!(filter.has_active_criteria());
assert_eq!(filter.to_string(), "archived:true");
let (_query, filter) = resolve_filter_criteria(&store, "archived:false").unwrap();
assert_eq!(filter.archived, Some(false));
}
#[test]
fn an_invalid_archived_value_is_refused_by_name() {
let (_tmp, store) = make_store();
let err = resolve_filter_criteria(&store, "archived:1").unwrap_err();
assert!(
err.to_string().contains("archived:true"),
"the diagnostic names the accepted spellings: {err}"
);
assert!(!query_matches("archived:true", "---\nstatus: todo\n---\n"));
}
#[test]
fn corpus_build_with_includes_archived_issues() {
let (_tmp, store) = make_store();
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
let active = sample_issue(1, "Active", "body");
write_issue(&store, "alice/2026-01-01-active.md", &active);
let mut old = sample_issue(2, "Old", "body");
old.archived = true;
fs::write(
store.issues_dir.join("alice/.2026-01-01-old.md"),
old.to_file_contents(),
)
.unwrap();
assert_eq!(
Corpus::build(&store).unwrap().issues.len(),
1,
"the default corpus never reads the archived file"
);
let all = Corpus::build_with(&store, true).unwrap();
assert_eq!(all.issues.len(), 2);
assert!(all.issues.iter().any(|issue| issue.archived));
}
#[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()),
cycle: None,
milestone: None,
reviewer: None,
start_date: None,
due_date: None,
overdue: None,
parent: None,
category: None,
scope: None,
archived: None,
project: None,
epic: None,
};
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_scope_is_active_even_though_it_is_not_a_modal_row() {
let filter = StructuredFilter {
scope: Some(vec!["triage".to_string(), "todo".to_string()]),
..Default::default()
};
assert!(
filter.has_active_criteria(),
"a filter that selects rows is active however it selects them"
);
assert_eq!(
filter.criteria().count(),
0,
"and it still is not a modal row — the TUI's row count is unchanged"
);
assert_eq!(
filter.to_string(),
"scope:triage,todo",
"the hint bar shows the columns it resolved to, not (no active criteria)"
);
}
#[test]
fn filter_display_keeps_criteria_overdue_and_scope_together() {
let filter = StructuredFilter {
assignee: Some("alice".to_string()),
status: Some("todo".to_string()),
overdue: Some(true),
scope: Some(vec!["triage".to_string()]),
..Default::default()
};
assert_eq!(
filter.to_string(),
"assignee:alice, status:todo, scope:triage, overdue:true",
"all three sources of a part, in the order they have always printed"
);
}
#[test]
fn filter_scope_round_trips_through_toml() {
let plain = StructuredFilter::default();
assert!(
!toml::to_string(&plain).unwrap().contains("scope"),
"an unset scope writes no key at all"
);
let filter = StructuredFilter {
scope: Some(vec!["triage".to_string()]),
..Default::default()
};
let back: StructuredFilter = toml::from_str(&toml::to_string(&filter).unwrap()).unwrap();
assert_eq!(back, filter);
}
#[test]
fn filter_scope_criterion_narrows_evaluate_issue_but_its_absence_does_not() {
let config = mapped_config();
let resolution = resolution_for(&config);
let in_box = issue_in(1, "Alpha", "triage");
let outside = issue_in(2, "Bravo", "wip");
let in_text = in_box.to_file_contents();
let out_text = outside.to_file_contents();
let scoped = StructuredFilter {
scope: Some(vec!["triage".to_string()]),
..Default::default()
};
assert!(
scoped.evaluate_issue(&in_box, &in_text, &resolution),
"a member of the scope passes"
);
assert!(
!scoped.evaluate_issue(&outside, &out_text, &resolution),
"and a non-member fails — the scope is what was asked for"
);
let unset = StructuredFilter::default();
assert!(
unset.evaluate_issue(&outside, &out_text, &resolution),
"an unset criterion narrows nothing (WP02 T010: a default view is untouched)"
);
}
#[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()),
cycle: None,
milestone: None,
reviewer: None,
start_date: None,
due_date: None,
overdue: None,
parent: None,
category: None,
scope: None,
archived: None,
project: None,
epic: None,
};
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 query_matches_supports_token_grammar_and_bare_terms() {
let haystack = "---\ncycle: Cycle 1\nmilestone: Backlog\nreviewer: alice\nstatus: todo\nstart_date: 2026-09-01\ndue_date: 2000-09-10\n---\nlogin bug\n";
assert!(query_matches("cycle:Cycle", haystack));
assert!(query_matches("milestone:Backlog reviewer:alice", haystack));
assert!(query_matches("overdue:true", haystack));
assert!(query_matches("overdue", haystack));
assert!(query_matches("group:cycle sort:due", haystack));
assert!(!query_matches("group:assignee", haystack));
assert!(query_matches("login bug", haystack));
assert!(!query_matches("cycle:Other", haystack));
assert!(!query_matches("missing term", haystack));
}
#[test]
fn t1498_a_bare_term_is_a_substring_not_a_subsequence() {
let hit = "---\nstatus: todo\n---\nthe greedy matcher\n";
let scattered = "---\nstatus: todo\n---\ngreat red dye\n";
assert!(fuzzy_score("greedy", scattered).is_some());
assert!(query_matches("greedy", hit));
assert!(
!query_matches("greedy", scattered),
"characters in order are not a substring match"
);
assert!(query_matches("GREEDY", hit), "case-insensitive");
assert!(query_matches("status:todo greedy", hit));
assert!(!query_matches("status:todo greedy", scattered));
}
#[test]
fn matches_uses_query_tokens_for_board_paths() {
let filter = StructuredFilter::default();
let haystack = "---\nstatus: todo\ndue_date: 2000-01-01\ncycle: Cycle 1\n---\nbody\n";
assert!(matches("overdue cycle:Cycle", &filter, haystack));
assert!(!matches("overdue cycle:Other", &filter, haystack));
}
#[test]
fn structured_filter_overdue_uses_due_date_and_status() {
let filter = StructuredFilter {
overdue: Some(true),
..Default::default()
};
let overdue_haystack = "---\nstatus: todo\ndue_date: 2000-01-01\n---\nbody\n";
let done_haystack = "---\nstatus: done\ndue_date: 2000-01-01\n---\nbody\n";
assert!(filter.evaluate(overdue_haystack));
assert!(!filter.evaluate(done_haystack));
}
#[test]
fn structured_filter_display_includes_new_fields() {
let filter = StructuredFilter {
cycle: Some("Cycle 1".to_string()),
milestone: Some("Backlog".to_string()),
reviewer: Some("alice".to_string()),
start_date: Some("2026-09-01".to_string()),
due_date: Some("2026-09-10".to_string()),
overdue: Some(true),
..Default::default()
};
assert!(filter.to_string().contains("cycle:Cycle 1"));
assert!(filter.to_string().contains("overdue:true"));
}
#[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()),
cycle: None,
milestone: None,
reviewer: None,
start_date: None,
due_date: None,
overdue: None,
parent: None,
category: None,
scope: None,
archived: None,
project: None,
epic: None,
};
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:?}"
);
}
#[test]
fn t024_the_parent_key_is_the_key_the_model_serialises() {
let issue = Issue::parse(
"---\nnumber: 1\ntitle: T\nstatus: todo\nlabels: []\npriority: medium\nparent: 7\nfiles: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\nB.",
)
.unwrap();
let fm = issue.frontmatter();
assert!(
fm.contains(&format!("\n{}:", FilterField::Parent.key())),
"`key()` ({}) must be a key the renderer emits:\n{fm}",
FilterField::Parent.key()
);
for field in FilterField::ALL {
let key = field.key();
if key == "cycle" || key == "milestone" {
continue;
}
let mut probe = issue.clone();
probe.parent = None;
let rendered = probe.frontmatter();
assert!(
rendered.contains(&format!("\n{key}:")) || rendered.contains("\nlabels:"),
"`{key}` is not a key the model emits"
);
}
}
#[test]
fn t024_all_label_and_key_are_consistent_and_the_nine_keep_their_order() {
let expected: [FilterField; 9] = [
FilterField::Assignee,
FilterField::Label,
FilterField::Status,
FilterField::Priority,
FilterField::Cycle,
FilterField::Milestone,
FilterField::Reviewer,
FilterField::StartDate,
FilterField::DueDate,
];
assert_eq!(
FilterField::ALL[..9],
expected,
"the existing nine criteria must keep their relative order"
);
assert_eq!(FilterField::ALL[9], FilterField::Parent, "appended tenth");
assert_eq!(
FilterField::ALL[10],
FilterField::Category,
"and Category is appended eleventh"
);
assert_eq!(
FilterField::ALL[11],
FilterField::Project,
"and Project is appended twelfth"
);
assert_eq!(
FilterField::ALL[12],
FilterField::Epic,
"and Epic is appended thirteenth"
);
assert_eq!(FilterField::ALL.len(), 13, "and ALL must be complete");
for field in FilterField::ALL {
let row = field.row();
assert_eq!(
FilterField::ALL[row],
field,
"{field:?} is not at its own row"
);
assert_eq!(
FilterField::ALL.iter().filter(|f| **f == field).count(),
1,
"{field:?} appears more than once in ALL"
);
assert!(!field.label().is_empty(), "{field:?} has an empty label");
assert!(!field.key().is_empty(), "{field:?} has an empty key");
}
}
#[test]
fn t024_row_navigation_wraps_at_the_new_bounds() {
assert_eq!(FilterField::Assignee.row(), 0, "the first row");
assert_eq!(FilterField::Parent.row(), 9, "not last any more");
assert_eq!(FilterField::Category.row(), 10, "not last any more");
assert_eq!(FilterField::Project.row(), 11, "not last any more");
assert_eq!(FilterField::Epic.row(), 12, "the new last row");
assert_eq!(
FilterField::Epic.next(),
FilterField::Assignee,
"the last row wraps to the first"
);
assert_eq!(
FilterField::Assignee.prev(),
FilterField::Epic,
"and the first row's predecessor is the new last"
);
assert_eq!(
FilterField::ALL[FilterField::Assignee.row() + FilterField::ALL.len() - 1],
FilterField::Epic,
"prev is a mod of len, not of a hard-coded index"
);
let mut field = FilterField::Assignee;
let mut seen = Vec::new();
for _ in 0..FilterField::ALL.len() {
seen.push(field);
field = field.next();
}
assert_eq!(field, FilterField::Assignee, "a full lap returns home");
seen.sort_by_key(|f| f.row());
assert_eq!(seen, FilterField::ALL.to_vec(), "visiting each once");
}
fn project_issue(number: u32, title: &str, projects: &[&str]) -> Issue {
let mut issue = sample_issue(number, title, "");
issue.projects = if projects.is_empty() {
None
} else {
Some(projects.iter().map(|p| (*p).to_string()).collect())
};
issue
}
#[test]
fn t026_the_project_key_is_the_key_the_model_serialises() {
let mut probe = sample_issue(1, "T", "");
probe.projects = Some(vec!["p-1".to_string()]);
let rendered = probe.frontmatter();
assert!(
rendered.contains(&format!("\n{}:\n- p-1", FilterField::Project.key())),
"`key()` ({}) must be the key the model serialises the association list under:\n{rendered}",
FilterField::Project.key()
);
assert!(
crate::model::KNOWN_FRONTMATTER_KEYS.contains(&FilterField::Project.key()),
"{} must be a registered frontmatter key",
FilterField::Project.key()
);
}
#[test]
fn t027_the_project_criterion_matches_by_membership_not_by_substring() {
let filter = |id: &str| StructuredFilter {
project: Some(id.to_string()),
..StructuredFilter::default()
};
let member = project_issue(1, "Member", &["p-1"]);
let other = project_issue(2, "Other", &["p-2"]);
let unassociated = project_issue(3, "None", &[]);
assert!(filter("p-1").project_matches(&member), "a member matches");
assert!(
!filter("p-1").project_matches(&other),
"a non-member does not"
);
assert!(
!filter("p-1").project_matches(&unassociated),
"an unassociated issue matches no project"
);
let longer = project_issue(4, "Longer", &["p-12"]);
assert!(
!filter("p-1").project_matches(&longer),
"p-1 must not match p-12"
);
let mut mentions = sample_issue(5, "mentions p-1", "this body says p-1");
mentions.projects = None;
assert!(!filter("p-1").project_matches(&mentions));
assert!(StructuredFilter::default().project_matches(&unassociated));
}
#[test]
fn t027_a_stored_but_unresolved_id_still_matches_the_criterion() {
let stale = project_issue(1, "Stale", &["p-gone"]);
let filter = StructuredFilter {
project: Some("p-gone".to_string()),
..StructuredFilter::default()
};
assert!(
filter.project_matches(&stale),
"a stored reference to a deleted project must still be findable"
);
assert!(
!StructuredFilter {
project: Some("p-other".to_string()),
..StructuredFilter::default()
}
.project_matches(&stale)
);
}
#[test]
fn t027_an_issue_in_two_projects_matches_both_and_an_unknown_id_matches_nothing() {
let both = project_issue(1, "Both", &["p-1", "p-2"]);
let only = |id: &str| StructuredFilter {
project: Some(id.to_string()),
..StructuredFilter::default()
};
assert!(only("p-1").project_matches(&both));
assert!(only("p-2").project_matches(&both));
assert!(!only("p-3").project_matches(&both));
let unknown = only("p-does-not-exist");
assert!(!unknown.project_matches(&both));
assert!(!unknown.project_matches(&project_issue(2, "Plain", &[])));
}
#[test]
fn t027_the_project_criterion_joins_the_full_predicate_and_the_hint_bar() {
let config = crate::config::Config::default();
let (_, resolution) = crate::workflow::active_resolution(&config);
let issue = project_issue(1, "Member", &["p-1"]);
let text = issue.frontmatter();
let filter = StructuredFilter {
project: Some("p-1".to_string()),
status: Some("todo".to_string()),
..StructuredFilter::default()
};
assert!(
filter.evaluate_issue(&issue, &text, &resolution),
"both active criteria hold"
);
let missed = StructuredFilter {
project: Some("p-1".to_string()),
status: Some("shipped".to_string()),
..StructuredFilter::default()
};
assert!(
!missed.evaluate_issue(&issue, &text, &resolution),
"the two layers AND together"
);
assert!(filter.has_active_criteria());
assert_eq!(filter.to_string(), "status:todo, project:p-1");
assert_eq!(
FilterField::Project.describe(),
"projects",
"a real frontmatter key is named honestly, unlike the derived category"
);
assert!(FilterField::Project.is_project());
assert!(
!FilterField::Project.is_derived() && !FilterField::Project.is_reference(),
"membership is its own kind, not a derived value or a parent reference"
);
}
fn epic_issue(number: u32, title: &str, epics: &[&str]) -> Issue {
let mut issue = sample_issue(number, title, "");
issue.epics = if epics.is_empty() {
None
} else {
Some(epics.iter().map(|e| (*e).to_string()).collect())
};
issue
}
fn epic_registry(epics: &[(&str, &str)]) -> crate::config::Config {
let mut config = crate::config::Config::default();
for (id, name) in epics {
config.epics.insert(
(*id).to_string(),
crate::config::EpicConfiguration {
name: (*name).to_string(),
..Default::default()
},
);
}
config
}
#[test]
fn t028_the_epic_key_is_the_exact_frontmatter_key_the_model_serialises() {
assert_eq!(
FilterField::Epic.key(),
crate::epics::EPICS_KEY,
"key() must be the service's own constant, not a second literal"
);
let mut probe = epic_issue(1, "T", &["e-aaa111"]);
probe.epics = Some(vec!["e-aaa111".to_string()]);
let rendered = probe.frontmatter();
assert!(
rendered.contains(&format!("\n{}:\n- e-aaa111", FilterField::Epic.key())),
"`key()` ({}) must be the key the model serialises the reference list under:\n{rendered}",
FilterField::Epic.key()
);
assert!(
crate::model::KNOWN_FRONTMATTER_KEYS.contains(&FilterField::Epic.key()),
"{} must be a registered frontmatter key",
FilterField::Epic.key()
);
assert_eq!(FilterField::Epic.token(), "epic");
assert_eq!(FilterField::Epic.describe(), "epics");
}
#[test]
fn t029_the_epic_criterion_matches_by_membership_not_by_substring() {
let filter = |id: &str| StructuredFilter {
epic: Some(id.to_string()),
..StructuredFilter::default()
};
let member = epic_issue(1, "Member", &["e-aaa111"]);
let other = epic_issue(2, "Other", &["e-bbb222"]);
let unassociated = epic_issue(3, "None", &[]);
assert!(filter("e-aaa111").epic_matches(&member), "a member matches");
assert!(
!filter("e-aaa111").epic_matches(&other),
"a non-member does not"
);
assert!(
!filter("e-aaa111").epic_matches(&unassociated),
"an unreferenced issue matches no epic"
);
let longer = epic_issue(4, "Longer", &["e-12"]);
assert!(
!filter("e-1").epic_matches(&longer),
"e-1 must not match e-12"
);
let mut mentions = sample_issue(5, "mentions e-aaa111", "this body says e-aaa111");
mentions.epics = None;
assert!(!filter("e-aaa111").epic_matches(&mentions));
assert!(StructuredFilter::default().epic_matches(&unassociated));
}
#[test]
fn t029_a_stored_but_unresolved_epic_still_matches_the_criterion() {
let stale = epic_issue(1, "Stale", &["e-gone"]);
let filter = StructuredFilter {
epic: Some("e-gone".to_string()),
..StructuredFilter::default()
};
assert!(
filter.epic_matches(&stale),
"a stored reference to a deleted epic must still be findable"
);
assert!(
!StructuredFilter {
epic: Some("e-other".to_string()),
..StructuredFilter::default()
}
.epic_matches(&stale)
);
}
#[test]
fn t029_an_issue_in_two_epics_matches_both_and_an_unknown_id_matches_nothing() {
let both = epic_issue(1, "Both", &["e-aaa111", "e-bbb222"]);
let only = |id: &str| StructuredFilter {
epic: Some(id.to_string()),
..StructuredFilter::default()
};
assert!(only("e-aaa111").epic_matches(&both));
assert!(only("e-bbb222").epic_matches(&both));
assert!(!only("e-ccc333").epic_matches(&both));
let unknown = only("e-does-not-exist");
assert!(!unknown.epic_matches(&both));
assert!(!unknown.epic_matches(&epic_issue(2, "Plain", &[])));
}
#[test]
fn t029_the_epic_bucket_label_partitions_with_a_labelled_no_epic_bucket() {
assert_eq!(epic_bucket_label(&[]), "(no epic)");
assert_eq!(epic_bucket_label(&["e-aaa111".to_string()]), "e-aaa111");
assert_eq!(
epic_bucket_label(&["e-aaa111".to_string(), "e-bbb222".to_string()]),
"(multiple epics)"
);
}
#[test]
fn t028_group_and_sort_accept_epic_and_still_reject_unknown_keys() {
assert!(query_matches("group:epic sort:epic", "status: todo"));
assert!(query_matches("group:epic", "status: todo"));
assert!(query_matches("sort:epic", "status: todo"));
assert!(!query_matches("group:assignee", "status: todo"));
assert!(!query_matches("sort:magic", "status: todo"));
assert!(!query_matches("group:magic sort:magic", "status: todo"));
}
#[test]
fn t029_a_name_resolves_to_its_id_and_an_unknown_reference_is_kept_verbatim() {
let config = epic_registry(&[("e-aaa111", "Alpha")]);
let (query, epic) = lift_epic_criterion(&config, "epic:Alpha status:todo");
assert_eq!(query, "status:todo", "the token is lifted out of the query");
assert_eq!(epic.as_deref(), Some("e-aaa111"));
let (_, epic) = lift_epic_criterion(&config, "epic:e-aaa111");
assert_eq!(epic.as_deref(), Some("e-aaa111"));
let (_, unknown) = lift_epic_criterion(&config, "epic:e-nope");
assert_eq!(unknown.as_deref(), Some("e-nope"));
let (_, last) = lift_epic_criterion(&config, "epic:Alpha epic:e-bbb222");
assert_eq!(last.as_deref(), Some("e-bbb222"));
}
#[test]
fn t029_the_epic_criterion_joins_the_full_predicate_and_the_hint_bar() {
let config = crate::config::Config::default();
let (_, resolution) = crate::workflow::active_resolution(&config);
let issue = epic_issue(1, "Member", &["e-aaa111"]);
let text = issue.frontmatter();
let filter = StructuredFilter {
epic: Some("e-aaa111".to_string()),
status: Some("todo".to_string()),
..StructuredFilter::default()
};
assert!(
filter.evaluate_issue(&issue, &text, &resolution),
"both active criteria hold"
);
let missed = StructuredFilter {
epic: Some("e-aaa111".to_string()),
status: Some("shipped".to_string()),
..StructuredFilter::default()
};
assert!(
!missed.evaluate_issue(&issue, &text, &resolution),
"the two layers AND together"
);
assert!(filter.has_active_criteria());
assert_eq!(
filter.to_string(),
"status:todo, epic:e-aaa111",
"the hint bar echoes the **token**, so the row is usable"
);
assert!(FilterField::Epic.is_epic());
assert!(
!FilterField::Epic.is_derived()
&& !FilterField::Epic.is_reference()
&& !FilterField::Epic.is_project(),
"membership is its own kind, and epic ≠project"
);
for field in FilterField::ALL {
assert_eq!(
field.is_derived(),
field == FilterField::Category,
"{field:?} derived-flag is wrong"
);
}
}
#[test]
fn t027_the_store_free_matcher_refuses_a_project_token_rather_than_matching_everything() {
assert!(!query_matches(
"project:p-1",
"projects:\n- p-1\nstatus: todo"
));
assert!(!query_matches("project:p-1", "status: todo"));
}
#[test]
fn t026_group_and_sort_accept_project_and_reject_unknown_keys() {
assert!(query_matches("group:project sort:project", "status: todo"));
assert!(query_matches(
"group:project sort:project",
"projects:\n- p-1"
));
assert!(!query_matches("group:assignee", "status: todo"));
assert!(!query_matches("sort:magic", "status: todo"));
assert!(!query_matches("group:project sort:magic", "status: todo"));
}
fn child_file(number: u32, title: &str, parent: Option<u32>) -> String {
let parent_line = parent.map(|p| format!("parent: {p}\n")).unwrap_or_default();
format!(
"---\nnumber: {number}\ntitle: {title}\nstatus: todo\nlabels: []\npriority: medium\n{parent_line}files: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\nBody text.\n"
)
}
#[test]
fn t025_a_parent_criterion_matches_exactly_its_direct_children() {
let files = [
child_file(1, "Parent", None),
child_file(2, "Child A", Some(1)),
child_file(3, "Child B", Some(1)),
child_file(4, "Grandchild", Some(2)),
child_file(5, "Unrelated", None),
];
let filter = StructuredFilter {
parent: Some("1".to_string()),
..StructuredFilter::default()
};
let matched: Vec<u32> = files
.iter()
.filter(|f| filter.evaluate(f))
.map(|f| {
f.lines()
.find_map(|l| l.strip_prefix("number: "))
.and_then(|n| n.parse().ok())
.unwrap()
})
.collect();
assert_eq!(
matched,
vec![2, 3],
"direct children only — a grandchild must not match its grandparent"
);
}
#[test]
fn t025_the_criterion_compares_the_number_exactly_not_as_a_substring() {
let filter = StructuredFilter {
parent: Some("1".to_string()),
..StructuredFilter::default()
};
assert!(filter.evaluate(&child_file(2, "A", Some(1))), "exactly 1");
assert!(
!filter.evaluate(&child_file(2, "A", Some(11))),
"11 must not match a criterion of 1"
);
assert!(!filter.evaluate(&child_file(2, "A", Some(10))), "nor 10");
let no_parent = "---\nnumber: 1\ntitle: mentions 1\nstatus: todo\nlabels: []\npriority: medium\nfiles: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\nBody with 1 in it.\n";
assert!(
!filter.evaluate(no_parent),
"an issue with no parent key must not match a parent criterion"
);
}
#[test]
fn t025_a_child_of_a_deleted_parent_matches_nothing() {
let filter = StructuredFilter {
parent: Some("1".to_string()),
..StructuredFilter::default()
};
let orphan = child_file(2, "Orphan", Some(1));
let parsed = Issue::parse(&orphan).unwrap();
let index = crate::hierarchy::HierarchyIndex::build(std::slice::from_ref(&parsed));
assert_eq!(index.unresolved().len(), 1, "the reference is diagnosed");
let other = StructuredFilter {
parent: Some("7".to_string()),
..StructuredFilter::default()
};
assert!(
!other.evaluate(&orphan),
"a live, different parent does not match"
);
assert!(
filter.evaluate(&orphan),
"the recorded reference is retained, so it still matches its own number"
);
}
#[test]
fn t025_the_query_grammar_matches_parent_exactly_too() {
let child = child_file(2, "A", Some(1));
assert!(query_matches("parent:1", &child), "exactly 1");
let decoy = child_file(2, "Issue 11 notes", Some(1))
.replace("Body text.\n", "Body text. See issue 11 for context.\n");
assert!(
!query_matches("parent:11", &decoy),
"a body mentioning 11 must not match a criterion of 11"
);
assert!(
query_matches("parent:1", &decoy),
"while the real parent still matches"
);
assert!(
query_matches("parent:11", &child_file(2, "A", Some(11))),
"and 11 matches when it is the real parent"
);
assert!(
query_matches("parent:", &child_file(2, "A", Some(1)))
&& query_matches("parent:", &child_file(3, "B", Some(7))),
"an empty value ignores the parent, so any two parents both match"
);
assert!(
!query_matches("parent:", &child_file(4, "C", None)),
"and an issue with no `parent:` key has no literal to match"
);
assert!(
query_matches("parent:1 Body", &child),
"a bare term still applies alongside the criterion"
);
assert!(
!query_matches("parent:1 nonsense", &child),
"and can still exclude"
);
}
#[test]
fn t025_an_empty_criterion_behaves_like_the_others() {
let mut filter = StructuredFilter::default();
assert_eq!(filter.parent, None);
assert_eq!(
FilterField::Parent.value_mut(&mut filter),
"",
"value_mut() creates the slot empty"
);
assert_eq!(
FilterField::Parent.value(&filter),
None,
"and value() reports no criterion"
);
assert!(
!filter.has_active_criteria(),
"an empty slot is not an active criterion"
);
filter.assignee = Some("alice".into());
assert!(filter.has_active_criteria());
FilterField::Parent.unset(&mut filter);
assert_eq!(filter.parent, None, "unset clears the slot");
}
fn mapped_config() -> crate::config::Config {
crate::config::Config {
issues_dir: std::path::PathBuf::from("/nonexistent"),
kanban: crate::config::KanbanSection {
configurations: indexmap::IndexMap::from([(
"default".to_string(),
crate::config::BoardConfiguration {
columns: vec![
"triage".into(),
"wip".into(),
"review".into(),
"shipped".into(),
"abandoned".into(),
],
done_column: Some("shipped".to_string()),
category_map: indexmap::IndexMap::from([
("triage".to_string(), "backlog".to_string()),
("wip".to_string(), "active".to_string()),
("review".to_string(), "active".to_string()),
("shipped".to_string(), "completed".to_string()),
("abandoned".to_string(), "canceled".to_string()),
]),
..Default::default()
},
)]),
},
..crate::config::Config::default()
}
}
fn issue_in(number: u32, title: &str, column: &str) -> Issue {
let mut issue = sample_issue(number, title, "");
issue.status = Status::new(column);
issue
}
fn filter_for(category: Option<&str>) -> StructuredFilter {
StructuredFilter {
category: category.map(str::to_string),
..StructuredFilter::default()
}
}
fn resolution_for(config: &crate::config::Config) -> crate::workflow::CategoryResolution {
let (_, resolution) = crate::workflow::active_resolution(config);
resolution
}
#[test]
fn t034_key_reports_the_token_and_is_derived() {
assert_eq!(FilterField::Category.key(), "category");
assert!(FilterField::Category.is_derived());
assert_eq!(
FilterField::Category.label(),
"Category",
"in the existing style"
);
for field in FilterField::ALL {
assert_eq!(
field.is_derived(),
field == FilterField::Category,
"{field:?} derived-flag is wrong"
);
}
assert_eq!(FilterField::Assignee.describe(), "assignee");
assert_eq!(
FilterField::Category.describe(),
"category (derived from status)",
"an explanation must not send a user looking for a field that does not exist"
);
}
#[test]
fn t035_a_mapped_completed_column_matches_category_completed() {
let config = mapped_config();
let resolution = resolution_for(&config);
let shipped = issue_in(1, "Shipped", "shipped");
let wip = issue_in(2, "Wip", "wip");
let filter = filter_for(Some("completed"));
assert!(
filter.category_matches(&shipped, &resolution),
"a completed column is in the completed category"
);
assert!(
!filter.category_matches(&wip, &resolution),
"and nothing else is"
);
}
#[test]
fn t035_one_category_spans_every_column_mapped_to_it() {
let config = mapped_config();
let resolution = resolution_for(&config);
let filter = filter_for(Some("active"));
for column in ["wip", "review"] {
assert!(
filter.category_matches(&issue_in(1, "A", column), &resolution),
"{column} is active"
);
}
assert!(
!filter.category_matches(&issue_in(1, "A", "triage"), &resolution),
"and triage is not"
);
}
#[test]
fn t035_an_unconfigured_column_matches_no_category() {
let config = mapped_config();
let resolution = resolution_for(&config);
let orphan = issue_in(1, "Orphan", "not-a-column");
for category in ["unstarted", "backlog", "active", "completed", "canceled"] {
assert!(
!filter_for(Some(category)).category_matches(&orphan, &resolution),
"an unconfigured column has no category, so `category:{category}` \\
must not match it"
);
}
}
#[test]
fn t035_a_canceled_column_is_in_canceled_and_not_in_completed() {
let config = mapped_config();
let resolution = resolution_for(&config);
let canceled = issue_in(1, "Abandoned", "abandoned");
assert!(filter_for(Some("canceled")).category_matches(&canceled, &resolution));
assert!(!filter_for(Some("completed")).category_matches(&canceled, &resolution));
}
#[test]
fn t035_evaluate_ignores_the_category_criterion_but_evaluate_issue_does_not() {
let config = mapped_config();
let resolution = resolution_for(&config);
let filter = filter_for(Some("completed"));
let in_wip = issue_in(1, "Mentioning shipped work", "wip");
let text = format!(
"{}\n\nwe shipped this last quarter\n",
in_wip.to_file_contents()
);
assert!(
filter.evaluate(&text),
"a substring test would wrongly match the body"
);
assert!(
!filter.evaluate_issue(&in_wip, &text, &resolution),
"the real criterion says no"
);
let done = issue_in(2, "Done", "shipped");
let text = done.to_file_contents();
assert!(filter.evaluate_issue(&done, &text, &resolution));
}
#[test]
fn t035_an_unknown_category_is_an_error_not_an_empty_result() {
let (_tmp, store) = store_with(mapped_config());
let err = crate::search::resolve_filter_criteria(&store, "category:finished")
.unwrap_err()
.to_string();
assert!(err.contains("unknown category 'finished'"), "{err}");
for key in crate::config::CATEGORY_KEYS {
assert!(err.contains(key), "'{key}' missing from: {err}");
}
}
fn store_with(config: crate::config::Config) -> (tempfile::TempDir, Store) {
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(dir.join("alice")).unwrap();
let config = crate::config::Config {
issues_dir: dir.clone(),
..config
};
config.save(&tmp.path().join("tix.toml")).unwrap();
let config =
crate::config::Config::load_with_warning(&tmp.path().join("tix.toml"), |_| {}).unwrap();
fs::write(
dir.join("alice/2026-01-01-alpha.md"),
"---\nnumber: 1\ntitle: Alpha\nstatus: triage\nlabels: []\n\
priority: medium\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n\
---\n\nbody\n",
)
.unwrap();
let store = Store {
issues_dir: dir,
config,
};
(tmp, store)
}
#[test]
fn t035_a_reduced_project_vocabulary_rejects_a_key_it_did_not_declare() {
let mut config = mapped_config();
config.categories.order = vec![
"backlog".to_string(),
"active".to_string(),
"completed".to_string(),
];
config.categories.default = "active".to_string();
let board = config.kanban.configurations.get_mut("default").unwrap();
board.category_map.shift_remove("abandoned");
board.columns.retain(|c| c != "abandoned");
let (_tmp, store) = store_with(config);
let err = crate::search::resolve_filter_criteria(&store, "category:unstarted")
.unwrap_err()
.to_string();
assert!(err.contains("unstarted"), "{err}");
assert!(
!err.contains("canceled"),
"a key the project did not declare must not be offered: {err}"
);
assert!(err.contains("backlog, active, completed"), "{err}");
let (query, criteria) =
crate::search::resolve_filter_criteria(&store, "category:Completed").unwrap();
assert_eq!(criteria.category.as_deref(), Some("completed"));
assert_eq!(query, "", "and the token is lifted out of the query");
}
#[test]
fn t035_a_query_without_a_category_token_is_unchanged() {
let (_tmp, store) = store_with(mapped_config());
for query in ["", "alice", "assignee:alice", "overdue"] {
let (resolved, criteria) =
crate::search::resolve_filter_criteria(&store, query).unwrap();
assert_eq!(resolved, query, "{query:?} must round-trip");
assert_eq!(criteria.category, None, "{query:?} gained a criterion");
}
}
#[test]
fn t035_a_category_token_is_lifted_out_and_canonicalized() {
let (_tmp, store) = store_with(mapped_config());
let (query, criteria) =
crate::search::resolve_filter_criteria(&store, "category:COMPLETED alpha").unwrap();
assert_eq!(
criteria.category.as_deref(),
Some("completed"),
"canonical, and carried structurally"
);
assert_eq!(
query, "alpha",
"the token is **removed**: a token left in the string meets a key \
`query_matches` refuses, which is an empty result with no error"
);
}
#[test]
fn t035_the_parent_and_category_resolvers_compose() {
let (_tmp, store) = store_with(mapped_config());
let (query, criteria) =
crate::search::resolve_filter_criteria(&store, "category:completed parent:1").unwrap();
assert_eq!(query, "parent:1", "parent stays, category is lifted");
assert_eq!(criteria.category.as_deref(), Some("completed"));
}
#[test]
fn t035_a_query_with_no_criterion_is_unaffected() {
let config = mapped_config();
let resolution = resolution_for(&config);
let issue = issue_in(1, "Alpha", "wip");
let text = issue.to_file_contents();
let empty = StructuredFilter::default();
assert!(crate::search::matches_issue(
"alpha",
&empty,
&issue,
&text,
&resolution
));
assert!(crate::search::matches_issue(
"",
&empty,
&issue,
&text,
&resolution
));
assert!(!crate::search::matches_issue(
"zzz",
&empty,
&issue,
&text,
&resolution
));
}
#[test]
fn t035_matching_reads_the_service_and_never_the_column_name() {
let config = crate::config::Config {
kanban: crate::config::KanbanSection {
configurations: indexmap::IndexMap::from([(
"default".to_string(),
crate::config::BoardConfiguration {
columns: vec!["todo".into(), "shipped".into(), "done".into()],
done_column: Some("done".to_string()),
category_map: indexmap::IndexMap::from([
("todo".to_string(), "unstarted".to_string()),
("shipped".to_string(), "completed".to_string()),
("done".to_string(), "active".to_string()),
]),
..Default::default()
},
)]),
},
..crate::config::Config::default()
};
let resolution = resolution_for(&config);
let shipped = issue_in(1, "Shipped", "shipped");
let done = issue_in(2, "Done column", "done");
assert!(
filter_for(Some("completed")).category_matches(&shipped, &resolution),
"the service says shipped is complete"
);
assert!(
!filter_for(Some("completed")).category_matches(&done, &resolution),
"and `done` is not, even though it is the done_column"
);
assert!(
filter_for(Some("unstarted"))
.category_matches(&issue_in(3, "Todo", "todo"), &resolution)
);
}
#[test]
fn t035_the_query_token_matcher_refuses_rather_than_guessing() {
let issue = issue_in(1, "Shipped", "shipped");
let text = issue.to_file_contents();
assert!(!query_matches("category:completed", &text));
let mention = issue_in(2, "Mentions completed", "triage");
assert!(
!query_matches("category:completed", &mention.to_file_contents()),
"and never matches the word in a title"
);
}
#[test]
fn t035_two_boards_with_different_column_names_agree() {
let a = mapped_config();
let b = crate::config::Config {
kanban: crate::config::KanbanSection {
configurations: indexmap::IndexMap::from([(
"default".to_string(),
crate::config::BoardConfiguration {
columns: vec![
"icebox".into(),
"doing".into(),
"verifying".into(),
"released".into(),
"dropped".into(),
],
done_column: Some("released".to_string()),
category_map: indexmap::IndexMap::from([
("icebox".to_string(), "backlog".to_string()),
("doing".to_string(), "active".to_string()),
("verifying".to_string(), "active".to_string()),
("released".to_string(), "completed".to_string()),
("dropped".to_string(), "canceled".to_string()),
]),
..Default::default()
},
)]),
},
..crate::config::Config::default()
};
for (column_a, column_b) in [
("triage", "icebox"),
("wip", "doing"),
("shipped", "released"),
("abandoned", "dropped"),
] {
for key in crate::config::CATEGORY_KEYS {
let want = filter_for(Some(key))
.category_matches(&issue_in(1, "X", column_a), &resolution_for(&a));
let got = filter_for(Some(key))
.category_matches(&issue_in(1, "X", column_b), &resolution_for(&b));
assert_eq!(
want, got,
"{column_a} ({key}) and {column_b} ({key}) disagree"
);
}
}
}
}