docterm 0.2.0

A TUI-first documentation browser for Dash/Zeal docsets, optimized for the terminal.
use nucleo::{
    Config, Matcher, Utf32Str,
    pattern::{CaseMatching, Normalization, Pattern},
};

use crate::model::SearchEntry;

/// Default maximum results returned by [`Searcher::search`].
pub const DEFAULT_LIMIT: usize = 50;

/// A scored fuzzy-search result.
#[derive(Debug, Clone)]
pub struct SearchResult {
    /// The matched entry.
    pub entry: SearchEntry,
    /// Nucleo score — higher is better.
    pub score: u32,
}

/// Fuzzy searcher backed by [nucleo].
///
/// Populate with entries loaded from [`crate::storage::Storage`], then call
/// [`Searcher::search`] to run a query.
///
/// ## Indexing strategy
///
/// * **Global index** — pass entries from
///   [`Storage::list_all_entries`](crate::storage::Storage::list_all_entries)
///   (the `/` key in the TUI).
/// * **Scoped index** — pass entries from
///   [`Storage::list_entries_for_docset`](crate::storage::Storage::list_entries_for_docset)
///   (the `Shift+/` key in the TUI).
pub struct Searcher {
    matcher: Matcher,
    entries: Vec<SearchEntry>,
}

impl Searcher {
    /// Build a searcher over `entries` (global or scoped — caller decides).
    pub fn new(entries: Vec<SearchEntry>) -> Self {
        Self {
            matcher: Matcher::new(Config::DEFAULT),
            entries,
        }
    }

    /// Number of indexed entries.
    pub fn len(&self) -> usize {
        self.entries.len()
    }

    /// `true` if the index contains no entries.
    pub fn is_empty(&self) -> bool {
        self.entries.is_empty()
    }

    /// Read-only view of all indexed entries.
    pub fn entries(&self) -> &[SearchEntry] {
        &self.entries
    }

