Skip to main content

lean_ctx/core/context_kernel/
stream_controller.rs

1//! Delta tracking for append-oriented content streams.
2
3use std::collections::HashMap;
4use std::hash::{Hash, Hasher};
5use std::time::{Duration, Instant};
6
7const PREFIX_LINES: usize = 10;
8
9/// Identifies a tracked append stream.
10#[derive(Debug, Clone, PartialEq, Eq, Hash)]
11pub struct StreamRef {
12    pub source_id: String,
13    pub stream_type: StreamType,
14}
15
16/// Classifies an append stream by its source.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
18pub enum StreamType {
19    Terminal,
20    BuildLog,
21    FileWatch,
22    Custom,
23}
24
25/// Tracks cursors and identity data for one stream generation.
26#[derive(Debug, Clone)]
27pub struct StreamState {
28    pub generation: u64,
29    pub line_cursor: usize,
30    pub byte_cursor: usize,
31    pub prefix_hash: u64,
32    pub last_seen: Instant,
33    pub total_lines: usize,
34}
35
36/// Minimal update needed to synchronize a stream consumer.
37#[derive(Debug, Clone)]
38pub enum StreamDelta {
39    /// Content unchanged since last check — deliver nothing.
40    Unchanged,
41    /// New lines appended — deliver only the new portion.
42    Append {
43        new_lines: Vec<String>,
44        from_line: usize,
45    },
46    /// Content rotated or replaced — deliver a full snapshot.
47    Rotation {
48        full_content: Vec<String>,
49        reason: String,
50    },
51    /// Stream expired — client should discard cached state.
52    Expired,
53}
54
55/// Tracks append streams and computes minimal synchronization deltas.
56pub struct StreamController {
57    streams: HashMap<StreamRef, StreamState>,
58    max_tracked: usize,
59    expiry: Duration,
60}
61
62impl StreamController {
63    /// Creates a controller with bounded tracking and expiry in seconds.
64    pub fn new(max_tracked: usize, expiry_secs: u64) -> Self {
65        Self {
66            streams: HashMap::new(),
67            max_tracked,
68            expiry: Duration::from_secs(expiry_secs),
69        }
70    }
71
72    /// Compares current content with tracked state and returns its minimal delta.
73    pub fn compute_delta(
74        &mut self,
75        stream_ref: &StreamRef,
76        current_content: &[String],
77    ) -> StreamDelta {
78        if current_content.is_empty() {
79            return StreamDelta::Unchanged;
80        }
81
82        let now = Instant::now();
83        let Some(state) = self.streams.get_mut(stream_ref) else {
84            let state = make_state(1, current_content, now);
85            self.ensure_capacity();
86            if self.max_tracked > 0 {
87                self.streams.insert(stream_ref.clone(), state);
88            }
89            return rotation(current_content, "first_seen");
90        };
91
92        state.last_seen = now;
93        if current_content.len() < state.total_lines {
94            replace_state(state, current_content, now);
95            return rotation(current_content, "truncated");
96        }
97
98        let previous_prefix_lines = PREFIX_LINES.min(state.total_lines);
99        let current_prefix_hash = compute_prefix_hash(current_content, previous_prefix_lines);
100        if current_prefix_hash != state.prefix_hash {
101            replace_state(state, current_content, now);
102            return rotation(current_content, "prefix_changed");
103        }
104
105        match current_content.len().cmp(&state.total_lines) {
106            std::cmp::Ordering::Equal => StreamDelta::Unchanged,
107            std::cmp::Ordering::Greater => {
108                let from_line = state.line_cursor;
109                let new_lines = current_content[from_line..].to_vec();
110                update_cursors(state, current_content);
111                StreamDelta::Append {
112                    new_lines,
113                    from_line,
114                }
115            }
116            std::cmp::Ordering::Less => {
117                replace_state(state, current_content, now);
118                rotation(current_content, "truncated")
119            }
120        }
121    }
122
123    /// Removes expired streams and returns the number removed.
124    pub fn gc(&mut self) -> usize {
125        let before = self.streams.len();
126        let expiry = self.expiry;
127        self.streams
128            .retain(|_, state| state.last_seen.elapsed() < expiry);
129        before - self.streams.len()
130    }
131
132    /// Returns the number of actively tracked streams.
133    pub fn tracked_count(&self) -> usize {
134        self.streams.len()
135    }
136
137    fn ensure_capacity(&mut self) {
138        if self.max_tracked == 0 || self.streams.len() < self.max_tracked {
139            return;
140        }
141        if let Some(oldest) = self
142            .streams
143            .iter()
144            .min_by_key(|(_, state)| state.last_seen)
145            .map(|(stream_ref, _)| stream_ref.clone())
146        {
147            self.streams.remove(&oldest);
148        }
149    }
150}
151
152fn compute_prefix_hash(lines: &[String], max_lines: usize) -> u64 {
153    let mut hasher = std::collections::hash_map::DefaultHasher::new();
154    for line in lines.iter().take(max_lines) {
155        line.hash(&mut hasher);
156    }
157    hasher.finish()
158}
159
160fn content_bytes(lines: &[String]) -> usize {
161    lines.iter().map(String::len).sum()
162}
163
164fn make_state(generation: u64, content: &[String], last_seen: Instant) -> StreamState {
165    StreamState {
166        generation,
167        line_cursor: content.len(),
168        byte_cursor: content_bytes(content),
169        prefix_hash: compute_prefix_hash(content, PREFIX_LINES),
170        last_seen,
171        total_lines: content.len(),
172    }
173}
174
175fn replace_state(state: &mut StreamState, content: &[String], last_seen: Instant) {
176    *state = make_state(state.generation.saturating_add(1), content, last_seen);
177}
178
179fn update_cursors(state: &mut StreamState, content: &[String]) {
180    state.line_cursor = content.len();
181    state.byte_cursor = content_bytes(content);
182    state.prefix_hash = compute_prefix_hash(content, PREFIX_LINES);
183    state.total_lines = content.len();
184}
185
186fn rotation(content: &[String], reason: &str) -> StreamDelta {
187    StreamDelta::Rotation {
188        full_content: content.to_vec(),
189        reason: reason.to_owned(),
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    fn stream_ref() -> StreamRef {
198        StreamRef {
199            source_id: "test-stream".into(),
200            stream_type: StreamType::Terminal,
201        }
202    }
203
204    fn lines(values: &[&str]) -> Vec<String> {
205        values.iter().map(|value| (*value).into()).collect()
206    }
207
208    fn controller_with(content: &[&str]) -> StreamController {
209        let mut controller = StreamController::new(8, 60);
210        controller.compute_delta(&stream_ref(), &lines(content));
211        controller
212    }
213
214    fn rotation(delta: StreamDelta) -> (Vec<String>, String) {
215        match delta {
216            StreamDelta::Rotation {
217                full_content,
218                reason,
219            } => (full_content, reason),
220            other => panic!("expected rotation, got {other:?}"),
221        }
222    }
223
224    #[test]
225    fn test_first_seen_returns_rotation() {
226        let content = lines(&["one", "two"]);
227        let mut controller = StreamController::new(8, 60);
228        let delta = controller.compute_delta(&stream_ref(), &content);
229        assert_eq!(rotation(delta), (content, "first_seen".into()));
230    }
231
232    #[test]
233    fn test_unchanged_content_returns_unchanged() {
234        let mut controller = controller_with(&["one", "two"]);
235        let delta = controller.compute_delta(&stream_ref(), &lines(&["one", "two"]));
236        assert!(matches!(delta, StreamDelta::Unchanged));
237    }
238
239    #[test]
240    fn test_append_detection() {
241        let mut controller = controller_with(&["one", "two"]);
242        let delta = controller.compute_delta(&stream_ref(), &lines(&["one", "two", "three"]));
243        match delta {
244            StreamDelta::Append {
245                new_lines,
246                from_line,
247            } => assert_eq!((new_lines, from_line), (lines(&["three"]), 2)),
248            other => panic!("expected append, got {other:?}"),
249        }
250    }
251
252    #[test]
253    fn test_prefix_change_returns_rotation() {
254        let mut controller = controller_with(&["one", "two"]);
255        let delta = controller.compute_delta(&stream_ref(), &lines(&["changed", "two"]));
256        assert_eq!(rotation(delta).1, "prefix_changed");
257    }
258
259    #[test]
260    fn test_truncation_returns_rotation() {
261        let mut controller = controller_with(&["one", "two", "three"]);
262        let delta = controller.compute_delta(&stream_ref(), &lines(&["one", "two"]));
263        assert_eq!(rotation(delta).1, "truncated");
264    }
265
266    #[test]
267    fn test_gc_removes_expired_streams() {
268        let mut controller = StreamController::new(8, 0);
269        controller.compute_delta(&stream_ref(), &lines(&["one"]));
270        assert_eq!((controller.gc(), controller.tracked_count()), (1, 0));
271    }
272
273    #[test]
274    fn test_empty_content_unchanged() {
275        let mut controller = StreamController::new(8, 60);
276        let delta = controller.compute_delta(&stream_ref(), &[]);
277        assert!(matches!(delta, StreamDelta::Unchanged));
278        assert_eq!(controller.tracked_count(), 0);
279    }
280}