Skip to main content

reflex/
errors.rs

1use thiserror::Error;
2
3#[derive(Debug, Error)]
4pub enum ReflexError {
5    #[error("Index not found. Run 'rfx index' to build the search index.")]
6    IndexNotFound,
7
8    #[error("Query syntax error: {0}")]
9    QuerySyntaxError(String),
10
11    #[error("I/O error: {0}")]
12    IoError(String),
13
14    #[error("Parse error: {0}")]
15    ParseError(String),
16
17    #[error("LLM error: {0}")]
18    LlmError(String),
19
20    /// A tool/API call carried an argument set the server cannot accept:
21    /// unknown key, wrong type, or a required key that is absent. The payload
22    /// is the full human-readable diagnostic (received keys, valid keys,
23    /// nearest-match suggestion). Maps to JSON-RPC `-32602` in the MCP layer.
24    #[error("{0}")]
25    InvalidParams(String),
26
27    /// The on-disk cache failed structural validation (bad magic bytes, short
28    /// file, broken SQLite). The payload is the inner finding, e.g.
29    /// `content.bin is too small - appears to be corrupted`. The Display text
30    /// keeps the historical wording so string-matching callers stay valid.
31    #[error(
32        "Cache appears to be corrupted: {0}. Run 'rfx clear' followed by 'rfx index' to rebuild."
33    )]
34    CacheCorrupted(String),
35
36    /// Another process holds the workspace index lock (`.reflex/index.lock`).
37    /// The payload names the lock path.
38    #[error(
39        "Another indexer is already running on this workspace ({0}). Wait for it to finish and retry."
40    )]
41    IndexLocked(String),
42
43    /// The detached background symbol pass (`rfx index-symbols-internal`) holds
44    /// `meta.db`. Raised BEFORE any SQLite call, so the agent sees progress instead
45    /// of `database is locked: Error code 5`, which is what 1.7.0 surfaced for the
46    /// four minutes a 1027-file pass was running.
47    #[error(
48        "symbol indexing in progress (pid {pid}, started {started_at}, {processed}/{total} files)"
49    )]
50    SymbolIndexingInProgress {
51        pid: u32,
52        /// Wall-clock start time, `HH:MM:SS`.
53        started_at: String,
54        processed: usize,
55        total: usize,
56    },
57
58    /// This `.reflex/` was written by a different Reflex build.
59    ///
60    /// Deliberately NOT `CacheCorrupted`: the MCP layer force-rebuilds on corruption,
61    /// so classifying a version mismatch as corruption makes N servers at differing
62    /// versions rebuild the same cache concurrently. That stampede is what produced
63    /// `content.bin is too small` in the field.
64    #[error(
65        "this .reflex/ was written by reflex {owner_version}{owner_sha}; this binary is {this_version}. \
66         Rebuild with force, or run the matching binary."
67    )]
68    CacheVersionMismatch {
69        owner_version: String,
70        /// Pre-formatted as ` (sha abc1234)`, or empty when unknown.
71        owner_sha: String,
72        this_version: String,
73    },
74}
75
76impl ReflexError {
77    pub fn kind(&self) -> &'static str {
78        match self {
79            Self::IndexNotFound => "IndexNotFound",
80            Self::QuerySyntaxError(_) => "QuerySyntaxError",
81            Self::IoError(_) => "IoError",
82            Self::ParseError(_) => "ParseError",
83            Self::LlmError(_) => "LlmError",
84            Self::InvalidParams(_) => "InvalidParams",
85            Self::CacheCorrupted(_) => "CacheCorrupted",
86            Self::IndexLocked(_) => "IndexLocked",
87            Self::SymbolIndexingInProgress { .. } => "SymbolIndexingInProgress",
88            Self::CacheVersionMismatch { .. } => "CacheVersionMismatch",
89        }
90    }
91
92    pub fn exit_code(&self) -> i32 {
93        match self {
94            Self::IndexNotFound => 2,
95            Self::QuerySyntaxError(_) => 3,
96            Self::IoError(_) => 4,
97            Self::ParseError(_) => 5,
98            Self::LlmError(_) => 6,
99            Self::InvalidParams(_) => 3,
100            Self::CacheCorrupted(_) => 2,
101            Self::IndexLocked(_) => 7,
102            Self::SymbolIndexingInProgress { .. } => 7,
103            Self::CacheVersionMismatch { .. } => 2,
104        }
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    #[test]
113    fn test_exit_codes() {
114        assert_eq!(ReflexError::IndexNotFound.exit_code(), 2);
115        assert_eq!(ReflexError::QuerySyntaxError("bad".into()).exit_code(), 3);
116        assert_eq!(ReflexError::IoError("fail".into()).exit_code(), 4);
117        assert_eq!(ReflexError::ParseError("oops".into()).exit_code(), 5);
118        assert_eq!(ReflexError::LlmError("timeout".into()).exit_code(), 6);
119        assert_eq!(ReflexError::InvalidParams("bad".into()).exit_code(), 3);
120        assert_eq!(ReflexError::CacheCorrupted("x".into()).exit_code(), 2);
121        assert_eq!(ReflexError::IndexLocked("x".into()).exit_code(), 7);
122    }
123
124    #[test]
125    fn test_new_variant_kinds_and_display() {
126        assert_eq!(
127            ReflexError::InvalidParams("x".into()).kind(),
128            "InvalidParams"
129        );
130        assert_eq!(
131            ReflexError::CacheCorrupted("x".into()).kind(),
132            "CacheCorrupted"
133        );
134        assert_eq!(ReflexError::IndexLocked("x".into()).kind(), "IndexLocked");
135        // Display text of CacheCorrupted must keep the historical wording.
136        let msg = ReflexError::CacheCorrupted(
137            "content.bin is too small - appears to be corrupted".into(),
138        )
139        .to_string();
140        assert_eq!(
141            msg,
142            "Cache appears to be corrupted: content.bin is too small - appears to be corrupted. \
143             Run 'rfx clear' followed by 'rfx index' to rebuild."
144        );
145        assert_eq!(
146            ReflexError::InvalidParams("Unknown argument \"q\"".into()).to_string(),
147            "Unknown argument \"q\""
148        );
149    }
150
151    #[test]
152    fn test_kind_strings() {
153        assert_eq!(ReflexError::IndexNotFound.kind(), "IndexNotFound");
154        assert_eq!(
155            ReflexError::QuerySyntaxError("x".into()).kind(),
156            "QuerySyntaxError"
157        );
158        assert_eq!(ReflexError::IoError("x".into()).kind(), "IoError");
159        assert_eq!(ReflexError::ParseError("x".into()).kind(), "ParseError");
160        assert_eq!(ReflexError::LlmError("x".into()).kind(), "LlmError");
161    }
162
163    #[test]
164    fn test_mcp_json_error_shape() {
165        let err = ReflexError::IndexNotFound;
166        let kind = err.kind();
167        let message = err.to_string();
168        let json_data = serde_json::json!({ "kind": kind, "message": message });
169
170        assert_eq!(json_data["kind"], "IndexNotFound");
171        assert!(json_data["message"].as_str().unwrap().contains("rfx index"));
172    }
173
174    #[test]
175    fn test_http_json_error_shape() {
176        let err = ReflexError::QuerySyntaxError("invalid pattern".into());
177        let kind = err.kind();
178        let msg = err.to_string();
179        let body = serde_json::json!({ "error": { "kind": kind, "message": msg } });
180
181        assert_eq!(body["error"]["kind"], "QuerySyntaxError");
182        assert!(
183            body["error"]["message"]
184                .as_str()
185                .unwrap()
186                .contains("invalid pattern")
187        );
188    }
189
190    #[test]
191    fn test_anyhow_downcast() {
192        let err: anyhow::Error = ReflexError::IndexNotFound.into();
193        let downcasted = err.downcast_ref::<ReflexError>().unwrap();
194        assert_eq!(downcasted.exit_code(), 2);
195        assert_eq!(downcasted.kind(), "IndexNotFound");
196    }
197
198    #[test]
199    fn test_non_reflex_error_fallback() {
200        let err = anyhow::anyhow!("some other error");
201        let exit_code = if let Some(re) = err.downcast_ref::<ReflexError>() {
202            re.exit_code()
203        } else {
204            1
205        };
206        assert_eq!(
207            exit_code, 1,
208            "Non-ReflexError should fall back to exit code 1"
209        );
210    }
211}