tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! `tix mentions`: the non-interactive mention surface (OpenSpec change
//! `issue-mentions`, design D7).
//!
//! The TUI's notice and this command answer the same three questions about the same store —
//! what is pending, what does it say, and what do the three answers do — so the reading and
//! the mutating half live here and the MCP tools call **these** functions rather than a second
//! copy (task 6.3's "no second implementation").
//!
//! Nothing here prompts. `tix view`/the TUI can surface a mention interactively; this module is
//! what makes the flow work from a script and without a TTY.

use std::fmt::Write as _;

use crate::error::{Result, TixError};
use crate::mentions::identity;
use crate::mentions::store::MentionStore;
use crate::mentions::{Mention, locate};
use crate::storage::{IssueSelector, Store};

/// What `tix mentions <verb>` was asked to do.
///
/// Borrows the caller's id rather than owning a copy: the ids come straight out of the
/// parsed command line, and every arm just reads one.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Action<'a> {
    /// List the local user's pending mentions.
    List,
    /// Print one mention in full.
    Show(&'a str),
    /// Mark one mention read (the only mutating answer).
    Read(&'a str),
    /// Leave one mention pending, explicitly. Writes nothing.
    Ignore(&'a str),
    /// Print where one mention sits in its issue.
    Jump(&'a str),
}

/// The mention store for this repository, or a refusal naming why there is none.
///
/// A **disabled** feature is refused rather than served an empty list: a store that has never
/// been written and a store that is switched off mean different things, and a command that
/// reported "no pending mentions" for the second would be telling the user their mentions were
/// handled when the feature is simply not running.
pub fn store_for(store: &Store) -> Result<MentionStore> {
    if !store.config.mentions.enabled {
        return Err(TixError::Config(
            "mentions are disabled (`[mentions] enabled = false` in tix.toml)".to_string(),
        ));
    }
    Ok(MentionStore::new(
        store.config.mentions.resolve_dir(&store.issues_dir),
    ))
}

/// The local user's pending mentions, in the store's display order.
pub fn pending(store: &Store) -> Result<Vec<Mention>> {
    let mentions = store_for(store)?;
    Ok(mentions.pending_for(&identity::local_candidates()))
}

/// One mention by id, whether or not it is still unread.
///
/// Read-only surfaces (`show`, `jump`) accept a mention the user has already acknowledged;
/// only `read` cares about the state, and it says so itself.
pub fn find(store: &Store, id: &str) -> Result<Mention> {
    let mentions = store_for(store)?;
    let candidates = identity::local_candidates();
    mentions
        .find(&candidates, id)
        .ok_or_else(|| TixError::NotFound(format!("mention {id} for the local user")))
}

/// One line for one mention: id, issue, context.
#[must_use]
pub fn line(mention: &Mention) -> String {
    format!(
        "{}  #{:<4} {}",
        mention.id, mention.issue_number, mention.context
    )
}

/// The full description of one mention, for `show` and the MCP result payload.
#[must_use]
pub fn describe(mention: &Mention) -> String {
    let mut out = String::new();
    let _ = writeln!(out, "id: {}", mention.id);
    let _ = writeln!(
        out,
        "issue: #{} {}",
        mention.issue_number, mention.issue_file
    );
    let _ = writeln!(out, "body: @{}", mention.body);
    let _ = writeln!(out, "context: {}", mention.context);
    let _ = writeln!(out, "created: {}", mention.created);
    let _ = writeln!(
        out,
        "state: {}",
        match mention.state {
            crate::mentions::MentionState::Unread => "unread",
            crate::mentions::MentionState::Read => "read",
        }
    );
    out
}

/// Where the mention sits in its issue right now: `<file>:<line>` and the line's text.
///
/// The location is recomputed from the issue, because the fingerprint is the identity — a line
/// number recorded at reconciliation time would be stale after any edit above the mention.
pub fn jump_report(store: &Store, mention: &Mention) -> Result<String> {
    let issue = store.get(IssueSelector::Number(mention.issue_number))?;
    let id = issue.id.clone().unwrap_or_else(|| issue.filename.clone());
    let located = locate(
        crate::mentions::IssueContent {
            id: &id,
            number: issue.number,
            file: &issue.filename,
            title: &issue.title,
            body: &issue.body,
        },
        &mention.key,
    );
    let mut out = String::new();
    match located {
        Some(found) => {
            let _ = writeln!(out, "{}  #{}", mention.id, mention.issue_number);
            let _ = writeln!(
                out,
                "{}:{}",
                mention.issue_file,
                found.line.map_or(0, |line| line + 1)
            );
            let _ = writeln!(out, "{}", found.context);
        }
        // The record outlived its text: report where the issue is and what was recorded,
        // rather than inventing a line.
        None => {
            let _ = writeln!(out, "{}  #{}", mention.id, mention.issue_number);
            let _ = writeln!(out, "{}", mention.issue_file);
            let _ = writeln!(
                out,
                "(the mention is no longer in the issue; last seen as: {})",
                mention.context
            );
        }
    }
    Ok(out)
}

/// Mark one mention read, returning the confirmation line.
///
/// The one mutating answer. "Ignore" is deliberately not here: it is the absence of a write,
/// which is what makes the mention show again.
pub fn mark_read(store: &Store, id: &str) -> Result<String> {
    let mentions = store_for(store)?;
    // Resolve first, so an unknown id is refused with `not found` rather than reported as a
    // silent no-op.
    let mention = mentions
        .find(&identity::local_candidates(), id)
        .ok_or_else(|| TixError::NotFound(format!("mention {id} for the local user")))?;
    if mentions.mark_read(&identity::local_candidates(), id)? {
        Ok(format!("marked read: {}", mention.id))
    } else {
        Ok(format!("already read: {}", mention.id))
    }
}

/// `tix mentions <verb>`.
pub fn run(store: &Store, action: &Action<'_>) -> Result<()> {
    let out = match action {
        Action::List => {
            let pending = pending(store)?;
            if pending.is_empty() {
                println!("no pending mentions");
            } else {
                for mention in &pending {
                    println!("{}", line(mention));
                }
            }
            return Ok(());
        }
        Action::Show(id) => describe(&find(store, id)?),
        Action::Read(id) => mark_read(store, id)?,
        Action::Ignore(id) => {
            let mention = find(store, id)?;
            format!(
                "ignored: {} (still unread \u{2014} it will be shown again)",
                mention.id
            )
        }
        Action::Jump(id) => jump_report(store, &find(store, id)?)?,
    };
    print!("{out}");
    if !out.ends_with('\n') {
        println!();
    }
    Ok(())
}

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

    fn repo() -> (tempfile::TempDir, Store, u32) {
        let tmp = tempfile::TempDir::new().unwrap();
        let dir = tmp.path().join("issues");
        std::fs::create_dir_all(&dir).unwrap();
        let mut config = crate::config::Config {
            issues_dir: dir,
            ..crate::config::Config::default()
        };
        config.layout.move_on_change = false;
        config.save(&tmp.path().join("tix.toml")).unwrap();
        let store = Store::open(&config).unwrap();
        let mut issue: Issue = serde_yaml::from_str(
            "number: 0\ntitle: \"T\"\nstatus: \"todo\"\nlabels: []\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n",
        )
        .unwrap();
        store.create(&mut issue, "blocked on @alice", None).unwrap();
        let number = issue.number;
        (tmp, store, number)
    }

    #[test]
    fn line_and_describe_name_the_issue_and_the_context() {
        let (_tmp, store, number) = repo();
        let shown = store_for(&store)
            .unwrap()
            .pending_for(&["alice".to_string()]);
        assert_eq!(shown.len(), 1);
        assert_eq!(
            line(&shown[0]),
            format!("{}  #{number:<4} blocked on @alice", shown[0].id)
        );
        let described = describe(&shown[0]);
        assert!(described.contains("body: @alice"), "{described}");
        assert!(described.contains("state: unread"), "{described}");
    }

    #[test]
    fn a_disabled_feature_is_refused_with_the_key_that_switched_it_off() {
        let (_tmp, mut store, _) = repo();
        store.config.mentions.enabled = false;
        let error = store_for(&store).unwrap_err().to_string();
        assert!(error.contains("enabled = false"), "{error}");
    }

    #[test]
    fn mark_read_resolves_the_id_and_is_idempotent() {
        let (_tmp, store, _) = repo();
        // The candidates are the local user's; in this test they are pinned by reading
        // the stored record's own body.
        let mentions = store_for(&store).unwrap();
        let candidates = vec!["alice".to_string()];
        let id = mentions.pending_for(&candidates)[0].id.clone();
        assert!(mentions.mark_read(&candidates, &id).unwrap());
        assert!(
            !mentions.mark_read(&candidates, &id).unwrap(),
            "already read"
        );
        assert_eq!(
            mentions.pending_for(&candidates),
            [] as [crate::mentions::Mention; 0]
        );
    }

    #[test]
    fn jump_reports_the_line_the_mention_is_on() {
        let (_tmp, store, _) = repo();
        let mentions = store_for(&store).unwrap();
        let mention = mentions.pending_for(&["alice".to_string()])[0].clone();
        let report = jump_report(&store, &mention).unwrap();
        assert!(report.contains(":1"), "the body's first line: {report}");
        assert!(report.contains("blocked on @alice"), "{report}");
    }

    #[test]
    fn an_unknown_id_is_not_found() {
        let (_tmp, store, _) = repo();
        let error = find(&store, "MNOSUCHID").unwrap_err().to_string();
        assert!(error.contains("MNOSUCHID"), "{error}");
    }
}