blz_core/error.rs
1//! Error types and handling for blz-core operations.
2//!
3//! This module provides a comprehensive error type that covers all possible failures
4//! in the blz cache system. Errors are categorized for easier handling and include
5//! context about recoverability for retry logic.
6//!
7//! ## Error Categories
8//!
9//! Errors are organized into logical categories:
10//!
11//! - **I/O Errors**: File system operations, disk access
12//! - **Network Errors**: HTTP requests, connectivity issues
13//! - **Parse Errors**: Markdown parsing, TOML/JSON deserialization
14//! - **Index Errors**: Search index operations
15//! - **Storage Errors**: Cache storage operations
16//! - **Configuration Errors**: Invalid settings or config files
17//! - **Resource Errors**: Memory limits, timeouts, quotas
18//!
19//! ## Recovery Hints
20//!
21//! Errors include information about whether they might be recoverable through retries:
22//!
23//! ```rust
24//! use blz_core::{Error, Result, MarkdownParser};
25//!
26//! fn handle_operation() -> Result<()> {
27//! match perform_operation() {
28//! Err(e) if e.is_recoverable() => {
29//! println!("Temporary failure, retrying...");
30//! // Implement retry logic
31//! }
32//! Err(e) => {
33//! println!("Permanent failure: {}", e);
34//! println!("Category: {}", e.category());
35//! }
36//! Ok(()) => println!("Success"),
37//! }
38//! Ok(())
39//! }
40//!
41//! fn perform_operation() -> Result<()> { Ok(()) }
42//! ```
43
44use thiserror::Error;
45
46/// The main error type for blz-core operations.
47///
48/// All public functions in blz-core return `Result<T, Error>` for consistent error handling.
49/// The error type includes automatic conversion from common standard library errors and
50/// provides additional metadata for error handling logic.
51///
52/// ## Error Source Chain
53///
54/// Errors maintain the full error chain through the `source()` method, allowing
55/// for detailed error inspection and debugging.
56///
57/// ## Display vs Debug
58///
59/// - `Display` provides user-friendly error messages
60/// - `Debug` includes full error details and source chain information
61#[derive(Error, Debug)]
62pub enum Error {
63 /// I/O operation failed.
64 ///
65 /// Covers file system operations like reading/writing files, creating directories,
66 /// checking file permissions, etc. The underlying `std::io::Error` is preserved
67 /// to maintain detailed error information.
68 ///
69 /// ## Recoverability
70 ///
71 /// Some I/O errors are recoverable (timeouts, interruptions), while others
72 /// are permanent (permission denied, file not found).
73 #[error("IO error: {0}")]
74 Io(#[from] std::io::Error),
75
76 /// Network operation failed.
77 ///
78 /// Covers HTTP requests for fetching llms.txt files, checking `ETags`,
79 /// and other network operations. The underlying `reqwest::Error` is preserved
80 /// for detailed connection information.
81 ///
82 /// ## Recoverability
83 ///
84 /// Connection and timeout errors are typically recoverable, while
85 /// authentication and malformed URL errors are permanent.
86 #[error("Network error: {0}")]
87 Network(#[from] reqwest::Error),
88
89 /// Parsing operation failed.
90 ///
91 /// Occurs when markdown content cannot be parsed, TOML/JSON deserialization
92 /// fails, or content doesn't match expected format.
93 ///
94 /// ## Common Causes
95 ///
96 /// - Malformed markdown syntax
97 /// - Invalid TOML configuration
98 /// - Unexpected content structure
99 /// - Character encoding issues
100 #[error("Parse error: {0}")]
101 Parse(String),
102
103 /// Search index operation failed.
104 ///
105 /// Covers failures in creating, updating, or querying the search index.
106 /// This includes Tantivy-related errors and index corruption.
107 ///
108 /// ## Common Causes
109 ///
110 /// - Index corruption
111 /// - Disk space exhaustion during indexing
112 /// - Invalid search queries
113 /// - Schema version mismatches
114 #[error("Index error: {0}")]
115 Index(String),
116
117 /// Storage operation failed.
118 ///
119 /// Covers cache storage operations beyond basic file I/O, such as
120 /// managing archived versions, checksum validation, and cache consistency.
121 ///
122 /// ## Common Causes
123 ///
124 /// - Cache corruption
125 /// - Concurrent access conflicts
126 /// - Checksum mismatches
127 /// - Archive management failures
128 #[error("Storage error: {0}")]
129 Storage(String),
130
131 /// Configuration is invalid or inaccessible.
132 ///
133 /// Occurs when configuration files are malformed, contain invalid values,
134 /// or cannot be accessed due to permissions or path issues.
135 ///
136 /// ## Common Causes
137 ///
138 /// - Invalid TOML syntax in config files
139 /// - Missing required configuration fields
140 /// - Configuration values outside valid ranges
141 /// - Config directory creation failures
142 #[error("Configuration error: {0}")]
143 Config(String),
144
145 /// Requested resource was not found.
146 ///
147 /// Used for missing files, non-existent sources, or requested content
148 /// that doesn't exist in the cache.
149 ///
150 /// ## Common Causes
151 ///
152 /// - Requested source alias doesn't exist
153 /// - File was deleted after being indexed
154 /// - Cache was cleared but references remain
155 #[error("Not found: {0}")]
156 NotFound(String),
157
158 /// URL is malformed or invalid.
159 ///
160 /// Occurs when URLs provided for llms.txt sources cannot be parsed
161 /// or contain invalid characters/schemes.
162 ///
163 /// ## Common Causes
164 ///
165 /// - Malformed URLs in configuration
166 /// - Unsupported URL schemes
167 /// - Invalid characters in URLs
168 #[error("Invalid URL: {0}")]
169 InvalidUrl(String),
170
171 /// Resource limit was exceeded.
172 ///
173 /// Used when operations exceed configured limits such as memory usage,
174 /// file size, or processing time constraints.
175 ///
176 /// ## Common Causes
177 ///
178 /// - Document exceeds maximum size limit
179 /// - Memory usage exceeds configured threshold
180 /// - Too many concurrent operations
181 #[error("Resource limited: {0}")]
182 ResourceLimited(String),
183
184 /// Operation timed out.
185 ///
186 /// Used for operations that exceed their configured timeout duration.
187 /// This is typically recoverable with retry logic.
188 ///
189 /// ## Common Causes
190 ///
191 /// - Network request timeouts
192 /// - Long-running parsing operations
193 /// - Index operations on large documents
194 #[error("Timeout: {0}")]
195 Timeout(String),
196
197 /// Serialization or deserialization failed.
198 ///
199 /// Occurs when converting between data formats (JSON, TOML, binary)
200 /// fails due to incompatible formats or corruption.
201 ///
202 /// ## Common Causes
203 ///
204 /// - JSON/TOML syntax errors
205 /// - Schema version mismatches
206 /// - Data corruption
207 /// - Incompatible format versions
208 #[error("Serialization error: {0}")]
209 Serialization(String),
210
211 /// Firecrawl CLI is not installed or not in PATH.
212 ///
213 /// Indicates that the `firecrawl` command cannot be found. Users need to
214 /// install the Firecrawl CLI to use web scraping functionality.
215 ///
216 /// ## Resolution
217 ///
218 /// Install Firecrawl CLI with: `npm install -g firecrawl`
219 #[error("Firecrawl CLI not installed. Install with: npm install -g firecrawl")]
220 FirecrawlNotInstalled,
221
222 /// Firecrawl CLI version is too old.
223 ///
224 /// The installed Firecrawl CLI version does not meet the minimum
225 /// requirements for this version of blz.
226 ///
227 /// ## Resolution
228 ///
229 /// Update Firecrawl CLI with: `npm update -g firecrawl`
230 #[error("Firecrawl CLI version {found} is too old (minimum required: {required})")]
231 FirecrawlVersionTooOld {
232 /// Version that was found.
233 found: String,
234 /// Minimum required version.
235 required: String,
236 },
237
238 /// Firecrawl CLI is not authenticated.
239 ///
240 /// The Firecrawl CLI requires authentication to use the API.
241 /// Users need to log in before using scrape functionality.
242 ///
243 /// ## Resolution
244 ///
245 /// Authenticate with: `firecrawl login`
246 #[error("Firecrawl CLI not authenticated. Run: firecrawl login")]
247 FirecrawlNotAuthenticated,
248
249 /// Firecrawl scrape operation failed.
250 ///
251 /// The scrape operation for a specific URL failed. This may be due to
252 /// network issues, invalid URLs, or site-specific restrictions.
253 ///
254 /// ## Recoverability
255 ///
256 /// This error is typically recoverable - retry may succeed.
257 #[error("Firecrawl scrape failed for '{url}': {reason}")]
258 FirecrawlScrapeFailed {
259 /// URL that failed to scrape.
260 url: String,
261 /// Reason for the failure.
262 reason: String,
263 },
264
265 /// Firecrawl command execution failed.
266 ///
267 /// A general Firecrawl CLI command failed to execute properly.
268 /// This covers execution failures that aren't specific to scraping.
269 ///
270 /// ## Recoverability
271 ///
272 /// This error is typically recoverable - retry may succeed.
273 #[error("Firecrawl command failed: {0}")]
274 FirecrawlCommandFailed(String),
275
276 /// Generic error for uncategorized failures.
277 ///
278 /// Used for errors that don't fit other categories or for
279 /// wrapping third-party errors that don't have specific mappings.
280 #[error("{0}")]
281 Other(String),
282}
283
284impl From<serde_json::Error> for Error {
285 fn from(err: serde_json::Error) -> Self {
286 Self::Serialization(err.to_string())
287 }
288}
289
290impl From<toml::ser::Error> for Error {
291 fn from(err: toml::ser::Error) -> Self {
292 Self::Serialization(err.to_string())
293 }
294}
295
296impl From<toml::de::Error> for Error {
297 fn from(err: toml::de::Error) -> Self {
298 Self::Serialization(err.to_string())
299 }
300}
301
302impl Error {
303 /// Check if the error might be recoverable through retry logic.
304 ///
305 /// Returns `true` for errors that are typically temporary and might succeed
306 /// if the operation is retried after a delay. This includes network timeouts,
307 /// connection failures, and temporary I/O issues.
308 ///
309 /// # Returns
310 ///
311 /// - `true` for potentially recoverable errors (timeouts, connection issues)
312 /// - `false` for permanent errors (parse failures, invalid configuration)
313 ///
314 /// # Examples
315 ///
316 /// ```rust
317 /// use blz_core::{Error, Result};
318 /// use std::io;
319 ///
320 /// let recoverable_errors = vec![
321 /// Error::Timeout("Request timed out".to_string()),
322 /// Error::Io(io::Error::new(io::ErrorKind::TimedOut, "timeout")),
323 /// Error::Io(io::Error::new(io::ErrorKind::Interrupted, "interrupted")),
324 /// ];
325 ///
326 /// let permanent_errors = vec![
327 /// Error::Parse("Invalid markdown".to_string()),
328 /// Error::Config("Missing field".to_string()),
329 /// Error::InvalidUrl("Not a URL".to_string()),
330 /// ];
331 ///
332 /// for error in recoverable_errors {
333 /// assert!(error.is_recoverable());
334 /// }
335 ///
336 /// for error in permanent_errors {
337 /// assert!(!error.is_recoverable());
338 /// }
339 /// ```
340 ///
341 /// ## Retry Strategy
342 ///
343 /// When an error is recoverable, consider implementing exponential backoff:
344 ///
345 /// ```rust
346 /// use blz_core::{Error, Result};
347 /// use std::time::Duration;
348 ///
349 /// async fn retry_operation<F, T>(mut op: F, max_attempts: u32) -> Result<T>
350 /// where
351 /// F: FnMut() -> Result<T>,
352 /// {
353 /// let mut attempts = 0;
354 /// let mut delay = Duration::from_millis(100);
355 ///
356 /// loop {
357 /// match op() {
358 /// Ok(result) => return Ok(result),
359 /// Err(e) if e.is_recoverable() && attempts < max_attempts => {
360 /// attempts += 1;
361 /// tokio::time::sleep(delay).await;
362 /// delay *= 2; // Exponential backoff
363 /// }
364 /// Err(e) => return Err(e),
365 /// }
366 /// }
367 /// }
368 ///
369 /// // Example usage:
370 /// // let result = retry_operation(|| fetch_document(), 3).await?;
371 /// ```
372 #[must_use]
373 pub fn is_recoverable(&self) -> bool {
374 match self {
375 Self::Network(e) => {
376 // Consider connection errors as recoverable
377 e.is_timeout() || e.is_connect()
378 },
379 // Timeout and Firecrawl transient errors are recoverable
380 Self::Timeout(_)
381 | Self::FirecrawlScrapeFailed { .. }
382 | Self::FirecrawlCommandFailed(_) => true,
383 Self::Io(e) => {
384 // Consider temporary I/O errors as recoverable
385 matches!(
386 e.kind(),
387 std::io::ErrorKind::TimedOut | std::io::ErrorKind::Interrupted
388 )
389 },
390 // All other errors (including Firecrawl installation/auth) are not recoverable
391 _ => false,
392 }
393 }
394
395 /// Get the error category as a string identifier.
396 ///
397 /// Returns a static string that categorizes the error type for logging,
398 /// metrics collection, and error handling logic. This is useful for
399 /// grouping errors in monitoring systems or implementing category-specific
400 /// error handling.
401 ///
402 /// # Returns
403 ///
404 /// A static string representing the error category:
405 ///
406 /// - `"io"` - File system and I/O operations
407 /// - `"network"` - HTTP requests and network operations
408 /// - `"parse"` - Content parsing and format conversion
409 /// - `"index"` - Search index operations
410 /// - `"storage"` - Cache storage and management
411 /// - `"config"` - Configuration and settings
412 /// - `"not_found"` - Missing resources or files
413 /// - `"invalid_url"` - URL format and validation
414 /// - `"resource_limited"` - Resource constraints and limits
415 /// - `"timeout"` - Operation timeouts
416 /// - `"serialization"` - Data format conversion
417 /// - `"other"` - Uncategorized errors
418 ///
419 /// # Examples
420 ///
421 /// ```rust
422 /// use blz_core::Error;
423 /// use std::collections::HashMap;
424 ///
425 /// // Track error counts by category
426 /// let mut error_counts: HashMap<String, u32> = HashMap::new();
427 ///
428 /// fn record_error(error: &Error, counts: &mut HashMap<String, u32>) {
429 /// let category = error.category().to_string();
430 /// *counts.entry(category).or_insert(0) += 1;
431 /// }
432 ///
433 /// // Usage in error handling
434 /// let errors = vec![
435 /// Error::Parse("Invalid format".to_string()),
436 /// Error::Config("Missing field".to_string()),
437 /// Error::NotFound("Resource not found".to_string()),
438 /// ];
439 ///
440 /// for error in &errors {
441 /// record_error(error, &mut error_counts);
442 /// }
443 /// ```
444 ///
445 /// ## Structured Logging
446 ///
447 /// ```rust
448 /// use blz_core::Error;
449 ///
450 /// fn log_error(error: &Error) {
451 /// println!(
452 /// "{{\"level\":\"error\",\"category\":\"{}\",\"message\":\"{}\"}}",
453 /// error.category(),
454 /// error
455 /// );
456 /// }
457 /// ```
458 #[must_use]
459 pub const fn category(&self) -> &'static str {
460 match self {
461 Self::Io(_) => "io",
462 Self::Network(_) => "network",
463 Self::Parse(_) => "parse",
464 Self::Index(_) => "index",
465 Self::Storage(_) => "storage",
466 Self::Config(_) => "config",
467 Self::NotFound(_) => "not_found",
468 Self::InvalidUrl(_) => "invalid_url",
469 Self::ResourceLimited(_) => "resource_limited",
470 Self::Timeout(_) => "timeout",
471 Self::Serialization(_) => "serialization",
472 Self::FirecrawlNotInstalled
473 | Self::FirecrawlVersionTooOld { .. }
474 | Self::FirecrawlNotAuthenticated
475 | Self::FirecrawlScrapeFailed { .. }
476 | Self::FirecrawlCommandFailed(_) => "firecrawl",
477 Self::Other(_) => "other",
478 }
479 }
480}
481
482/// Convenience type alias for `std::result::Result<T, Error>`.
483///
484/// This type is used throughout blz-core for consistent error handling.
485/// It's equivalent to `std::result::Result<T, blz_core::Error>` but more concise.
486///
487/// # Examples
488///
489/// ```rust
490/// use blz_core::{Result, Config};
491///
492/// fn load_config() -> Result<Config> {
493/// Config::load() // Returns Result<Config>
494/// }
495///
496/// fn main() -> Result<()> {
497/// let config = load_config()?;
498/// println!("Loaded config with root: {}", config.paths.root.display());
499/// Ok(())
500/// }
501/// ```
502pub type Result<T> = std::result::Result<T, Error>;
503
504#[cfg(test)]
505#[allow(
506 clippy::panic,
507 clippy::disallowed_macros,
508 clippy::unwrap_used,
509 clippy::unnecessary_wraps
510)]
511mod tests {
512 use super::*;
513 use proptest::prelude::*;
514 use std::io;
515
516 #[test]
517 fn test_error_display_formatting() {
518 // Given: Different error variants
519 let errors = vec![
520 Error::Parse("invalid syntax".to_string()),
521 Error::Index("search failed".to_string()),
522 Error::Storage("disk full".to_string()),
523 Error::Config("missing field".to_string()),
524 Error::NotFound("document".to_string()),
525 Error::InvalidUrl("not a url".to_string()),
526 Error::ResourceLimited("too many requests".to_string()),
527 Error::Timeout("operation timed out".to_string()),
528 Error::Other("unknown error".to_string()),
529 ];
530
531 for error in errors {
532 // When: Converting to string
533 let error_string = error.to_string();
534
535 // Then: Should contain descriptive information
536 assert!(!error_string.is_empty());
537 match error {
538 Error::Parse(msg) => {
539 assert!(error_string.contains("Parse error"));
540 assert!(error_string.contains(&msg));
541 },
542 Error::Index(msg) => {
543 assert!(error_string.contains("Index error"));
544 assert!(error_string.contains(&msg));
545 },
546 Error::Storage(msg) => {
547 assert!(error_string.contains("Storage error"));
548 assert!(error_string.contains(&msg));
549 },
550 Error::Config(msg) => {
551 assert!(error_string.contains("Configuration error"));
552 assert!(error_string.contains(&msg));
553 },
554 Error::NotFound(msg) => {
555 assert!(error_string.contains("Not found"));
556 assert!(error_string.contains(&msg));
557 },
558 Error::InvalidUrl(msg) => {
559 assert!(error_string.contains("Invalid URL"));
560 assert!(error_string.contains(&msg));
561 },
562 Error::ResourceLimited(msg) => {
563 assert!(error_string.contains("Resource limited"));
564 assert!(error_string.contains(&msg));
565 },
566 Error::Timeout(msg) => {
567 assert!(error_string.contains("Timeout"));
568 assert!(error_string.contains(&msg));
569 },
570 Error::Other(msg) => {
571 assert_eq!(error_string, msg);
572 },
573 _ => {},
574 }
575 }
576 }
577
578 #[test]
579 fn test_error_from_io_error() {
580 // Given: Different types of I/O errors
581 let io_errors = vec![
582 io::Error::new(io::ErrorKind::NotFound, "file not found"),
583 io::Error::new(io::ErrorKind::PermissionDenied, "access denied"),
584 io::Error::new(io::ErrorKind::TimedOut, "operation timed out"),
585 io::Error::new(io::ErrorKind::Interrupted, "interrupted"),
586 ];
587
588 for io_err in io_errors {
589 // When: Converting to our Error type
590 let error: Error = io_err.into();
591
592 // Then: Should be IO error variant
593 match error {
594 Error::Io(inner) => {
595 assert!(!inner.to_string().is_empty());
596 },
597 _ => panic!("Expected IO error variant"),
598 }
599 }
600 }
601
602 #[test]
603 fn test_error_from_reqwest_error() {
604 // Given: Mock reqwest errors (using builder pattern since reqwest errors are opaque)
605 // Note: We can't easily create reqwest::Error instances in tests, so we'll focus on
606 // what we can test about the conversion
607
608 // This test ensures the From implementation exists and compiles
609 // In practice, reqwest errors would come from actual HTTP operations
610 fn create_network_error_result() -> Result<()> {
611 // This would typically come from reqwest operations
612 let _client = reqwest::Client::new();
613 // We can't easily trigger a reqwest error in tests without network calls
614 // but we can verify the error type conversion works by checking the variant
615 Ok(())
616 }
617
618 // When/Then: The conversion should compile and work (tested implicitly)
619 assert!(create_network_error_result().is_ok());
620 }
621
622 #[test]
623 fn test_error_categories() {
624 // Given: All error variants
625 let error_categories = vec![
626 (Error::Io(io::Error::other("test")), "io"),
627 (Error::Parse("test".to_string()), "parse"),
628 (Error::Index("test".to_string()), "index"),
629 (Error::Storage("test".to_string()), "storage"),
630 (Error::Config("test".to_string()), "config"),
631 (Error::NotFound("test".to_string()), "not_found"),
632 (Error::InvalidUrl("test".to_string()), "invalid_url"),
633 (
634 Error::ResourceLimited("test".to_string()),
635 "resource_limited",
636 ),
637 (Error::Timeout("test".to_string()), "timeout"),
638 (Error::Serialization("test".to_string()), "serialization"),
639 (Error::FirecrawlNotInstalled, "firecrawl"),
640 (
641 Error::FirecrawlVersionTooOld {
642 found: "1.0.0".to_string(),
643 required: "1.1.0".to_string(),
644 },
645 "firecrawl",
646 ),
647 (Error::FirecrawlNotAuthenticated, "firecrawl"),
648 (
649 Error::FirecrawlScrapeFailed {
650 url: "test".to_string(),
651 reason: "test".to_string(),
652 },
653 "firecrawl",
654 ),
655 (
656 Error::FirecrawlCommandFailed("test".to_string()),
657 "firecrawl",
658 ),
659 (Error::Other("test".to_string()), "other"),
660 ];
661
662 for (error, expected_category) in error_categories {
663 // When: Getting error category
664 let category = error.category();
665
666 // Then: Should match expected category
667 assert_eq!(category, expected_category);
668 }
669 }
670
671 #[test]
672 fn test_error_recoverability() {
673 // Given: Various error scenarios
674 let recoverable_errors = vec![
675 Error::Io(io::Error::new(io::ErrorKind::TimedOut, "timeout")),
676 Error::Io(io::Error::new(io::ErrorKind::Interrupted, "interrupted")),
677 Error::Timeout("request timeout".to_string()),
678 ];
679
680 let non_recoverable_errors = vec![
681 Error::Io(io::Error::new(io::ErrorKind::NotFound, "not found")),
682 Error::Io(io::Error::new(io::ErrorKind::PermissionDenied, "denied")),
683 Error::Parse("bad syntax".to_string()),
684 Error::Index("corrupt index".to_string()),
685 Error::Storage("disk failure".to_string()),
686 Error::Config("invalid config".to_string()),
687 Error::NotFound("missing".to_string()),
688 Error::InvalidUrl("bad url".to_string()),
689 Error::ResourceLimited("quota exceeded".to_string()),
690 Error::Other("generic error".to_string()),
691 ];
692
693 // When/Then: Testing recoverability
694 for error in recoverable_errors {
695 assert!(
696 error.is_recoverable(),
697 "Expected {error:?} to be recoverable"
698 );
699 }
700
701 for error in non_recoverable_errors {
702 assert!(
703 !error.is_recoverable(),
704 "Expected {error:?} to be non-recoverable"
705 );
706 }
707 }
708
709 // ============================================================
710 // Firecrawl Error Tests
711 // ============================================================
712
713 #[test]
714 fn test_firecrawl_error_display() {
715 // FirecrawlNotInstalled
716 assert!(
717 Error::FirecrawlNotInstalled
718 .to_string()
719 .contains("not installed")
720 );
721 assert!(
722 Error::FirecrawlNotInstalled
723 .to_string()
724 .contains("npm install")
725 );
726
727 // FirecrawlVersionTooOld
728 let version_error = Error::FirecrawlVersionTooOld {
729 found: "1.0.0".to_string(),
730 required: "1.1.0".to_string(),
731 };
732 assert!(version_error.to_string().contains("1.0.0"));
733 assert!(version_error.to_string().contains("1.1.0"));
734 assert!(version_error.to_string().contains("too old"));
735
736 // FirecrawlNotAuthenticated
737 assert!(
738 Error::FirecrawlNotAuthenticated
739 .to_string()
740 .contains("not authenticated")
741 );
742 assert!(
743 Error::FirecrawlNotAuthenticated
744 .to_string()
745 .contains("firecrawl login")
746 );
747
748 // FirecrawlScrapeFailed
749 let scrape_error = Error::FirecrawlScrapeFailed {
750 url: "https://example.com".to_string(),
751 reason: "timeout".to_string(),
752 };
753 assert!(scrape_error.to_string().contains("https://example.com"));
754 assert!(scrape_error.to_string().contains("timeout"));
755
756 // FirecrawlCommandFailed
757 let cmd_error = Error::FirecrawlCommandFailed("permission denied".to_string());
758 assert!(cmd_error.to_string().contains("permission denied"));
759 assert!(cmd_error.to_string().contains("command failed"));
760 }
761
762 #[test]
763 fn test_firecrawl_error_recoverability() {
764 // Permanent errors (require user action)
765 assert!(!Error::FirecrawlNotInstalled.is_recoverable());
766 assert!(
767 !Error::FirecrawlVersionTooOld {
768 found: "1.0.0".to_string(),
769 required: "1.1.0".to_string(),
770 }
771 .is_recoverable()
772 );
773 assert!(!Error::FirecrawlNotAuthenticated.is_recoverable());
774
775 // Recoverable errors (transient failures)
776 assert!(
777 Error::FirecrawlScrapeFailed {
778 url: "https://example.com".to_string(),
779 reason: "timeout".to_string(),
780 }
781 .is_recoverable()
782 );
783 assert!(Error::FirecrawlCommandFailed("failed".to_string()).is_recoverable());
784 }
785
786 #[test]
787 fn test_firecrawl_error_category() {
788 assert_eq!(Error::FirecrawlNotInstalled.category(), "firecrawl");
789 assert_eq!(Error::FirecrawlNotAuthenticated.category(), "firecrawl");
790 assert_eq!(
791 Error::FirecrawlVersionTooOld {
792 found: "1.0.0".to_string(),
793 required: "1.1.0".to_string(),
794 }
795 .category(),
796 "firecrawl"
797 );
798 assert_eq!(
799 Error::FirecrawlScrapeFailed {
800 url: "test".to_string(),
801 reason: "test".to_string(),
802 }
803 .category(),
804 "firecrawl"
805 );
806 assert_eq!(
807 Error::FirecrawlCommandFailed("test".to_string()).category(),
808 "firecrawl"
809 );
810 }
811
812 #[test]
813 fn test_error_debug_formatting() {
814 // Given: Error with detailed information
815 let error = Error::Parse("Failed to parse JSON at line 42".to_string());
816
817 // When: Debug formatting
818 let debug_str = format!("{error:?}");
819
820 // Then: Should contain variant name and message
821 assert!(debug_str.contains("Parse"));
822 assert!(debug_str.contains("Failed to parse JSON at line 42"));
823 }
824
825 #[test]
826 fn test_error_chain_source() {
827 // Given: IO error that can be converted to our error type
828 let io_error = io::Error::new(io::ErrorKind::PermissionDenied, "access denied");
829 let blz_error: Error = io_error.into();
830
831 // When: Checking error source
832 let source = std::error::Error::source(&blz_error);
833
834 // Then: Should maintain the source chain
835 assert!(source.is_some());
836 let source_str = source.unwrap().to_string();
837 assert!(source_str.contains("access denied"));
838 }
839
840 #[test]
841 fn test_result_type_alias() {
842 // Given: Function that returns our Result type
843 fn test_function() -> Result<i32> {
844 Ok(42)
845 }
846
847 fn test_error_function() -> Result<i32> {
848 Err(Error::Other("test error".to_string()))
849 }
850
851 // When: Using the Result type
852 let ok_result = test_function();
853 let err_result = test_error_function();
854
855 // Then: Should work as expected
856 assert!(ok_result.is_ok());
857 assert_eq!(ok_result.unwrap(), 42);
858
859 assert!(err_result.is_err());
860 if let Err(Error::Other(msg)) = err_result {
861 assert_eq!(msg, "test error");
862 } else {
863 panic!("Expected Other error");
864 }
865 }
866
867 // Property-based tests
868 proptest! {
869 #[test]
870 fn test_parse_error_with_arbitrary_messages(msg in r".{0,1000}") {
871 let error = Error::Parse(msg.clone());
872 let error_string = error.to_string();
873
874 prop_assert!(error_string.contains("Parse error"));
875 prop_assert!(error_string.contains(&msg));
876 prop_assert_eq!(error.category(), "parse");
877 prop_assert!(!error.is_recoverable());
878 }
879
880 #[test]
881 fn test_index_error_with_arbitrary_messages(msg in r".{0,1000}") {
882 let error = Error::Index(msg.clone());
883 let error_string = error.to_string();
884
885 prop_assert!(error_string.contains("Index error"));
886 prop_assert!(error_string.contains(&msg));
887 prop_assert_eq!(error.category(), "index");
888 prop_assert!(!error.is_recoverable());
889 }
890
891 #[test]
892 fn test_storage_error_with_arbitrary_messages(msg in r".{0,1000}") {
893 let error = Error::Storage(msg.clone());
894 let error_string = error.to_string();
895
896 prop_assert!(error_string.contains("Storage error"));
897 prop_assert!(error_string.contains(&msg));
898 prop_assert_eq!(error.category(), "storage");
899 prop_assert!(!error.is_recoverable());
900 }
901
902 #[test]
903 fn test_config_error_with_arbitrary_messages(msg in r".{0,1000}") {
904 let error = Error::Config(msg.clone());
905 let error_string = error.to_string();
906
907 prop_assert!(error_string.contains("Configuration error"));
908 prop_assert!(error_string.contains(&msg));
909 prop_assert_eq!(error.category(), "config");
910 prop_assert!(!error.is_recoverable());
911 }
912
913 #[test]
914 fn test_other_error_with_arbitrary_messages(msg in r".{0,1000}") {
915 let error = Error::Other(msg.clone());
916 let error_string = error.to_string();
917
918 prop_assert_eq!(error_string, msg);
919 prop_assert_eq!(error.category(), "other");
920 prop_assert!(!error.is_recoverable());
921 }
922 }
923
924 // Security-focused tests
925 #[test]
926 fn test_error_with_malicious_messages() {
927 // Given: Error messages with potentially malicious content
928 let long_message = "very_long_message_".repeat(1000);
929 let malicious_messages = vec![
930 "\n\r\x00\x01malicious",
931 "<script>alert('xss')</script>",
932 "'; DROP TABLE users; --",
933 "../../../etc/passwd",
934 "\u{202e}reverse text\u{202d}",
935 &long_message,
936 ];
937
938 for malicious_msg in malicious_messages {
939 // When: Creating errors with malicious messages
940 let errors = vec![
941 Error::Parse(malicious_msg.to_string()),
942 Error::Index(malicious_msg.to_string()),
943 Error::Storage(malicious_msg.to_string()),
944 Error::Config(malicious_msg.to_string()),
945 Error::NotFound(malicious_msg.to_string()),
946 Error::InvalidUrl(malicious_msg.to_string()),
947 Error::ResourceLimited(malicious_msg.to_string()),
948 Error::Timeout(malicious_msg.to_string()),
949 Error::Other(malicious_msg.to_string()),
950 ];
951
952 for error in errors {
953 // Then: Should handle malicious content safely
954 let error_string = error.to_string();
955 assert!(!error_string.is_empty());
956
957 // Should preserve the malicious content (not sanitize it)
958 // This is intentional - error handling should not modify user content
959 // Sanitization should happen at display time if needed
960 assert!(error_string.contains(malicious_msg));
961 }
962 }
963 }
964
965 #[test]
966 fn test_error_with_unicode_messages() {
967 // Given: Error messages with Unicode content
968 let unicode_messages = vec![
969 "エラーが発生しました", // Japanese
970 "حدث خطأ", // Arabic
971 "Произошла ошибка", // Russian
972 "🚨 Błąd krytyczny! 🚨", // Polish with emojis
973 "Error: файл не найден", // Mixed languages
974 ];
975
976 for unicode_msg in unicode_messages {
977 // When: Creating errors with Unicode messages
978 let error = Error::Parse(unicode_msg.to_string());
979
980 // Then: Should handle Unicode correctly
981 let error_string = error.to_string();
982 assert!(error_string.contains(unicode_msg));
983 assert_eq!(error.category(), "parse");
984 }
985 }
986
987 #[test]
988 fn test_error_empty_messages() {
989 // Given: Errors with empty messages
990 let errors_with_empty_msgs = vec![
991 Error::Parse(String::new()),
992 Error::Index(String::new()),
993 Error::Storage(String::new()),
994 Error::Config(String::new()),
995 Error::NotFound(String::new()),
996 Error::InvalidUrl(String::new()),
997 Error::ResourceLimited(String::new()),
998 Error::Timeout(String::new()),
999 Error::Other(String::new()),
1000 ];
1001
1002 for error in errors_with_empty_msgs {
1003 // When: Converting to string
1004 let error_string = error.to_string();
1005
1006 // Then: Check error formatting behavior
1007 if let Error::Other(_) = error {
1008 // Other errors just show the message (which is empty)
1009 assert_eq!(error_string, "");
1010 } else {
1011 // All other errors have descriptive prefixes even with empty messages
1012 assert!(!error_string.is_empty());
1013 assert!(
1014 error_string.contains(':'),
1015 "Error should contain colon separator: '{error_string}'"
1016 );
1017 }
1018 }
1019 }
1020
1021 #[test]
1022 fn test_error_size() {
1023 // Given: Error enum
1024 // When: Checking size
1025 let error_size = std::mem::size_of::<Error>();
1026
1027 // Then: Should be reasonably sized (not too large)
1028 // This helps ensure the error type is efficient to pass around
1029 assert!(error_size <= 64, "Error type too large: {error_size} bytes");
1030 }
1031}