1use std::collections::BTreeMap;
2
3use crate::ContextEntry;
4
5#[derive(Debug, Clone)]
7pub struct SessionGroup {
8 pub session_id: String,
10 pub entries: Vec<ContextEntry>,
12}
13
14#[must_use]
20pub fn group_entries_by_session(
21 entries: &[ContextEntry],
22 proximity_threshold_secs: i64,
23) -> Vec<SessionGroup> {
24 assert!(
25 proximity_threshold_secs >= 0,
26 "proximity_threshold_secs must be non-negative"
27 );
28
29 let mut explicit_groups: BTreeMap<String, Vec<ContextEntry>> = BTreeMap::new();
30 let mut fallback_entries: Vec<ContextEntry> = Vec::new();
31
32 for entry in entries {
33 if let Some(session_id) = &entry.session_id {
34 explicit_groups
35 .entry(session_id.clone())
36 .or_default()
37 .push(entry.clone());
38 } else {
39 fallback_entries.push(entry.clone());
40 }
41 }
42
43 let mut groups: Vec<SessionGroup> = explicit_groups
44 .into_iter()
45 .map(|(session_id, entries)| SessionGroup {
46 session_id,
47 entries,
48 })
49 .collect();
50
51 for group in &mut groups {
52 group.entries.sort_by_key(|e| e.timestamp);
53 }
54
55 groups.sort_by_key(|g| g.entries.first().map_or(i64::MAX, |e| e.timestamp));
56
57 fallback_entries.sort_by_key(|entry| entry.timestamp);
58
59 let mut current_group: Vec<ContextEntry> = Vec::new();
60 let mut current_earliest_timestamp: Option<i64> = None;
61 let mut previous_timestamp: Option<i64> = None;
62
63 for entry in fallback_entries {
64 if let Some(prev_ts) = previous_timestamp {
65 let gap = entry.timestamp.saturating_sub(prev_ts);
66 if gap > proximity_threshold_secs {
67 if let Some(earliest) = current_earliest_timestamp {
68 groups.push(SessionGroup {
69 session_id: format!("__fallback__{earliest}"),
70 entries: std::mem::take(&mut current_group),
71 });
72 }
73 current_earliest_timestamp = Some(entry.timestamp);
74 }
75 } else {
76 current_earliest_timestamp = Some(entry.timestamp);
77 }
78
79 previous_timestamp = Some(entry.timestamp);
80 current_group.push(entry);
81 }
82
83 if !current_group.is_empty() {
84 let earliest =
85 current_earliest_timestamp.expect("invariant: timestamp set when group non-empty");
86 groups.push(SessionGroup {
87 session_id: format!("__fallback__{earliest}"),
88 entries: current_group,
89 });
90 }
91
92 groups.sort_by_key(|g| g.entries.first().map_or(i64::MAX, |e| e.timestamp));
93
94 groups
95}
96
97#[cfg(test)]
98mod tests {
99 use super::{group_entries_by_session, SessionGroup};
100 use crate::{kind, ContextEntry};
101
102 fn make_entry(id: &str, timestamp: i64, session_id: Option<&str>) -> ContextEntry {
103 ContextEntry {
104 id: id.to_string(),
105 content: format!("content-{id}"),
106 timestamp,
107 kind: kind::MANUAL.to_owned(),
108 scope: None,
109 session_id: session_id.map(ToString::to_string),
110 token_count: None,
111 metadata: None,
112 }
113 }
114
115 fn assert_group_ids(groups: &[SessionGroup], expected_ids: &[&str]) {
116 let actual_ids: Vec<&str> = groups
117 .iter()
118 .map(|group| group.session_id.as_str())
119 .collect();
120 assert_eq!(actual_ids, expected_ids);
121 }
122
123 #[test]
124 fn empty_input_returns_empty_output() {
125 let groups = group_entries_by_session(&[], 60);
126 assert!(groups.is_empty());
127 }
128
129 #[test]
130 fn single_entry_with_session_id_creates_one_explicit_group() {
131 let entries = vec![make_entry("e1", 100, Some("s1"))];
132
133 let groups = group_entries_by_session(&entries, 60);
134
135 assert_eq!(groups.len(), 1);
136 assert_group_ids(&groups, &["s1"]);
137 assert_eq!(groups[0].entries.len(), 1);
138 assert_eq!(groups[0].entries[0].id, "e1");
139 }
140
141 #[test]
142 fn single_entry_without_session_id_creates_one_fallback_group() {
143 let entries = vec![make_entry("e1", 100, None)];
144
145 let groups = group_entries_by_session(&entries, 60);
146
147 assert_eq!(groups.len(), 1);
148 assert_group_ids(&groups, &["__fallback__100"]);
149 assert_eq!(groups[0].entries.len(), 1);
150 assert_eq!(groups[0].entries[0].id, "e1");
151 }
152
153 #[test]
154 fn same_session_id_entries_are_grouped_together() {
155 let entries = vec![
156 make_entry("e1", 100, Some("s1")),
157 make_entry("e2", 200, Some("s1")),
158 make_entry("e3", 300, Some("s1")),
159 ];
160
161 let groups = group_entries_by_session(&entries, 60);
162
163 assert_eq!(groups.len(), 1);
164 assert_group_ids(&groups, &["s1"]);
165 let ids: Vec<&str> = groups[0]
166 .entries
167 .iter()
168 .map(|entry| entry.id.as_str())
169 .collect();
170 assert_eq!(ids, vec!["e1", "e2", "e3"]);
171 }
172
173 #[test]
174 fn different_session_ids_create_separate_groups() {
175 let entries = vec![
176 make_entry("e1", 100, Some("s1")),
177 make_entry("e2", 200, Some("s2")),
178 make_entry("e3", 300, Some("s1")),
179 ];
180
181 let groups = group_entries_by_session(&entries, 60);
182
183 assert_eq!(groups.len(), 2);
184 assert_group_ids(&groups, &["s1", "s2"]);
185 assert_eq!(groups[0].entries.len(), 2);
186 assert_eq!(groups[1].entries.len(), 1);
187 }
188
189 #[test]
190 fn fallback_entries_within_threshold_stay_in_one_group() {
191 let entries = vec![
192 make_entry("e1", 100, None),
193 make_entry("e2", 150, None),
194 make_entry("e3", 200, None),
195 ];
196
197 let groups = group_entries_by_session(&entries, 60);
198
199 assert_eq!(groups.len(), 1);
200 assert_group_ids(&groups, &["__fallback__100"]);
201 assert_eq!(groups[0].entries.len(), 3);
202 }
203
204 #[test]
205 fn fallback_entries_with_gap_above_threshold_split_groups() {
206 let entries = vec![
207 make_entry("e1", 100, None),
208 make_entry("e2", 150, None),
209 make_entry("e3", 300, None),
210 ];
211
212 let groups = group_entries_by_session(&entries, 60);
213
214 assert_eq!(groups.len(), 2);
215 assert_group_ids(&groups, &["__fallback__100", "__fallback__300"]);
216 assert_eq!(groups[0].entries.len(), 2);
217 assert_eq!(groups[1].entries.len(), 1);
218 }
219
220 #[test]
221 fn mixed_explicit_and_fallback_entries_do_not_mix() {
222 let entries = vec![
223 make_entry("e1", 100, Some("s1")),
224 make_entry("e2", 110, None),
225 make_entry("e3", 120, Some("s1")),
226 make_entry("e4", 130, None),
227 ];
228
229 let groups = group_entries_by_session(&entries, 30);
230
231 assert_eq!(groups.len(), 2);
232 assert_group_ids(&groups, &["s1", "__fallback__110"]);
233 assert_eq!(groups[0].entries.len(), 2);
234 assert_eq!(groups[1].entries.len(), 2);
235 }
236
237 #[test]
238 fn unsorted_fallback_timestamps_are_sorted_before_grouping() {
239 let entries = vec![
240 make_entry("e3", 210, None),
241 make_entry("e1", 100, None),
242 make_entry("e2", 150, None),
243 ];
244
245 let groups = group_entries_by_session(&entries, 60);
246
247 assert_eq!(groups.len(), 1);
248 assert_group_ids(&groups, &["__fallback__100"]);
249
250 let ordered_ids: Vec<&str> = groups[0]
251 .entries
252 .iter()
253 .map(|entry| entry.id.as_str())
254 .collect();
255 assert_eq!(ordered_ids, vec!["e1", "e2", "e3"]);
256 }
257
258 #[test]
259 fn entries_at_exact_threshold_boundary_stay_together() {
260 let entries = vec![
261 make_entry("e1", 100, None),
262 make_entry("e2", 160, None),
263 make_entry("e3", 220, None),
264 ];
265
266 let groups = group_entries_by_session(&entries, 60);
267
268 assert_eq!(groups.len(), 1);
269 assert_group_ids(&groups, &["__fallback__100"]);
270 assert_eq!(groups[0].entries.len(), 3);
271 }
272
273 #[test]
274 fn fallback_groups_interleave_chronologically_with_explicit_groups() {
275 let entries = vec![
276 make_entry("f1", 50, None),
277 make_entry("e1", 200, Some("s1")),
278 make_entry("f2", 60, None),
279 ];
280
281 let groups = group_entries_by_session(&entries, 60);
282
283 assert_eq!(groups.len(), 2);
284 assert_eq!(groups[0].session_id, "__fallback__50");
285 assert_eq!(groups[1].session_id, "s1");
286 }
287
288 #[test]
289 fn explicit_group_entries_are_sorted_by_timestamp_regression() {
290 let entries = vec![
291 make_entry("e3", 300, Some("s1")),
292 make_entry("e2", 200, Some("s1")),
293 make_entry("e1", 100, Some("s1")),
294 ];
295
296 let groups = group_entries_by_session(&entries, 60);
297
298 assert_eq!(groups.len(), 1);
299 assert_group_ids(&groups, &["s1"]);
300 let ids: Vec<&str> = groups[0]
301 .entries
302 .iter()
303 .map(|entry| entry.id.as_str())
304 .collect();
305 assert_eq!(ids, vec!["e1", "e2", "e3"]);
306 }
307
308 #[test]
309 fn explicit_groups_are_sorted_by_earliest_timestamp() {
310 let entries = vec![
311 make_entry("a1", 200, Some("z")),
312 make_entry("b1", 100, Some("a")),
313 ];
314
315 let groups = group_entries_by_session(&entries, 60);
316
317 assert_eq!(groups.len(), 2);
318 assert_group_ids(&groups, &["a", "z"]);
319 }
320
321 #[test]
322 fn threshold_zero_splits_distinct_timestamps() {
323 let entries = vec![
324 make_entry("e1", 100, None),
325 make_entry("e2", 101, None),
326 make_entry("e3", 102, None),
327 ];
328
329 let groups = group_entries_by_session(&entries, 0);
330
331 assert_eq!(groups.len(), 3);
332 assert_group_ids(
333 &groups,
334 &["__fallback__100", "__fallback__101", "__fallback__102"],
335 );
336 assert_eq!(groups[0].entries.len(), 1);
337 assert_eq!(groups[1].entries.len(), 1);
338 assert_eq!(groups[2].entries.len(), 1);
339 }
340
341 #[test]
342 fn duplicate_timestamps_with_zero_threshold_stay_in_same_group() {
343 let entries = vec![
344 make_entry("e1", 100, None),
345 make_entry("e2", 100, None),
346 make_entry("e3", 101, None),
347 ];
348
349 let groups = group_entries_by_session(&entries, 0);
350
351 assert_eq!(groups.len(), 2);
352 assert_group_ids(&groups, &["__fallback__100", "__fallback__101"]);
353
354 let first_ids: Vec<&str> = groups[0]
355 .entries
356 .iter()
357 .map(|entry| entry.id.as_str())
358 .collect();
359 assert_eq!(first_ids, vec!["e1", "e2"]);
360 assert_eq!(groups[1].entries.len(), 1);
361 }
362}