headless_lms_mcp/engine/read/
window.rs1use 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
11pub(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
35pub(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
68pub(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
83pub(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
95fn 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
113pub(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 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
188pub(crate) trait HasId {
190 fn id(&self) -> Uuid;
191}