pub mod anchor;
pub mod identity;
pub mod reconcile;
pub mod scan;
pub mod store;
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MentionState {
Unread,
Read,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Mention {
pub id: String,
pub key: String,
pub issue_number: u32,
pub issue_id: String,
pub issue_file: String,
pub body: String,
pub context: String,
pub created: String,
pub state: MentionState,
#[serde(flatten)]
pub extra: BTreeMap<String, toml::Value>,
}
impl Mention {
#[must_use]
pub fn is_unread(&self) -> bool {
self.state == MentionState::Unread
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct MentionFile {
#[serde(default, rename = "mention", skip_serializing_if = "Vec::is_empty")]
pub mentions: Vec<Mention>,
#[serde(flatten)]
pub extra: BTreeMap<String, toml::Value>,
}
impl MentionFile {
#[must_use]
pub fn new(mentions: Vec<Mention>) -> Self {
MentionFile {
mentions,
extra: BTreeMap::new(),
}
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.mentions.is_empty() && self.extra.is_empty()
}
}
#[derive(Debug, Clone, Copy)]
pub struct IssueContent<'a> {
pub id: &'a str,
pub number: u32,
pub file: &'a str,
pub title: &'a str,
pub body: &'a str,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Located {
pub parsed: reconcile::Parsed,
pub occurrence: scan::Occurrence,
}
#[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()
}
#[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 {
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() {
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()))
);
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}");
}
}