Skip to main content

headless_lms_mcp/engine/
error.rs

1//! Errors and warnings of the edit engines.
2
3use serde::Serialize;
4use serde_json::{Value, json};
5use uuid::Uuid;
6
7/// Why an edit was rejected. Serialised as the `code` of a tool error.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
9#[serde(rename_all = "snake_case")]
10pub enum EditErrorCode {
11    InvalidOp,
12    UnknownField,
13    MissingField,
14    FieldNotAllowed,
15    InvalidValue,
16    InvalidPath,
17    NotFound,
18    NumericIndex,
19    PositionRequired,
20    PositionNeedsChildren,
21    PositionNeedsSibling,
22    InvalidRange,
23    MoveIntoItself,
24    LabelInvalid,
25    LabelReused,
26    LabelUndefined,
27    UnknownIds,
28    MissingIds,
29    DuplicateIds,
30    ExerciseReadOnly,
31    NotATextBlock,
32    TextNotFound,
33    TextAmbiguous,
34    SplitInsideMarkup,
35    MergeNotAdjacent,
36    ReadOnlyField,
37    DerivedField,
38    WrongTool,
39    PinnedFrontPage,
40    DefaultModule,
41    ModuleNotEmpty,
42    ChapterNeedsPage,
43    DuplicatePath,
44    InvalidUrlPath,
45    InvariantViolated,
46    ExpectedCountMismatch,
47    InvalidRegex,
48    ReorderMismatch,
49    CitationKey,
50    InvalidBibtex,
51    DuplicateTerm,
52    /// A read's value is over the result budget; `details` lists smaller paths to read instead.
53    TooLarge,
54    /// The CMS validator rejected blocks (`details` lists its errors), or a page outside chapters
55    /// would hold exercises or chapter listings.
56    InvalidBlocks,
57}
58
59/// An edit rejected before anything was staged. Every message names the correct form where one
60/// exists, so the agent can resend a fixed call.
61#[derive(Debug, Clone, PartialEq, Serialize)]
62pub struct EditError {
63    pub code: EditErrorCode,
64    pub message: String,
65    /// 0-based index of the failing op, when the failure belongs to one.
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub op: Option<usize>,
68    /// The corrected form, e.g. a rewritten op or path.
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub fix: Option<String>,
71    /// Candidates, sibling lists, unknown ids and similar machine-readable context.
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub details: Option<Value>,
74}
75
76impl EditError {
77    /// An error without a fix or details; the builders below add them.
78    pub fn new(code: EditErrorCode, message: impl Into<String>) -> Self {
79        Self {
80            code,
81            message: message.into(),
82            op: None,
83            fix: None,
84            details: None,
85        }
86    }
87
88    /// Adds the corrected form of the rejected call.
89    pub fn with_fix(mut self, fix: impl Into<String>) -> Self {
90        self.fix = Some(fix.into());
91        self
92    }
93
94    /// Adds machine-readable context for the error.
95    pub fn with_details(mut self, details: Value) -> Self {
96        self.details = Some(details);
97        self
98    }
99
100    /// Attributes the error to op `index` unless an inner step already did.
101    pub fn at_op(mut self, index: usize) -> Self {
102        self.op.get_or_insert(index);
103        self
104    }
105
106    /// `path` addresses an item by position; `items` names what goes by id instead, e.g.
107    /// "blocks".
108    pub(crate) fn numeric_index(path: &str, items: &str) -> Self {
109        Self::new(
110            EditErrorCode::NumericIndex,
111            format!("\"{path}\" uses a numeric index; {items} are addressed by id"),
112        )
113    }
114
115    /// A bulk edit found `found` matches where the call expected `expected`; `matches` describes
116    /// each one for the agent.
117    pub(crate) fn expected_count_mismatch(
118        expected: usize,
119        found: usize,
120        matches: Vec<Value>,
121    ) -> Self {
122        Self::new(
123            EditErrorCode::ExpectedCountMismatch,
124            format!("expected {expected} matches but found {found}; nothing was changed"),
125        )
126        .with_details(json!({ "matches": matches }))
127    }
128
129    /// An op that places items came without `position`; `fix` shows where items can go.
130    pub(crate) fn position_required(fix: &str) -> Self {
131        Self::new(
132            EditErrorCode::PositionRequired,
133            "this op needs \"position\"",
134        )
135        .with_fix(fix)
136    }
137
138    /// A range's `through` (`last`) comes before its start; `siblings` are the list's paths in
139    /// order.
140    pub(crate) fn reversed_range(first: Uuid, last: Uuid, siblings: Vec<String>) -> Self {
141        Self::new(
142            EditErrorCode::InvalidRange,
143            format!("the range is reversed: /{last} comes before /{first}"),
144        )
145        .with_fix(format!("swap the ends: from /{last} through /{first}"))
146        .with_details(json!({ "siblings_in_order": siblings }))
147    }
148}
149
150impl std::fmt::Display for EditError {
151    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
152        match self.op {
153            Some(op) => write!(f, "op {op}: {}", self.message)?,
154            None => write!(f, "{}", self.message)?,
155        }
156        if let Some(fix) = &self.fix {
157            write!(f, " Fix: {fix}")?;
158        }
159        Ok(())
160    }
161}
162
163impl std::error::Error for EditError {}
164
165/// Something the call did that the agent should know about but that didn't stop it.
166#[derive(Debug, Clone, PartialEq, Serialize)]
167pub struct Warning {
168    pub code: WarningCode,
169    pub message: String,
170    #[serde(skip_serializing_if = "Option::is_none")]
171    pub op: Option<usize>,
172}
173
174impl Warning {
175    /// A warning not yet attributed to an op; the engines' apply loops attribute it.
176    pub fn new(code: WarningCode, message: impl Into<String>) -> Self {
177        Self {
178            code,
179            message: message.into(),
180            op: None,
181        }
182    }
183}
184
185/// What a [`Warning`] is about.
186#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
187#[serde(rename_all = "snake_case")]
188pub enum WarningCode {
189    /// An id sent on a block meant to be new was replaced by a server-assigned one.
190    IgnoredClientId,
191    /// A copied exercise block got a new exercise with no submissions.
192    ExerciseCopied,
193    /// A new page in a live course was hidden because it has no content yet.
194    NewPageHidden,
195    /// A url_path was adjusted to fit the chapter or to be unique.
196    UrlPathAdjusted,
197    /// An exercise got a deadline of its own, which chapter deadlines are preferred to.
198    OwnExerciseDeadline,
199    /// An exercise's own deadline is before its chapter opens or after the chapter's deadline.
200    DeadlineOutsideChapterWindow,
201}