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}
113
114/// All errors that can arise inside mini-app-core.
115#[derive(Error, Debug)]
116pub enum MiniAppError {
117    /// Validation failed for a specific field.
118    #[error("validation error on field '{field}': {reason}")]
119    Validation { field: String, reason: String },
120
121    /// No row with the given `id` was found.
122    #[error("row not found: {id}")]
123    NotFound { id: String },
124
125    /// `schema.yaml` could not be parsed.
126    #[error("schema parse error: {0}")]
127    Schema(String),
128
129    /// A SQLite storage error occurred.
130    #[error("storage error: {0}")]
131    Storage(#[from] rusqlite::Error),
132
133    /// A filesystem I/O error occurred.
134    #[error("io error: {0}")]
135    Io(#[from] std::io::Error),
136
137    /// An environment-variable or `.env` configuration error occurred.
138    #[error("config error: {0}")]
139    Config(String),
140
141    /// The requested table is not mounted in the registry.
142    #[error("table not found: {table}")]
143    TableNotFound { table: String },
144
145    /// Multi-table mode requires a `table` argument that was omitted.
146    #[error("table argument is required in multi-table mode")]
147    TableRequired,
148
149    /// A schema file already exists for the given table.
150    #[error("schema already exists: {table}")]
151    SchemaExists { table: String },
152
153    /// A backup I/O or SQLite backup operation failed.
154    #[error("backup error: {0}")]
155    Backup(String),
156
157    /// A snapshot I/O or SQLite snapshot operation failed.
158    #[error("snapshot error: {0}")]
159    Snapshot(String),
160
161    /// `schema_batch` was aborted because one of its ops failed.
162    #[error("batch aborted at op #{op_index}: {reason}")]
163    BatchAborted { op_index: usize, reason: String },
164
165    /// The destination path supplied to `row_materialize` is not absolute.
166    #[error("materialize dest must be absolute: {path}")]
167    MaterializeDestRelative { path: String },
168
169    /// The destination path is absolute but invalid for another reason.
170    #[error("materialize dest invalid '{path}': {reason}")]
171    MaterializeDestInvalid { path: String, reason: String },
172
173    /// A filesystem I/O error occurred during `row_materialize`.
174    #[error("materialize io error: {0}")]
175    MaterializeIo(String),
176
177    /// SHA-256 computation failed during `row_materialize`.
178    #[error("materialize sha256 error: {0}")]
179    MaterializeSha256(String),
180
181    /// The row id specified in a `ById` selector was not found.
182    #[error("materialize row not found: {id}")]
183    MaterializeRowNotFound { id: String },
184
185    /// A `ByFilter` selector matched zero rows and `ignore_empty` is false.
186    #[error("materialize filter matched zero rows")]
187    MaterializeEmptyResult,
188
189    /// Serialization to the requested output format failed.
190    #[error("materialize format error: {0}")]
191    MaterializeFormatError(String),
192
193    /// A projected field name is not present in the table schema.
194    #[error("materialize unknown field: {field}")]
195    MaterializeFieldUnknown { field: String },
196
197    /// `row_materialize` parameters are structurally inconsistent.
198    #[error("materialize invalid param '{field}': {reason}")]
199    MaterializeInvalidParam { field: String, reason: String },
200
201    /// No query alias with the given `name` was found in `_aliases`.
202    #[error("alias not found: {name}")]
203    AliasNotFound { name: String },
204
205    /// An alias with the given `name` already exists in `_aliases`.
206    #[error("alias already exists: {name}")]
207    AliasAlreadyExists { name: String },
208
209    /// `alias_run` was called without `params` but the alias requires parameter
210    /// injection (its `params_schema` is non-null).
211    #[error("alias '{name}' requires params but none were provided")]
212    AliasParamsRequired { name: String },
213
214    /// MiniJinja template rendering failed during `alias_run`.
215    #[error("alias template render error: {0}")]
216    AliasTemplateError(String),
217
218    /// An id prefix matched more than one row.
219    #[error("ambiguous id prefix '{id_prefix}': {n} candidates", n = candidates.len())]
220    AmbiguousId {
221        id_prefix: String,
222        candidates: Vec<String>,
223    },
224
225    /// A structural inconsistency was detected in a `query_aggregate` request
226    /// (empty sources, ATTACH-limit exceeded, inner-without-group-by, etc.).
227    #[error("aggregator error: {0}")]
228    Aggregator(String),
229
230    /// A partial-edit tool targeted a field whose schema type is not `String`.
231    #[error("field '{field}' is not a string field (type: {actual_type})")]
232    FieldTypeError { field: String, actual_type: String },
233
234    /// `content_replace` found zero occurrences of `old_str`.
235    #[error("old_str not found in field '{field}'")]
236    StringNotFound { field: String },
237
238    /// `content_replace` found multiple occurrences of `old_str` and
239    /// `replace_all` was not requested.
240    #[error("ambiguous match: {matches} occurrences of old_str in field '{field}'")]
241    AmbiguousMatch {
242        field: String,
243        matches: u32,
244        candidates: Vec<MatchCandidate>,
245    },
246
247    /// `content_insert` received a `line` that exceeds `total_lines + 1`.
248    #[error("line {line} is out of range (field has {total_lines} lines)")]
249    LineOutOfRange { line: u32, total_lines: u32 },
250}
251
252impl MiniAppError {
253    /// Returns the machine-readable error code for this variant.
254    pub fn code(&self) -> &'static str {
255        match self {
256            MiniAppError::Validation { .. } => codes::VALIDATION_ERROR,
257            MiniAppError::NotFound { .. } => codes::NOT_FOUND,
258            MiniAppError::Schema(_) => codes::SCHEMA_ERROR,
259            MiniAppError::Storage(_) => codes::STORAGE_ERROR,
260            MiniAppError::Io(_) => codes::IO_ERROR,
261            MiniAppError::Config(_) => codes::CONFIG_ERROR,
262            MiniAppError::TableNotFound { .. } => codes::TABLE_NOT_FOUND,
263            MiniAppError::TableRequired => codes::TABLE_REQUIRED,
264            MiniAppError::SchemaExists { .. } => codes::SCHEMA_EXISTS,
265            MiniAppError::Backup(_) => codes::BACKUP_ERROR,
266            MiniAppError::Snapshot(_) => codes::SNAPSHOT_ERROR,
267            MiniAppError::BatchAborted { .. } => codes::BATCH_ABORTED,
268            MiniAppError::MaterializeDestRelative { .. } => codes::MATERIALIZE_DEST_RELATIVE,
269            MiniAppError::MaterializeDestInvalid { .. } => codes::MATERIALIZE_DEST_INVALID,
270            MiniAppError::MaterializeIo(_) => codes::MATERIALIZE_IO_ERROR,
271            MiniAppError::MaterializeSha256(_) => codes::MATERIALIZE_SHA256_ERROR,
272            MiniAppError::MaterializeRowNotFound { .. } => codes::MATERIALIZE_ROW_NOT_FOUND,
273            MiniAppError::MaterializeEmptyResult => codes::MATERIALIZE_EMPTY_RESULT,
274            MiniAppError::MaterializeFormatError(_) => codes::MATERIALIZE_FORMAT_ERROR,
275            MiniAppError::MaterializeFieldUnknown { .. } => codes::MATERIALIZE_FIELD_UNKNOWN,
276            MiniAppError::MaterializeInvalidParam { .. } => codes::MATERIALIZE_INVALID_PARAM,
277            MiniAppError::AliasNotFound { .. } => codes::ALIAS_NOT_FOUND,
278            MiniAppError::AliasAlreadyExists { .. } => codes::ALIAS_ALREADY_EXISTS,
279            MiniAppError::AliasParamsRequired { .. } => codes::ALIAS_PARAMS_REQUIRED,
280            MiniAppError::AliasTemplateError(_) => codes::ALIAS_TEMPLATE_ERROR,
281            MiniAppError::AmbiguousId { .. } => codes::AMBIGUOUS_ID,
282            MiniAppError::Aggregator(_) => codes::AGGREGATOR_ERROR,
283            MiniAppError::FieldTypeError { .. } => codes::TYPE_ERROR,
284            MiniAppError::StringNotFound { .. } => codes::STRING_NOT_FOUND,
285            MiniAppError::AmbiguousMatch { .. } => codes::AMBIGUOUS_MATCH,
286            MiniAppError::LineOutOfRange { .. } => codes::OUT_OF_RANGE,
287        }
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    #[test]
296    fn error_code_all_variants() {
297        let cases: Vec<(&str, MiniAppError)> = vec![
298            (
299                codes::VALIDATION_ERROR,
300                MiniAppError::Validation {
301                    field: "f".into(),
302                    reason: "r".into(),
303                },
304            ),
305            (codes::NOT_FOUND, MiniAppError::NotFound { id: "x".into() }),
306            (codes::SCHEMA_ERROR, MiniAppError::Schema("s".into())),
307            (
308                codes::IO_ERROR,
309                MiniAppError::Io(std::io::Error::other("e")),
310            ),
311            (codes::CONFIG_ERROR, MiniAppError::Config("c".into())),
312            (
313                codes::TABLE_NOT_FOUND,
314                MiniAppError::TableNotFound { table: "t".into() },
315            ),
316            (codes::TABLE_REQUIRED, MiniAppError::TableRequired),
317            (
318                codes::SCHEMA_EXISTS,
319                MiniAppError::SchemaExists {
320                    table: "my_table".into(),
321                },
322            ),
323            (
324                codes::BACKUP_ERROR,
325                MiniAppError::Backup("disk full".into()),
326            ),
327            (
328                codes::SNAPSHOT_ERROR,
329                MiniAppError::Snapshot("snapshot failed".into()),
330            ),
331            (
332                codes::BATCH_ABORTED,
333                MiniAppError::BatchAborted {
334                    op_index: 2,
335                    reason: "schema not found".into(),
336                },
337            ),
338            (
339                codes::MATERIALIZE_DEST_RELATIVE,
340                MiniAppError::MaterializeDestRelative {
341                    path: "relative/path".into(),
342                },
343            ),
344            (
345                codes::MATERIALIZE_DEST_INVALID,
346                MiniAppError::MaterializeDestInvalid {
347                    path: "/bad/path".into(),
348                    reason: "parent dir not writable".into(),
349                },
350            ),
351            (
352                codes::MATERIALIZE_IO_ERROR,
353                MiniAppError::MaterializeIo("write failed".into()),
354            ),
355            (
356                codes::MATERIALIZE_SHA256_ERROR,
357                MiniAppError::MaterializeSha256("task panicked".into()),
358            ),
359            (
360                codes::MATERIALIZE_ROW_NOT_FOUND,
361                MiniAppError::MaterializeRowNotFound { id: "row-1".into() },
362            ),
363            (
364                codes::MATERIALIZE_EMPTY_RESULT,
365                MiniAppError::MaterializeEmptyResult,
366            ),
367            (
368                codes::MATERIALIZE_FORMAT_ERROR,
369                MiniAppError::MaterializeFormatError("yaml error".into()),
370            ),
371            (
372                codes::MATERIALIZE_FIELD_UNKNOWN,
373                MiniAppError::MaterializeFieldUnknown {
374                    field: "unknown_field".into(),
375                },
376            ),
377            (
378                codes::MATERIALIZE_INVALID_PARAM,
379                MiniAppError::MaterializeInvalidParam {
380                    field: "concat".into(),
381                    reason: "concat=true requires ByFilter selector".into(),
382                },
383            ),
384            (
385                codes::ALIAS_NOT_FOUND,
386                MiniAppError::AliasNotFound {
387                    name: "my_alias".into(),
388                },
389            ),
390            (
391                codes::ALIAS_ALREADY_EXISTS,
392                MiniAppError::AliasAlreadyExists {
393                    name: "my_alias".into(),
394                },
395            ),
396            (
397                codes::ALIAS_PARAMS_REQUIRED,
398                MiniAppError::AliasParamsRequired {
399                    name: "my_alias".into(),
400                },
401            ),
402            (
403                codes::ALIAS_TEMPLATE_ERROR,
404                MiniAppError::AliasTemplateError("template syntax error".into()),
405            ),
406            (
407                codes::AMBIGUOUS_ID,
408                MiniAppError::AmbiguousId {
409                    id_prefix: "abc".into(),
410                    candidates: vec!["abc-1".into(), "abc-2".into()],
411                },
412            ),
413            (
414                codes::AGGREGATOR_ERROR,
415                MiniAppError::Aggregator("empty sources".into()),
416            ),
417            (
418                codes::TYPE_ERROR,
419                MiniAppError::FieldTypeError {
420                    field: "count".into(),
421                    actual_type: "Number".into(),
422                },
423            ),
424            (
425                codes::STRING_NOT_FOUND,
426                MiniAppError::StringNotFound {
427                    field: "body".into(),
428                },
429            ),
430            (
431                codes::AMBIGUOUS_MATCH,
432                MiniAppError::AmbiguousMatch {
433                    field: "body".into(),
434                    matches: 2,
435                    candidates: vec![],
436                },
437            ),
438            (
439                codes::OUT_OF_RANGE,
440                MiniAppError::LineOutOfRange {
441                    line: 100,
442                    total_lines: 50,
443                },
444            ),
445        ];
446        for (expected_code, err) in cases {
447            assert_eq!(
448                err.code(),
449                expected_code,
450                "wrong code for variant containing code {}",
451                expected_code
452            );
453        }
454    }
455
456    #[test]
457    fn backup_error_code_is_not_storage_or_io() {
458        let err = MiniAppError::Backup("some rusqlite error".to_string());
459        assert_eq!(err.code(), codes::BACKUP_ERROR);
460        assert_ne!(err.code(), codes::STORAGE_ERROR);
461        assert_ne!(err.code(), codes::IO_ERROR);
462    }
463}