tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
#[allow(clippy::wildcard_imports)]
use super::*;

/// The corpus: one `Issue` per file (in [`Store::list`] order) and the
/// byte-for-byte serialized file text matching it.
pub struct Corpus {
    pub issues: Vec<Issue>,
    /// Serialized file text per issue, built on first use (tix #1520).
    ///
    /// `texts()[i]` is exactly `issues[i].to_file_contents()` — the same
    /// invariant [`Corpus::build`] has always upheld — but *when* it is
    /// computed is now deferred: a startup that never searches should not pay
    /// to serialize every issue. Access through [`Corpus::texts`].
    texts: std::cell::OnceCell<Vec<String>>,
}

impl Corpus {
    /// Snapshot the full serialized text of every issue under `store`.
    ///
    /// `texts[i]` is exactly `issues[i].to_file_contents()`, so the corpus is
    /// byte-identical to the on-disk files (modulo whatever the serializer
    /// itself produces). Files are read once here, through [`Store::list`];
    /// consumers never re-read them (C-005/NFR-007 keep the corpus a single,
    /// stable source).
    pub fn build(store: &Store) -> Result<Corpus> {
        Self::build_with(store, false)
    }

    /// Snapshot the corpus, optionally including archived issues (issue #1492).
    ///
    /// `with_archived == false` is the default path and reads
    /// [`Store::list`], which never opens an archived file. `true` reads
    /// [`Store::list_all`] so `tix search --with-archived` can match the archived
    /// set; the archived issues carry `archived == true`.
    pub fn build_with(store: &Store, with_archived: bool) -> Result<Corpus> {
        let issues = if with_archived {
            store.list_all()?
        } else {
            store.list()?
        };
        Ok(Self::from_issues(issues))
    }

    /// Snapshot a corpus from an **already-read** issue set (tix #1518).
    ///
    /// The TUI's startup reads the tree exactly once and shares that single
    /// enumeration between the board, the rollover plan and this corpus; this
    /// constructor is that sharing point. It upholds the same invariant
    /// [`Corpus::build`] does — `texts[i] == issues[i].to_file_contents()` — so a
    /// corpus built here is byte-identical to one built by a fresh walk.
    pub fn from_issues(issues: Vec<Issue>) -> Corpus {
        // No serialization here: `texts()` builds the cached snapshot on first
        // use (tix #1520). Startup that never searches pays nothing.
        Corpus {
            issues,
            texts: std::cell::OnceCell::new(),
        }
    }

    /// The serialized file text of every issue, built on first use and cached
    /// thereafter (tix #1520).
    ///
    /// `texts()[i]` is exactly `issues[i].to_file_contents()`, so a caller
    /// cannot tell whether the corpus was built by a full walk or shared from an
    /// already-read issue set — only that it is not paying to serialize issues
    /// no search will ever look at.
    pub fn texts(&self) -> &[String] {
        self.texts.get_or_init(|| {
            self.issues
                .iter()
                .map(|issue| issue.to_file_contents())
                .collect()
        })
    }

    /// Build a corpus with a fixed serialized-text snapshot (tests only).
    ///
    /// Production always goes through [`Corpus::from_issues`], which defers
    /// serialization; a test that wants to pin the matcher's input without a
    /// store uses this instead of reaching into the private cache.
    #[cfg(test)]
    pub fn with_texts(issues: Vec<Issue>, texts: Vec<String>) -> Corpus {
        Corpus {
            issues,
            texts: std::cell::OnceCell::from(texts),
        }
    }

    /// Whether the serialized snapshot has been built yet (tests only).
    #[cfg(test)]
    pub fn texts_initialized(&self) -> bool {
        self.texts.get().is_some()
    }

    /// Index of the issue with `number` `n`, if present (stable, linear scan).
    pub fn by_number(&self, n: u32) -> Option<usize> {
        self.issues.iter().position(|issue| issue.number == n)
    }

    /// Apply a filesystem delta to the corpus without re-reading the tree
    /// (issue #10 follow-up).
    ///
    /// Re-reads only the paths the watcher reported changed, drops the removed
    /// ones, and keeps `texts[i] == issues[i].to_file_contents()` and the
    /// corpus ordered by `filename` exactly as [`Store::scan`] returns it — so
    /// an incrementally updated corpus is byte-identical to a full
    /// [`Corpus::build_with`] over the same files.
    ///
    /// Paths [`Store::scan`] would not index are ignored: non-`.md` files,
    /// files in a `.tix-tmp-*`/`*-assets/` segment, and anything nested deeper
    /// than one segment (see [`crate::storage::is_issue_rel_path`]). A file
    /// that vanished or cannot be read since the watcher saw it counts as
    /// removed. A **parse** failure is returned as an error with the corpus
    /// left untouched — all-or-nothing — so a half-written file cannot replace
    /// the last good corpus, and so the caller's board stays as it was.
    ///
    /// The delta is applied as given: the archived-view policy lives in the
    /// caller (`App::refresh_from_disk` strips archived paths when the archived
    /// toggle is off).
    pub fn apply(&mut self, store: &Store, delta: &FsDelta) -> Result<()> {
        if !delta
            .changed
            .iter()
            .any(|p| crate::storage::is_issue_rel_path(p))
            && !delta
                .removed
                .iter()
                .any(|p| crate::storage::is_issue_rel_path(p))
        {
            return Ok(());
        }
        // Read every changed file first: a parse error must leave `self` as it
        // was, which is only possible if nothing is mutated before all reads
        // succeed.
        let mut updates: Vec<Issue> = Vec::new();
        for path in &delta.changed {
            if !crate::storage::is_issue_rel_path(path) {
                continue;
            }
            match store.read_issue_at(path) {
                Ok(issue) => updates.push(issue),
                // Gone or unreadable since the walk: a removal, not an error.
                Err(TixError::NotFound(_) | TixError::Io(_)) => {}
                Err(e) => return Err(e),
            }
        }
        // Every indexable changed path replaces its corpus entry, and every
        // indexable removed path drops one.
        let dropped: HashSet<&Path> = delta
            .changed
            .iter()
            .chain(delta.removed.iter())
            .filter(|path| crate::storage::is_issue_rel_path(path))
            .map(PathBuf::as_path)
            .collect();
        let mut combined: Vec<Issue> = Vec::with_capacity(self.issues.len() + updates.len());
        let issues = std::mem::take(&mut self.issues);
        for issue in issues {
            if !dropped.contains(Path::new(&issue.filename)) {
                combined.push(issue);
            }
        }
        combined.extend(updates);
        // `scan` sorts by filename; matching it keeps an incremental corpus
        // byte-identical to a rebuild.
        combined.sort_by(|a, b| a.filename.cmp(&b.filename));
        self.issues = combined;
        // The serialized snapshot is stale now (tix #1520). Dropping the cache
        // rather than rebuilding it keeps the deferral: the next reader
        // re-serializes from the updated issue set, and `texts()` still upholds
        // `texts[i] == issues[i].to_file_contents()`.
        self.texts = std::cell::OnceCell::new();
        Ok(())
    }
}

