Skip to main content

headless_lms_mcp/handlers/exercise/
paths.rs

1//! Exercise paths: `/tasks/<task id>/...` and `/review_instructions/...`, and the page-content
2//! paths of the instructions they lead into.
3
4use 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/// Which part of an exercise a path starts at.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub(super) enum ExercisePart {
16    Task(Uuid),
17    ReviewInstructions,
18}
19
20/// Splits `/tasks/<task id>/rest` or `/review_instructions/rest` into the part and the page-content
21/// path of the rest, `""` for the part's top level.
22pub(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
49/// The prefix to put back on the paths a read of `part` returns.
50pub(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
57/// The instruction list `part` of the exercise is in `state`, or why it has none.
58pub(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
93/// Prefixes every block path in a read result with `prefix`.
94pub(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/// The ops of one instruction list, with their indexes in the call.
112#[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
119/// Groups the ops by the instructions they edit and rewrites their paths to page-content paths.
120pub(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
172/// The call's index of op `op` of a group whose ops are at `indexes` in the call.
173pub(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
182/// `error` of one group's ops, with the op index it names translated to the call's.
183pub(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}