Skip to main content

termstate_store/
lib.rs

1//! The terminal side of OSC 7501: the set of records one terminal holds.
2//!
3//! [`Store`] applies decoded [`Message`]s from `termstate-core` following the
4//! "Records and ids", "States" (lifetime) and "Limits" sections of the
5//! Program Status Protocol, revision 0.4:
6//!
7//! - a report replaces its record completely;
8//! - `clear` removes the addressed record and every record beneath it;
9//! - a record without `app` inherits it from its nearest ancestor;
10//! - process exit and a new shell prompt drop `working` and `blocked`
11//!   (and, by policy, `idle`); `done` and `error` survive;
12//! - at most 256 records, least recently updated evicted first;
13//! - a full reset (RIS) removes everything.
14//!
15//! Presentation (notifications, icons, rate limiting) is left to the caller.
16//! Every mutating method returns the [`Change`]s it made, in order.
17#![no_std]
18
19extern crate alloc;
20
21use alloc::collections::BTreeMap;
22use alloc::string::{String, ToString};
23use alloc::vec::Vec;
24
25use termstate_core::limits::{MAX_RECORDS, MIN_RECORDS};
26use termstate_core::{Kind, Message, Report, State};
27
28/// What one record holds: exactly the keys of the last report for its id.
29#[derive(Clone, Debug, PartialEq, Eq)]
30pub struct Record {
31    /// The state.
32    pub state: State,
33    /// What a blocked program needs (only with `blocked`).
34    pub kind: Option<Kind>,
35    /// 0..=100 (only with `working` or `blocked`); `None` is indeterminate.
36    pub progress: Option<u8>,
37    /// The `app` the report carried. See [`Store::effective_app`] for the
38    /// inherited value.
39    pub app: Option<String>,
40    /// Decoded `title`.
41    pub title: Option<String>,
42    /// Decoded `msg`.
43    pub msg: Option<String>,
44}
45
46impl Record {
47    /// Converts a validated report into an owned record.
48    pub fn from_report(r: &Report<'_>) -> Self {
49        Record {
50            state: r.state,
51            kind: r.kind,
52            progress: r.progress,
53            app: r.app.map(|a| a.as_str().into()),
54            title: r.title.map(|t| t.to_string()),
55            msg: r.msg.map(|t| t.to_string()),
56        }
57    }
58}
59
60/// One change to the store. Ids are `None` for the root record.
61#[derive(Clone, Debug, PartialEq, Eq)]
62pub enum Change {
63    /// The record was created or replaced.
64    Set(Option<String>),
65    /// The record was removed by `clear`, a lifetime event, or a reset.
66    Removed(Option<String>),
67    /// The record was removed to make room under the record cap.
68    Evicted(Option<String>),
69}
70
71#[derive(Clone, Debug)]
72struct Entry {
73    record: Record,
74    touched: u64,
75}
76
77/// The records of one terminal. See the [crate docs](crate).
78#[derive(Clone, Debug)]
79pub struct Store {
80    // The root record is stored under "", which sorts first and is an
81    // ancestor of every id.
82    records: BTreeMap<String, Entry>,
83    capacity: usize,
84    clock: u64,
85    drop_idle: bool,
86}
87
88impl Default for Store {
89    fn default() -> Self {
90        Self::new()
91    }
92}
93
94fn key(id: Option<&str>) -> &str {
95    id.unwrap_or("")
96}
97
98fn id_of(key: &str) -> Option<String> {
99    (!key.is_empty()).then(|| key.into())
100}
101
102/// Whether `key` is `ancestor` or beneath it.
103fn within(ancestor: &str, key: &str) -> bool {
104    ancestor.is_empty()
105        || key == ancestor
106        || (key.len() > ancestor.len()
107            && key.starts_with(ancestor)
108            && key.as_bytes()[ancestor.len()] == b'/')
109}
110
111impl Store {
112    /// A store with the spec's full cap of 256 records.
113    pub fn new() -> Self {
114        Self::with_capacity(MAX_RECORDS)
115    }
116
117    /// A store holding at most `capacity` records, clamped to 64..=256
118    /// (terminals MAY choose a lower limit but MUST support at least 64).
119    pub fn with_capacity(capacity: usize) -> Self {
120        Store {
121            records: BTreeMap::new(),
122            capacity: capacity.clamp(MIN_RECORDS, MAX_RECORDS),
123            clock: 0,
124            drop_idle: false,
125        }
126    }
127
128    /// Also drop `idle` records on process exit and new prompts (the spec
129    /// allows either). Off by default.
130    pub fn set_drop_idle_on_lifetime_events(&mut self, on: bool) {
131        self.drop_idle = on;
132    }
133
134    /// The record cap.
135    pub fn capacity(&self) -> usize {
136        self.capacity
137    }
138
139    /// Number of records.
140    pub fn len(&self) -> usize {
141        self.records.len()
142    }
143
144    /// Whether there are no records.
145    pub fn is_empty(&self) -> bool {
146        self.records.is_empty()
147    }
148
149    /// The record for `id` (`None` is the root record).
150    pub fn get(&self, id: Option<&str>) -> Option<&Record> {
151        self.records.get(key(id)).map(|e| &e.record)
152    }
153
154    /// All records, ordered by id with the root first.
155    pub fn iter(&self) -> impl Iterator<Item = (Option<&str>, &Record)> {
156        self.records
157            .iter()
158            .map(|(k, e)| ((!k.is_empty()).then_some(k.as_str()), &e.record))
159    }
160
161    /// The `app` of the record, or of its nearest ancestor record that has
162    /// one. Ancestors do not need to exist. `None` if no record on the path
163    /// carries an app.
164    pub fn effective_app(&self, id: Option<&str>) -> Option<&str> {
165        let mut k = key(id);
166        loop {
167            if let Some(app) = self.records.get(k).and_then(|e| e.record.app.as_deref()) {
168                return Some(app);
169            }
170            if k.is_empty() {
171                return None;
172            }
173            k = k.rfind('/').map_or("", |i| &k[..i]);
174        }
175    }
176
177    /// Applies one decoded message. Feature-detection messages change nothing.
178    pub fn apply(&mut self, message: &Message<'_>) -> Vec<Change> {
179        match message {
180            Message::Report(r) => self.set(r),
181            Message::Clear { id } => self.clear(id.map(|i| i.as_str())),
182            Message::Feature(_) => Vec::new(),
183        }
184    }
185
186    /// Replaces the addressed record with `report`.
187    pub fn set(&mut self, report: &Report<'_>) -> Vec<Change> {
188        let mut changes = Vec::new();
189        let k = key(report.id.map(|i| i.as_str()));
190        self.clock += 1;
191        let entry = Entry {
192            record: Record::from_report(report),
193            touched: self.clock,
194        };
195        if let Some(existing) = self.records.get_mut(k) {
196            *existing = entry;
197        } else {
198            if self.records.len() >= self.capacity {
199                let oldest = self
200                    .records
201                    .iter()
202                    .min_by_key(|(_, e)| e.touched)
203                    .map(|(k, _)| k.clone());
204                if let Some(oldest) = oldest {
205                    self.records.remove(&oldest);
206                    changes.push(Change::Evicted(id_of(&oldest)));
207                }
208            }
209            self.records.insert(k.to_string(), entry);
210        }
211        changes.push(Change::Set(id_of(k)));
212        changes
213    }
214
215    /// Removes the record `id` and every record beneath it, or every record
216    /// when `id` is `None`.
217    pub fn clear(&mut self, id: Option<&str>) -> Vec<Change> {
218        let ancestor = key(id);
219        self.remove_where(|k, _| within(ancestor, k))
220    }
221
222    /// The process attached to the terminal exited: drops `working` and
223    /// `blocked` records (and `idle` ones if so configured).
224    pub fn process_exited(&mut self) -> Vec<Change> {
225        self.lifetime_event()
226    }
227
228    /// A new shell prompt began (OSC 133 `A`): same effect as
229    /// [`process_exited`](Self::process_exited).
230    pub fn prompt_started(&mut self) -> Vec<Change> {
231        self.lifetime_event()
232    }
233
234    /// A full reset (RIS): removes every record. A soft reset (DECSTR) must
235    /// not call this.
236    pub fn reset(&mut self) -> Vec<Change> {
237        self.clear(None)
238    }
239
240    fn lifetime_event(&mut self) -> Vec<Change> {
241        let drop_idle = self.drop_idle;
242        self.remove_where(|_, r| r.state.is_transient() || (drop_idle && r.state == State::Idle))
243    }
244
245    fn remove_where(&mut self, mut pred: impl FnMut(&str, &Record) -> bool) -> Vec<Change> {
246        let doomed: Vec<String> = self
247            .records
248            .iter()
249            .filter(|(k, e)| pred(k, &e.record))
250            .map(|(k, _)| k.clone())
251            .collect();
252        doomed
253            .into_iter()
254            .map(|k| {
255                self.records.remove(&k);
256                Change::Removed(id_of(&k))
257            })
258            .collect()
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265    use termstate_core::parse_body;
266
267    fn apply(s: &mut Store, body: &str) -> Vec<Change> {
268        s.apply(&parse_body(body.as_bytes()).unwrap())
269    }
270
271    #[test]
272    fn within_matches_segments_only() {
273        assert!(within("", "a"));
274        assert!(within("a", "a"));
275        assert!(within("a", "a/b"));
276        assert!(!within("a", "ab"));
277        assert!(!within("a/b", "a"));
278    }
279
280    #[test]
281    fn eviction_spares_updates_and_takes_least_recent() {
282        let mut s = Store::with_capacity(0);
283        assert_eq!(s.capacity(), MIN_RECORDS);
284        for i in 0..MIN_RECORDS {
285            apply(&mut s, &alloc::format!("state=idle:id=r{i}"));
286        }
287        // Touch r0 so r1 becomes the least recently updated.
288        assert_eq!(
289            apply(&mut s, "state=done:id=r0"),
290            [Change::Set(Some("r0".into()))]
291        );
292        assert_eq!(
293            apply(&mut s, "state=idle:id=new"),
294            [
295                Change::Evicted(Some("r1".into())),
296                Change::Set(Some("new".into()))
297            ]
298        );
299        assert_eq!(s.len(), MIN_RECORDS);
300    }
301}