Skip to main content

notedthat_core/
error.rs

1//! Domain error types: `Error` and `StorageError`.
2
3use crate::search::SearchError;
4use thiserror::Error;
5
6/// Domain error mapped to ยง6.12 HTTP status codes.
7/// This is the general error for the API layer โ€” storage-specific errors use [`StorageError`].
8#[derive(Debug, Error)]
9pub enum Error {
10    /// Input from the client was invalid (e.g., malformed slug, invalid path).
11    #[error("invalid input: {message}")]
12    InvalidInput {
13        /// Human-readable description of what was invalid.
14        message: String,
15    },
16
17    /// The requested resource was not found.
18    #[error("not found: {resource}")]
19    NotFound {
20        /// Identifies the missing resource (e.g., `"kb:my-notes"`).
21        resource: String,
22    },
23
24    /// The request payload exceeded the allowed size limit.
25    #[error("payload too large: {size} bytes (limit {limit})")]
26    PayloadTooLarge {
27        /// The actual payload size in bytes.
28        size: u64,
29        /// The maximum allowed size in bytes.
30        limit: u64,
31    },
32
33    /// The derived S3 bucket name exceeds 63 characters.
34    #[error("bucket name too long: {name} ({len} chars, max 63)")]
35    BucketNameTooLong {
36        /// The full bucket name that was too long.
37        name: String,
38        /// The length in bytes of the too-long name.
39        len: usize,
40    },
41
42    /// A required configuration value was missing or invalid.
43    #[error("configuration error: {message}")]
44    Config {
45        /// Human-readable description of the configuration problem.
46        message: String,
47    },
48
49    /// A storage-layer error (see [`StorageError`]).
50    #[error(transparent)]
51    Storage(StorageError),
52
53    /// Malformed `Range: bytes=` header โ€” unparseable syntax. Maps to HTTP 400.
54    #[error("malformed Range header: {0}")]
55    MalformedRange(String),
56
57    /// Backend returned 304 Not Modified (conditional request). Maps to HTTP 304.
58    #[error("not modified")]
59    NotModified,
60
61    /// Backend returned 412 Precondition Failed (conditional request). Maps to HTTP 412.
62    #[error("precondition failed")]
63    PreconditionFailed,
64
65    /// Backend returned 416 Range Not Satisfiable. `complete_length` is the total object size.
66    /// Maps to HTTP 416 with `Content-Range: bytes */complete_length`.
67    #[error("range not satisfiable (object size: {complete_length})")]
68    RangeNotSatisfiable {
69        /// The total size of the object in bytes.
70        complete_length: u64,
71    },
72}
73
74/// Storage-layer error โ€” distinct from [`enum@Error`] so that different backends
75/// (S3, in-memory mock, future prefix-per-KB) share a stable failure surface.
76#[derive(Debug)]
77pub enum StorageError {
78    /// The requested object was not found in storage.
79    NotFound {
80        /// The key of the missing object.
81        key: String,
82    },
83
84    /// The storage bucket for the KB was not found.
85    BucketNotFound {
86        /// The bucket name that was not found.
87        bucket: String,
88    },
89
90    /// The storage backend is temporarily unavailable.
91    BackendUnavailable {
92        /// The underlying error message from the backend.
93        message: String,
94    },
95
96    /// An unexpected storage error. The inner error provides details.
97    Other {
98        /// The root cause.
99        source: Box<dyn std::error::Error + Send + Sync>,
100    },
101
102    /// S3 backend returned 304 Not Modified.
103    NotModified,
104
105    /// S3 backend returned 412 Precondition Failed.
106    PreconditionFailed,
107
108    /// S3 backend returned 416 Range Not Satisfiable. `complete_length` is the total object size
109    /// extracted from the `Content-Range: bytes */N` header in the error response.
110    RangeNotSatisfiable {
111        /// The total size of the object in bytes.
112        complete_length: u64,
113    },
114}
115
116impl std::fmt::Display for StorageError {
117    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118        match self {
119            Self::NotFound { key } => write!(f, "object not found: {key}"),
120            Self::BucketNotFound { bucket } => write!(f, "bucket not found: {bucket}"),
121            Self::BackendUnavailable { message } => write!(f, "backend unavailable: {message}"),
122            Self::Other { source } => write!(f, "storage error: {source}"),
123            Self::NotModified => write!(f, "not modified"),
124            Self::PreconditionFailed => write!(f, "precondition failed"),
125            Self::RangeNotSatisfiable { complete_length } => {
126                write!(f, "range not satisfiable (object size: {complete_length})")
127            }
128        }
129    }
130}
131
132impl std::error::Error for StorageError {
133    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
134        if let Self::Other { source } = self {
135            Some(source.as_ref())
136        } else {
137            None
138        }
139    }
140}
141
142impl StorageError {
143    /// Returns `true` if this error represents a missing object or bucket.
144    pub fn is_not_found(&self) -> bool {
145        matches!(self, Self::NotFound { .. } | Self::BucketNotFound { .. })
146    }
147}
148
149impl From<StorageError> for Error {
150    fn from(err: StorageError) -> Self {
151        match err {
152            StorageError::NotModified => Error::NotModified,
153            StorageError::PreconditionFailed => Error::PreconditionFailed,
154            StorageError::RangeNotSatisfiable { complete_length } => {
155                Error::RangeNotSatisfiable { complete_length }
156            }
157            other => Error::Storage(other),
158        }
159    }
160}
161
162impl From<SearchError> for Error {
163    fn from(e: SearchError) -> Self {
164        match e {
165            SearchError::InvalidInput { message } => Error::InvalidInput { message },
166            SearchError::UnknownKb { slug } => Error::NotFound {
167                resource: format!("knowledgebase '{slug}'"),
168            },
169            SearchError::BackendUnavailable { message } => {
170                Error::Storage(StorageError::BackendUnavailable { message })
171            }
172            SearchError::Internal { message } => Error::Config { message },
173        }
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn test_error_invalid_input_display() {
183        let e = Error::InvalidInput {
184            message: "bad".into(),
185        };
186        let s = e.to_string();
187        assert!(s.contains("bad"), "Display should contain the message: {s}");
188    }
189
190    #[test]
191    fn test_error_payload_too_large_display() {
192        let e = Error::PayloadTooLarge {
193            size: 17_000_000_u64,
194            limit: 16_777_216_u64,
195        };
196        let s = e.to_string();
197        assert!(
198            s.contains("17000000") || s.contains("17_000_000"),
199            "Display should contain size: {s}"
200        );
201        assert!(
202            s.contains("16777216") || s.contains("16_777_216"),
203            "Display should contain limit: {s}"
204        );
205    }
206
207    #[test]
208    fn test_storage_error_not_found_display() {
209        let e = StorageError::NotFound { key: "foo".into() };
210        let s = e.to_string();
211        assert!(s.contains("foo"), "Display should contain the key: {s}");
212    }
213
214    #[test]
215    fn test_storage_error_is_not_found_true_for_not_found() {
216        let e = StorageError::NotFound { key: "bar".into() };
217        assert!(e.is_not_found());
218    }
219
220    #[test]
221    fn test_storage_error_is_not_found_true_for_bucket_not_found() {
222        let e = StorageError::BucketNotFound {
223            bucket: "my-bucket".into(),
224        };
225        assert!(e.is_not_found());
226    }
227
228    #[test]
229    fn test_storage_error_is_not_found_false_for_backend_unavailable() {
230        let e = StorageError::BackendUnavailable {
231            message: "connection refused".into(),
232        };
233        assert!(!e.is_not_found());
234    }
235
236    #[test]
237    fn test_from_storage_error_for_error() {
238        let se = StorageError::NotFound { key: "obj".into() };
239        let e: Error = Error::from(se);
240        assert!(matches!(e, Error::Storage(_)));
241    }
242
243    #[test]
244    fn test_error_implements_std_error() {
245        fn assert_std_error<T: std::error::Error>(_: &T) {}
246        let e = Error::InvalidInput {
247            message: "test".into(),
248        };
249        assert_std_error(&e);
250    }
251
252    #[test]
253    fn test_storage_error_implements_std_error() {
254        fn assert_std_error<T: std::error::Error>(_: &T) {}
255        let e = StorageError::BucketNotFound { bucket: "b".into() };
256        assert_std_error(&e);
257    }
258
259    #[test]
260    fn test_error_not_found_display() {
261        let e = Error::NotFound {
262            resource: "kb:my-notes".into(),
263        };
264        let s = e.to_string();
265        assert!(
266            s.contains("my-notes"),
267            "Display should contain resource: {s}"
268        );
269    }
270
271    #[test]
272    fn test_error_bucket_name_too_long_fields() {
273        let e = Error::BucketNameTooLong {
274            name: "nt-toolong-name".into(),
275            len: 15_usize,
276        };
277        if let Error::BucketNameTooLong { name, len } = &e {
278            assert_eq!(name, "nt-toolong-name");
279            assert_eq!(*len, 15);
280        } else {
281            panic!("Wrong variant");
282        }
283    }
284
285    #[test]
286    fn storage_error_range_not_satisfiable_converts_to_error() {
287        let storage_err = StorageError::RangeNotSatisfiable {
288            complete_length: 100,
289        };
290        let err: Error = Error::from(storage_err);
291        assert!(matches!(
292            err,
293            Error::RangeNotSatisfiable {
294                complete_length: 100
295            }
296        ));
297    }
298
299    #[test]
300    fn storage_error_not_modified_converts_to_error() {
301        let storage_err = StorageError::NotModified;
302        let err: Error = Error::from(storage_err);
303        assert!(matches!(err, Error::NotModified));
304    }
305
306    #[test]
307    fn storage_error_precondition_failed_converts_to_error() {
308        let storage_err = StorageError::PreconditionFailed;
309        let err: Error = Error::from(storage_err);
310        assert!(matches!(err, Error::PreconditionFailed));
311    }
312}