1use crate::timeline::Step;
27use anyhow::{Result, anyhow};
28
29#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
31pub struct StepRange {
32 pub start: Option<usize>,
33 pub end: Option<usize>,
34}
35
36impl StepRange {
37 pub fn is_identity(&self) -> bool {
38 self.start.is_none() && self.end.is_none()
39 }
40
41 fn contains(&self, idx: usize) -> bool {
42 if let Some(s) = self.start
43 && idx < s
44 {
45 return false;
46 }
47 if let Some(e) = self.end
48 && idx >= e
49 {
50 return false;
51 }
52 true
53 }
54}
55
56pub fn parse_duration_ms(raw: &str) -> Result<u64> {
59 let s = raw.trim().to_ascii_lowercase();
60 if s.is_empty() {
61 return Err(anyhow!("empty duration"));
62 }
63 if let Ok(n) = s.parse::<u64>() {
65 return Ok(n * 1_000);
66 }
67 let mut total_ms: u64 = 0;
68 let mut num_buf = String::new();
69 let mut unit_buf = String::new();
70 for ch in s.chars() {
71 if ch.is_ascii_digit() {
72 if !unit_buf.is_empty() {
73 total_ms = total_ms
74 .checked_add(commit_component(&num_buf, &unit_buf)?)
75 .ok_or_else(|| anyhow!("duration overflowed u64"))?;
76 num_buf.clear();
77 unit_buf.clear();
78 }
79 num_buf.push(ch);
80 } else if ch.is_ascii_alphabetic() {
81 unit_buf.push(ch);
82 } else {
83 return Err(anyhow!("unexpected character `{ch}` in duration `{raw}`"));
84 }
85 }
86 if num_buf.is_empty() {
87 return Err(anyhow!("duration `{raw}` has no number"));
88 }
89 if unit_buf.is_empty() {
90 return Err(anyhow!(
91 "duration `{raw}` has no unit suffix (try `{num_buf}s`)"
92 ));
93 }
94 total_ms = total_ms
95 .checked_add(commit_component(&num_buf, &unit_buf)?)
96 .ok_or_else(|| anyhow!("duration overflowed u64"))?;
97 Ok(total_ms)
98}
99
100fn commit_component(num: &str, unit: &str) -> Result<u64> {
101 let n: u64 = num
102 .parse()
103 .map_err(|_| anyhow!("invalid number `{num}` in duration"))?;
104 let multiplier_ms: u64 = match unit {
105 "s" | "sec" | "secs" | "second" | "seconds" => 1_000,
106 "m" | "min" | "mins" | "minute" | "minutes" => 60 * 1_000,
107 "h" | "hr" | "hrs" | "hour" | "hours" => 60 * 60 * 1_000,
108 "d" | "day" | "days" => 24 * 60 * 60 * 1_000,
109 other => {
110 return Err(anyhow!(
111 "unknown duration unit `{other}` (use s / m / h / d)"
112 ));
113 }
114 };
115 n.checked_mul(multiplier_ms)
116 .ok_or_else(|| anyhow!("duration component `{num}{unit}` overflowed"))
117}
118
119pub fn parse_step_range(raw: &str) -> Result<StepRange> {
124 let s = raw.trim();
125 let Some((left, right)) = s.split_once("..") else {
126 return Err(anyhow!(
127 "range `{raw}` must contain `..` (e.g. `100..500`, `..500`, `100..`)"
128 ));
129 };
130 let start = if left.trim().is_empty() {
131 None
132 } else {
133 Some(
134 left.trim()
135 .parse::<usize>()
136 .map_err(|_| anyhow!("range start `{left}` is not a number"))?,
137 )
138 };
139 let end = if right.trim().is_empty() {
140 None
141 } else {
142 Some(
143 right
144 .trim()
145 .parse::<usize>()
146 .map_err(|_| anyhow!("range end `{right}` is not a number"))?,
147 )
148 };
149 if let (Some(s), Some(e)) = (start, end)
150 && s > e
151 {
152 return Err(anyhow!("range `{raw}` has start > end ({s} > {e})"));
153 }
154 Ok(StepRange { start, end })
155}
156
157pub fn step_range_from_bounds(after_step: Option<usize>, before_step: Option<usize>) -> StepRange {
160 StepRange {
161 start: after_step,
162 end: before_step,
163 }
164}
165
166pub fn slice_steps(
173 steps: Vec<Step>,
174 range: &StepRange,
175 after_ms: Option<u64>,
176 before_ms: Option<u64>,
177) -> Vec<Step> {
178 let has_time_filter = after_ms.is_some() || before_ms.is_some();
179 let time_anchor = has_time_filter
184 .then(|| steps.iter().find_map(|s| s.timestamp_ms))
185 .flatten();
186
187 steps
188 .into_iter()
189 .enumerate()
190 .filter(|(idx, step)| {
191 if !range.contains(*idx) {
192 return false;
193 }
194 if let Some(start) = time_anchor {
195 let Some(ts) = step.timestamp_ms else {
196 return false;
199 };
200 let offset = ts.saturating_sub(start);
201 if let Some(a) = after_ms
202 && offset < a
203 {
204 return false;
205 }
206 if let Some(b) = before_ms
207 && offset >= b
208 {
209 return false;
210 }
211 }
212 true
213 })
214 .map(|(_, step)| step)
215 .collect()
216}
217
218pub fn warn_if_time_filter_ignored(steps: &[Step], after_ms: Option<u64>, before_ms: Option<u64>) {
222 let requested = after_ms.is_some() || before_ms.is_some();
223 let has_ts = steps.iter().any(|s| s.timestamp_ms.is_some());
224 if requested && !has_ts {
225 eprintln!(
226 "agx: --after / --before requested but session has no step timestamps; skipping time filter"
227 );
228 }
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234 use crate::timeline::{assistant_text_step, user_text_step};
235
236 #[test]
237 fn parse_duration_basic_units() {
238 assert_eq!(parse_duration_ms("30s").unwrap(), 30_000);
239 assert_eq!(parse_duration_ms("5m").unwrap(), 5 * 60 * 1_000);
240 assert_eq!(parse_duration_ms("2h").unwrap(), 2 * 60 * 60 * 1_000);
241 assert_eq!(parse_duration_ms("1d").unwrap(), 24 * 60 * 60 * 1_000);
242 }
243
244 #[test]
245 fn parse_duration_long_unit_names() {
246 assert_eq!(parse_duration_ms("30sec").unwrap(), 30_000);
247 assert_eq!(parse_duration_ms("5minutes").unwrap(), 5 * 60 * 1_000);
248 assert_eq!(parse_duration_ms("2hours").unwrap(), 2 * 60 * 60 * 1_000);
249 }
250
251 #[test]
252 fn parse_duration_compound() {
253 assert_eq!(parse_duration_ms("1h30m").unwrap(), (60 + 30) * 60 * 1_000);
254 assert_eq!(
255 parse_duration_ms("2h15m30s").unwrap(),
256 (2 * 60 * 60 + 15 * 60 + 30) * 1_000
257 );
258 }
259
260 #[test]
261 fn parse_duration_case_insensitive() {
262 assert_eq!(parse_duration_ms("2H").unwrap(), 2 * 60 * 60 * 1_000);
263 assert_eq!(parse_duration_ms("5MIN").unwrap(), 5 * 60 * 1_000);
264 }
265
266 #[test]
267 fn parse_duration_bare_integer_is_seconds() {
268 assert_eq!(parse_duration_ms("90").unwrap(), 90_000);
272 }
273
274 #[test]
275 fn parse_duration_rejects_empty_and_malformed() {
276 assert!(parse_duration_ms("").is_err());
277 assert!(parse_duration_ms(" ").is_err());
278 assert!(parse_duration_ms("h").is_err()); assert!(parse_duration_ms("5x").is_err()); assert!(parse_duration_ms("5.5h").is_err()); }
282
283 #[test]
284 fn parse_step_range_closed() {
285 let r = parse_step_range("100..500").unwrap();
286 assert_eq!(r.start, Some(100));
287 assert_eq!(r.end, Some(500));
288 }
289
290 #[test]
291 fn parse_step_range_open_start() {
292 let r = parse_step_range("..500").unwrap();
293 assert_eq!(r.start, None);
294 assert_eq!(r.end, Some(500));
295 }
296
297 #[test]
298 fn parse_step_range_open_end() {
299 let r = parse_step_range("100..").unwrap();
300 assert_eq!(r.start, Some(100));
301 assert_eq!(r.end, None);
302 }
303
304 #[test]
305 fn parse_step_range_empty_is_identity() {
306 let r = parse_step_range("..").unwrap();
307 assert!(r.is_identity());
308 }
309
310 #[test]
311 fn parse_step_range_rejects_reversed() {
312 assert!(parse_step_range("500..100").is_err());
313 }
314
315 #[test]
316 fn parse_step_range_rejects_non_range() {
317 assert!(parse_step_range("not a range").is_err());
318 assert!(parse_step_range("100").is_err());
319 }
320
321 #[test]
322 fn step_range_contains_respects_exclusive_end() {
323 let r = StepRange {
324 start: Some(2),
325 end: Some(5),
326 };
327 assert!(!r.contains(1));
328 assert!(r.contains(2));
329 assert!(r.contains(4));
330 assert!(!r.contains(5)); }
332
333 #[test]
334 fn slice_steps_by_index_range() {
335 let steps: Vec<_> = (0..10).map(|i| user_text_step(&format!("s{i}"))).collect();
336 let sliced = slice_steps(
337 steps,
338 &StepRange {
339 start: Some(2),
340 end: Some(5),
341 },
342 None,
343 None,
344 );
345 assert_eq!(sliced.len(), 3);
346 assert!(sliced[0].detail.contains("s2"));
347 assert!(sliced[2].detail.contains("s4"));
348 }
349
350 #[test]
351 fn slice_steps_by_time_offset() {
352 let mut steps = vec![
353 user_text_step("t0"),
354 assistant_text_step("t5"),
355 assistant_text_step("t10"),
356 assistant_text_step("t20"),
357 ];
358 steps[0].timestamp_ms = Some(1_000_000);
359 steps[1].timestamp_ms = Some(1_000_000 + 5_000);
360 steps[2].timestamp_ms = Some(1_000_000 + 10_000);
361 steps[3].timestamp_ms = Some(1_000_000 + 20_000);
362 let sliced = slice_steps(steps, &StepRange::default(), Some(5_000), Some(15_000));
364 assert_eq!(sliced.len(), 2);
365 assert!(sliced[0].detail.contains("t5"));
366 assert!(sliced[1].detail.contains("t10"));
367 }
368
369 #[test]
370 fn slice_steps_time_filter_no_op_without_timestamps() {
371 let steps = vec![user_text_step("a"), user_text_step("b")];
372 let sliced = slice_steps(steps, &StepRange::default(), Some(1_000), Some(10_000));
375 assert_eq!(sliced.len(), 2);
376 }
377
378 #[test]
379 fn slice_steps_identity_when_no_filters() {
380 let steps: Vec<_> = (0..5).map(|i| user_text_step(&format!("s{i}"))).collect();
381 let before = steps.len();
382 let sliced = slice_steps(steps, &StepRange::default(), None, None);
383 assert_eq!(sliced.len(), before);
384 }
385
386 #[test]
387 fn step_range_from_bounds_combines_after_and_before() {
388 let r = step_range_from_bounds(Some(10), Some(50));
389 assert_eq!(r.start, Some(10));
390 assert_eq!(r.end, Some(50));
391 let r = step_range_from_bounds(None, Some(50));
393 assert_eq!(r.start, None);
394 assert_eq!(r.end, Some(50));
395 let r = step_range_from_bounds(Some(10), None);
396 assert_eq!(r.start, Some(10));
397 assert_eq!(r.end, None);
398 }
399}