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 serde::Serialize;
15use thiserror::Error;
16
17/// A single occurrence of `old_str` found inside a string field, used to
18/// populate the `candidates` array in an `AMBIGUOUS_MATCH` error response.
19#[derive(Debug, Clone, Serialize)]
20pub struct MatchCandidate {
21    /// 1-indexed line number where the match starts.
22    pub line: u32,
23    /// 1-indexed column (byte offset within the line + 1) where the match starts.
24    pub col: u32,
25    /// Short surrounding context (~30 chars) for display in error responses.
26    pub snippet: String,
27}
28
29/// Structured error codes emitted in the `data.code` field of every MCP error
30/// response. These are `&'static str` constants so callers can pattern-match
31/// them programmatically.
32pub mod codes {
33    /// Returned when a required field is missing or a value has the wrong type.
34    pub const VALIDATION_ERROR: &str = "VALIDATION_ERROR";
35    /// Returned when a requested row does not exist.
36    pub const NOT_FOUND: &str = "NOT_FOUND";
37    /// Returned when `schema.yaml` cannot be parsed or is structurally invalid.
38    pub const SCHEMA_ERROR: &str = "SCHEMA_ERROR";
39    /// Returned when a SQLite operation fails.
40    pub const STORAGE_ERROR: &str = "STORAGE_ERROR";
41    /// Returned when an I/O operation (file open, read) fails.
42    pub const IO_ERROR: &str = "IO_ERROR";
43    /// Returned when environment-variable or `.env` configuration is invalid.
44    pub const CONFIG_ERROR: &str = "CONFIG_ERROR";
45    /// Returned when the requested table is not mounted in the registry.
46    pub const TABLE_NOT_FOUND: &str = "TABLE_NOT_FOUND";
47    /// Returned when `table` argument is required but was omitted.
48    ///
49    /// This occurs in multi-table mode when more than one table is mounted and
50    /// no default table is configured.
51    pub const TABLE_REQUIRED: &str = "TABLE_REQUIRED";
52    /// Returned when a schema file already exists and `schema_create` would
53    /// overwrite it.
54    pub const SCHEMA_EXISTS: &str = "SCHEMA_EXISTS";
55    /// Returned when a backup I/O or SQLite backup operation fails.
56    pub const BACKUP_ERROR: &str = "BACKUP_ERROR";
57    /// Returned when `schema_batch` is aborted because one of its ops fails.
58    pub const BATCH_ABORTED: &str = "BATCH_ABORTED";
59    /// Returned when a snapshot I/O or SQLite snapshot operation fails.
60    pub const SNAPSHOT_ERROR: &str = "SNAPSHOT_ERROR";
61    /// Returned when the `row_materialize` dest path is relative (absolute required).
62    pub const MATERIALIZE_DEST_RELATIVE: &str = "MATERIALIZE_DEST_RELATIVE";
63    /// Returned when the `row_materialize` dest path is invalid for another reason.
64    pub const MATERIALIZE_DEST_INVALID: &str = "MATERIALIZE_DEST_INVALID";
65    /// Returned when a file I/O error occurs during `row_materialize`.
66    pub const MATERIALIZE_IO_ERROR: &str = "MATERIALIZE_IO_ERROR";
67    /// Returned when SHA-256 computation fails during `row_materialize`.
68    pub const MATERIALIZE_SHA256_ERROR: &str = "MATERIALIZE_SHA256_ERROR";
69    /// Returned when the specified row id is not found during `row_materialize`.
70    pub const MATERIALIZE_ROW_NOT_FOUND: &str = "MATERIALIZE_ROW_NOT_FOUND";
71    /// Returned when the filter in `row_materialize` matches zero rows.
72    pub const MATERIALIZE_EMPTY_RESULT: &str = "MATERIALIZE_EMPTY_RESULT";
73    /// Returned when serialization to the requested format fails during `row_materialize`.
74    pub const MATERIALIZE_FORMAT_ERROR: &str = "MATERIALIZE_FORMAT_ERROR";
75    /// Returned when a projected field name is not present in the schema.
76    pub const MATERIALIZE_FIELD_UNKNOWN: &str = "MATERIALIZE_FIELD_UNKNOWN";
77    /// Returned when `row_materialize` parameters are structurally invalid.
78    pub const MATERIALIZE_INVALID_PARAM: &str = "MATERIALIZE_INVALID_PARAM";
79    /// Returned when a named query alias does not exist in `_aliases`.
80    pub const ALIAS_NOT_FOUND: &str = "ALIAS_NOT_FOUND";
81    /// Returned when `alias_create` is called but an alias with the same name
82    /// already exists in the table's `_aliases` storage.
83    pub const ALIAS_ALREADY_EXISTS: &str = "ALIAS_ALREADY_EXISTS";
84    /// Returned when `alias_run` is called without `params` but the alias has
85    /// a non-null `params_schema` (i.e. the alias requires parameter injection).
86    pub const ALIAS_PARAMS_REQUIRED: &str = "ALIAS_PARAMS_REQUIRED";
87    /// Returned when MiniJinja template rendering fails (syntax error or
88    /// missing variable) during `alias_run`.
89    pub const ALIAS_TEMPLATE_ERROR: &str = "ALIAS_TEMPLATE_ERROR";
90    /// Returned when an id prefix matches more than one row and the caller
91    /// must disambiguate by using a longer prefix or the full UUID.
92    pub const AMBIGUOUS_ID: &str = "AMBIGUOUS_ID";
93    /// Returned when the `query_aggregate` tool receives a structurally
94    /// inconsistent request (empty sources, ATTACH-limit exceeded,
95    /// inner-without-group-by, etc.) — distinct from per-field validation
96    /// errors (those use `VALIDATION_ERROR`) and from raw SQLite failures
97    /// (those use `STORAGE_ERROR`).
98    pub const AGGREGATOR_ERROR: &str = "AGGREGATOR_ERROR";
99    /// Returned when a partial-edit tool (`content_view` / `content_replace` /
100    /// `content_insert`) targets a field whose schema type is not `String`.
101    pub const TYPE_ERROR: &str = "TYPE_ERROR";
102    /// Returned when `content_replace` finds zero occurrences of `old_str` in
103    /// the target field (or within the scoped `view_range`).
104    pub const STRING_NOT_FOUND: &str = "STRING_NOT_FOUND";
105    /// Returned when `content_replace` finds two or more occurrences of
106    /// `old_str` and `replace_all` was not set — caller must scope with
107    /// `view_range` or pass `replace_all=true`.
108    pub const AMBIGUOUS_MATCH: &str = "AMBIGUOUS_MATCH";
109    /// Returned when `content_insert` receives a `line` value that exceeds
110    /// `total_lines + 1`.
111    pub const OUT_OF_RANGE: &str = "OUT_OF_RANGE";
112    /// Returned when `data_snapshot` is called with `upload=true` but the
113    /// server binary was built without the `s3-upload` feature, or the
114    /// `MINI_APP_S3_*` environment variables are incomplete.
115    pub const UPLOAD_NOT_CONFIGURED: &str = "UPLOAD_NOT_CONFIGURED";
116    /// Returned when an S3-compatible upload operation fails (network error,
117    /// auth rejection, or local snapshot file read failure).
118    pub const UPLOAD_FAILED: &str = "UPLOAD_FAILED";
119}
120
121/// All errors that can arise inside mini-app-core.
122#[derive(Error, Debug)]
123pub enum MiniAppError {
124    /// Validation failed for a specific field.
125    #[error("validation error on field '{field}': {reason}")]
126    Validation { field: String, reason: String },
127
128    /// No row with the given `id` was found.
129    #[error("row not found: {id}")]
130    NotFound { id: String },
131
132    /// `schema.yaml` could not be parsed.
133    #[error("schema parse error: {0}")]
134    Schema(String),
135
136    /// A SQLite storage error occurred.
137    #[error("storage error: {0}")]
138    Storage(#[from] rusqlite::Error),
139
140    /// A filesystem I/O error occurred.
141    #[error("io error: {0}")]
142    Io(#[from] std::io::Error),
143
144    /// An environment-variable or `.env` configuration error occurred.
145    #[error("config error: {0}")]
146    Config(String),
147
148    /// The requested table is not mounted in the registry.
149    #[error("table not found: {table}")]
150    TableNotFound { table: String },
151
152    /// Multi-table mode requires a `table` argument that was omitted.
153    #[error("table argument is required in multi-table mode")]
154    TableRequired,
155
156    /// A schema file already exists for the given table.
157    #[error("schema already exists: {table}")]
158    SchemaExists { table: String },
159
160    /// A backup I/O or SQLite backup operation failed.
161    #[error("backup error: {0}")]
162    Backup(String),
163
164    /// A snapshot I/O or SQLite snapshot operation failed.
165    #[error("snapshot error: {0}")]
166    Snapshot(String),
167
168    /// `schema_batch` was aborted because one of its ops failed.
169    #[error("batch aborted at op #{op_index}: {reason}")]
170    BatchAborted { op_index: usize, reason: String },
171
172    /// The destination path supplied to `row_materialize` is not absolute.
173    #[error("materialize dest must be absolute: {path}")]
174    MaterializeDestRelative { path: String },
175
176    /// The destination path is absolute but invalid for another reason.
177    #[error("materialize dest invalid '{path}': {reason}")]
178    MaterializeDestInvalid { path: String, reason: String },
179
180    /// A filesystem I/O error occurred during `row_materialize`.
181    #[error("materialize io error: {0}")]
182    MaterializeIo(String),
183
184    /// SHA-256 computation failed during `row_materialize`.
185    #[error("materialize sha256 error: {0}")]
186    MaterializeSha256(String),
187
188    /// The row id specified in a `ById` selector was not found.
189    #[error("materialize row not found: {id}")]
190    MaterializeRowNotFound { id: String },
191
192    /// A `ByFilter` selector matched zero rows and `ignore_empty` is false.
193    #[error("materialize filter matched zero rows")]
194    MaterializeEmptyResult,
195
196    /// Serialization to the requested output format failed.
197    #[error("materialize format error: {0}")]
198    MaterializeFormatError(String),
199
200    /// A projected field name is not present in the table schema.
201    #[error("materialize unknown field: {field}")]
202    MaterializeFieldUnknown { field: String },
203
204    /// `row_materialize` parameters are structurally inconsistent.
205    #[error("materialize invalid param '{field}': {reason}")]
206    MaterializeInvalidParam { field: String, reason: String },
207
208    /// No query alias with the given `name` was found in `_aliases`.
209    #[error("alias not found: {name}")]
210    AliasNotFound { name: String },
211
212    /// An alias with the given `name` already exists in `_aliases`.
213    #[error("alias already exists: {name}")]
214    AliasAlreadyExists { name: String },
215
216    /// `alias_run` was called without `params` but the alias requires parameter
217    /// injection (its `params_schema` is non-null).
218    #[error("alias '{name}' requires params but none were provided")]
219    AliasParamsRequired { name: String },
220
221    /// MiniJinja template rendering failed during `alias_run`.
222    #[error("alias template render error: {0}")]
223    AliasTemplateError(String),
224
225    /// An id prefix matched more than one row.
226    #[error("ambiguous id prefix '{id_prefix}': {n} candidates", n = candidates.len())]
227    AmbiguousId {
228        id_prefix: String,
229        candidates: Vec<String>,
230    },
231
232    /// A structural inconsistency was detected in a `query_aggregate` request
233    /// (empty sources, ATTACH-limit exceeded, inner-without-group-by, etc.).
234    #[error("aggregator error: {0}")]
235    Aggregator(String),
236
237    /// A partial-edit tool targeted a field whose schema type is not `String`.
238    #[error("field '{field}' is not a string field (type: {actual_type})")]
239    FieldTypeError { field: String, actual_type: String },
240
241    /// `content_replace` found zero occurrences of `old_str`.
242    #[error("old_str not found in field '{field}'")]
243    StringNotFound { field: String },
244
245    /// `content_replace` found multiple occurrences of `old_str` and
246    /// `replace_all` was not requested.
247    #[error("ambiguous match: {matches} occurrences of old_str in field '{field}'")]
248    AmbiguousMatch {
249        field: String,
250        matches: u32,
251        candidates: Vec<MatchCandidate>,
252    },
253
254    /// `content_insert` received a `line` that exceeds `total_lines + 1`.
255    #[error("line {line} is out of range (field has {total_lines} lines)")]
256    LineOutOfRange { line: u32, total_lines: u32 },
257
258    /// `data_snapshot` was asked to upload but the upload backend is not
259    /// available (feature disabled or `MINI_APP_S3_*` env incomplete).
260    #[error("upload not configured: {0}")]
261    UploadNotConfigured(String),
262
263    /// An S3-compatible upload operation failed.
264    #[error("upload failed: {0}")]
265    Upload(String),
266}
267
268impl MiniAppError {
269    /// Returns the machine-readable error code for this variant.
270    pub fn code(&self) -> &'static str {
271        match self {
272            MiniAppError::Validation { .. } => codes::VALIDATION_ERROR,
273            MiniAppError::NotFound { .. } => codes::NOT_FOUND,
274            MiniAppError::Schema(_) => codes::SCHEMA_ERROR,
275            MiniAppError::Storage(_) => codes::STORAGE_ERROR,
276            MiniAppError::Io(_) => codes::IO_ERROR,
277            MiniAppError::Config(_) => codes::CONFIG_ERROR,
278            MiniAppError::TableNotFound { .. } => codes::TABLE_NOT_FOUND,
279            MiniAppError::TableRequired => codes::TABLE_REQUIRED,
280            MiniAppError::SchemaExists { .. } => codes::SCHEMA_EXISTS,
281            MiniAppError::Backup(_) => codes::BACKUP_ERROR,
282            MiniAppError::Snapshot(_) => codes::SNAPSHOT_ERROR,
283            MiniAppError::BatchAborted { .. } => codes::BATCH_ABORTED,
284            MiniAppError::MaterializeDestRelative { .. } => codes::MATERIALIZE_DEST_RELATIVE,
285            MiniAppError::MaterializeDestInvalid { .. } => codes::MATERIALIZE_DEST_INVALID,
286            MiniAppError::MaterializeIo(_) => codes::MATERIALIZE_IO_ERROR,
287            MiniAppError::MaterializeSha256(_) => codes::MATERIALIZE_SHA256_ERROR,
288            MiniAppError::MaterializeRowNotFound { .. } => codes::MATERIALIZE_ROW_NOT_FOUND,
289            MiniAppError::MaterializeEmptyResult => codes::MATERIALIZE_EMPTY_RESULT,
290            MiniAppError::MaterializeFormatError(_) => codes::MATERIALIZE_FORMAT_ERROR,
291            MiniAppError::MaterializeFieldUnknown { .. } => codes::MATERIALIZE_FIELD_UNKNOWN,
292            MiniAppError::MaterializeInvalidParam { .. } => codes::MATERIALIZE_INVALID_PARAM,
293            MiniAppError::AliasNotFound { .. } => codes::ALIAS_NOT_FOUND,
294            MiniAppError::AliasAlreadyExists { .. } => codes::ALIAS_ALREADY_EXISTS,
295            MiniAppError::AliasParamsRequired { .. } => codes::ALIAS_PARAMS_REQUIRED,
296            MiniAppError::AliasTemplateError(_) => codes::ALIAS_TEMPLATE_ERROR,
297            MiniAppError::AmbiguousId { .. } => codes::AMBIGUOUS_ID,
298            MiniAppError::Aggregator(_) => codes::AGGREGATOR_ERROR,
299            MiniAppError::FieldTypeError { .. } => codes::TYPE_ERROR,
300            MiniAppError::StringNotFound { .. } => codes::STRING_NOT_FOUND,
301            MiniAppError::AmbiguousMatch { .. } => codes::AMBIGUOUS_MATCH,
302            MiniAppError::LineOutOfRange { .. } => codes::OUT_OF_RANGE,
303            MiniAppError::UploadNotConfigured(_) => codes::UPLOAD_NOT_CONFIGURED,
304            MiniAppError::Upload(_) => codes::UPLOAD_FAILED,
305        }
306    }
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    #[test]
314    fn error_code_all_variants() {
315        let cases: Vec<(&str, MiniAppError)> = vec![
316            (
317                codes::VALIDATION_ERROR,
318                MiniAppError::Validation {
319                    field: "f".into(),
320                    reason: "r".into(),
321                },
322            ),
323            (codes::NOT_FOUND, MiniAppError::NotFound { id: "x".into() }),
324            (codes::SCHEMA_ERROR, MiniAppError::Schema("s".into())),
325            (
326                codes::IO_ERROR,
327                MiniAppError::Io(std::io::Error::other("e")),
328            ),
329            (codes::CONFIG_ERROR, MiniAppError::Config("c".into())),
330            (
331                codes::TABLE_NOT_FOUND,
332                MiniAppError::TableNotFound { table: "t".into() },
333            ),
334            (codes::TABLE_REQUIRED, MiniAppError::TableRequired),
335            (
336                codes::SCHEMA_EXISTS,
337                MiniAppError::SchemaExists {
338                    table: "my_table".into(),
339                },
340            ),
341            (
342                codes::BACKUP_ERROR,
343                MiniAppError::Backup("disk full".into()),
344            ),
345            (
346                codes::SNAPSHOT_ERROR,
347                MiniAppError::Snapshot("snapshot failed".into()),
348            ),
349            (
350                codes::BATCH_ABORTED,
351                MiniAppError::BatchAborted {
352                    op_index: 2,
353                    reason: "schema not found".into(),
354                },
355            ),
356            (
357                codes::MATERIALIZE_DEST_RELATIVE,
358                MiniAppError::MaterializeDestRelative {
359                    path: "relative/path".into(),
360                },
361            ),
362            (
363                codes::MATERIALIZE_DEST_INVALID,
364                MiniAppError::MaterializeDestInvalid {
365                    path: "/bad/path".into(),
366                    reason: "parent dir not writable".into(),
367                },
368            ),
369            (
370                codes::MATERIALIZE_IO_ERROR,
371                MiniAppError::MaterializeIo("write failed".into()),
372            ),
373            (
374                codes::MATERIALIZE_SHA256_ERROR,
375                MiniAppError::MaterializeSha256("task panicked".into()),
376            ),
377            (
378                codes::MATERIALIZE_ROW_NOT_FOUND,
379                MiniAppError::MaterializeRowNotFound { id: "row-1".into() },
380            ),
381            (
382                codes::MATERIALIZE_EMPTY_RESULT,
383                MiniAppError::MaterializeEmptyResult,
384            ),
385            (
386                codes::MATERIALIZE_FORMAT_ERROR,
387                MiniAppError::MaterializeFormatError("yaml error".into()),
388            ),
389            (
390                codes::MATERIALIZE_FIELD_UNKNOWN,
391                MiniAppError::MaterializeFieldUnknown {
392                    field: "unknown_field".into(),
393                },
394            ),
395            (
396                codes::MATERIALIZE_INVALID_PARAM,
397                MiniAppError::MaterializeInvalidParam {
398                    field: "concat".into(),
399                    reason: "concat=true requires ByFilter selector".into(),
400                },
401            ),
402            (
403                codes::ALIAS_NOT_FOUND,
404                MiniAppError::AliasNotFound {
405                    name: "my_alias".into(),
406                },
407            ),
408            (
409                codes::ALIAS_ALREADY_EXISTS,
410                MiniAppError::AliasAlreadyExists {
411                    name: "my_alias".into(),
412                },
413            ),
414            (
415                codes::ALIAS_PARAMS_REQUIRED,
416                MiniAppError::AliasParamsRequired {
417                    name: "my_alias".into(),
418                },
419            ),
420            (
421                codes::ALIAS_TEMPLATE_ERROR,
422                MiniAppError::AliasTemplateError("template syntax error".into()),
423            ),
424            (
425                codes::AMBIGUOUS_ID,
426                MiniAppError::AmbiguousId {
427                    id_prefix: "abc".into(),
428                    candidates: vec!["abc-1".into(), "abc-2".into()],
429                },
430            ),
431            (
432                codes::AGGREGATOR_ERROR,
433                MiniAppError::Aggregator("empty sources".into()),
434            ),
435            (
436                codes::TYPE_ERROR,
437                MiniAppError::FieldTypeError {
438                    field: "count".into(),
439                    actual_type: "Number".into(),
440                },
441            ),
442            (
443                codes::STRING_NOT_FOUND,
444                MiniAppError::StringNotFound {
445                    field: "body".into(),
446                },
447            ),
448            (
449                codes::AMBIGUOUS_MATCH,
450                MiniAppError::AmbiguousMatch {
451                    field: "body".into(),
452                    matches: 2,
453                    candidates: vec![],
454                },
455            ),
456            (
457                codes::OUT_OF_RANGE,
458                MiniAppError::LineOutOfRange {
459                    line: 100,
460                    total_lines: 50,
461                },
462            ),
463            (
464                codes::UPLOAD_NOT_CONFIGURED,
465                MiniAppError::UploadNotConfigured("missing MINI_APP_S3_BUCKET".into()),
466            ),
467            (
468                codes::UPLOAD_FAILED,
469                MiniAppError::Upload("put rejected".into()),
470            ),
471        ];
472        for (expected_code, err) in cases {
473            assert_eq!(
474                err.code(),
475                expected_code,
476                "wrong code for variant containing code {}",
477                expected_code
478            );
479        }
480    }
481
482    #[test]
483    fn backup_error_code_is_not_storage_or_io() {
484        let err = MiniAppError::Backup("some rusqlite error".to_string());
485        assert_eq!(err.code(), codes::BACKUP_ERROR);
486        assert_ne!(err.code(), codes::STORAGE_ERROR);
487        assert_ne!(err.code(), codes::IO_ERROR);
488    }
489}