tix-tracker 0.12.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::{query_matches, 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, with_archived: bool) -> Result<()> {
    // `parent:<sel>` carries a **selector** and `category:<key>` is **derived**, so
    // neither can be matched from the file's bytes. `find` resolves both — it is the
    // core, and MCP's `search_issues` calls it directly, so the resolution has to
    // live there rather than in this printer.
    //
    // This function used to resolve them as well, and the double resolution was a
    // real defect rather than a redundancy: `resolve_filter_criteria` **lifts** the
    // `category:` token out of the query, so the second call saw a query with no
    // token in it and returned a criterion of `None`. `tix search category:completed`
    // reported "No issues found" for exactly the issues it named. One place, not two.
    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(())
}

/// 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>> {
    find_with(store, query, false)
}

/// [`find`], with the archived set optionally included (issue #1492).
///
/// `with_archived` selects the corpus: the default reads the active issues only,
/// so an archived file is never parsed; `true` reads active + archived, so
/// `tix search --with-archived` can match a dot-prefixed issue.
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(':') {
        // The category criterion cannot be judged from the haystack, so the predicate
        // is `matches_issue` — see its doc comment for why every consumer has to go
        // through it rather than `query_matches`.
        //
        // `find` resolves the criteria **itself** rather than trusting its caller,
        // because `find` is the public core: MCP's `search_issues` calls it directly,
        // and a core that trusted a pre-resolved query would silently return an empty
        // set for a `category:` token from a caller that forgot.
        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))
    }
}

/// The context lines a hit prints under it.
///
/// For a text criterion, the lines the matcher matched. For `category:`, **no issue
/// file contains a `category` line**, so the derivation is what the user needs to
/// see: the line it came from. The status line is **added**, not substituted, so a
/// combined `category:completed assignee:alice` keeps its `assignee:` context and
/// gains the derivation.
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
}

/// 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 {
    #[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)"
        );
    }
}