tix-tracker 0.10.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! Reconciliation: the multiset difference, plus the fuzzy fallback (design D2/D5).
//!
//! Given the fingerprints present in the content just written and the records already stored
//! for the issue, produce the new **pending set**:
//!
//! - a fingerprint in the content but not recorded → a **new** occurrence, `unread`;
//! - a fingerprint recorded but no longer in the content → **dropped** (the store holds the
//!   live pending set, not an audit log — design D5);
//! - a fingerprint in both → **unchanged**, its recorded state kept.
//!
//! The comparison is a **multiset** difference, not a set difference: two occurrences whose
//! contexts normalize identically are indistinguishable by content, so they are tallied, and
//! resolving one leaves the other.
//!
//! ## The fuzzy fallback
//!
//! A key disappears when the *containing sentence* is edited, which would re-notify a mention
//! over a typo fix. So when a recorded key vanishes and a new occurrence for the same issue and
//! body appears whose context is similar enough — character-bigram Jaccard at or above
//! [`FUZZY_THRESHOLD`] — the two are paired and the recorded state is **carried over**. Pairs
//! are taken greedily by descending similarity so the choice is deterministic.

use std::collections::BTreeMap;

use crate::mentions::{Mention, MentionState, anchor};

/// The character-bigram Jaccard similarity a vanished and an appeared occurrence must reach to
/// be treated as the same occurrence — `600` permille, i.e. a similarity of `0.6`.
///
/// Expressed in permille rather than as an `f64` so the comparison and the pairing order are
/// exact integer arithmetic: a float here would make the threshold a rounding question, and the
/// sort order would depend on `partial_cmp` over values that can never be `NaN` anyway.
pub const FUZZY_THRESHOLD_PERMILLE: u32 = 600;

/// One occurrence parsed out of the content just written.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Parsed {
    /// The mention body exactly as written.
    pub body: String,
    /// [`anchor::fingerprint`] of the occurrence.
    pub key: String,
    /// The containing line, trimmed.
    pub context: String,
}

/// The issue a reconciliation is about.
#[derive(Debug, Clone, Copy)]
pub struct IssueRef<'a> {
    /// The issue's canonical id (never recycled).
    pub id: &'a str,
    /// The issue's human-facing number.
    pub number: u32,
    /// Store-relative path of the issue file.
    pub file: &'a str,
}

/// The complete pending set after reconciling `existing` against `parsed`.
///
/// The result is ordered by the occurrences' position in the content, so a listing reads in
/// document order. Records that vanished and were not matched by the fuzzy fallback are absent.
#[must_use]
pub fn reconcile(
    existing: &[Mention],
    parsed: &[Parsed],
    issue: IssueRef<'_>,
    now: &str,
) -> Vec<Mention> {
    let mut used = vec![false; existing.len()];
    let mut assigned: Vec<Option<Mention>> = vec![None; parsed.len()];

    // 1. Exact matches, consumed as a multiset.
    for (index, occurrence) in parsed.iter().enumerate() {
        if let Some(found) = existing
            .iter()
            .enumerate()
            .position(|(i, record)| !used[i] && record.key == occurrence.key)
        {
            used[found] = true;
            assigned[index] = Some(refreshed(&existing[found], occurrence, issue));
        }
    }

    // 2. The fuzzy fallback: pair each leftover record with the most similar leftover
    //    occurrence of the same body.
    let mut pairs: Vec<(u32, usize, usize)> = Vec::new();
    for (old_index, record) in existing.iter().enumerate() {
        if used[old_index] {
            continue;
        }
        for (new_index, occurrence) in parsed.iter().enumerate() {
            if assigned[new_index].is_some() {
                continue;
            }
            if !record.body.eq_ignore_ascii_case(&occurrence.body) {
                continue;
            }
            let similarity = similarity_permille(
                &bigrams(&anchor::normalize(&record.context)),
                &bigrams(&anchor::normalize(&occurrence.context)),
            );
            if similarity >= FUZZY_THRESHOLD_PERMILLE {
                pairs.push((similarity, old_index, new_index));
            }
        }
    }
    pairs.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)).then(a.2.cmp(&b.2)));
    for (_, old_index, new_index) in pairs {
        if used[old_index] || assigned[new_index].is_some() {
            continue;
        }
        used[old_index] = true;
        assigned[new_index] = Some(refreshed(&existing[old_index], &parsed[new_index], issue));
    }

    // 3. Whatever is left in the content is genuinely new.
    assigned
        .into_iter()
        .enumerate()
        .map(|(index, slot)| slot.unwrap_or_else(|| new_mention(&parsed[index], issue, now)))
        .collect()
}

