Skip to main content

headless_lms_mcp/engine/read/
window.rs

1//! Windows over lists of units that fill a byte budget, shared by the page and structure reads.
2
3use serde_json::{Value, json};
4use uuid::Uuid;
5
6use super::{ReadOptions, ReadResult, Selector, StopReason};
7use crate::engine::error::{EditError, EditErrorCode};
8use crate::engine::path::parse_uuid;
9use crate::text::serialized_len;
10
11/// A window over a whole list, which `offset` and `limit` select. `noun` names what a path that
12/// `through` and `around` start from points at, e.g. "a block".
13pub(crate) fn list_read<T: HasId>(
14    selector: &Selector,
15    items: &[T],
16    parent: Option<String>,
17    options: &ReadOptions,
18    render: &dyn Fn(&T) -> Value,
19    noun: &str,
20) -> Result<ReadResult, EditError> {
21    if selector.through.is_some() || selector.around.is_some() {
22        return Err(EditError::new(
23            EditErrorCode::InvalidValue,
24            format!("through and around start from {noun} path, not a list"),
25        ));
26    }
27    let start = selector.offset.unwrap_or(0) as usize;
28    let end = selector
29        .limit
30        .map(|l| start + l as usize)
31        .unwrap_or(items.len());
32    Ok(window(selector, items, start, end, parent, options, render))
33}
34
35/// The `[start, end)` window `through`, `around` or `limit` select from the siblings around the
36/// one at `index`; `None` when the selector reads that sibling alone.
37pub(crate) fn sibling_window(
38    selector: &Selector,
39    index: usize,
40    sibling_ids: &[Uuid],
41) -> Result<Option<(usize, usize)>, EditError> {
42    if let Some(through) = &selector.through {
43        let last = parse_uuid(through.trim_start_matches('/'), through)?;
44        let last_index = sibling_ids
45            .iter()
46            .position(|id| *id == last)
47            .filter(|last_index| *last_index >= index);
48        return match last_index {
49            Some(last_index) => Ok(Some((index, last_index + 1))),
50            None => Err(EditError::new(
51                EditErrorCode::InvalidRange,
52                "through must be a later sibling of path",
53            )
54            .with_details(json!({
55                "siblings_in_order": sibling_ids.iter().map(|id| format!("/{id}")).collect::<Vec<_>>()
56            }))),
57        };
58    }
59    if let Some(around) = selector.around {
60        return Ok(Some((
61            index.saturating_sub(around as usize),
62            (index + around as usize + 1).min(sibling_ids.len()),
63        )));
64    }
65    Ok(selector.limit.map(|limit| (index, index + limit as usize)))
66}
67
68/// Errors with `InvalidValue` when `selector` has window fields, for reads of `what` that have none.
69pub(crate) fn reject_window(selector: &Selector, what: &str) -> Result<(), EditError> {
70    if selector.through.is_some()
71        || selector.around.is_some()
72        || selector.offset.is_some()
73        || selector.limit.is_some()
74    {
75        return Err(EditError::new(
76            EditErrorCode::InvalidValue,
77            format!("through, around, offset and limit don't apply to {what}"),
78        ));
79    }
80    Ok(())
81}
82
83/// A single requested unit; over budget, its children collapse so the agent can descend.
84pub(crate) fn single_unit(
85    selector: &Selector,
86    mut unit: Value,
87    options: &ReadOptions,
88) -> ReadResult {
89    if serialized_len(&unit) > options.budget_bytes {
90        collapse_children(&mut unit);
91    }
92    ReadResult::single(selector, vec![unit])
93}
94
95/// Replaces every child list of `unit` (`innerBlocks`, `modules`, `chapters`, `pages`) with a
96/// count and the path to read it from.
97fn collapse_children(unit: &mut Value) {
98    if let Some(object) = unit.as_object_mut() {
99        let path = object
100            .get("path")
101            .and_then(Value::as_str)
102            .unwrap_or_default()
103            .to_string();
104        for key in ["innerBlocks", "modules", "chapters", "pages"] {
105            if let Some(Value::Array(children)) = object.get_mut(key) {
106                let count = children.len();
107                *children = vec![json!({"collapsed": count, "path": format!("{path}/{key}")})];
108            }
109        }
110    }
111}
112
113/// Fills a window `[start, end)` of `list` with whole units until the budget runs out.
114pub(crate) fn window<T>(
115    selector: &Selector,
116    list: &[T],
117    start: usize,
118    end: usize,
119    parent: Option<String>,
120    options: &ReadOptions,
121    render: &dyn Fn(&T) -> Value,
122) -> ReadResult
123where
124    T: HasId,
125{
126    let end = end.min(list.len());
127    let mut items = Vec::new();
128    let mut used = 0;
129    let mut stopped_by = None;
130    let mut next_index = None;
131    for (index, item) in list.iter().enumerate().take(end).skip(start) {
132        let mut unit = render(item);
133        let mut size = serialized_len(&unit);
134        if items.is_empty() && size > options.budget_bytes {
135            collapse_children(&mut unit);
136            size = serialized_len(&unit);
137        }
138        if !items.is_empty() && used + size > options.budget_bytes {
139            stopped_by = Some(StopReason::Budget);
140            next_index = Some(index);
141            break;
142        }
143        used += size;
144        items.push(unit);
145    }
146    if stopped_by.is_none()
147        && end < list.len()
148        && selector.through.is_none()
149        && selector.around.is_none()
150    {
151        stopped_by = Some(StopReason::Limit);
152        next_index = Some(end);
153    }
154    let next = next_index.map(|index| {
155        let path = Some(format!("/{}", list[index].id()));
156        // A budget stop continues the same window; a limit stop starts the next one.
157        let (through, limit) = match (stopped_by, &selector.through, selector.limit) {
158            (Some(StopReason::Budget), Some(through), _) => (Some(through.clone()), None),
159            (Some(StopReason::Budget), None, Some(limit)) => {
160                (None, Some(limit.saturating_sub(items.len() as u32).max(1)))
161            }
162            (Some(StopReason::Budget), None, None) => {
163                (Some(format!("/{}", list[end - 1].id())), None)
164            }
165            _ => (None, selector.limit),
166        };
167        Selector {
168            path,
169            through,
170            limit,
171            mode: selector.mode,
172            state: selector.state,
173            ..Selector::default()
174        }
175    });
176    ReadResult {
177        selector: selector.clone(),
178        total: Some(list.len()),
179        parent,
180        items,
181        value: None,
182        has_more: next.is_some(),
183        next,
184        stopped_by,
185    }
186}
187
188/// An item with an id that selectors can anchor on.
189pub(crate) trait HasId {
190    fn id(&self) -> Uuid;
191}