use crate::error::Result;
use crate::search::Corpus;
use crate::search::SearchHit;
use crate::search::{query_matches, search_substring};
use crate::storage::Store;
const MAX_LINE_WIDTH: usize = 200;
pub fn run(store: &Store, query: &str, with_archived: bool) -> Result<()> {
let hits = find_with(store, query, with_archived)?;
if hits.is_empty() {
println!("No issues found.");
return Ok(());
}
for hit in hits {
println!(
"#{} {} — {} ({})",
hit.issue.number, hit.issue.title, hit.issue.status, hit.issue.filename
);
for line in hit.matched_lines {
println!(" {}", clipped(&line));
}
}
Ok(())
}
pub fn find(store: &Store, query: &str) -> Result<Vec<SearchHit>> {
find_with(store, query, false)
}
pub fn find_with(store: &Store, query: &str, with_archived: bool) -> Result<Vec<SearchHit>> {
let corpus = Corpus::build_with(store, with_archived)?;
if query.contains(':') {
let (query, criteria) = crate::search::resolve_filter_criteria(store, query)?;
let (_, resolution) = crate::workflow::active_resolution(&store.config);
Ok(corpus
.issues
.iter()
.zip(corpus.texts().iter())
.filter(|(issue, text)| {
crate::search::matches_issue(&query, &criteria, issue, text, &resolution)
})
.map(|(issue, text)| SearchHit {
issue: issue.clone(),
matched_lines: matched_lines(&query, &criteria, text),
})
.collect())
} else {
Ok(search_substring(&corpus, query))
}
}
fn matched_lines(query: &str, filter: &crate::search::StructuredFilter, text: &str) -> Vec<String> {
let mut lines: Vec<String> = text
.lines()
.filter(|line| query_matches(query, line))
.map(str::to_string)
.collect();
let wants_category = query.split_whitespace().any(|t| {
t.split_once(':')
.is_some_and(|(k, _)| k.eq_ignore_ascii_case("category"))
}) || filter.category.is_some();
if wants_category && !lines.iter().any(|l| l.starts_with("status:")) {
lines.extend(
text.lines()
.filter(|line| line.starts_with("status:"))
.map(str::to_string),
);
}
lines
}
fn clipped(line: &str) -> String {
if line.chars().count() <= MAX_LINE_WIDTH {
return line.to_string();
}
let head: String = line.chars().take(MAX_LINE_WIDTH).collect();
format!("{head}…")
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
use crate::model::{Issue, Priority, Status};
use std::fs;
use tempfile::TempDir;
fn make_store() -> (TempDir, Store) {
let tmp = 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();
(
tmp,
Store {
issues_dir: dir,
config,
},
)
}
fn seed(store: &Store, number: u32, title: &str, body: &str) {
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
let mut issue = Issue {
projects: None,
id: None,
number,
title: title.to_string(),
status: Status::new("todo"),
labels: vec![],
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: 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/2026-01-01-{}.md", crate::slug::slugify(title));
fs::write(
store.issues_dir.join(&issue.filename),
issue.to_file_contents(),
)
.unwrap();
}
#[test]
fn find_matches_case_insensitively_across_the_record() {
let (tmp, store) = make_store();
let _ = tmp;
seed(&store, 1, "Login bug", "The redirect URL is broken.");
let hits = find(&store, "REDIRECT").unwrap();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].issue.number, 1);
assert!(
hits[0]
.matched_lines
.iter()
.any(|l| l.contains("redirect URL")),
"matched-line context must come back for the MCP result text"
);
}
#[test]
fn find_zero_matches_is_empty() {
let (tmp, store) = make_store();
let _ = tmp;
seed(&store, 1, "Login bug", "body");
assert_eq!(
find(&store, "definitely-not-here").unwrap(),
[] as [crate::search::SearchHit; 0]
);
}
#[test]
fn find_orders_hits_by_number_ascending() {
let (tmp, store) = make_store();
let _ = tmp;
seed(&store, 9, "Zed bug", "The redirect loop");
seed(&store, 3, "Alpha", "The redirect loop, too");
let hits = find(&store, "redirect loop").unwrap();
assert_eq!(
hits.iter().map(|h| h.issue.number).collect::<Vec<_>>(),
vec![3, 9],
"stable number-ascending order (NFR-007)"
);
}
}