/// One substring search hit: the issue and every corpus line that contains
/// the matched query (context for the CLI result block, NFR-007).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SearchHit {
    pub issue: Issue,
    pub matched_lines: Vec<String>,
}

/// Case-insensitive substring search over the corpus.
///
/// For each corpus text containing `query` (case-insensitive), every line
/// containing the match is collected verbatim as matched-line context. Hits
/// are ordered by issue `number` ascending (stable, NFR-007).
///
/// An empty query matches **nothing** by contract: the CLI refuses it via
/// clap, and the TUI treats an empty filter as "no filter" — callers decide,
/// this function returns an empty vec so they never see a surprise.
pub fn search_substring(corpus: &Corpus, query: &str) -> Vec<SearchHit> {
    if query.is_empty() {
        return Vec::new();
    }
    let needle = query.to_lowercase();
    let mut hits: Vec<SearchHit> = Vec::new();
    for (issue, text) in corpus.issues.iter().zip(corpus.texts().iter()) {
        let hay = text.to_lowercase();
        if !hay.contains(&needle) {
            continue;
        }
        let matched_lines: Vec<String> = text
            .lines()
            .filter(|line| line.to_lowercase().contains(&needle))
            .map(str::to_string)
            .collect();
        hits.push(SearchHit {
            issue: issue.clone(),
            matched_lines,
        });
    }
    hits.sort_by_key(|hit| hit.issue.number);
    hits
}

/// Deterministic fuzzy subsequence score: higher is a better match, `None`
/// when `query` is not a subsequence of `haystack` (case-insensitive).
///
/// The scorer is the fzf/vim-style "fast substring" heuristic, kept simple
/// and pure (no normalization, no unicode tables, no dependencies):
///
/// - every matched character contributes a presence bonus plus an
///   earlier-position bonus (`1000 - position`), so matches near the start of
///   the text outrank those buried deep in it;
/// - adjacent matched characters (contiguous runs) get a run bonus;
/// - a match that starts at the very beginning gets a prefix bonus;
/// - an exact match (equal strings, case-insensitive) dominates.
///
/// An empty query is an implicit match with score `Some(0)` — the TUI treats
/// it as "no filter". Deterministic and fast enough for > 500 issues per
/// keystroke (verified by the perf tripwire test).
pub fn fuzzy_score(query: &str, haystack: &str) -> Option<u32> {
    let q = query.to_lowercase();
    let h = haystack.to_lowercase();
    if q.is_empty() {
        return Some(0);
    }
    if q == h {
        return Some(EXACT_MATCH_SCORE);
    }
    let positions = greedy_subsequence_positions(&q, &h)?;
    let mut score = 0u32;
    let mut prev: Option<usize> = None;
    for pos in &positions {
        score += 100;
        score += (1000 - (*pos).min(1000)) as u32;
        if prev.is_some_and(|p| *pos == p + 1) {
            score += RUN_BONUS;
        }
        prev = Some(*pos);
    }
    if positions.first() == Some(&0) {
        score += PREFIX_BONUS;
    }
    Some(score)
}

/// Bonus for a contiguous pair of matched characters.
pub(super) const RUN_BONUS: u32 = 150;

/// Bonus when the first matched character is the first character of the text.
pub(super) const PREFIX_BONUS: u32 = 500;

/// Score for an exact (case-insensitive) equality — dominates any partial.
pub(super) const EXACT_MATCH_SCORE: u32 = 50_000;

/// Greedy leftmost subsequence match, case-insensitive. Returns the haystack
/// positions of `query`'s characters in order, or `None` when `query` is not
/// a subsequence of `haystack`.
pub(super) fn greedy_subsequence_positions(query: &str, haystack: &str) -> Option<Vec<usize>> {
    let q: Vec<char> = query.chars().collect();
    if q.is_empty() {
        return Some(Vec::new());
    }
    let h: Vec<char> = haystack.chars().collect();
    let mut positions = Vec::with_capacity(q.len());
    let mut hi = 0usize;
    /* tolerate differing-cased surrogates the same way the lowercase scan does */
    for qc in &q {
        let found = h[hi..]
            .iter()
            .position(|hc| *hc == *qc)
            .map(|off| off + hi)?;
        positions.push(found);
        hi = found + 1;
    }
    Some(positions)
}