/// A record re-anchored to a fresh occurrence, keeping its id, state, and creation stamp.
fn refreshed(record: &Mention, occurrence: &Parsed, issue: IssueRef<'_>) -> Mention {
    Mention {
        id: record.id.clone(),
        key: occurrence.key.clone(),
        issue_number: issue.number,
        issue_id: issue.id.to_string(),
        issue_file: issue.file.to_string(),
        body: occurrence.body.clone(),
        context: occurrence.context.clone(),
        created: record.created.clone(),
        state: record.state,
        extra: record.extra.clone(),
    }
}

/// A brand-new record for an occurrence never seen before.
fn new_mention(occurrence: &Parsed, issue: IssueRef<'_>, now: &str) -> Mention {
    Mention {
        id: format!("M{}", crate::id::generate_issue_id()),
        key: occurrence.key.clone(),
        issue_number: issue.number,
        issue_id: issue.id.to_string(),
        issue_file: issue.file.to_string(),
        body: occurrence.body.clone(),
        context: occurrence.context.clone(),
        created: now.to_string(),
        state: MentionState::Unread,
        extra: BTreeMap::new(),
    }
}

/// The character bigrams of `text`, as a multiset of counts.
fn bigrams(text: &str) -> BTreeMap<(char, char), usize> {
    let chars: Vec<char> = text.chars().collect();
    let mut out = BTreeMap::new();
    for pair in chars.windows(2) {
        *out.entry((pair[0], pair[1])).or_insert(0) += 1;
    }
    out
}

