1use std::io::IsTerminal;
16
17use serde::{Deserialize, Serialize};
18
19const MAX_MESSAGE_CHARS: usize = 280;
20const MAX_LINK_CHARS: usize = 300;
21const LINK_PREFIXES: &[&str] = &["https://leanctx.com/", "https://github.com/yvgude/lean-ctx"];
22const MAX_PENDING: usize = 3;
24const MAX_SHOWN: usize = 200;
26const MAX_RECEIVED: usize = 20;
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30pub struct Notice {
31 pub id: String,
32 pub message: String,
33 pub link: Option<String>,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38struct ReceivedNotice {
39 id: String,
40 message: String,
41 link: Option<String>,
42 received_at: String,
43}
44
45#[derive(Debug, Default, Serialize, Deserialize)]
46struct NoticeStore {
47 pending: Vec<Notice>,
48 shown: Vec<String>,
49 #[serde(default)]
50 received: Vec<ReceivedNotice>,
51}
52
53fn valid_link(link: &str) -> bool {
54 link.len() <= MAX_LINK_CHARS
55 && LINK_PREFIXES.iter().any(|prefix| link.starts_with(prefix))
56 && link
57 .bytes()
58 .all(|byte| byte.is_ascii_graphic() && !b"\"'<>\\`".contains(&byte))
59}
60
61pub fn parse(value: &serde_json::Value) -> Option<Notice> {
63 let id = uuid::Uuid::parse_str(value.get("id")?.as_str()?).ok()?;
64 let message = value.get("message")?.as_str()?.trim();
65 let chars = message.chars().count();
66 if !(1..=MAX_MESSAGE_CHARS).contains(&chars) || message.chars().any(char::is_control) {
67 return None;
68 }
69 let link = match value.get("link") {
70 None | Some(serde_json::Value::Null) => None,
71 Some(link) => Some(link.as_str().filter(|link| valid_link(link))?.to_string()),
72 };
73 Some(Notice {
74 id: id.to_string(),
75 message: message.to_string(),
76 link,
77 })
78}
79
80pub(crate) fn from_response(response: &serde_json::Value) -> Vec<Notice> {
82 response
83 .get("notices")
84 .and_then(serde_json::Value::as_array)
85 .map(|notices| notices.iter().filter_map(parse).take(MAX_PENDING).collect())
86 .unwrap_or_default()
87}
88
89fn store_path() -> Result<std::path::PathBuf, String> {
90 crate::core::paths::state_dir().map(|dir| dir.join("telemetry_notices.json"))
91}
92
93fn load() -> NoticeStore {
94 store_path()
95 .ok()
96 .and_then(|path| std::fs::read_to_string(path).ok())
97 .and_then(|text| serde_json::from_str(&text).ok())
98 .unwrap_or_default()
99}
100
101fn save(store: &NoticeStore) {
102 let Ok(path) = store_path() else { return };
103 if let Some(parent) = path.parent() {
104 let _ = std::fs::create_dir_all(parent);
105 }
106 if let Ok(bytes) = serde_json::to_vec(store) {
107 let _ = crate::core::atomic_fs::try_atomic_write(&path, &bytes, None);
108 }
109}
110
111#[cfg(test)]
113pub(crate) fn pending() -> Vec<Notice> {
114 load().pending
115}
116
117pub(crate) fn remember(notices: Vec<Notice>) {
120 if notices.is_empty() {
121 return;
122 }
123 let mut store = load();
124 let now = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
125 for notice in notices {
126 if !store
127 .received
128 .iter()
129 .any(|received| received.id == notice.id)
130 {
131 store.received.push(ReceivedNotice {
132 id: notice.id.clone(),
133 message: notice.message.clone(),
134 link: notice.link.clone(),
135 received_at: now.clone(),
136 });
137 }
138 if !store.shown.contains(¬ice.id)
139 && !store.pending.iter().any(|pending| pending.id == notice.id)
140 {
141 store.pending.push(notice);
142 }
143 }
144 let excess = store.pending.len().saturating_sub(MAX_PENDING);
145 store.pending.drain(..excess);
146 let excess = store.received.len().saturating_sub(MAX_RECEIVED);
147 store.received.drain(..excess);
148 save(&store);
149}
150
151fn render(notice: &Notice) -> String {
152 let mut text = format!("\x1b[1mLeanCTX notice:\x1b[0m {}\n", notice.message);
153 if let Some(link) = ¬ice.link {
154 text.push_str(&format!(" \x1b[2m{link}\x1b[0m\n"));
155 }
156 text
157}
158
159pub fn maybe_show() {
162 if !(std::io::stderr().is_terminal() && crate::core::host_env::stdin_is_terminal()) {
163 return;
164 }
165 let mut store = load();
166 if store.pending.is_empty() {
167 return;
168 }
169 for notice in std::mem::take(&mut store.pending) {
170 eprint!("{}", render(¬ice));
171 store.shown.push(notice.id);
172 }
173 eprintln!();
174 let excess = store.shown.len().saturating_sub(MAX_SHOWN);
175 store.shown.drain(..excess);
176 save(&store);
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182 use serde_json::json;
183
184 const ID: &str = "6f1c2b9e-3d4a-4b5c-8d6e-7f8091a2b3c4";
185
186 #[test]
187 fn only_plain_text_notices_with_our_own_links_are_kept() {
188 let response = json!({"message": "accepted", "notices": [
189 {"id": ID, "message": "Fix is in 3.11.3: run `lean-ctx update`", "link": "https://leanctx.com/changelog"},
190 {"id": ID, "message": "no link", "link": null},
191 {"id": ID, "message": "phish", "link": "https://evil.example/"},
192 {"id": ID, "message": "bell\u{7}", "link": null},
193 {"id": "not-a-uuid", "message": "x", "link": null},
194 {"id": ID, "message": "x".repeat(281), "link": null},
195 {"id": ID, "message": "quote", "link": "https://leanctx.com/\"x"},
196 ]});
197 let notices = from_response(&response);
198 assert_eq!(notices.len(), 2, "{notices:?}");
199 assert_eq!(
200 notices[0].link.as_deref(),
201 Some("https://leanctx.com/changelog")
202 );
203 assert_eq!(notices[1].link, None);
204 {
205 let found = &from_response(&json!({"message": "accepted"}));
206 assert!(found.is_empty(), "{found:?}");
207 }
208 }
209
210 #[test]
211 #[serial_test::serial]
212 fn a_notice_is_kept_once_and_never_again_after_it_was_shown() {
213 let _iso = crate::core::data_dir::isolated_data_dir();
214 let notice = Notice {
215 id: ID.into(),
216 message: "hello".into(),
217 link: None,
218 };
219 remember(vec![notice.clone()]);
220 remember(vec![notice.clone()]);
221 assert_eq!(load().pending, vec![notice.clone()]);
222 let mut store = load();
224 store.shown.push(store.pending.remove(0).id);
225 save(&store);
226 remember(vec![notice]);
227 {
228 let found = &load().pending;
229 assert!(found.is_empty(), "{found:?}");
230 }
231 }
232
233 #[test]
234 #[serial_test::serial]
235 fn the_cockpit_keeps_a_notice_after_the_terminal_showed_it() {
236 let _iso = crate::core::data_dir::isolated_data_dir();
237 save(&NoticeStore::default());
238 let notice = Notice {
239 id: ID.into(),
240 message: "Fix is in 3.11.5".into(),
241 link: Some("https://leanctx.com/changelog".into()),
242 };
243 remember(vec![notice.clone()]);
244 let mut store = load();
245 store.shown.push(store.pending.remove(0).id);
246 save(&store);
247 remember(vec![notice]);
248 let received = load().received;
249 assert_eq!(received.len(), 1, "recorded once, kept after showing");
250 assert_eq!(received[0].message, "Fix is in 3.11.5");
251 assert!(chrono::DateTime::parse_from_rfc3339(&received[0].received_at).is_ok());
252
253 for n in 0..(MAX_RECEIVED + 5) {
254 remember(vec![Notice {
255 id: format!("00000000-0000-4000-8000-{n:012}"),
256 message: format!("notice {n}"),
257 link: None,
258 }]);
259 }
260 let received = load().received;
261 assert_eq!(received.len(), MAX_RECEIVED, "bounded");
262 assert_eq!(
263 received.last().map(|r| r.message.as_str()),
264 Some("notice 24")
265 );
266 }
267
268 #[test]
269 #[serial_test::serial]
270 fn a_store_written_before_the_history_still_loads() {
271 let _iso = crate::core::data_dir::isolated_data_dir();
272 let path = store_path().unwrap();
273 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
274 std::fs::write(&path, format!(r#"{{"pending":[],"shown":["{ID}"]}}"#)).unwrap();
275 let store = load();
276 assert_eq!(store.shown, vec![ID.to_string()]);
277 assert!(store.received.is_empty());
278 }
279
280 #[test]
281 fn rendering_names_the_sender_and_keeps_the_link_on_its_own_line() {
282 let text = render(&Notice {
283 id: ID.into(),
284 message: "Update available".into(),
285 link: Some("https://leanctx.com/changelog".into()),
286 });
287 assert!(text.contains("LeanCTX notice:"));
288 assert!(text.contains("Update available"));
289 assert!(text.ends_with("https://leanctx.com/changelog\x1b[0m\n"));
290 }
291}