Skip to main content

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}