use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use crate::error::{Result, TixError};
use crate::mentions::{IssueContent, Mention, MentionFile, MentionState, collect, reconcile};
use crate::slug::slugify;
#[derive(Debug, Clone)]
pub struct MentionStore {
dir: PathBuf,
}
impl MentionStore {
#[must_use]
pub fn new(dir: PathBuf) -> Self {
MentionStore { dir }
}
#[must_use]
pub fn dir(&self) -> &Path {
&self.dir
}
#[must_use]
pub fn slug(body: &str) -> String {
slugify(body)
}
fn path(&self, slug: &str) -> PathBuf {
self.dir.join(format!("{slug}.toml"))
}
pub fn read(&self, slug: &str) -> Result<Option<MentionFile>> {
let path = self.path(slug);
if !path.is_file() {
return Ok(None);
}
let text = std::fs::read_to_string(&path)?;
let file: MentionFile = toml::from_str(&text)
.map_err(|e| TixError::Parse(format!("{}: {e}", path.display())))?;
Ok(Some(file))
}
fn write(&self, slug: &str, file: &MentionFile) -> Result<()> {
let path = self.path(slug);
if file.is_empty() {
if path.is_file() {
crate::storage::journal_record_write(&path);
std::fs::remove_file(&path)?;
}
return Ok(());
}
std::fs::create_dir_all(&self.dir)?;
let text = toml::to_string(file)
.map_err(|e| TixError::Parse(format!("serialize {}: {e}", path.display())))?;
crate::storage::atomic_write(&self.dir, &path, text.as_bytes())
}
pub fn targets(&self) -> Result<Vec<String>> {
if !self.dir.is_dir() {
return Ok(Vec::new());
}
let mut out = Vec::new();
for entry in std::fs::read_dir(&self.dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().is_some_and(|e| e == "toml") {
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
out.push(stem.to_string());
}
}
}
out.sort();
Ok(out)
}
pub fn reconcile(&self, content: IssueContent<'_>) -> bool {
let mut groups: BTreeMap<String, Vec<reconcile::Parsed>> = BTreeMap::new();
for located in collect(content) {
let slug = Self::slug(&located.parsed.body);
groups.entry(slug).or_default().push(located.parsed);
}
let now = crate::model::utc_now_rfc3339();
let mut changed = false;
for (slug, parsed) in &groups {
match self.reconcile_target(slug, parsed, content, &now) {
Ok(true) => changed = true,
Ok(false) => {}
Err(e) => {
crate::storage::mention_disclosure(format!("mentions: {e} (left untouched)"))
}
}
}
changed |= self.reconcile_removals(content, groups.keys().map(String::as_str).collect());
changed
}
fn reconcile_removals(
&self,
content: IssueContent<'_>,
present: std::collections::BTreeSet<&str>,
) -> bool {
let Ok(targets) = self.targets() else {
return false;
};
let mut changed = false;
for slug in targets {
if present.contains(slug.as_str()) {
continue;
}
let holds_this_issue = match self.read(&slug) {
Ok(Some(file)) => file.mentions.iter().any(|m| m.issue_id == content.id),
Ok(None) => false,
Err(e) => {
crate::storage::mention_disclosure(format!("mentions: {e} (left untouched)"));
continue;
}
};
if !holds_this_issue {
continue;
}
match self.reconcile_target(&slug, &[], content, "") {
Ok(true) => changed = true,
Ok(false) => {}
Err(e) => {
crate::storage::mention_disclosure(format!("mentions: {e} (left untouched)"))
}
}
}
changed
}
fn reconcile_target(
&self,
slug: &str,
parsed: &[reconcile::Parsed],
content: IssueContent<'_>,
now: &str,
) -> Result<bool> {
let file = self.read(slug)?;
let existing = file.clone().unwrap_or_default();
let (mine, others): (Vec<Mention>, Vec<Mention>) = existing
.mentions
.into_iter()
.partition(|m| m.issue_id == content.id);
let reconciled = reconcile::reconcile(
&mine,
parsed,
reconcile::IssueRef {
id: content.id,
number: content.number,
file: content.file,
},
now,
);
if reconciled == mine {
return Ok(false);
}
let mut next = MentionFile {
mentions: others,
extra: if let Some(file) = file {
file.extra
} else {
BTreeMap::new()
},
};
next.mentions.extend(reconciled);
self.write(slug, &next)?;
Ok(true)
}
#[must_use]
pub fn pending_for(&self, candidates: &[String]) -> Vec<Mention> {
self.pooled(candidates, |m| m.is_unread())
}
#[must_use]
pub fn unread_count(&self, candidates: &[String]) -> usize {
self.pending_for(candidates).len()
}
#[must_use]
pub fn pending_for_issue(&self, candidates: &[String], issue_number: u32) -> Vec<Mention> {
self.pooled(candidates, |m| {
m.is_unread() && m.issue_number == issue_number
})
}
fn pooled(&self, candidates: &[String], keep: impl Fn(&Mention) -> bool) -> Vec<Mention> {
let mut out = Vec::new();
let mut seen = std::collections::BTreeSet::new();
for candidate in candidates {
let slug = Self::slug(candidate);
if !seen.insert(slug.clone()) {
continue;
}
if let Ok(Some(file)) = self.read(&slug) {
out.extend(file.mentions.into_iter().filter(|m| keep(m)));
}
}
out.sort_by(|a, b| {
a.issue_number
.cmp(&b.issue_number)
.then(a.created.cmp(&b.created))
.then(a.id.cmp(&b.id))
});
out
}
#[must_use]
pub fn find(&self, candidates: &[String], id: &str) -> Option<Mention> {
let mut seen = std::collections::BTreeSet::new();
for candidate in candidates {
let slug = Self::slug(candidate);
if !seen.insert(slug.clone()) {
continue;
}
if let Ok(Some(file)) = self.read(&slug) {
if let Some(found) = file.mentions.into_iter().find(|m| m.id == id) {
return Some(found);
}
}
}
None
}
pub fn mark_read(&self, candidates: &[String], id: &str) -> Result<bool> {
for candidate in candidates {
let slug = Self::slug(candidate);
let Some(mut file) = self.read(&slug)? else {
continue;
};
let mut changed = false;
for mention in &mut file.mentions {
if mention.id == id && mention.state != MentionState::Read {
mention.state = MentionState::Read;
changed = true;
}
}
if changed {
self.write(&slug, &file)?;
return Ok(true);
}
}
Ok(false)
}
pub fn clear_issue(&self, issue_id: &str) -> Result<()> {
for slug in self.targets()? {
let Some(file) = self.read(&slug)? else {
continue;
};
let before = file.mentions.len();
let next = MentionFile {
mentions: file
.mentions
.into_iter()
.filter(|m| m.issue_id != issue_id)
.collect(),
extra: file.extra,
};
if next.mentions.len() != before {
self.write(&slug, &next)?;
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn content<'a>(id: &'a str, title: &'a str, body: &'a str) -> IssueContent<'a> {
IssueContent {
id,
number: 42,
file: "herbert-maschke/task/todo/2026-10-09-fix.md",
title,
body,
}
}
#[test]
fn reading_a_project_with_no_mentions_creates_no_directory() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("docs/mentions");
let store = MentionStore::new(dir.clone());
assert_eq!(
store.pending_for(&["alice".to_string()]),
[] as [crate::mentions::Mention; 0]
);
assert_eq!(store.targets().unwrap(), Vec::<String>::new());
assert!(!dir.exists(), "a read must not create the directory");
}
#[test]
fn a_handle_mention_creates_the_target_file() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("docs/mentions");
let store = MentionStore::new(dir);
assert!(store.reconcile(content("1A2B3C4D", "", "blocked on @herbert-maschke")));
let file = store.read("herbert-maschke").unwrap().expect("file");
assert_eq!(file.mentions.len(), 1);
assert_eq!(file.mentions[0].body, "herbert-maschke");
assert_eq!(file.mentions[0].issue_number, 42);
}
#[test]
fn an_email_mention_creates_its_own_file() {
let tmp = TempDir::new().unwrap();
let store = MentionStore::new(tmp.path().join("docs/mentions"));
assert!(store.reconcile(content("1A2B3C4D", "ping @thyseus@pm.me", "")));
let file = store.read("thyseus-pm-me").unwrap().expect("file");
assert_eq!(file.mentions[0].body, "thyseus@pm.me");
}
#[test]
fn a_second_reconcile_of_unchanged_content_writes_nothing() {
let tmp = TempDir::new().unwrap();
let store = MentionStore::new(tmp.path().join("docs/mentions"));
let c = content("1A2B3C4D", "", "cc @alice");
assert!(store.reconcile(c));
assert!(!store.reconcile(c), "a no-op must not rewrite the file");
}
#[test]
fn removing_the_mention_text_drops_the_record() {
let tmp = TempDir::new().unwrap();
let store = MentionStore::new(tmp.path().join("docs/mentions"));
let _ = store.reconcile(content("1A2B3C4D", "", "cc @alice"));
let _ = store.reconcile(content("1A2B3C4D", "", "nothing here"));
assert_eq!(
store.pending_for(&["alice".to_string()]),
[] as [crate::mentions::Mention; 0]
);
assert_eq!(
store.targets().unwrap(),
Vec::<String>::new(),
"empty file removed"
);
}
#[test]
fn another_issue_records_are_preserved_when_one_issue_is_reconciled() {
let tmp = TempDir::new().unwrap();
let store = MentionStore::new(tmp.path().join("docs/mentions"));
let mut other = content("ZZZZZZZZ", "", "cc @alice");
other.number = 7;
other.file = "alice/task/todo/other.md";
store.reconcile(other);
store.reconcile(content("1A2B3C4D", "", "cc @alice"));
let file = store.read("alice").unwrap().expect("file");
assert_eq!(file.mentions.len(), 2);
assert!(file.mentions.iter().any(|m| m.issue_id == "ZZZZZZZZ"));
}
#[test]
fn mark_read_persists_and_stops_pending() {
let tmp = TempDir::new().unwrap();
let store = MentionStore::new(tmp.path().join("docs/mentions"));
store.reconcile(content("1A2B3C4D", "", "cc @alice"));
let candidates = vec!["alice".to_string()];
let pending = store.pending_for(&candidates);
assert_eq!(pending.len(), 1);
assert!(store.mark_read(&candidates, &pending[0].id).unwrap());
assert_eq!(
store.pending_for(&candidates),
[] as [crate::mentions::Mention; 0]
);
let file = store.read("alice").unwrap().expect("file");
assert_eq!(file.mentions[0].state, MentionState::Read);
}
#[test]
fn find_returns_a_record_whether_or_not_it_is_read() {
let tmp = TempDir::new().unwrap();
let store = MentionStore::new(tmp.path().join("docs/mentions"));
store.reconcile(content("1A2B3C4D", "", "cc @alice"));
let candidates = vec!["alice".to_string()];
let id = store.pending_for(&candidates)[0].id.clone();
assert!(store.find(&candidates, &id).is_some());
store.mark_read(&candidates, &id).unwrap();
assert!(
store.find(&candidates, &id).is_some(),
"still findable once read"
);
assert!(store.find(&candidates, "MNOPE1234").is_none());
}
#[test]
fn marking_an_unknown_id_is_a_no_op() {
let tmp = TempDir::new().unwrap();
let store = MentionStore::new(tmp.path().join("docs/mentions"));
store.reconcile(content("1A2B3C4D", "", "cc @alice"));
assert!(
!store
.mark_read(&["alice".to_string()], "MNOPE1234")
.unwrap()
);
}
#[test]
fn unread_count_pools_two_candidate_files_for_one_user() {
let tmp = TempDir::new().unwrap();
let store = MentionStore::new(tmp.path().join("docs/mentions"));
store.reconcile(content("1A2B3C4D", "", "hi @herbert-maschke"));
store.reconcile(content("5A6B7C8D", "", "hi @thyseus@pm.me"));
let candidates = vec!["herbert-maschke".to_string(), "thyseus@pm.me".to_string()];
assert_eq!(store.unread_count(&candidates), 2);
let doubled = vec!["herbert-maschke".to_string(), "herbert-maschke".to_string()];
assert_eq!(store.unread_count(&doubled), 1);
}
#[test]
fn pending_for_issue_filters_by_number() {
let tmp = TempDir::new().unwrap();
let store = MentionStore::new(tmp.path().join("docs/mentions"));
store.reconcile(content("1A2B3C4D", "", "cc @alice"));
let mut other = content("ZZZZZZZZ", "", "cc @alice");
other.number = 7;
store.reconcile(other);
let candidates = vec!["alice".to_string()];
assert_eq!(store.pending_for_issue(&candidates, 42).len(), 1);
assert_eq!(
store.pending_for_issue(&candidates, 99),
[] as [crate::mentions::Mention; 0]
);
}
#[test]
fn clear_issue_drops_only_that_issue() {
let tmp = TempDir::new().unwrap();
let store = MentionStore::new(tmp.path().join("docs/mentions"));
store.reconcile(content("1A2B3C4D", "", "cc @alice and @bob"));
let mut other = content("ZZZZZZZZ", "", "cc @alice");
other.number = 7;
store.reconcile(other);
store.clear_issue("1A2B3C4D").unwrap();
assert_eq!(store.targets().unwrap(), vec!["alice".to_string()]);
let left = store.pending_for(&["bob".to_string()]);
assert_eq!(left, [] as [crate::mentions::Mention; 0]);
let alice = store.pending_for(&["alice".to_string()]);
assert_eq!(alice.len(), 1);
assert_eq!(alice[0].issue_id, "ZZZZZZZZ");
}
#[test]
fn a_malformed_target_file_is_left_untouched_and_reconciliation_continues() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("docs/mentions");
std::fs::create_dir_all(&dir).unwrap();
let broken = dir.join("alice.toml");
std::fs::write(&broken, "this is not = = toml").unwrap();
let store = MentionStore::new(dir);
assert!(store.reconcile(content("1A2B3C4D", "", "cc @alice and @bob")));
assert_eq!(
std::fs::read_to_string(&broken).unwrap(),
"this is not = = toml"
);
assert!(store.read("bob").unwrap().is_some());
}
#[test]
fn a_mention_moved_from_a_handle_to_an_email_leaves_the_old_target() {
let tmp = TempDir::new().unwrap();
let store = MentionStore::new(tmp.path().join("docs/mentions"));
store.reconcile(content("1A2B3C4D", "", "cc @thyseus"));
assert_eq!(store.targets().unwrap(), vec!["thyseus".to_string()]);
store.reconcile(content("1A2B3C4D", "", "cc @thyseus@pm.me"));
assert_eq!(store.targets().unwrap(), vec!["thyseus-pm-me".to_string()]);
}
}