Skip to main content

headless_lms_mcp/handlers/exercise/
specs.rs

1//! Reading a task's specs through `get_exercise`: `/tasks/<task id>/specs[/<kind>[/<pointer>]]`.
2
3use std::collections::HashMap;
4
5use headless_lms_models::{exercise_tasks, library::entity_snapshots::PageSnapshot};
6use serde_json::{Value, json};
7use sqlx::PgConnection;
8use uuid::Uuid;
9
10use crate::engine::path::escape_segment;
11use crate::engine::read::ReadState;
12use crate::engine::{EditError, EditErrorCode};
13use crate::text::serialized_len;
14use crate::tools::ToolError;
15
16const AUTHORITY: &str = "private_spec is what the editor saves and the only authoritative spec; the service derives the others from it when it changes.";
17const DERIVED_AT_PUBLISH: &str = "This task's private spec isn't published yet, so its public and model solution specs are derived at publish; they are null until then.";
18
19/// One of a task's specs.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum SpecKind {
22    Private,
23    Public,
24    ModelSolution,
25}
26
27impl SpecKind {
28    const ALL: [SpecKind; 3] = [SpecKind::Private, SpecKind::Public, SpecKind::ModelSolution];
29
30    fn name(self) -> &'static str {
31        match self {
32            SpecKind::Private => "private_spec",
33            SpecKind::Public => "public_spec",
34            SpecKind::ModelSolution => "model_solution_spec",
35        }
36    }
37}
38
39/// A path into a task's specs.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct SpecPath {
42    pub task_id: Uuid,
43    /// `None` for all three.
44    pub kind: Option<SpecKind>,
45    /// A JSON pointer into the spec, `""` for the whole spec.
46    pub pointer: String,
47}
48
49impl SpecPath {
50    /// The path as get_exercise addresses it.
51    fn to_path(&self, kind: Option<SpecKind>, pointer: &str) -> String {
52        let mut path = format!("/tasks/{}/specs", self.task_id);
53        if let Some(kind) = kind {
54            path.push('/');
55            path.push_str(kind.name());
56            path.push_str(pointer);
57        }
58        path
59    }
60}
61
62/// The spec path `path` names; `None` if it isn't one, an error if it is a malformed one.
63pub fn parse_spec_path(path: &str) -> Option<Result<SpecPath, EditError>> {
64    let rest = path.strip_prefix("/tasks/")?;
65    let (task, rest) = rest.split_once('/')?;
66    let rest = rest.strip_prefix("specs")?;
67    if !rest.is_empty() && !rest.starts_with('/') {
68        return None;
69    }
70    let invalid = || {
71        EditError::new(
72            EditErrorCode::InvalidPath,
73            format!("{path:?} isn't a spec path"),
74        )
75        .with_fix("spec paths are /tasks/<task id>/specs, /tasks/<task id>/specs/<private_spec|public_spec|model_solution_spec> and a JSON pointer after that, e.g. /tasks/<task id>/specs/private_spec/items/0")
76    };
77    let Ok(task_id) = Uuid::parse_str(task) else {
78        return Some(Err(invalid()));
79    };
80    if rest.is_empty() {
81        return Some(Ok(SpecPath {
82            task_id,
83            kind: None,
84            pointer: String::new(),
85        }));
86    }
87    let rest = &rest[1..];
88    let (kind, pointer) = match rest.split_once('/') {
89        Some((kind, pointer)) => (kind, format!("/{pointer}")),
90        None => (rest, String::new()),
91    };
92    let Some(kind) = SpecKind::ALL.into_iter().find(|k| k.name() == kind) else {
93        return Some(Err(invalid()));
94    };
95    Some(Ok(SpecPath {
96        task_id,
97        kind: Some(kind),
98        pointer,
99    }))
100}
101
102/// A task's specs in one state of the course.
103#[derive(Debug, Clone, Default, PartialEq)]
104pub struct TaskSpecs {
105    pub private_spec: Option<Value>,
106    pub public_spec: Option<Value>,
107    pub model_solution_spec: Option<Value>,
108    /// The stored files the private spec references, by id.
109    pub private_spec_files: Vec<Uuid>,
110    /// The derived specs are left null: the private spec differs from the published one.
111    pub is_derived_at_publish: bool,
112}
113
114impl TaskSpecs {
115    fn spec(&self, kind: SpecKind) -> Option<&Value> {
116        match kind {
117            SpecKind::Private => self.private_spec.as_ref(),
118            SpecKind::Public => self.public_spec.as_ref(),
119            SpecKind::ModelSolution => self.model_solution_spec.as_ref(),
120        }
121    }
122}
123
124/// The specs of the exercise's tasks in `read_state`, by task id. `page` is the page holding the
125/// exercise in that state.
126pub async fn load_task_specs(
127    conn: &mut PgConnection,
128    exercise_id: Uuid,
129    page: Option<&PageSnapshot>,
130    read_state: ReadState,
131) -> Result<HashMap<Uuid, TaskSpecs>, ToolError> {
132    let live: HashMap<Uuid, exercise_tasks::ExerciseTask> =
133        exercise_tasks::get_exercise_tasks_by_exercise_id(conn, exercise_id)
134            .await?
135            .into_iter()
136            .map(|task| (task.id, task))
137            .collect();
138    let snapshot_tasks = page.into_iter().flat_map(|page| {
139        let slides: Vec<Uuid> = page
140            .exercise_slides
141            .iter()
142            .filter(|slide| slide.exercise_id == exercise_id)
143            .map(|slide| slide.id)
144            .collect();
145        page.exercise_tasks
146            .iter()
147            .filter(move |task| slides.contains(&task.exercise_slide_id))
148    });
149    let mut specs = HashMap::new();
150    for task in snapshot_tasks {
151        let live_task = live.get(&task.id);
152        let is_published =
153            live_task.is_some_and(|live_task| live_task.private_spec == task.private_spec);
154        let derived = |spec: fn(&exercise_tasks::ExerciseTask) -> &Option<Value>| {
155            live_task
156                .filter(|_| is_published)
157                .and_then(|live_task| spec(live_task).clone())
158        };
159        specs.insert(
160            task.id,
161            TaskSpecs {
162                private_spec: task.private_spec.clone(),
163                public_spec: derived(|t| &t.public_spec),
164                model_solution_spec: derived(|t| &t.model_solution_spec),
165                private_spec_files: task.private_spec_files.clone(),
166                is_derived_at_publish: read_state == ReadState::Staged && !is_published,
167            },
168        );
169    }
170    Ok(specs)
171}
172
173/// The read of a spec path, or an error naming smaller paths when it is over `budget_bytes`.
174pub fn read_specs(
175    path: &SpecPath,
176    specs: Option<&TaskSpecs>,
177    budget_bytes: usize,
178) -> Result<Value, ToolError> {
179    let specs = specs.ok_or_else(|| {
180        ToolError::Edit(EditError::new(
181            EditErrorCode::NotFound,
182            format!("the exercise has no task {} in this state", path.task_id),
183        ))
184    })?;
185    let value = match path.kind {
186        None => json!({
187            "private_spec": specs.private_spec,
188            "public_spec": specs.public_spec,
189            "model_solution_spec": specs.model_solution_spec,
190        }),
191        Some(kind) => {
192            let spec = specs.spec(kind).cloned().unwrap_or(Value::Null);
193            spec.pointer(&path.pointer).cloned().ok_or_else(|| {
194                ToolError::Edit(EditError::new(
195                    EditErrorCode::NotFound,
196                    format!("{} has nothing at {:?}", kind.name(), path.pointer),
197                ))
198            })?
199        }
200    };
201    let size = serialized_len(&value);
202    if size > budget_bytes {
203        let children: Vec<(Option<SpecKind>, String, &Value)> = match (path.kind, &value) {
204            (None, _) => SpecKind::ALL
205                .into_iter()
206                .filter_map(|kind| Some((Some(kind), String::new(), specs.spec(kind)?)))
207                .collect(),
208            (kind, Value::Object(map)) => map
209                .iter()
210                .map(|(key, child)| {
211                    (
212                        kind,
213                        format!("{}/{}", path.pointer, escape_segment(key)),
214                        child,
215                    )
216                })
217                .collect(),
218            (kind, Value::Array(items)) => items
219                .iter()
220                .enumerate()
221                .map(|(index, child)| (kind, format!("{}/{index}", path.pointer), child))
222                .collect(),
223            _ => Vec::new(),
224        };
225        let path_count = children.len();
226        let smaller: Vec<Value> = children
227            .into_iter()
228            .take(100)
229            .map(|(kind, pointer, child)| {
230                json!({"path": path.to_path(kind, &pointer), "bytes": serialized_len(child)})
231            })
232            .collect();
233        return Err(ToolError::Edit(
234            EditError::new(
235                EditErrorCode::TooLarge,
236                format!(
237                    "{} is {size} bytes, over the {budget_bytes} bytes a read may return",
238                    path.to_path(path.kind, &path.pointer)
239                ),
240            )
241            .with_fix("read the smaller paths listed in details, one or a few per selector")
242            .with_details(json!({ "paths": smaller, "path_count": path_count })),
243        ));
244    }
245    let mut read = json!({
246        "path": path.to_path(path.kind, &path.pointer),
247        "value": value,
248        "note": AUTHORITY,
249    });
250    if path.kind.is_none_or(|kind| kind == SpecKind::Private) {
251        read["private_spec_files"] = json!(specs.private_spec_files);
252    }
253    if specs.is_derived_at_publish {
254        read["derived_at_publish"] = json!(DERIVED_AT_PUBLISH);
255    }
256    Ok(read)
257}