tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! Durable `@handle` and `@email` mentions.
//!
//! A *mention* is a person-addressed token inside an issue's title or body: `@herbert-maschke`
//! (the username slug the assignee storage segment uses) or `@thyseus@pm.me` (a full email).
//! Every write that persists issue content reconciles the mentions it contains against a
//! per-target store ([`store`]), and the mentioned target is shown their pending mentions when
//! they next open the issue.
//!
//! The layers, in the order data flows through them:
//!
//! 1. [`scan`] finds mention tokens, skipping fenced code blocks and inline code spans.
//! 2. [`anchor`] turns an occurrence into a **content-addressed fingerprint** — the identity
//!    that survives a mention being inserted before or after it.
//! 3. [`reconcile`] compares the fingerprints present in the written content with the ones
//!    already recorded and produces the new pending set.
//! 4. [`store`] reads and writes the per-target TOML files.
//!
//! Provenance: OpenSpec change `issue-mentions` (`proposal.md`, `design.md`, and
//! `specs/issue-mentions/spec.md` — the requirements this module implements).

pub mod anchor;
pub mod identity;
pub mod reconcile;
pub mod scan;
pub mod store;

use std::collections::BTreeMap;

use serde::{Deserialize, Serialize};

/// Whether the target has seen an occurrence.
///
/// The vocabulary is deliberately two words. "Ignored" is **not** a third state: ignoring a
/// mention leaves it `unread`, which is exactly what makes it show again the next time the
/// issue is opened (design D5 — the store holds the live pending set, not an audit log).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MentionState {
    /// Not yet acknowledged; the target sees it when they open the issue.
    Unread,
    /// Acknowledged; never surfaced as pending again.
    Read,
}

/// One mention occurrence awaiting (or having received) its target's attention.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Mention {
    /// Addressable id (`M` + 8 Crockford base32 characters), used by `tix mentions read
    /// <id>` and by the TUI's action keys.
    pub id: String,
    /// The [`anchor::fingerprint`] that identifies this occurrence across edits. Two
    /// occurrences with the same key are interchangeable by construction.
    pub key: String,
    /// The mentioned issue's human-facing number.
    pub issue_number: u32,
    /// The mentioned issue's canonical id, so a record survives a file rename.
    pub issue_id: String,
    /// Store-relative path of the mentioned issue, as of the last reconciliation.
    pub issue_file: String,
    /// The mention body exactly as written: a handle or a full email.
    pub body: String,
    /// The containing line, trimmed — what the eye needs to recognise the mention.
    pub context: String,
    /// RFC 3339 UTC, when the occurrence was first recorded.
    pub created: String,
    /// Whether the target has seen it.
    pub state: MentionState,
    /// Keys this build does not know. Preserved on rewrite so a hand-edited file keeps its
    /// extra annotations (design D4).
    #[serde(flatten)]
    pub extra: BTreeMap<String, toml::Value>,
}

impl Mention {
    /// Whether this occurrence is still pending (the target has not acknowledged it).
    #[must_use]
    pub fn is_unread(&self) -> bool {
        self.state == MentionState::Unread
    }
}

/// One target's mention file (`docs/mentions/<slug>.toml`).
///
/// The file is a **table with one `mention` array of tables**, so a hand-edited file with its
/// own top-level keys keeps them (the same preservation rule as [`Mention::extra`]).
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct MentionFile {
    /// The target's mentions, in the order they appear in the issue(s).
    #[serde(default, rename = "mention", skip_serializing_if = "Vec::is_empty")]
    pub mentions: Vec<Mention>,
    /// Unknown top-level keys, preserved on rewrite.
    #[serde(flatten)]
    pub extra: BTreeMap<String, toml::Value>,
}

impl MentionFile {
    /// Wrap an explicit list of mentions.
    #[must_use]
    pub fn new(mentions: Vec<Mention>) -> Self {
        MentionFile {
            mentions,
            extra: BTreeMap::new(),
        }
    }

    /// Whether the document holds nothing at all — the state in which its file is removed
    /// rather than written empty.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.mentions.is_empty() && self.extra.is_empty()
    }
}

