Skip to main content

agx_core/
slice.rs

1//! Timeline slicing — parsers + application for `--after` / `--before`
2//! duration filters, `--after-step` / `--before-step` / `--range` index
3//! filters, and the `:@<duration>` TUI jump command.
4//!
5//! Duration grammar (permissive, case-insensitive):
6//!
7//! - `30s` / `30sec`        → 30 seconds
8//! - `5m`  / `5min`         → 5 minutes
9//! - `2h`  / `2hr`          → 2 hours
10//! - `1d`  / `1day`         → 1 day
11//! - `1h30m` / `90m30s`     → concatenated components, summed
12//! - Bare integer           → seconds (e.g. `300` = 5m)
13//!
14//! Range grammar: `start..end` (exclusive end, mirrors Rust's
15//! `Range<usize>`). Open-ended forms: `..500`, `100..`, or just `..`
16//! (no-op). 1-based step numbers internally convert to 0-based so
17//! `--range 1..11` = the first 10 steps regardless of format.
18//!
19//! Time semantics: `--after 2h` / `--before 10m` are relative to the
20//! *session's first step*, not to wall-clock now. This is unambiguous
21//! for archived sessions where "now" is meaningless, and matches the
22//! intuitive read of "give me what happened in the first 10 minutes of
23//! this session". Sessions with no timestamps get a stderr warning and
24//! pass through unfiltered.
25
26use crate::timeline::Step;
27use anyhow::{Result, anyhow};
28
29/// Inclusive start, exclusive end, both 0-based.
30#[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
56/// Parse a duration string like `1h30m`, `45s`, `2h`, `90m30s`, or a
57/// bare integer (seconds). Returns milliseconds.
58pub 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    // Bare integer → seconds.
64    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
119/// Parse `start..end`, `..end`, `start..`, or `..` into a [`StepRange`].
120/// End is always exclusive — `1..11` means the first ten steps. The CLI
121/// surface accepts 1-based numbers; the conversion to 0-based indices
122/// happens at the slice site.
123pub 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
157/// Build a `StepRange` from top-level `--after-step` / `--before-step`
158/// scalars. Either may be `None` for an open bound.
159pub 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
166/// Slice the steps by index range and optional time bounds. Time
167/// bounds are offsets in milliseconds from the session's first step's
168/// timestamp. When no step carries a timestamp, time filters are
169/// silently skipped (and the caller gets a stderr warning from
170/// `warn_if_time_filter_ignored` — kept out here so this function
171/// stays pure).
172pub 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    // Resolve the time-filter anchor once. `None` → time filter is a
180    // no-op for this session (either no timestamps anywhere, or no
181    // `--after` / `--before` in play). Either way the closure below
182    // can treat the time branch as inactive with a single check.
183    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                    // Step has no timestamp in a session that does —
197                    // drop it rather than include anomalously.
198                    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
218/// Print a one-line stderr warning when the user asked for a time
219/// filter but the session has no usable timestamps. Keeps
220/// `slice_steps` itself pure.
221pub 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        // Convention: bare integer without a unit means seconds. Lets
269        // users write `--after 90` for "90 seconds into the session"
270        // without reaching for the suffix.
271        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()); // no number
279        assert!(parse_duration_ms("5x").is_err()); // unknown unit
280        assert!(parse_duration_ms("5.5h").is_err()); // no floats in this grammar
281    }
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)); // exclusive
331    }
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        // Keep steps at 5s ≤ offset < 15s.
363        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        // Time filters present, but no step has a timestamp. Slice
373        // should leave the steps alone.
374        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        // Open ends.
392        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}