use crate::error::Result;
use crate::search::Corpus;
use crate::search::SearchHit;
use crate::search::search_substring;
use crate::storage::Store;
const MAX_LINE_WIDTH: usize = 200;
pub fn run(store: &Store, query: &str) -> Result<()> {
let hits = find(store, query)?;
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>> {
let corpus = Corpus::build(store)?;
Ok(search_substring(&corpus, query))
}
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 {
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 {
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,
links: vec![],
files: vec![],
created: "2026-01-01".to_string(),
updated: "2026-01-01".to_string(),
filename: String::new(),
body: body.to_string(),
};
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!(find(&store, "definitely-not-here").unwrap().is_empty());
}
#[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)"
);
}
}