use std::collections::BTreeMap;
use crate::mentions::{Mention, MentionState, anchor};
pub const FUZZY_THRESHOLD_PERMILLE: u32 = 600;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Parsed {
pub body: String,
pub key: String,
pub context: String,
}
#[derive(Debug, Clone, Copy)]
pub struct IssueRef<'a> {
pub id: &'a str,
pub number: u32,
pub file: &'a str,
}
#[must_use]
pub fn reconcile(
existing: &[Mention],
parsed: &[Parsed],
issue: IssueRef<'_>,
now: &str,
) -> Vec<Mention> {
let mut used = vec![false; existing.len()];
let mut assigned: Vec<Option<Mention>> = vec![None; parsed.len()];
for (index, occurrence) in parsed.iter().enumerate() {
if let Some(found) = existing
.iter()
.enumerate()
.position(|(i, record)| !used[i] && record.key == occurrence.key)
{
used[found] = true;
assigned[index] = Some(refreshed(&existing[found], occurrence, issue));
}
}
let mut pairs: Vec<(u32, usize, usize)> = Vec::new();
for (old_index, record) in existing.iter().enumerate() {
if used[old_index] {
continue;
}
for (new_index, occurrence) in parsed.iter().enumerate() {
if assigned[new_index].is_some() {
continue;
}
if !record.body.eq_ignore_ascii_case(&occurrence.body) {
continue;
}
let similarity = similarity_permille(
&bigrams(&anchor::normalize(&record.context)),
&bigrams(&anchor::normalize(&occurrence.context)),
);
if similarity >= FUZZY_THRESHOLD_PERMILLE {
pairs.push((similarity, old_index, new_index));
}
}
}
pairs.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)).then(a.2.cmp(&b.2)));
for (_, old_index, new_index) in pairs {
if used[old_index] || assigned[new_index].is_some() {
continue;
}
used[old_index] = true;
assigned[new_index] = Some(refreshed(&existing[old_index], &parsed[new_index], issue));
}
assigned
.into_iter()
.enumerate()
.map(|(index, slot)| slot.unwrap_or_else(|| new_mention(&parsed[index], issue, now)))
.collect()
}
fn refreshed(record: &Mention, occurrence: &Parsed, issue: IssueRef<'_>) -> Mention {
Mention {
id: record.id.clone(),
key: occurrence.key.clone(),
issue_number: issue.number,
issue_id: issue.id.to_string(),
issue_file: issue.file.to_string(),
body: occurrence.body.clone(),
context: occurrence.context.clone(),
created: record.created.clone(),
state: record.state,
extra: record.extra.clone(),
}
}
fn new_mention(occurrence: &Parsed, issue: IssueRef<'_>, now: &str) -> Mention {
Mention {
id: format!("M{}", crate::id::generate_issue_id()),
key: occurrence.key.clone(),
issue_number: issue.number,
issue_id: issue.id.to_string(),
issue_file: issue.file.to_string(),
body: occurrence.body.clone(),
context: occurrence.context.clone(),
created: now.to_string(),
state: MentionState::Unread,
extra: BTreeMap::new(),
}
}
fn bigrams(text: &str) -> BTreeMap<(char, char), usize> {
let chars: Vec<char> = text.chars().collect();
let mut out = BTreeMap::new();
for pair in chars.windows(2) {
*out.entry((pair[0], pair[1])).or_insert(0) += 1;
}
out
}
fn similarity_permille(
a: &BTreeMap<(char, char), usize>,
b: &BTreeMap<(char, char), usize>,
) -> u32 {
let mut intersection: u64 = 0;
let mut union: u64 = 0;
for (gram, count) in a {
let other = b.get(gram).copied().unwrap_or(0);
intersection += (*count).min(other) as u64;
union += (*count).max(other) as u64;
}
for (gram, count) in b {
if !a.contains_key(gram) {
union += *count as u64;
}
}
if union == 0 {
return 1000;
}
((intersection * 1000) / union) as u32
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
const ISSUE: IssueRef<'static> = IssueRef {
id: "1A2B3C4D",
number: 42,
file: "herbert-maschke/task/todo/2026-10-09-fix.md",
};
fn parsed(body: &str, line: &str) -> Parsed {
let col = line.find('@').unwrap_or(0);
Parsed {
body: body.to_string(),
key: anchor::fingerprint(ISSUE.id, ISSUE.number, body, line, col),
context: line.trim().to_string(),
}
}
fn bodies(records: &[Mention]) -> Vec<&str> {
records.iter().map(|m| m.body.as_str()).collect()
}
#[test]
fn a_new_occurrence_is_added_as_unread() {
let p = parsed("alice", "cc @alice please");
let out = reconcile(&[], &[p], ISSUE, "now");
assert_eq!(out.len(), 1);
assert_eq!(out[0].state, MentionState::Unread);
assert_eq!(out[0].issue_file, ISSUE.file);
assert!(out[0].id.starts_with('M'));
}
#[test]
fn inserting_before_and_after_leaves_the_existing_mention_untouched() {
let first = parsed("alice", "cc @alice please");
let before = reconcile(&[], std::slice::from_ref(&first), ISSUE, "now");
let read = Mention {
state: MentionState::Read,
..before[0].clone()
};
let inserted_before = parsed("zoe", "@zoe jumps the queue");
let inserted_after = parsed("bob", "and @bob after");
let after = reconcile(
std::slice::from_ref(&read),
&[inserted_before, first.clone(), inserted_after],
ISSUE,
"later",
);
assert_eq!(bodies(&after), ["zoe", "alice", "bob"]);
let alice = after.iter().find(|m| m.body == "alice").expect("alice");
assert_eq!(alice.id, read.id);
assert_eq!(alice.state, MentionState::Read);
assert_eq!(after.iter().filter(|m| m.is_unread()).count(), 2);
}
#[test]
fn an_identical_repeat_is_tallied_twice() {
let body = "@alice ping\n@alice ping";
let first = parsed("alice", body.lines().next().unwrap());
let second = parsed("alice", body.lines().nth(1).unwrap());
assert_eq!(
first.key, second.key,
"identical contexts share a fingerprint"
);
let existing = reconcile(&[], std::slice::from_ref(&first), ISSUE, "now");
let marked = vec![Mention {
state: MentionState::Read,
..existing[0].clone()
}];
let after = reconcile(&marked, &[first.clone(), second], ISSUE, "later");
assert_eq!(after.len(), 2, "resolving one leaves the other");
}
#[test]
fn a_removed_mention_is_dropped() {
let keep = parsed("alice", "cc @alice");
let gone = parsed("bob", "and @bob");
let existing = reconcile(&[], &[keep.clone(), gone.clone()], ISSUE, "now");
let after = reconcile(&existing, std::slice::from_ref(&keep), ISSUE, "later");
assert_eq!(bodies(&after), ["alice"]);
}
#[test]
fn a_typo_fix_in_the_same_sentence_carries_the_state_over() {
let before = parsed("alice", "blocked on @alice's review of the parser");
let existing = vec![Mention {
state: MentionState::Read,
..reconcile(&[], std::slice::from_ref(&before), ISSUE, "now")[0].clone()
}];
let after_text = parsed("alice", "blocked on @alice's review of the parse");
let after = reconcile(&existing, std::slice::from_ref(&after_text), ISSUE, "later");
assert_eq!(after.len(), 1);
assert_eq!(after[0].state, MentionState::Read, "not re-notified");
assert_eq!(after[0].id, existing[0].id);
assert_eq!(after[0].key, after_text.key, "re-anchored to the new text");
}
#[test]
fn a_rewritten_sentence_is_re_notified_as_a_new_occurrence() {
let before = parsed("alice", "blocked on @alice's review of the parser");
let existing = vec![Mention {
state: MentionState::Read,
..reconcile(&[], std::slice::from_ref(&before), ISSUE, "now")[0].clone()
}];
let after_text = parsed("alice", "please @alice take the database ticket instead");
let after = reconcile(&existing, std::slice::from_ref(&after_text), ISSUE, "later");
assert_eq!(after.len(), 1);
assert_eq!(after[0].state, MentionState::Unread);
assert_ne!(after[0].id, existing[0].id);
}
#[test]
fn the_fuzzy_fallback_never_pairs_a_different_body() {
let before = parsed("alice", "cc @alice about the parser");
let existing = vec![Mention {
state: MentionState::Read,
..reconcile(&[], std::slice::from_ref(&before), ISSUE, "now")[0].clone()
}];
let after_text = parsed("bob", "cc @bob about the parser");
let after = reconcile(&existing, std::slice::from_ref(&after_text), ISSUE, "later");
assert_eq!(bodies(&after), ["bob"]);
assert_eq!(after[0].state, MentionState::Unread);
}
}