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    /// Returned when the `query_aggregate` tool receives a structurally
81    /// inconsistent request (empty sources, ATTACH-limit exceeded,
82    /// inner-without-group-by, etc.) โ€” distinct from per-field validation
83    /// errors (those use `VALIDATION_ERROR`) and from raw SQLite failures
84    /// (those use `STORAGE_ERROR`).
85    pub const AGGREGATOR_ERROR: &str = "AGGREGATOR_ERROR";
86}
87
88/// All errors that can arise inside mini-app-core.
89#[derive(Error, Debug)]
90pub enum MiniAppError {
91    /// Validation failed for a specific field.
92    #[error("validation error on field '{field}': {reason}")]
93    Validation { field: String, reason: String },
94
95    /// No row with the given `id` was found.
96    #[error("row not found: {id}")]
97    NotFound { id: String },
98
99    /// `schema.yaml` could not be parsed.
100    #[error("schema parse error: {0}")]
101    Schema(String),
102
103    /// A SQLite storage error occurred.
104    #[error("storage error: {0}")]
105    Storage(#[from] rusqlite::Error),
106
107    /// A filesystem I/O error occurred.
108    #[error("io error: {0}")]
109    Io(#[from] std::io::Error),
110
111    /// An environment-variable or `.env` configuration error occurred.
112    #[error("config error: {0}")]
113    Config(String),
114
115    /// The requested table is not mounted in the registry.
116    #[error("table not found: {table}")]
117    TableNotFound { table: String },
118
119    /// Multi-table mode requires a `table` argument that was omitted.
120    #[error("table argument is required in multi-table mode")]
121    TableRequired,
122
123    /// A schema file already exists for the given table.
124    #[error("schema already exists: {table}")]
125    SchemaExists { table: String },
126
127    /// A backup I/O or SQLite backup operation failed.
128    #[error("backup error: {0}")]
129    Backup(String),
130
131    /// A snapshot I/O or SQLite snapshot operation failed.
132    #[error("snapshot error: {0}")]
133    Snapshot(String),
134
135    /// `schema_batch` was aborted because one of its ops failed.
136    #[error("batch aborted at op #{op_index}: {reason}")]
137    BatchAborted { op_index: usize, reason: String },
138
139    /// The destination path supplied to `row_materialize` is not absolute.
140    #[error("materialize dest must be absolute: {path}")]
141    MaterializeDestRelative { path: String },
142
143    /// The destination path is absolute but invalid for another reason.
144    #[error("materialize dest invalid '{path}': {reason}")]
145    MaterializeDestInvalid { path: String, reason: String },
146
147    /// A filesystem I/O error occurred during `row_materialize`.
148    #[error("materialize io error: {0}")]
149    MaterializeIo(String),
150
151    /// SHA-256 computation failed during `row_materialize`.
152    #[error("materialize sha256 error: {0}")]
153    MaterializeSha256(String),
154
155    /// The row id specified in a `ById` selector was not found.
156    #[error("materialize row not found: {id}")]
157    MaterializeRowNotFound { id: String },
158
159    /// A `ByFilter` selector matched zero rows and `ignore_empty` is false.
160    #[error("materialize filter matched zero rows")]
161    MaterializeEmptyResult,
162
163    /// Serialization to the requested output format failed.
164    #[error("materialize format error: {0}")]
165    MaterializeFormatError(String),
166
167    /// A projected field name is not present in the table schema.
168    #[error("materialize unknown field: {field}")]
169    MaterializeFieldUnknown { field: String },
170
171    /// `row_materialize` parameters are structurally inconsistent.
172    #[error("materialize invalid param '{field}': {reason}")]
173    MaterializeInvalidParam { field: String, reason: String },
174
175    /// No query alias with the given `name` was found in `_aliases`.
176    #[error("alias not found: {name}")]
177    AliasNotFound { name: String },
178
179    /// An alias with the given `name` already exists in `_aliases`.
180    #[error("alias already exists: {name}")]
181    AliasAlreadyExists { name: String },
182
183    /// `alias_run` was called without `params` but the alias requires parameter
184    /// injection (its `params_schema` is non-null).
185    #[error("alias '{name}' requires params but none were provided")]
186    AliasParamsRequired { name: String },
187
188    /// MiniJinja template rendering failed during `alias_run`.
189    #[error("alias template render error: {0}")]
190    AliasTemplateError(String),
191
192    /// An id prefix matched more than one row.
193    #[error("ambiguous id prefix '{id_prefix}': {n} candidates", n = candidates.len())]
194    AmbiguousId {
195        id_prefix: String,
196        candidates: Vec<String>,
197    },
198
199    /// A structural inconsistency was detected in a `query_aggregate` request
200    /// (empty sources, ATTACH-limit exceeded, inner-without-group-by, etc.).
201    #[error("aggregator error: {0}")]
202    Aggregator(String),
203}
204
205impl MiniAppError {
206    /// Returns the machine-readable error code for this variant.
207    pub fn code(&self) -> &'static str {
208        match self {
209            MiniAppError::Validation { .. } => codes::VALIDATION_ERROR,
210            MiniAppError::NotFound { .. } => codes::NOT_FOUND,
211            MiniAppError::Schema(_) => codes::SCHEMA_ERROR,
212            MiniAppError::Storage(_) => codes::STORAGE_ERROR,
213            MiniAppError::Io(_) => codes::IO_ERROR,
214            MiniAppError::Config(_) => codes::CONFIG_ERROR,
215            MiniAppError::TableNotFound { .. } => codes::TABLE_NOT_FOUND,
216            MiniAppError::TableRequired => codes::TABLE_REQUIRED,
217            MiniAppError::SchemaExists { .. } => codes::SCHEMA_EXISTS,
218            MiniAppError::Backup(_) => codes::BACKUP_ERROR,
219            MiniAppError::Snapshot(_) => codes::SNAPSHOT_ERROR,
220            MiniAppError::BatchAborted { .. } => codes::BATCH_ABORTED,
221            MiniAppError::MaterializeDestRelative { .. } => codes::MATERIALIZE_DEST_RELATIVE,
222            MiniAppError::MaterializeDestInvalid { .. } => codes::MATERIALIZE_DEST_INVALID,
223            MiniAppError::MaterializeIo(_) => codes::MATERIALIZE_IO_ERROR,
224            MiniAppError::MaterializeSha256(_) => codes::MATERIALIZE_SHA256_ERROR,
225            MiniAppError::MaterializeRowNotFound { .. } => codes::MATERIALIZE_ROW_NOT_FOUND,
226            MiniAppError::MaterializeEmptyResult => codes::MATERIALIZE_EMPTY_RESULT,
227            MiniAppError::MaterializeFormatError(_) => codes::MATERIALIZE_FORMAT_ERROR,
228            MiniAppError::MaterializeFieldUnknown { .. } => codes::MATERIALIZE_FIELD_UNKNOWN,
229            MiniAppError::MaterializeInvalidParam { .. } => codes::MATERIALIZE_INVALID_PARAM,
230            MiniAppError::AliasNotFound { .. } => codes::ALIAS_NOT_FOUND,
231            MiniAppError::AliasAlreadyExists { .. } => codes::ALIAS_ALREADY_EXISTS,
232            MiniAppError::AliasParamsRequired { .. } => codes::ALIAS_PARAMS_REQUIRED,
233            MiniAppError::AliasTemplateError(_) => codes::ALIAS_TEMPLATE_ERROR,
234            MiniAppError::AmbiguousId { .. } => codes::AMBIGUOUS_ID,
235            MiniAppError::Aggregator(_) => codes::AGGREGATOR_ERROR,
236        }
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    #[test]
245    fn error_code_all_variants() {
246        let cases: Vec<(&str, MiniAppError)> = vec![
247            (
248                codes::VALIDATION_ERROR,
249                MiniAppError::Validation {
250                    field: "f".into(),
251                    reason: "r".into(),
252                },
253            ),
254            (codes::NOT_FOUND, MiniAppError::NotFound { id: "x".into() }),
255            (codes::SCHEMA_ERROR, MiniAppError::Schema("s".into())),
256            (
257                codes::IO_ERROR,
258                MiniAppError::Io(std::io::Error::other("e")),
259            ),
260            (codes::CONFIG_ERROR, MiniAppError::Config("c".into())),
261            (
262                codes::TABLE_NOT_FOUND,
263                MiniAppError::TableNotFound { table: "t".into() },
264            ),
265            (codes::TABLE_REQUIRED, MiniAppError::TableRequired),
266            (
267                codes::SCHEMA_EXISTS,
268                MiniAppError::SchemaExists {
269                    table: "my_table".into(),
270                },
271            ),
272            (
273                codes::BACKUP_ERROR,
274                MiniAppError::Backup("disk full".into()),
275            ),
276            (
277                codes::SNAPSHOT_ERROR,
278                MiniAppError::Snapshot("snapshot failed".into()),
279            ),
280            (
281                codes::BATCH_ABORTED,
282                MiniAppError::BatchAborted {
283                    op_index: 2,
284                    reason: "schema not found".into(),
285                },
286            ),
287            (
288                codes::MATERIALIZE_DEST_RELATIVE,
289                MiniAppError::MaterializeDestRelative {
290                    path: "relative/path".into(),
291                },
292            ),
293            (
294                codes::MATERIALIZE_DEST_INVALID,
295                MiniAppError::MaterializeDestInvalid {
296                    path: "/bad/path".into(),
297                    reason: "parent dir not writable".into(),
298                },
299            ),
300            (
301                codes::MATERIALIZE_IO_ERROR,
302                MiniAppError::MaterializeIo("write failed".into()),
303            ),
304            (
305                codes::MATERIALIZE_SHA256_ERROR,
306                MiniAppError::MaterializeSha256("task panicked".into()),
307            ),
308            (
309                codes::MATERIALIZE_ROW_NOT_FOUND,
310                MiniAppError::MaterializeRowNotFound { id: "row-1".into() },
311            ),
312            (
313                codes::MATERIALIZE_EMPTY_RESULT,
314                MiniAppError::MaterializeEmptyResult,
315            ),
316            (
317                codes::MATERIALIZE_FORMAT_ERROR,
318                MiniAppError::MaterializeFormatError("yaml error".into()),
319            ),
320            (
321                codes::MATERIALIZE_FIELD_UNKNOWN,
322                MiniAppError::MaterializeFieldUnknown {
323                    field: "unknown_field".into(),
324                },
325            ),
326            (
327                codes::MATERIALIZE_INVALID_PARAM,
328                MiniAppError::MaterializeInvalidParam {
329                    field: "concat".into(),
330                    reason: "concat=true requires ByFilter selector".into(),
331                },
332            ),
333            (
334                codes::ALIAS_NOT_FOUND,
335                MiniAppError::AliasNotFound {
336                    name: "my_alias".into(),
337                },
338            ),
339            (
340                codes::ALIAS_ALREADY_EXISTS,
341                MiniAppError::AliasAlreadyExists {
342                    name: "my_alias".into(),
343                },
344            ),
345            (
346                codes::ALIAS_PARAMS_REQUIRED,
347                MiniAppError::AliasParamsRequired {
348                    name: "my_alias".into(),
349                },
350            ),
351            (
352                codes::ALIAS_TEMPLATE_ERROR,
353                MiniAppError::AliasTemplateError("template syntax error".into()),
354            ),
355            (
356                codes::AMBIGUOUS_ID,
357                MiniAppError::AmbiguousId {
358                    id_prefix: "abc".into(),
359                    candidates: vec!["abc-1".into(), "abc-2".into()],
360                },
361            ),
362            (
363                codes::AGGREGATOR_ERROR,
364                MiniAppError::Aggregator("empty sources".into()),
365            ),
366        ];
367        for (expected_code, err) in cases {
368            assert_eq!(
369                err.code(),
370                expected_code,
371                "wrong code for variant containing code {}",
372                expected_code
373            );
374        }
375    }
376
377    #[test]
378    fn backup_error_code_is_not_storage_or_io() {
379        let err = MiniAppError::Backup("some rusqlite error".to_string());
380        assert_eq!(err.code(), codes::BACKUP_ERROR);
381        assert_ne!(err.code(), codes::STORAGE_ERROR);
382        assert_ne!(err.code(), codes::IO_ERROR);
383    }
384}