headless_lms_mcp/handlers/exercise/
paths.rs1use headless_lms_models::library::agent_changesets::course_state::CourseState;
5use serde_json::{Value, json};
6use uuid::Uuid;
7
8use super::REVIEW_INSTRUCTIONS;
9use crate::engine::{EditError, EditErrorCode};
10use crate::instruction_lists::{self, InstructionsList};
11use crate::tools::ToolError;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub(super) enum ExercisePart {
16 Task(Uuid),
17 ReviewInstructions,
18}
19
20pub(super) fn split_exercise_path(path: &str) -> Result<(ExercisePart, String), EditError> {
23 let invalid = || {
24 EditError::new(
25 EditErrorCode::InvalidPath,
26 format!("{path:?} isn't in the exercise's instructions"),
27 )
28 .with_fix("paths start at /tasks/<task id> or /review_instructions, e.g. /tasks/<task id>/<block id>/attributes/content")
29 };
30 let rest = path.strip_prefix('/').ok_or_else(invalid)?;
31 let (head, rest) = rest.split_once('/').unwrap_or((rest, ""));
32 let (part, rest) = match head {
33 "tasks" => {
34 let (task, rest) = rest.split_once('/').unwrap_or((rest, ""));
35 let task_id = Uuid::parse_str(task).map_err(|_| invalid())?;
36 (ExercisePart::Task(task_id), rest)
37 }
38 REVIEW_INSTRUCTIONS => (ExercisePart::ReviewInstructions, rest),
39 _ => return Err(invalid()),
40 };
41 let rest = if rest.is_empty() {
42 String::new()
43 } else {
44 format!("/{rest}")
45 };
46 Ok((part, rest))
47}
48
49pub(super) fn part_prefix(part: ExercisePart) -> String {
51 match part {
52 ExercisePart::Task(task_id) => format!("/tasks/{task_id}"),
53 ExercisePart::ReviewInstructions => format!("/{REVIEW_INSTRUCTIONS}"),
54 }
55}
56
57pub(super) fn part_list(
59 state: &CourseState,
60 exercise_id: Uuid,
61 part: ExercisePart,
62) -> Result<InstructionsList, EditError> {
63 let exercise = state.exercise(exercise_id).ok_or_else(|| {
64 EditError::new(
65 EditErrorCode::NotFound,
66 format!("exercise {exercise_id} doesn't exist in this state"),
67 )
68 })?;
69 match part {
70 ExercisePart::Task(task_id) => {
71 if exercise.tasks.iter().any(|task| task.id == task_id) {
72 Ok(InstructionsList::Task {
73 exercise_id,
74 task_id,
75 })
76 } else {
77 Err(EditError::new(
78 EditErrorCode::NotFound,
79 format!("exercise {exercise_id} has no task {task_id}"),
80 )
81 .with_details(json!({"tasks": exercise.tasks.iter().map(|t| t.id).collect::<Vec<_>>()})))
82 }
83 }
84 ExercisePart::ReviewInstructions => instruction_lists::review_list(state, exercise).ok_or_else(|| {
85 EditError::new(
86 EditErrorCode::NotFound,
87 format!("exercise {exercise_id} has no peer or self reviews, so it shows no review instructions"),
88 )
89 }),
90 }
91}
92
93pub(super) fn prefix_paths(value: &mut Value, prefix: &str) {
95 match value {
96 Value::Object(map) => {
97 for (key, child) in map.iter_mut() {
98 match child {
99 Value::String(path) if key == "path" || key == "through" || key == "parent" => {
100 *path = format!("{prefix}{path}");
101 }
102 _ => prefix_paths(child, prefix),
103 }
104 }
105 }
106 Value::Array(items) => items.iter_mut().for_each(|item| prefix_paths(item, prefix)),
107 _ => {}
108 }
109}
110
111#[derive(Debug)]
113pub(super) struct PartOps {
114 pub(super) part: ExercisePart,
115 pub(super) indexes: Vec<usize>,
116 pub(super) raw: Vec<Value>,
117}
118
119pub(super) fn group_ops(raw_ops: &[Value]) -> Result<Vec<PartOps>, EditError> {
121 let mut groups: Vec<PartOps> = Vec::new();
122 for (index, raw) in raw_ops.iter().enumerate() {
123 let mut op = raw.clone();
124 let Some(object) = op.as_object_mut() else {
125 return Err(EditError::new(
126 EditErrorCode::InvalidOp,
127 "each op must be an object like {\"op\":\"remove\",\"path\":\"/tasks/<task id>/<block id>\"}",
128 )
129 .at_op(index));
130 };
131 if object.contains_key("to_page") {
132 return Err(EditError::new(
133 EditErrorCode::FieldNotAllowed,
134 "to_page isn't available in exercise instructions",
135 )
136 .at_op(index));
137 }
138 let mut part = None;
139 for key in ["path", "from", "through"] {
140 let Some(Value::String(path)) = object.get(key) else {
141 continue;
142 };
143 let (path_part, rest) = split_exercise_path(path).map_err(|e| e.at_op(index))?;
144 if part.is_some_and(|part| part != path_part) {
145 return Err(EditError::new(
146 EditErrorCode::InvalidPath,
147 "an op works within one task's or the review instructions; it can't move or copy between them",
148 )
149 .at_op(index));
150 }
151 part = Some(path_part);
152 object.insert(key.to_string(), Value::String(rest));
153 }
154 let part = part.ok_or_else(|| {
155 EditError::new(EditErrorCode::MissingField, "the op has no path").at_op(index)
156 })?;
157 match groups.iter_mut().find(|group| group.part == part) {
158 Some(group) => {
159 group.indexes.push(index);
160 group.raw.push(op);
161 }
162 None => groups.push(PartOps {
163 part,
164 indexes: vec![index],
165 raw: vec![op],
166 }),
167 }
168 }
169 Ok(groups)
170}
171
172pub(super) fn call_index(indexes: &[usize], op: usize) -> Result<usize, ToolError> {
174 indexes.get(op).copied().ok_or_else(|| {
175 ToolError::Internal(format!(
176 "op {op} of a group of {} has no index in the call",
177 indexes.len()
178 ))
179 })
180}
181
182pub(super) fn in_call(mut error: EditError, indexes: &[usize]) -> EditError {
184 error.op = error.op.and_then(|op| indexes.get(op).copied());
185 error
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191 use crate::test_fixtures::id;
192
193 #[test]
194 fn exercise_paths_split_into_part_and_block_path() {
195 let task = id("task");
196 assert_eq!(
197 split_exercise_path(&format!("/tasks/{task}")).unwrap(),
198 (ExercisePart::Task(task), String::new())
199 );
200 assert_eq!(
201 split_exercise_path(&format!("/tasks/{task}/{}/attributes/content", id("p1"))).unwrap(),
202 (
203 ExercisePart::Task(task),
204 format!("/{}/attributes/content", id("p1"))
205 )
206 );
207 assert_eq!(
208 split_exercise_path("/review_instructions").unwrap(),
209 (ExercisePart::ReviewInstructions, String::new())
210 );
211 for invalid in ["", "/tasks", "/tasks/x", "/name", "tasks/1"] {
212 assert!(split_exercise_path(invalid).is_err(), "{invalid}");
213 }
214 }
215
216 #[test]
217 fn ops_group_by_instructions_and_keep_their_call_indexes() {
218 let (task, p1) = (id("task"), id("p1"));
219 let groups = group_ops(&[
220 json!({"op": "remove", "path": format!("/review_instructions/{p1}")}),
221 json!({"op": "add", "path": format!("/tasks/{task}"), "position": "end", "value": {"name": "core/paragraph"}}),
222 json!({"op": "remove", "path": format!("/review_instructions/{}", id("p2"))}),
223 ])
224 .unwrap();
225 assert_eq!(groups.len(), 2);
226 assert_eq!(groups[0].part, ExercisePart::ReviewInstructions);
227 assert_eq!(groups[0].indexes, vec![0, 2]);
228 assert_eq!(groups[0].raw[0]["path"], json!(format!("/{p1}")));
229 assert_eq!(groups[1].raw[0]["path"], json!(""));
230
231 let across = group_ops(&[json!({
232 "op": "move",
233 "from": format!("/tasks/{task}/{p1}"),
234 "path": "/review_instructions",
235 "position": "end",
236 })]);
237 assert_eq!(across.unwrap_err().code, EditErrorCode::InvalidPath);
238 let to_page = group_ops(&[
239 json!({"op": "move", "path": format!("/tasks/{task}"), "to_page": id("pg")}),
240 ]);
241 assert_eq!(to_page.unwrap_err().code, EditErrorCode::FieldNotAllowed);
242 }
243
244 #[test]
245 fn read_paths_get_the_instructions_prefix() {
246 let mut read = json!({"items": [{"path": "/a", "innerBlocks": [{"path": "/b"}]}], "parent": "/c/innerBlocks", "next": {"path": "/d"}});
247 prefix_paths(&mut read, "/tasks/t");
248 assert_eq!(
249 read["items"][0]["innerBlocks"][0]["path"],
250 json!("/tasks/t/b")
251 );
252 assert_eq!(read["parent"], json!("/tasks/t/c/innerBlocks"));
253 assert_eq!(read["next"]["path"], json!("/tasks/t/d"));
254 }
255}