1use std::collections::HashSet;
15
16use crate::ambient::validate::is_usable_title;
17use crate::backend::obj::{self, Block};
18use crate::backend::storage::store::Store;
19
20pub const META_TITLE: &str = "term:ambient_summary";
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum Replace {
26 IfEmpty,
29 IfNews,
32}
33
34pub fn store_title(
39 store: &Store,
40 block_id: &str,
41 title: &str,
42 replace: Replace,
43 still_current: impl Fn() -> bool,
44) -> Result<bool, String> {
45 if !is_usable_title(title) {
46 return Ok(false);
47 }
48 store
49 .with_tx(|tx| {
50 if !still_current() {
51 return Ok(false);
52 }
53 let mut block = tx.must_get::<Block>(block_id)?;
54 let current = obj::meta_get_string(&block.meta, META_TITLE, "");
55 if is_usable_title(¤t) {
56 let keep = match replace {
57 Replace::IfEmpty => true,
58 Replace::IfNews => !is_title_news(¤t, title),
59 };
60 if keep {
61 return Ok(false);
62 }
63 }
64 block.meta.insert(META_TITLE.to_string(), serde_json::Value::String(title.to_string()));
65 tx.update(&mut block)?;
66 Ok(true)
67 })
68 .map_err(|e| e.to_string())
69}
70
71const STOP_WORDS: &[&str] = &[
73 "a", "an", "the", "and", "or", "of", "to", "for", "in", "on", "with", "by", "at", "from", "into", "its", "it",
74];
75
76const NEWS_MAX_OVERLAP: f64 = 0.5;
78
79fn topic_words(title: &str) -> HashSet<String> {
80 title
81 .to_lowercase()
82 .split(|c: char| !c.is_ascii_alphanumeric())
83 .filter(|w| !w.is_empty() && !STOP_WORDS.contains(w))
84 .map(str::to_string)
85 .collect()
86}
87
88pub fn is_title_news(current: &str, candidate: &str) -> bool {
92 let a = topic_words(current);
93 let b = topic_words(candidate);
94 if a.is_empty() || b.is_empty() {
95 return a.len() != b.len();
96 }
97 let shared = a.intersection(&b).count();
98 let overlap = shared as f64 / (a.len() + b.len() - shared) as f64;
99 overlap < NEWS_MAX_OVERLAP
100}
101
102#[cfg(test)]
103mod tests {
104 use super::*;
105
106 #[test]
107 fn a_rewording_is_not_news() {
108 for (current, candidate) in [
109 ("Fix the login race", "Fix login race condition"),
110 ("Fix the login race", "fix the LOGIN race"),
111 ("Harden the swarm ambient summary", "Harden swarm summary"),
112 ] {
113 assert!(!is_title_news(current, candidate), "{current:?} -> {candidate:?}");
114 }
115 }
116
117 #[test]
118 fn a_new_topic_is_news() {
119 for (current, candidate) in [
120 ("Fix the login race", "Set up CI for the docs site"),
121 ("Harden the swarm ambient summary", "Add UDP discovery for LAN peers"),
122 ("Review PR 4230", "Write the multi-host swarm spec"),
123 ] {
124 assert!(is_title_news(current, candidate), "{current:?} -> {candidate:?}");
125 }
126 }
127
128 fn store_with_block(meta: &[(&str, &str)]) -> Store {
129 let store = Store::open_in_memory().unwrap();
130 let mut block = Block {
131 oid: "b1".to_string(),
132 parentoref: String::new(),
133 version: 0,
134 runtimeopts: None,
135 stickers: None,
136 meta: obj::MetaMapType::new(),
137 subblockids: None,
138 };
139 for (k, v) in meta {
140 block.meta.insert(k.to_string(), serde_json::Value::String(v.to_string()));
141 }
142 store.insert(&mut block).unwrap();
143 store
144 }
145
146 fn title_of(store: &Store) -> String {
147 obj::meta_get_string(&store.must_get::<Block>("b1").unwrap().meta, META_TITLE, "")
148 }
149
150 #[test]
151 fn either_writer_fills_an_empty_or_placeholder_title() {
152 for replace in [Replace::IfEmpty, Replace::IfNews] {
153 for before in [&[][..], &[(META_TITLE, "(none yet)")][..]] {
154 let store = store_with_block(before);
155 assert_eq!(store_title(&store, "b1", "Fix the login race", replace, || true), Ok(true));
156 assert_eq!(title_of(&store), "Fix the login race");
157 }
158 }
159 }
160
161 #[test]
162 fn recovery_never_replaces_a_title() {
163 let store = store_with_block(&[(META_TITLE, "Set up CI for the docs site")]);
164 assert_eq!(store_title(&store, "b1", "Something else entirely", Replace::IfEmpty, || true), Ok(false));
165 assert_eq!(title_of(&store), "Set up CI for the docs site");
166 }
167
168 #[test]
169 fn the_pane_replaces_a_title_only_with_news() {
170 let store = store_with_block(&[(META_TITLE, "Fix the login race")]);
171 assert_eq!(store_title(&store, "b1", "Fix login race condition", Replace::IfNews, || true), Ok(false));
172 assert_eq!(title_of(&store), "Fix the login race");
173 assert_eq!(store_title(&store, "b1", "Set up CI for the docs site", Replace::IfNews, || true), Ok(true));
174 assert_eq!(title_of(&store), "Set up CI for the docs site");
175 }
176
177 #[test]
178 fn a_superseded_request_stores_nothing() {
179 let store = store_with_block(&[(META_TITLE, "Set up CI for the docs site")]);
180 assert_eq!(store_title(&store, "b1", "Fix the login race", Replace::IfNews, || false), Ok(false));
181 assert_eq!(title_of(&store), "Set up CI for the docs site");
182 }
183
184 #[test]
185 fn a_placeholder_is_never_stored() {
186 let store = store_with_block(&[]);
187 assert_eq!(store_title(&store, "b1", "(none yet)", Replace::IfNews, || true), Ok(false));
188 assert_eq!(title_of(&store), "");
189 }
190
191 #[test]
192 fn other_meta_survives_the_write() {
193 let store = store_with_block(&[("agentName", "AgentX")]);
194 store_title(&store, "b1", "Fix the login race", Replace::IfEmpty, || true).unwrap();
195 let meta = store.must_get::<Block>("b1").unwrap().meta;
196 assert_eq!(obj::meta_get_string(&meta, "agentName", ""), "AgentX");
197 }
198}