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};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Action<'a> {
List,
Show(&'a str),
Read(&'a str),
Ignore(&'a str),
Jump(&'a str),
}
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),
))
}
pub fn pending(store: &Store) -> Result<Vec<Mention>> {
let mentions = store_for(store)?;
Ok(mentions.pending_for(&identity::local_candidates()))
}
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")))
}
#[must_use]
pub fn line(mention: &Mention) -> String {
format!(
"{} #{:<4} {}",
mention.id, mention.issue_number, mention.context
)
}
#[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
}
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);
}
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)
}
pub fn mark_read(store: &Store, id: &str) -> Result<String> {
let mentions = store_for(store)?;
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))
}
}
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();
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}");
}
}