Skip to main content

lean_ctx/core/
telemetry_notices.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Notices from the LeanCTX team, carried back with a telemetry acknowledgement.
4//!
5//! The server matches a notice against what the batch already reports (version,
6//! AI client, OS, runtime environment, a tool that failed), so a fix can reach
7//! exactly the installations it concerns without anyone knowing who they are.
8//! Nothing extra is sent. A notice is plain text with an optional link to
9//! leanctx.com or the LeanCTX GitHub repository; anything else is dropped.
10//! It is shown once, to the person at an interactive terminal, and never
11//! enters an MCP response: server-provided text must not reach the AI model.
12//! The most recent notices are also kept, with the time they arrived, for the
13//! local cockpit's Inbox, which a person reads and marks done there.
14
15use 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"];
22/// Pending notices kept until shown; the server sends at most three.
23const MAX_PENDING: usize = 3;
24/// IDs of shown notices remembered so a resend is not shown again.
25const MAX_SHOWN: usize = 200;
26/// Received notices kept with their text for the cockpit Inbox, newest last.
27const 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/// A notice as it arrived. Showing it in the terminal does not remove it.
37#[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
61/// One notice from the response, or `None` when any part is malformed.
62pub 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
80/// The valid notices of an ingest response (`{"notices": [...]}`).
81pub(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/// Notices waiting to be shown.
112#[cfg(test)]
113pub(crate) fn pending() -> Vec<Notice> {
114    load().pending
115}
116
117/// Keeps notices that were neither shown nor already pending, and records each
118/// notice once, with its arrival time, for the cockpit.
119pub(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(&notice.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) = &notice.link {
154        text.push_str(&format!("  \x1b[2m{link}\x1b[0m\n"));
155    }
156    text
157}
158
159/// Shows pending notices once, on stderr of an interactive command. Never for
160/// MCP, hooks or piped use.
161pub 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(&notice));
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        // What `maybe_show` does once it printed.
223        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}