tix-tracker 0.3.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! `tix search <string>`: case-insensitive substring over the full markdown
//! corpus (`contracts/cli.md` §search, FR-012).
//!
//! The corpus and matcher come from [`crate::search`] so the CLI and the
//! TUI `/` filter search byte-identical text — frontmatter, body and the
//! `## Comments` section — never asset-dir contents (C-005). Output goes to
//! stdout in a parseable shape: one result block per hit, ordered by issue
//! `number` ascending, with each matched context line on its own indented
//! line. Zero matches prints exactly `No issues found.` and still succeeds
//! (exit 0). The empty query never reaches this handler: clap rejects it with
//! a usage error (exit 2).

use crate::error::Result;
use crate::search::Corpus;
use crate::search::SearchHit;
use crate::search::search_substring;
use crate::storage::Store;

/// Sane display width for a matched context line (NFR-007).
const MAX_LINE_WIDTH: usize = 200;

/// Search the whole store for a case-insensitive substring and print one
/// result block per hit.
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(())
}

/// The search core (WP03): return every [`SearchHit`] for a case-insensitive
/// substring query, reusing the shared corpus/matcher in `crate::search`
/// unchanged (no re-implementation, T015).
///
/// This is what MCP's `search_issues` tool (WP04) calls — the hits carry the
/// matched context lines for the tool result text. Side-effect policy
/// (C-005): the core never prints; [`run`] formats + truncates the hits
/// exactly as before (same grouping, same `MAX_LINE_WIDTH` clipping).
pub fn find(store: &Store, query: &str) -> Result<Vec<SearchHit>> {
    let corpus = Corpus::build(store)?;
    Ok(search_substring(&corpus, query))
}

/// Truncate a matched context line to [`MAX_LINE_WIDTH`] characters, adding
/// an ellipsis when cut (byte-safe, multibyte-aware).
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)"
        );
    }
}