    /// Fuzzy-match `query` against entry names and return at most `limit` hits.
    ///
    /// Supports nucleo extended syntax: `^` (prefix), `$` (postfix),
    /// `'` (exact substring), `!` (inverse), and `|` (OR).
    ///
    /// Results are sorted by score DESC then `usage_count` DESC so
    /// frequently-accessed entries surface first on ties.
    ///
    /// Pass [`DEFAULT_LIMIT`] or `usize::MAX` for `limit` to control the cap.
    /// Returns an empty `Vec` when `query` is blank.
    pub fn search(&mut self, query: &str, limit: usize) -> Vec<SearchResult> {
        if query.is_empty() {
            return Vec::new();
        }

        let pattern = Pattern::parse(query, CaseMatching::Smart, Normalization::Smart);

        // Explicit field borrows: immutable for `entries`, mutable for `matcher`.
        let entries = &self.entries;
        let matcher = &mut self.matcher;
        let mut buf: Vec<char> = Vec::new();
        // Collect (index, score) first so cloning of the `SearchEntry`s
        // happens only after truncation to `limit`.  For a corpus of N
        // entries with M hits, this drops clones from O(M) to O(limit).
        let mut hits: Vec<(usize, u32)> = Vec::new();

        for (idx, entry) in entries.iter().enumerate() {
            buf.clear();
            let haystack = Utf32Str::new(&entry.name, &mut buf);
            if let Some(score) = pattern.score(haystack, matcher) {
                hits.push((idx, score));
            }
        }

        hits.sort_unstable_by(|a, b| {
            b.1.cmp(&a.1)
                .then_with(|| entries[b.0].usage_count.cmp(&entries[a.0].usage_count))
        });
        hits.truncate(limit);

        hits.into_iter()
            .map(|(idx, score)| SearchResult {
                entry: entries[idx].clone(),
                score,
            })
            .collect()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn make_entry(id: i64, docset_id: i64, name: &str, entry_type: &str) -> SearchEntry {
        SearchEntry {
            id,
            docset_id,
            name: name.to_string(),
            entry_type: entry_type.to_string(),
            path: format!("{}.html", name.to_lowercase()),
            usage_count: 0,
            docset_name: "TestDocset".to_string(),
            docset_version: None,
        }
    }

    #[test]
    fn empty_query_returns_nothing() {
        let mut s = Searcher::new(vec![make_entry(1, 1, "Vec", "Struct")]);
        assert!(s.search("", 10).is_empty());
    }

    #[test]
    fn empty_index_returns_nothing() {
        let mut s = Searcher::new(vec![]);
        assert!(s.search("vec", 10).is_empty());
    }

    #[test]
    fn exact_name_matches() {
        let entries = vec![
            make_entry(1, 1, "Vec", "Struct"),
            make_entry(2, 1, "HashMap", "Struct"),
        ];
        let mut s = Searcher::new(entries);
        let results = s.search("Vec", 10);
        assert!(!results.is_empty());
        assert_eq!(results[0].entry.name, "Vec");
    }

    #[test]
    fn partial_match_returns_results() {
        let entries = vec![
            make_entry(1, 1, "Vec", "Struct"),
            make_entry(2, 1, "VecDeque", "Struct"),
            make_entry(3, 1, "HashMap", "Struct"),
        ];
        let mut s = Searcher::new(entries);
        let results = s.search("vec", DEFAULT_LIMIT);
        assert!(results.len() >= 2);
        let names: Vec<&str> = results.iter().map(|r| r.entry.name.as_str()).collect();
        assert!(names.contains(&"Vec"));
        assert!(names.contains(&"VecDeque"));
    }

    #[test]
    fn limit_caps_result_count() {
        let entries: Vec<_> = (0..20)
            .map(|i| make_entry(i, 1, &format!("func_{i}"), "Function"))
            .collect();
        let mut s = Searcher::new(entries);
        let results = s.search("func", 5);
        assert!(results.len() <= 5);
    }

    #[test]
    fn results_ordered_by_score_desc() {
        let entries = vec![
            make_entry(1, 1, "spawn_blocking", "Function"),
            make_entry(2, 1, "spawn", "Function"),
        ];
        let mut s = Searcher::new(entries);
        let results = s.search("spawn", DEFAULT_LIMIT);
        assert!(!results.is_empty());
        for w in results.windows(2) {
            assert!(w[0].score >= w[1].score, "results must be score-descending");
        }
    }

    #[test]
    fn usage_count_breaks_score_ties() {
        let mut e1 = make_entry(1, 1, "run", "Function");
        e1.usage_count = 5;
        let mut e2 = make_entry(2, 2, "run", "Function");
        e2.usage_count = 20;
        let mut s = Searcher::new(vec![e1, e2]);
        let results = s.search("run", DEFAULT_LIMIT);
        assert_eq!(results.len(), 2);
        // Same score (identical name) → higher usage_count comes first.
        assert!(results[0].entry.usage_count >= results[1].entry.usage_count);
    }

    #[test]
    fn len_and_is_empty() {
        let s = Searcher::new(vec![]);
        assert!(s.is_empty());
        assert_eq!(s.len(), 0);

        let s2 = Searcher::new(vec![make_entry(1, 1, "A", "Struct")]);
        assert!(!s2.is_empty());
        assert_eq!(s2.len(), 1);
    }

    #[test]
    fn multiple_searches_are_independent() {
        let entries = vec![
            make_entry(1, 1, "Vec", "Struct"),
            make_entry(2, 1, "HashMap", "Struct"),
        ];
        let mut s = Searcher::new(entries);
        let r1 = s.search("Vec", DEFAULT_LIMIT);
        let r2 = s.search("HashMap", DEFAULT_LIMIT);
        assert_eq!(r1[0].entry.name, "Vec");
        assert_eq!(r2[0].entry.name, "HashMap");
    }

    #[test]
    fn case_insensitive_smart_matching() {
        let entries = vec![make_entry(1, 1, "HashMap", "Struct")];
        let mut s = Searcher::new(entries);
        // lowercase query should still match mixed-case entry
        assert!(!s.search("hashmap", DEFAULT_LIMIT).is_empty());
    }

    #[test]
    fn unrelated_query_returns_no_results() {
        let entries = vec![
            make_entry(1, 1, "Vec", "Struct"),
            make_entry(2, 1, "HashMap", "Struct"),
        ];
        let mut s = Searcher::new(entries);
        // "zzzzz" should not fuzzy-match anything meaningful
        let results = s.search("zzzzz", DEFAULT_LIMIT);
        for r in &results {
            // Any match must have a non-zero score (nucleo contract).
            assert!(r.score > 0);
        }
    }

    #[test]
    fn large_corpus_small_limit_still_returns_top_ranked() {
        // Guards the truncate-before-clone refactor: with N=10_000 matching
        // entries and limit=5, we must still receive the 5 highest-scored
        // results.  If the truncation were skipped, the test would still
        // pass but take much longer — this at least locks the behavioral
        // contract in place.
        let mut entries: Vec<_> = (0..10_000)
            .map(|i| make_entry(i, 1, &format!("func_{i:05}"), "Function"))
            .collect();
        // Boost usage_count on one specific entry so it wins ties.
        entries[42].usage_count = 999;

        let mut s = Searcher::new(entries);
        let results = s.search("func", 5);
        assert_eq!(results.len(), 5);
        // The boosted entry should surface within the top results.
        assert!(
            results.iter().any(|r| r.entry.usage_count == 999),
            "usage-boosted entry should tie-break into the top-5"
        );
    }

    #[test]
    fn limit_zero_returns_empty() {
        let entries = vec![make_entry(1, 1, "Vec", "Struct")];
        let mut s = Searcher::new(entries);
        assert!(s.search("Vec", 0).is_empty());
    }

    #[test]
    fn results_are_deduplicated_by_index_not_shared_state() {
        // Running the same query twice must return identical results —
        // the refactored implementation reuses the same `matcher`.
        let entries = vec![
            make_entry(1, 1, "Vec", "Struct"),
            make_entry(2, 1, "VecDeque", "Struct"),
            make_entry(3, 1, "HashMap", "Struct"),
        ];
        let mut s = Searcher::new(entries);
        let r1 = s.search("vec", DEFAULT_LIMIT);
        let r2 = s.search("vec", DEFAULT_LIMIT);
        let ids1: Vec<i64> = r1.iter().map(|r| r.entry.id).collect();
        let ids2: Vec<i64> = r2.iter().map(|r| r.entry.id).collect();
        assert_eq!(ids1, ids2);
    }
}