/// The parts of an issue a reconciliation needs.
///
/// A struct rather than a `&Issue` so this module does not depend on the model — the store's
/// call site projects the issue into these five values, and a test can too.
#[derive(Debug, Clone, Copy)]
pub struct IssueContent<'a> {
    /// The issue's canonical id (never recycled), or its filename stem when it has none.
    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 issue title.
    pub title: &'a str,
    /// The issue body (including any `## Comments` section).
    pub body: &'a str,
}

/// An occurrence found in an issue, with the fingerprint that identifies it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Located {
    /// The occurrence's fingerprint input.
    pub parsed: reconcile::Parsed,
    /// Where it was found.
    pub occurrence: scan::Occurrence,
}

/// Every mention occurrence in `content`, with its fingerprint, in document order.
#[must_use]
pub fn collect(content: IssueContent<'_>) -> Vec<Located> {
    scan::occurrences(content.title, content.body)
        .into_iter()
        .map(|occurrence| {
            let key = anchor::fingerprint(
                content.id,
                content.number,
                &occurrence.body,
                &occurrence.line_text,
                occurrence.token_col,
            );
            Located {
                parsed: reconcile::Parsed {
                    body: occurrence.body.clone(),
                    key,
                    context: occurrence.context.clone(),
                },
                occurrence,
            }
        })
        .collect()
}

/// Where an occurrence with `key` sits in the issue **now**, if it is still there.
///
/// The location is recomputed from the content rather than stored, because the fingerprint is
/// the identity: a stored line number would be stale the moment the issue was edited above the
/// mention.
#[must_use]
pub fn locate(content: IssueContent<'_>, key: &str) -> Option<scan::Occurrence> {
    collect(content)
        .into_iter()
        .find(|located| located.parsed.key == key)
        .map(|located| located.occurrence)
}

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

    fn sample() -> Mention {
        Mention {
            id: "M1A2B3C4".to_string(),
            key: "0f1e2d3c4b5a6978".to_string(),
            issue_number: 42,
            issue_id: "1A2B3C4D".to_string(),
            issue_file: "herbert-maschke/task/todo/2026-10-09-fix.md".to_string(),
            body: "herbert-maschke".to_string(),
            context: "blocked on @herbert-maschke's review".to_string(),
            created: "2026-10-10T09:31:00Z".to_string(),
            state: MentionState::Unread,
            extra: BTreeMap::new(),
        }
    }

    #[test]
    fn a_record_round_trips_through_toml() {
        let file = MentionFile::new(vec![sample()]);
        let text = toml::to_string(&file).expect("serialize");
        let back: MentionFile = toml::from_str(&text).expect("deserialize");
        assert_eq!(back, file);
        assert!(text.contains("[[mention]]"), "{text}");
        assert!(text.contains("state = \"unread\""), "{text}");
    }

    #[test]
    fn an_extra_hand_written_key_survives_a_rewrite() {
        // A human added a note to the record and a key of their own to the document.
        let text = r#"
note = "kept"

[[mention]]
id = "M1A2B3C4"
key = "0f1e2d3c4b5a6978"
issue_number = 42
issue_id = "1A2B3C4D"
issue_file = "a/b/c.md"
body = "herbert-maschke"
context = "@herbert-maschke"
created = "2026-10-10T09:31:00Z"
state = "unread"
reviewer_note = "asked in standup"
"#;
        let parsed: MentionFile = toml::from_str(text).expect("deserialize");
        assert_eq!(
            parsed.extra.get("note"),
            Some(&toml::Value::String("kept".to_string()))
        );
        assert_eq!(
            parsed.mentions[0].extra.get("reviewer_note"),
            Some(&toml::Value::String("asked in standup".to_string()))
        );
        // Rewriting keeps both the document-level and the record-level extra key.
        let rewritten = toml::to_string(&parsed).expect("serialize");
        assert!(rewritten.contains("note = \"kept\""), "{rewritten}");
        assert!(
            rewritten.contains("reviewer_note = \"asked in standup\""),
            "{rewritten}"
        );
    }

    #[test]
    fn read_and_unread_are_distinguishable_and_serialize_lowercase() {
        assert!(sample().is_unread());
        let read = Mention {
            state: MentionState::Read,
            ..sample()
        };
        assert!(!read.is_unread());
        let text = toml::to_string(&MentionFile::new(vec![read])).expect("serialize");
        assert!(text.contains("state = \"read\""), "{text}");
    }
}