pub const BANK_OPEN: &str = "<!-- topics -->";
pub const BANK_CLOSE: &str = "<!-- /topics -->";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TopicNode {
pub key: String,
pub text: String,
pub source: String,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct MergeStats {
pub merged: usize,
pub inserted: usize,
pub unchanged: usize,
}
impl MergeStats {
pub fn changed(&self) -> bool {
self.merged + self.inserted > 0
}
}
const DROP: &[&str] = &[
"not", "no", "never", "dont", "doesnt", "isnt", "arent", "wasnt", "werent", "cant", "wont", "anymore", "longer",
"am", "is", "are", "was", "were", "be", "been", "a", "an", "the", "i", "im", "my", "me", "user", "users", "now",
"really", "very", "actually", "please", "remember", "that", "note", "fact", "do", "does", "also", "still", "to",
"correction", "update", "updated", "fyi", "btw", "so", "and", "but", "ok", "okay", "well", "just",
];
pub fn clauses(fact: &str) -> Vec<String> {
let content = |s: &str| words(s).iter().filter(|w| !DROP.contains(&w.as_str())).count();
let mut out = Vec::new();
for sentence in fact.split(['.', ';', '!', '\n']) {
let sentence = sentence.trim().trim_start_matches(|c: char| !c.is_alphanumeric()).trim();
if sentence.is_empty() {
continue;
}
let parts: Vec<&str> = sentence.split(" and ").collect();
if parts.len() > 1 && parts.iter().all(|p| content(p) >= 2) {
out.extend(parts.iter().map(|p| p.trim().to_owned()));
} else {
out.push(sentence.to_owned());
}
}
out
}
const SINGLE: &[(&str, &str)] = &[
("live in", "lives in"),
("lives in", "lives in"),
("living in", "lives in"),
("moved to", "lives in"),
("work at", "works at"),
("works at", "works at"),
("working at", "works at"),
("name is", "name"),
("call me", "name"),
("timezone is", "timezone"),
("time zone is", "timezone"),
("prefer", "prefers"),
("prefers", "prefers"),
("would rather", "prefers"),
("rather", "prefers"),
];
fn words(s: &str) -> Vec<String> {
s.to_lowercase()
.split(|c: char| !c.is_alphanumeric() && c != '\'')
.map(|w| w.replace('\'', ""))
.filter(|w| !w.is_empty())
.collect()
}
pub fn topic_key(fact: &str) -> String {
let lower = format!(" {} ", words(fact).join(" "));
for (cue, key) in SINGLE {
if lower.contains(&format!(" {cue} ")) {
return key.to_string();
}
}
let ws = words(fact);
if let Some(i) = ws.iter().position(|w| w == "favorite" || w == "favourite") {
let attr: Vec<&str> = ws[i + 1..].iter().take_while(|w| *w != "is" && *w != "are").map(String::as_str).collect();
if !attr.is_empty() {
return format!("favorite {}", attr.join(" "));
}
}
ws.into_iter().filter(|w| !DROP.contains(&w.as_str())).collect::<Vec<_>>().join(" ")
}
pub fn parse_bank(md: &str) -> (String, Vec<TopicNode>) {
let (Some(open), Some(close)) = (md.find(BANK_OPEN), md.find(BANK_CLOSE)) else {
return (md.to_owned(), Vec::new());
};
if close < open {
return (md.to_owned(), Vec::new());
}
let body = &md[open + BANK_OPEN.len()..close];
let rest = format!("{}{}", &md[..open], &md[close + BANK_CLOSE.len()..]);
let nodes = body
.split("\n\n")
.filter_map(|block| {
let block = block.trim();
let key = block.strip_prefix("<!-- topic:key=")?.split(" -->").next()?.to_owned();
let line = block.lines().nth(1)?.trim();
let text = line.strip_prefix("- ")?;
let (text, source) = match text.rsplit_once(" _(from ") {
Some((t, s)) => (t.to_owned(), s.trim_end_matches(")_").to_owned()),
None => (text.to_owned(), String::new()),
};
Some(TopicNode { key, text, source })
})
.collect();
(rest.trim_end().to_owned(), nodes)
}
pub fn render_bank(nodes: &[TopicNode]) -> String {
let mut s = format!("{BANK_OPEN}\n");
for n in nodes {
let src = if n.source.is_empty() { String::new() } else { format!(" _(from {})_", n.source) };
s.push_str(&format!("\n<!-- topic:key={} -->\n- {}{}\n", n.key, n.text, src));
}
s.push_str(&format!("\n{BANK_CLOSE}"));
s
}
pub fn merge(mut bank: Vec<TopicNode>, facts: &[String], source: &str) -> (Vec<TopicNode>, MergeStats) {
let mut stats = MergeStats::default();
let split: Vec<String> = facts.iter().flat_map(|f| clauses(f)).collect();
for fact in &split {
let key = topic_key(fact);
if key.is_empty() {
continue;
}
match bank.iter_mut().find(|n| n.key == key) {
Some(n) if n.text == *fact => stats.unchanged += 1,
Some(n) => {
n.text = fact.clone();
n.source = source.to_owned();
stats.merged += 1;
}
None => {
bank.push(TopicNode { key, text: fact.clone(), source: source.to_owned() });
stats.inserted += 1;
}
}
}
(bank, stats)
}
pub fn merge_into_memory(md: &str, facts: &[String], source: &str) -> (String, MergeStats) {
let (rest, bank) = parse_bank(md);
let (bank, stats) = merge(bank, facts, source);
let rendered = render_bank(&bank);
let out = if rest.trim().is_empty() { rendered } else { format!("{rest}\n\n{rendered}") };
(out, stats)
}
#[cfg(test)]
#[path = "topics_tests.rs"]
mod tests;