/// The multiset Jaccard similarity of two bigram maps, in permille (`1000` = identical).
fn similarity_permille(
    a: &BTreeMap<(char, char), usize>,
    b: &BTreeMap<(char, char), usize>,
) -> u32 {
    let mut intersection: u64 = 0;
    let mut union: u64 = 0;
    for (gram, count) in a {
        let other = b.get(gram).copied().unwrap_or(0);
        intersection += (*count).min(other) as u64;
        union += (*count).max(other) as u64;
    }
    for (gram, count) in b {
        if !a.contains_key(gram) {
            union += *count as u64;
        }
    }
    if union == 0 {
        return 1000;
    }
    ((intersection * 1000) / union) as u32
}

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

    const ISSUE: IssueRef<'static> = IssueRef {
        id: "1A2B3C4D",
        number: 42,
        file: "herbert-maschke/task/todo/2026-10-09-fix.md",
    };

    fn parsed(body: &str, line: &str) -> Parsed {
        let col = line.find('@').unwrap_or(0);
        Parsed {
            body: body.to_string(),
            key: anchor::fingerprint(ISSUE.id, ISSUE.number, body, line, col),
            context: line.trim().to_string(),
        }
    }

    fn bodies(records: &[Mention]) -> Vec<&str> {
        records.iter().map(|m| m.body.as_str()).collect()
    }

    #[test]
    fn a_new_occurrence_is_added_as_unread() {
        let p = parsed("alice", "cc @alice please");
        let out = reconcile(&[], &[p], ISSUE, "now");
        assert_eq!(out.len(), 1);
        assert_eq!(out[0].state, MentionState::Unread);
        assert_eq!(out[0].issue_file, ISSUE.file);
        assert!(out[0].id.starts_with('M'));
    }

    #[test]
    fn inserting_before_and_after_leaves_the_existing_mention_untouched() {
        let first = parsed("alice", "cc @alice please");
        let before = reconcile(&[], std::slice::from_ref(&first), ISSUE, "now");
        let read = Mention {
            state: MentionState::Read,
            ..before[0].clone()
        };

        let inserted_before = parsed("zoe", "@zoe jumps the queue");
        let inserted_after = parsed("bob", "and @bob after");
        let after = reconcile(
            std::slice::from_ref(&read),
            &[inserted_before, first.clone(), inserted_after],
            ISSUE,
            "later",
        );

        assert_eq!(bodies(&after), ["zoe", "alice", "bob"]);
        let alice = after.iter().find(|m| m.body == "alice").expect("alice");
        // The existing mention kept its id and its read state; only the two new
        // mentions arrive unread.
        assert_eq!(alice.id, read.id);
        assert_eq!(alice.state, MentionState::Read);
        assert_eq!(after.iter().filter(|m| m.is_unread()).count(), 2);
    }

    #[test]
    fn an_identical_repeat_is_tallied_twice() {
        let body = "@alice ping\n@alice ping";
        let first = parsed("alice", body.lines().next().unwrap());
        let second = parsed("alice", body.lines().nth(1).unwrap());
        assert_eq!(
            first.key, second.key,
            "identical contexts share a fingerprint"
        );

        let existing = reconcile(&[], std::slice::from_ref(&first), ISSUE, "now");
        let marked = vec![Mention {
            state: MentionState::Read,
            ..existing[0].clone()
        }];
        let after = reconcile(&marked, &[first.clone(), second], ISSUE, "later");
        assert_eq!(after.len(), 2, "resolving one leaves the other");
    }

    #[test]
    fn a_removed_mention_is_dropped() {
        let keep = parsed("alice", "cc @alice");
        let gone = parsed("bob", "and @bob");
        let existing = reconcile(&[], &[keep.clone(), gone.clone()], ISSUE, "now");
        let after = reconcile(&existing, std::slice::from_ref(&keep), ISSUE, "later");
        assert_eq!(bodies(&after), ["alice"]);
    }

    #[test]
    fn a_typo_fix_in_the_same_sentence_carries_the_state_over() {
        let before = parsed("alice", "blocked on @alice's review of the parser");
        let existing = vec![Mention {
            state: MentionState::Read,
            ..reconcile(&[], std::slice::from_ref(&before), ISSUE, "now")[0].clone()
        }];
        let after_text = parsed("alice", "blocked on @alice's review of the parse");
        let after = reconcile(&existing, std::slice::from_ref(&after_text), ISSUE, "later");
        assert_eq!(after.len(), 1);
        assert_eq!(after[0].state, MentionState::Read, "not re-notified");
        assert_eq!(after[0].id, existing[0].id);
        assert_eq!(after[0].key, after_text.key, "re-anchored to the new text");
    }

    #[test]
    fn a_rewritten_sentence_is_re_notified_as_a_new_occurrence() {
        let before = parsed("alice", "blocked on @alice's review of the parser");
        let existing = vec![Mention {
            state: MentionState::Read,
            ..reconcile(&[], std::slice::from_ref(&before), ISSUE, "now")[0].clone()
        }];
        let after_text = parsed("alice", "please @alice take the database ticket instead");
        let after = reconcile(&existing, std::slice::from_ref(&after_text), ISSUE, "later");
        assert_eq!(after.len(), 1);
        assert_eq!(after[0].state, MentionState::Unread);
        assert_ne!(after[0].id, existing[0].id);
    }

    #[test]
    fn the_fuzzy_fallback_never_pairs_a_different_body() {
        let before = parsed("alice", "cc @alice about the parser");
        let existing = vec![Mention {
            state: MentionState::Read,
            ..reconcile(&[], std::slice::from_ref(&before), ISSUE, "now")[0].clone()
        }];
        // Same context shape, different person: the state must not transfer.
        let after_text = parsed("bob", "cc @bob about the parser");
        let after = reconcile(&existing, std::slice::from_ref(&after_text), ISSUE, "later");
        assert_eq!(bodies(&after), ["bob"]);
        assert_eq!(after[0].state, MentionState::Unread);
    }
}