Skip to main content

mini_app_core/
error.rs

1/// Application-level error type for mini-app-mcp.
2///
3/// All public functions return `Result<T, MiniAppError>`. This enum is the
4/// single error type shared across schema parsing, storage, validation, and
5/// configuration layers.
6///
7/// # Crux compliance
8/// Every variant maps to a unique `code` string constant (e.g.
9/// `"VALIDATION_ERROR"`) so that downstream MCP transport layers can build a
10/// structured JSON `data` object — satisfying the "structured JSON error" Crux
11/// constraint. The actual `rmcp::ErrorData` conversion lives in the mcp crate
12/// (`crates/mcp/src/error_conv.rs`) as a `pub(crate)` free function (ACL
13/// adapter, Outline rust book §5-1-10 K-orphan-rule).
14use thiserror::Error;
15
16/// Structured error codes emitted in the `data.code` field of every MCP error
17/// response. These are `&'static str` constants so callers can pattern-match
18/// them programmatically.
19pub mod codes {
20    /// Returned when a required field is missing or a value has the wrong type.
21    pub const VALIDATION_ERROR: &str = "VALIDATION_ERROR";
22    /// Returned when a requested row does not exist.
23    pub const NOT_FOUND: &str = "NOT_FOUND";
24    /// Returned when `schema.yaml` cannot be parsed or is structurally invalid.
25    pub const SCHEMA_ERROR: &str = "SCHEMA_ERROR";
26    /// Returned when a SQLite operation fails.
27    pub const STORAGE_ERROR: &str = "STORAGE_ERROR";
28    /// Returned when an I/O operation (file open, read) fails.
29    pub const IO_ERROR: &str = "IO_ERROR";
30    /// Returned when environment-variable or `.env` configuration is invalid.
31    pub const CONFIG_ERROR: &str = "CONFIG_ERROR";
32    /// Returned when the requested table is not mounted in the registry.
33    pub const TABLE_NOT_FOUND: &str = "TABLE_NOT_FOUND";
34    /// Returned when `table` argument is required but was omitted.
35    ///
36    /// This occurs in multi-table mode when more than one table is mounted and
37    /// no default table is configured.
38    pub const TABLE_REQUIRED: &str = "TABLE_REQUIRED";
39    /// Returned when a schema file already exists and `schema_create` would
40    /// overwrite it.
41    pub const SCHEMA_EXISTS: &str = "SCHEMA_EXISTS";
42    /// Returned when a backup I/O or SQLite backup operation fails.
43    pub const BACKUP_ERROR: &str = "BACKUP_ERROR";
44    /// Returned when `schema_batch` is aborted because one of its ops fails.
45    pub const BATCH_ABORTED: &str = "BATCH_ABORTED";
46    /// Returned when a snapshot I/O or SQLite snapshot operation fails.
47    pub const SNAPSHOT_ERROR: &str = "SNAPSHOT_ERROR";
48    /// Returned when the `row_materialize` dest path is relative (absolute required).
49    pub const MATERIALIZE_DEST_RELATIVE: &str = "MATERIALIZE_DEST_RELATIVE";
50    /// Returned when the `row_materialize` dest path is invalid for another reason.
51    pub const MATERIALIZE_DEST_INVALID: &str = "MATERIALIZE_DEST_INVALID";
52    /// Returned when a file I/O error occurs during `row_materialize`.
53    pub const MATERIALIZE_IO_ERROR: &str = "MATERIALIZE_IO_ERROR";
54    /// Returned when SHA-256 computation fails during `row_materialize`.
55    pub const MATERIALIZE_SHA256_ERROR: &str = "MATERIALIZE_SHA256_ERROR";
56    /// Returned when the specified row id is not found during `row_materialize`.
57    pub const MATERIALIZE_ROW_NOT_FOUND: &str = "MATERIALIZE_ROW_NOT_FOUND";
58    /// Returned when the filter in `row_materialize` matches zero rows.
59    pub const MATERIALIZE_EMPTY_RESULT: &str = "MATERIALIZE_EMPTY_RESULT";
60    /// Returned when serialization to the requested format fails during `row_materialize`.
61    pub const MATERIALIZE_FORMAT_ERROR: &str = "MATERIALIZE_FORMAT_ERROR";
62    /// Returned when a projected field name is not present in the schema.
63    pub const MATERIALIZE_FIELD_UNKNOWN: &str = "MATERIALIZE_FIELD_UNKNOWN";
64    /// Returned when `row_materialize` parameters are structurally invalid.
65    pub const MATERIALIZE_INVALID_PARAM: &str = "MATERIALIZE_INVALID_PARAM";
66    /// Returned when a named query alias does not exist in `_aliases`.
67    pub const ALIAS_NOT_FOUND: &str = "ALIAS_NOT_FOUND";
68    /// Returned when `alias_create` is called but an alias with the same name
69    /// already exists in the table's `_aliases` storage.
70    pub const ALIAS_ALREADY_EXISTS: &str = "ALIAS_ALREADY_EXISTS";
71    /// Returned when `alias_run` is called without `params` but the alias has
72    /// a non-null `params_schema` (i.e. the alias requires parameter injection).
73    pub const ALIAS_PARAMS_REQUIRED: &str = "ALIAS_PARAMS_REQUIRED";
74    /// Returned when MiniJinja template rendering fails (syntax error or
75    /// missing variable) during `alias_run`.
76    pub const ALIAS_TEMPLATE_ERROR: &str = "ALIAS_TEMPLATE_ERROR";
77    /// Returned when an id prefix matches more than one row and the caller
78    /// must disambiguate by using a longer prefix or the full UUID.
79    pub const AMBIGUOUS_ID: &str = "AMBIGUOUS_ID";
80}
81
82/// All errors that can arise inside mini-app-core.
83#[derive(Error, Debug)]
84pub enum MiniAppError {
85    /// Validation failed for a specific field.
86    #[error("validation error on field '{field}': {reason}")]
87    Validation { field: String, reason: String },
88
89    /// No row with the given `id` was found.
90    #[error("row not found: {id}")]
91    NotFound { id: String },
92
93    /// `schema.yaml` could not be parsed.
94    #[error("schema parse error: {0}")]
95    Schema(String),
96
97    /// A SQLite storage error occurred.
98    #[error("storage error: {0}")]
99    Storage(#[from] rusqlite::Error),
100
101    /// A filesystem I/O error occurred.
102    #[error("io error: {0}")]
103    Io(#[from] std::io::Error),
104
105    /// An environment-variable or `.env` configuration error occurred.
106    #[error("config error: {0}")]
107    Config(String),
108
109    /// The requested table is not mounted in the registry.
110    #[error("table not found: {table}")]
111    TableNotFound { table: String },
112
113    /// Multi-table mode requires a `table` argument that was omitted.
114    #[error("table argument is required in multi-table mode")]
115    TableRequired,
116
117    /// A schema file already exists for the given table.
118    #[error("schema already exists: {table}")]
119    SchemaExists { table: String },
120
121    /// A backup I/O or SQLite backup operation failed.
122    #[error("backup error: {0}")]
123    Backup(String),
124
125    /// A snapshot I/O or SQLite snapshot operation failed.
126    #[error("snapshot error: {0}")]
127    Snapshot(String),
128
129    /// `schema_batch` was aborted because one of its ops failed.
130    #[error("batch aborted at op #{op_index}: {reason}")]
131    BatchAborted { op_index: usize, reason: String },
132
133    /// The destination path supplied to `row_materialize` is not absolute.
134    #[error("materialize dest must be absolute: {path}")]
135    MaterializeDestRelative { path: String },
136
137    /// The destination path is absolute but invalid for another reason.
138    #[error("materialize dest invalid '{path}': {reason}")]
139    MaterializeDestInvalid { path: String, reason: String },
140
141    /// A filesystem I/O error occurred during `row_materialize`.
142    #[error("materialize io error: {0}")]
143    MaterializeIo(String),
144
145    /// SHA-256 computation failed during `row_materialize`.
146    #[error("materialize sha256 error: {0}")]
147    MaterializeSha256(String),
148
149    /// The row id specified in a `ById` selector was not found.
150    #[error("materialize row not found: {id}")]
151    MaterializeRowNotFound { id: String },
152
153    /// A `ByFilter` selector matched zero rows and `ignore_empty` is false.
154    #[error("materialize filter matched zero rows")]
155    MaterializeEmptyResult,
156
157    /// Serialization to the requested output format failed.
158    #[error("materialize format error: {0}")]
159    MaterializeFormatError(String),
160
161    /// A projected field name is not present in the table schema.
162    #[error("materialize unknown field: {field}")]
163    MaterializeFieldUnknown { field: String },
164
165    /// `row_materialize` parameters are structurally inconsistent.
166    #[error("materialize invalid param '{field}': {reason}")]
167    MaterializeInvalidParam { field: String, reason: String },
168
169    /// No query alias with the given `name` was found in `_aliases`.
170    #[error("alias not found: {name}")]
171    AliasNotFound { name: String },
172
173    /// An alias with the given `name` already exists in `_aliases`.
174    #[error("alias already exists: {name}")]
175    AliasAlreadyExists { name: String },
176
177    /// `alias_run` was called without `params` but the alias requires parameter
178    /// injection (its `params_schema` is non-null).
179    #[error("alias '{name}' requires params but none were provided")]
180    AliasParamsRequired { name: String },
181
182    /// MiniJinja template rendering failed during `alias_run`.
183    #[error("alias template render error: {0}")]
184    AliasTemplateError(String),
185
186    /// An id prefix matched more than one row.
187    #[error("ambiguous id prefix '{id_prefix}': {n} candidates", n = candidates.len())]
188    AmbiguousId {
189        id_prefix: String,
190        candidates: Vec<String>,
191    },
192}
193
194impl MiniAppError {
195    /// Returns the machine-readable error code for this variant.
196    pub fn code(&self) -> &'static str {
197        match self {
198            MiniAppError::Validation { .. } => codes::VALIDATION_ERROR,
199            MiniAppError::NotFound { .. } => codes::NOT_FOUND,
200            MiniAppError::Schema(_) => codes::SCHEMA_ERROR,
201            MiniAppError::Storage(_) => codes::STORAGE_ERROR,
202            MiniAppError::Io(_) => codes::IO_ERROR,
203            MiniAppError::Config(_) => codes::CONFIG_ERROR,
204            MiniAppError::TableNotFound { .. } => codes::TABLE_NOT_FOUND,
205            MiniAppError::TableRequired => codes::TABLE_REQUIRED,
206            MiniAppError::SchemaExists { .. } => codes::SCHEMA_EXISTS,
207            MiniAppError::Backup(_) => codes::BACKUP_ERROR,
208            MiniAppError::Snapshot(_) => codes::SNAPSHOT_ERROR,
209            MiniAppError::BatchAborted { .. } => codes::BATCH_ABORTED,
210            MiniAppError::MaterializeDestRelative { .. } => codes::MATERIALIZE_DEST_RELATIVE,
211            MiniAppError::MaterializeDestInvalid { .. } => codes::MATERIALIZE_DEST_INVALID,
212            MiniAppError::MaterializeIo(_) => codes::MATERIALIZE_IO_ERROR,
213            MiniAppError::MaterializeSha256(_) => codes::MATERIALIZE_SHA256_ERROR,
214            MiniAppError::MaterializeRowNotFound { .. } => codes::MATERIALIZE_ROW_NOT_FOUND,
215            MiniAppError::MaterializeEmptyResult => codes::MATERIALIZE_EMPTY_RESULT,
216            MiniAppError::MaterializeFormatError(_) => codes::MATERIALIZE_FORMAT_ERROR,
217            MiniAppError::MaterializeFieldUnknown { .. } => codes::MATERIALIZE_FIELD_UNKNOWN,
218            MiniAppError::MaterializeInvalidParam { .. } => codes::MATERIALIZE_INVALID_PARAM,
219            MiniAppError::AliasNotFound { .. } => codes::ALIAS_NOT_FOUND,
220            MiniAppError::AliasAlreadyExists { .. } => codes::ALIAS_ALREADY_EXISTS,
221            MiniAppError::AliasParamsRequired { .. } => codes::ALIAS_PARAMS_REQUIRED,
222            MiniAppError::AliasTemplateError(_) => codes::ALIAS_TEMPLATE_ERROR,
223            MiniAppError::AmbiguousId { .. } => codes::AMBIGUOUS_ID,
224        }
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    #[test]
233    fn error_code_all_variants() {
234        let cases: Vec<(&str, MiniAppError)> = vec![
235            (
236                codes::VALIDATION_ERROR,
237                MiniAppError::Validation {
238                    field: "f".into(),
239                    reason: "r".into(),
240                },
241            ),
242            (codes::NOT_FOUND, MiniAppError::NotFound { id: "x".into() }),
243            (codes::SCHEMA_ERROR, MiniAppError::Schema("s".into())),
244            (
245                codes::IO_ERROR,
246                MiniAppError::Io(std::io::Error::other("e")),
247            ),
248            (codes::CONFIG_ERROR, MiniAppError::Config("c".into())),
249            (
250                codes::TABLE_NOT_FOUND,
251                MiniAppError::TableNotFound { table: "t".into() },
252            ),
253            (codes::TABLE_REQUIRED, MiniAppError::TableRequired),
254            (
255                codes::SCHEMA_EXISTS,
256                MiniAppError::SchemaExists {
257                    table: "my_table".into(),
258                },
259            ),
260            (
261                codes::BACKUP_ERROR,
262                MiniAppError::Backup("disk full".into()),
263            ),
264            (
265                codes::SNAPSHOT_ERROR,
266                MiniAppError::Snapshot("snapshot failed".into()),
267            ),
268            (
269                codes::BATCH_ABORTED,
270                MiniAppError::BatchAborted {
271                    op_index: 2,
272                    reason: "schema not found".into(),
273                },
274            ),
275            (
276                codes::MATERIALIZE_DEST_RELATIVE,
277                MiniAppError::MaterializeDestRelative {
278                    path: "relative/path".into(),
279                },
280            ),
281            (
282                codes::MATERIALIZE_DEST_INVALID,
283                MiniAppError::MaterializeDestInvalid {
284                    path: "/bad/path".into(),
285                    reason: "parent dir not writable".into(),
286                },
287            ),
288            (
289                codes::MATERIALIZE_IO_ERROR,
290                MiniAppError::MaterializeIo("write failed".into()),
291            ),
292            (
293                codes::MATERIALIZE_SHA256_ERROR,
294                MiniAppError::MaterializeSha256("task panicked".into()),
295            ),
296            (
297                codes::MATERIALIZE_ROW_NOT_FOUND,
298                MiniAppError::MaterializeRowNotFound { id: "row-1".into() },
299            ),
300            (
301                codes::MATERIALIZE_EMPTY_RESULT,
302                MiniAppError::MaterializeEmptyResult,
303            ),
304            (
305                codes::MATERIALIZE_FORMAT_ERROR,
306                MiniAppError::MaterializeFormatError("yaml error".into()),
307            ),
308            (
309                codes::MATERIALIZE_FIELD_UNKNOWN,
310                MiniAppError::MaterializeFieldUnknown {
311                    field: "unknown_field".into(),
312                },
313            ),
314            (
315                codes::MATERIALIZE_INVALID_PARAM,
316                MiniAppError::MaterializeInvalidParam {
317                    field: "concat".into(),
318                    reason: "concat=true requires ByFilter selector".into(),
319                },
320            ),
321            (
322                codes::ALIAS_NOT_FOUND,
323                MiniAppError::AliasNotFound {
324                    name: "my_alias".into(),
325                },
326            ),
327            (
328                codes::ALIAS_ALREADY_EXISTS,
329                MiniAppError::AliasAlreadyExists {
330                    name: "my_alias".into(),
331                },
332            ),
333            (
334                codes::ALIAS_PARAMS_REQUIRED,
335                MiniAppError::AliasParamsRequired {
336                    name: "my_alias".into(),
337                },
338            ),
339            (
340                codes::ALIAS_TEMPLATE_ERROR,
341                MiniAppError::AliasTemplateError("template syntax error".into()),
342            ),
343            (
344                codes::AMBIGUOUS_ID,
345                MiniAppError::AmbiguousId {
346                    id_prefix: "abc".into(),
347                    candidates: vec!["abc-1".into(), "abc-2".into()],
348                },
349            ),
350        ];
351        for (expected_code, err) in cases {
352            assert_eq!(
353                err.code(),
354                expected_code,
355                "wrong code for variant containing code {}",
356                expected_code
357            );
358        }
359    }
360
361    #[test]
362    fn backup_error_code_is_not_storage_or_io() {
363        let err = MiniAppError::Backup("some rusqlite error".to_string());
364        assert_eq!(err.code(), codes::BACKUP_ERROR);
365        assert_ne!(err.code(), codes::STORAGE_ERROR);
366        assert_ne!(err.code(), codes::IO_ERROR);
367    }
368}