tix-tracker 0.8.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! The occurrence fingerprint: a mention's identity across edits (design D2).
//!
//! Counting mentions, or numbering them by position, breaks as soon as one is inserted before
//! another — which is the normal way a mention is added. Instead each occurrence is keyed by a
//! **content-addressed** fingerprint over:
//!
//! ```text
//! key = fnv1a64(issue_id \0 issue_number \0 lower(body) \0 normalized_window)[..16 hex]
//! ```
//!
//! `normalized_window` is the containing line, truncated symmetrically to [`WINDOW`]
//! characters on each side of the token, whitespace-collapsed and lowercased. Anchoring to the
//! **containing line** is what makes the key immune to insertions elsewhere in the issue;
//! normalizing case and whitespace makes it immune to reflow and capitalization.
//!
//! The hash is FNV-1a 64 rather than a cryptographic digest: the key is an internal identity,
//! never a security boundary or an external contract, and the crate's no-new-dependency rule
//! (see [`crate::id`], which hand-rolls its own randomness for the same reason) rules out a
//! `sha2` dependency for sixteen hex characters of incident-free deduplication.

/// How many characters of context are kept on **each** side of the token in the window.
pub const WINDOW: usize = 120;

/// The fingerprint is exactly this many lowercase hex characters.
pub const KEY_CHARS: usize = 16;

const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;

/// The occurrence fingerprint.
///
/// `token_col` is the sigil's character column in `line`.
#[must_use]
pub fn fingerprint(
    issue_id: &str,
    issue_number: u32,
    body: &str,
    line: &str,
    token_col: usize,
) -> String {
    let window = normalized_window(line, token_col);
    let mut payload = String::with_capacity(issue_id.len() + window.len() + body.len() + 24);
    payload.push_str(issue_id);
    payload.push('\0');
    payload.push_str(&issue_number.to_string());
    payload.push('\0');
    for lower in body.to_lowercase().chars() {
        payload.push(lower);
    }
    payload.push('\0');
    payload.push_str(&window);
    format!(
        "{:0width$x}",
        fnv1a64(payload.as_bytes()),
        width = KEY_CHARS
    )
}

/// Collapse whitespace runs to one space, lowercase, and trim.
#[must_use]
pub fn normalize(text: &str) -> String {
    let mut out = String::with_capacity(text.len());
    let mut pending_space = false;
    for ch in text.chars() {
        if ch.is_whitespace() {
            pending_space = !out.is_empty();
            continue;
        }
        if pending_space {
            out.push(' ');
            pending_space = false;
        }
        for lower in ch.to_lowercase() {
            out.push(lower);
        }
    }
    out
}

/// The containing line, windowed around `token_col` and normalized.
fn normalized_window(line: &str, token_col: usize) -> String {
    let chars: Vec<char> = line.chars().collect();
    let len = chars.len();
    let col = token_col.min(len);
    let start = col.saturating_sub(WINDOW);
    let end = (col + WINDOW).min(len);
    let slice: String = chars[start..end].iter().collect();
    normalize(&slice)
}

/// FNV-1a 64 over `bytes` — deterministic across runs, machines, and processes.
fn fnv1a64(bytes: &[u8]) -> u64 {
    let mut hash = FNV_OFFSET;
    for byte in bytes {
        hash ^= u64::from(*byte);
        hash = hash.wrapping_mul(FNV_PRIME);
    }
    hash
}

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

    fn key(body: &str, line: &str) -> String {
        fingerprint("1A2B3C4D", 42, body, line, line.find('@').unwrap_or(0))
    }

    #[test]
    fn an_identical_line_yields_a_stable_key() {
        let line = "blocked on @herbert-maschke's review";
        assert_eq!(key("herbert-maschke", line), key("herbert-maschke", line));
        assert_eq!(key("herbert-maschke", line).len(), KEY_CHARS);
    }

    #[test]
    fn a_whitespace_or_case_only_change_keeps_the_key() {
        let base = key("herbert-maschke", "blocked on @herbert-maschke's review");
        assert_eq!(
            base,
            key("herbert-maschke", "Blocked  ON   @herbert-maschke's Review")
        );
        assert_eq!(
            base,
            key(
                "herbert-maschke",
                "  blocked on @herbert-maschke's review  "
            )
        );
    }

    #[test]
    fn the_key_is_insensitive_to_the_body_casing() {
        assert_eq!(
            key("herbert-maschke", "cc @herbert-maschke"),
            key("HERBERT-MASCHKE", "cc @herbert-maschke")
        );
    }

    #[test]
    fn inserting_a_mention_on_another_line_leaves_every_existing_key_unchanged() {
        let before = "line one @alice here\nline two @bob there";
        let after = "line one @alice here\ninserted @carol between\nline two @bob there";
        let a = key("alice", before.lines().next().unwrap());
        let b = key("bob", before.lines().nth(1).unwrap());
        let a2 = key("alice", after.lines().next().unwrap());
        let b2 = key("bob", after.lines().nth(2).unwrap());
        assert_eq!(a, a2);
        assert_eq!(b, b2);
    }

    #[test]
    fn a_different_body_on_the_same_line_is_a_different_key() {
        let line = "cc @alice and @bob";
        assert_ne!(
            fingerprint("ID", 1, "alice", line, line.find("@alice").unwrap()),
            fingerprint("ID", 1, "bob", line, line.find("@bob").unwrap())
        );
    }

    #[test]
    fn a_different_issue_identity_is_a_different_key() {
        let line = "cc @alice";
        assert_ne!(
            fingerprint("ID1", 1, "alice", line, 3),
            fingerprint("ID2", 1, "alice", line, 3)
        );
        assert_ne!(
            fingerprint("ID1", 1, "alice", line, 3),
            fingerprint("ID1", 2, "alice", line, 3)
        );
    }

    #[test]
    fn normalize_collapses_case_and_whitespace() {
        assert_eq!(normalize("  Blocked   ON @Alice.\n"), "blocked on @alice.");
    }
}