Skip to main content

composio_sdk/
error.rs

1use serde::Deserialize;
2use thiserror::Error;
3
4/// Main error type for the Composio SDK
5#[derive(Debug, Error)]
6pub enum ComposioError {
7    /// API error returned from Composio backend
8    #[error("API error: {message} (status: {status})")]
9    ApiError {
10        /// HTTP status code
11        status: u16,
12        /// Error message
13        message: String,
14        /// Error code (optional)
15        code: Option<String>,
16        /// Error slug identifier (optional)
17        slug: Option<String>,
18        /// Request ID for debugging (optional)
19        request_id: Option<String>,
20        /// Suggested fix for the error (optional)
21        suggested_fix: Option<String>,
22        /// Detailed field-level errors (optional)
23        errors: Option<Vec<ErrorDetail>>,
24    },
25
26    /// Network error from HTTP client
27    #[error("Network error: {0}")]
28    NetworkError(#[from] reqwest::Error),
29
30    /// JSON serialization error
31    #[error("Serialization error: {0}")]
32    SerializationError(#[from] serde_json::Error),
33
34    /// Validation error
35    #[error("Validation error: {0}")]
36    ValidationError(String),
37
38    /// Execution error
39    #[error("Execution error: {0}")]
40    ExecutionError(String),
41
42    /// Invalid input provided by user
43    #[error("Invalid input: {0}")]
44    InvalidInput(String),
45
46    /// Configuration error
47    #[error("Configuration error: {0}")]
48    ConfigError(String),
49
50    /// File not found error
51    #[error("File not found: {0}")]
52    FileNotFound(String),
53
54    /// Invalid file error
55    #[error("Invalid file: {0}")]
56    InvalidFile(String),
57
58    /// File upload failed
59    #[error("File upload failed: {0}")]
60    UploadFailed(String),
61
62    /// File download failed
63    #[error("File download failed: {0}")]
64    DownloadFailed(String),
65
66    /// File too large error
67    #[error("File too large: {0}")]
68    FileTooLarge(String),
69
70    /// I/O error
71    #[error("I/O error: {0}")]
72    IoError(#[from] std::io::Error),
73
74    /// Invalid schema error
75    #[error("Invalid schema: {0}")]
76    InvalidSchema(String),
77
78    // ============================================
79    // Resource & Not Found Errors
80    // ============================================
81    
82    /// Resource not found error
83    #[error("Resource not found: {0}")]
84    NotFound(String),
85
86    /// Tool not found error
87    #[error("Tool not found: {tool_slug}. Use COMPOSIO_SEARCH_TOOLS to discover available tools")]
88    ToolNotFound {
89        /// Tool slug that was not found
90        tool_slug: String,
91    },
92
93    /// Connected account not found error
94    #[error("Connected account not found: {account_id}")]
95    ConnectedAccountNotFound {
96        /// Account ID that was not found
97        account_id: String,
98    },
99
100    /// Invalid connected account error
101    #[error("Invalid connected account: {reason}")]
102    InvalidConnectedAccount {
103        /// Reason for invalidity
104        reason: String,
105    },
106
107    /// Multiple connected accounts found when one was expected
108    #[error("Multiple connected accounts found for user '{user_id}' and toolkit '{toolkit}'. Please specify connected_account_id explicitly")]
109    MultipleConnectedAccounts {
110        /// User ID
111        user_id: String,
112        /// Toolkit slug
113        toolkit: String,
114        /// Number of accounts found
115        count: usize,
116    },
117
118    /// No items found in collection
119    #[error("No items found: {0}")]
120    NoItemsFound(String),
121
122    // ============================================
123    // Authentication & API Key Errors
124    // ============================================
125    
126    /// API key not provided error
127    #[error("API Key not provided. Either provide API key or export it as 'COMPOSIO_API_KEY' environment variable")]
128    ApiKeyNotProvided,
129
130    /// Invalid API key error
131    #[error("Invalid API key: {0}")]
132    InvalidApiKey(String),
133
134    // ============================================
135    // Enum & Validation Errors
136    // ============================================
137    
138    /// Invalid enum value with suggestions
139    #[error("Invalid value '{value}' for enum '{enum_name}'{suggestion}")]
140    InvalidEnum {
141        /// The invalid value provided
142        value: String,
143        /// Name of the enum type
144        enum_name: String,
145        /// Suggested correct value (if found)
146        suggestion: String,
147        /// List of valid values
148        valid_values: Vec<String>,
149    },
150
151    /// Invalid parameters provided
152    #[error("Invalid parameters: {0}")]
153    InvalidParams(String),
154
155    // ============================================
156    // Versioning Errors
157    // ============================================
158    
159    /// Toolkit version required error
160    #[error("Toolkit version not specified. For manual execution of the tool please pass a specific toolkit version.\n\nPossible fixes:\n1. Pass the toolkit version as a parameter to the execute function ('latest' is not supported in manual execution)\n2. Set the toolkit versions in the Composio config (toolkit_versions={{'<toolkit-slug>': '<toolkit-version>'}})\n3. Set the toolkit version in the environment variable (COMPOSIO_TOOLKIT_VERSION_<TOOLKIT_SLUG>)\n4. Set dangerously_skip_version_check to True (this might cause unexpected behavior when new versions of the tools are released)")]
161    ToolVersionRequired,
162
163    /// Version selection error
164    #[error("Error selecting version for tool '{tool}': requested '{requested}', locked '{locked}'")]
165    VersionSelectionError {
166        /// Tool name
167        tool: String,
168        /// Requested version
169        requested: String,
170        /// Locked version
171        locked: String,
172    },
173
174    /// Invalid version string
175    #[error("Invalid version string: {0}")]
176    InvalidVersionString(String),
177
178    /// Lock file error
179    #[error("Lock file error: {0}")]
180    LockFileError(String),
181
182    /// Invalid lock file
183    #[error("Invalid lock file: {0}")]
184    InvalidLockFile(String),
185
186    // ============================================
187    // Trigger & Webhook Errors
188    // ============================================
189    
190    /// Trigger error
191    #[error("Trigger error: {0}")]
192    TriggerError(String),
193
194    /// Webhook signature verification failed
195    #[error("Webhook signature verification failed: {0}")]
196    WebhookSignatureVerificationError(String),
197
198    /// Invalid webhook payload
199    #[error("Invalid webhook payload: {0}")]
200    WebhookPayloadError(String),
201
202    /// Trigger subscription error
203    #[error("Trigger subscription error: {0}")]
204    TriggerSubscriptionError(String),
205
206    /// Invalid trigger filters
207    #[error("Invalid trigger filters: {0}")]
208    InvalidTriggerFilters(String),
209
210    // ============================================
211    // Tool Execution & Modifier Errors
212    // ============================================
213    
214    /// Invalid modifier error
215    #[error("Invalid modifier: {0}")]
216    InvalidModifier(String),
217
218    /// Tool execution function not set
219    #[error("Tool execution function not set: {0}")]
220    ExecuteToolFnNotSet(String),
221
222    /// Error processing tool execution request
223    #[error("Error processing tool execution request: {0}")]
224    ErrorProcessingToolExecution(String),
225
226    // ============================================
227    // Timeout & Size Errors
228    // ============================================
229    
230    /// Request timeout error
231    #[error("Request timeout: {0}")]
232    Timeout(String),
233
234    /// Response too large error
235    #[error("Response too large: {size} bytes exceeds maximum of {max_size} bytes")]
236    ResponseTooLarge {
237        /// Actual size in bytes
238        size: usize,
239        /// Maximum allowed size in bytes
240        max_size: usize,
241    },
242
243    // ============================================
244    // Usage & SDK Errors
245    // ============================================
246    
247    /// SDK usage error
248    #[error("Usage error: {0}")]
249    UsageError(String),
250
251    /// Toolkit error
252    #[error("Toolkit error: {0}")]
253    ToolkitError(String),
254}
255
256/// Detailed error information for individual field errors
257#[derive(Debug, Clone, PartialEq, Deserialize)]
258pub struct ErrorDetail {
259    /// Field name that caused the error (optional)
260    pub field: Option<String>,
261    /// Error message for this field
262    pub message: String,
263}
264
265/// Error response structure from Composio API
266#[derive(Debug, Clone, Deserialize)]
267pub struct ErrorResponse {
268    /// Error message
269    pub message: String,
270    /// Error code (optional)
271    pub code: Option<String>,
272    /// Error slug identifier (optional)
273    pub slug: Option<String>,
274    /// HTTP status code
275    pub status: u16,
276    /// Request ID for debugging (optional)
277    pub request_id: Option<String>,
278    /// Suggested fix for the error (optional)
279    pub suggested_fix: Option<String>,
280    /// Detailed field-level errors (optional)
281    pub errors: Option<Vec<ErrorDetail>>,
282}
283
284impl ComposioError {
285    /// Create an ApiError from an HTTP response
286    ///
287    /// This method attempts to parse the response body as an ErrorResponse.
288    /// If parsing fails, it creates a generic ApiError with the status code.
289    pub async fn from_response(response: reqwest::Response) -> Self {
290        let status = response.status().as_u16();
291
292        match response.json::<ErrorResponse>().await {
293            Ok(err_resp) => ComposioError::ApiError {
294                status,
295                message: err_resp.message,
296                code: err_resp.code,
297                slug: err_resp.slug,
298                request_id: err_resp.request_id,
299                suggested_fix: err_resp.suggested_fix,
300                errors: err_resp.errors,
301            },
302            Err(_) => ComposioError::ApiError {
303                status,
304                message: format!("HTTP error {}", status),
305                code: None,
306                slug: None,
307                request_id: None,
308                suggested_fix: None,
309                errors: None,
310            },
311        }
312    }
313
314    /// Create an InvalidEnum error with suggestions
315    ///
316    /// Uses fuzzy matching to suggest the closest valid value.
317    /// Similar to Python's EnumStringNotFound with difflib.
318    ///
319    /// # Example
320    ///
321    /// ```rust
322    /// use composio_sdk::error::ComposioError;
323    ///
324    /// let valid_values = vec!["github".to_string(), "gmail".to_string(), "slack".to_string()];
325    /// let error = ComposioError::invalid_enum("gihub", "Toolkit", valid_values);
326    /// // Error message: "Invalid value 'gihub' for enum 'Toolkit'. Did you mean 'github'?"
327    /// ```
328    pub fn invalid_enum(value: impl Into<String>, enum_name: impl Into<String>, valid_values: Vec<String>) -> Self {
329        let value = value.into();
330        let enum_name = enum_name.into();
331        
332        // Find closest match using simple string distance
333        let suggestion = find_closest_match(&value, &valid_values)
334            .map(|m| format!(". Did you mean '{}'?", m))
335            .unwrap_or_default();
336
337        ComposioError::InvalidEnum {
338            value,
339            enum_name,
340            suggestion,
341            valid_values,
342        }
343    }
344
345    /// Create a MultipleConnectedAccounts error
346    pub fn multiple_connected_accounts(
347        user_id: impl Into<String>,
348        toolkit: impl Into<String>,
349        count: usize,
350    ) -> Self {
351        ComposioError::MultipleConnectedAccounts {
352            user_id: user_id.into(),
353            toolkit: toolkit.into(),
354            count,
355        }
356    }
357
358    /// Create a VersionSelectionError
359    pub fn version_selection_error(
360        tool: impl Into<String>,
361        requested: impl Into<String>,
362        locked: impl Into<String>,
363    ) -> Self {
364        ComposioError::VersionSelectionError {
365            tool: tool.into(),
366            requested: requested.into(),
367            locked: locked.into(),
368        }
369    }
370
371    /// Create a ResponseTooLarge error
372    pub fn response_too_large(size: usize, max_size: usize) -> Self {
373        ComposioError::ResponseTooLarge { size, max_size }
374    }
375
376    /// Create a ToolNotFound error
377    pub fn tool_not_found(tool_slug: impl Into<String>) -> Self {
378        ComposioError::ToolNotFound {
379            tool_slug: tool_slug.into(),
380        }
381    }
382
383    /// Create a ConnectedAccountNotFound error
384    pub fn connected_account_not_found(account_id: impl Into<String>) -> Self {
385        ComposioError::ConnectedAccountNotFound {
386            account_id: account_id.into(),
387        }
388    }
389
390    /// Format validation error with detailed field information
391    ///
392    /// Provides a developer-friendly error message highlighting:
393    /// - Missing required fields
394    /// - Invalid field values
395    /// - Field-specific error messages
396    ///
397    /// This is particularly useful for debugging API validation errors
398    /// and improving monitoring/logging output.
399    ///
400    /// # Returns
401    ///
402    /// A formatted string with:
403    /// - Base error message
404    /// - List of missing fields (if any)
405    /// - Detailed field-level errors with parameter names
406    ///
407    /// # Example
408    ///
409    /// ```rust
410    /// use composio_sdk::error::{ComposioError, ErrorDetail};
411    ///
412    /// let error = ComposioError::ApiError {
413    ///     status: 400,
414    ///     message: "Validation failed".to_string(),
415    ///     code: Some("VALIDATION_ERROR".to_string()),
416    ///     slug: None,
417    ///     request_id: Some("req_123".to_string()),
418    ///     suggested_fix: None,
419    ///     errors: Some(vec![
420    ///         ErrorDetail {
421    ///             field: Some("user_id".to_string()),
422    ///             message: "Field required".to_string(),
423    ///         },
424    ///         ErrorDetail {
425    ///             field: Some("toolkit".to_string()),
426    ///             message: "Invalid toolkit name".to_string(),
427    ///         }
428    ///     ]),
429    /// };
430    /// 
431    /// let formatted = error.format_validation_error();
432    /// println!("{}", formatted);
433    /// // Output:
434    /// // Validation failed
435    /// // - Following fields are missing: ["user_id"]
436    /// // - Invalid toolkit name on parameter `toolkit`
437    /// ```
438    pub fn format_validation_error(&self) -> String {
439        match self {
440            ComposioError::ApiError { 
441                message, 
442                errors, 
443                request_id,
444                suggested_fix,
445                .. 
446            } => {
447                let mut output = message.clone();
448                
449                // Add request ID for tracking
450                if let Some(req_id) = request_id {
451                    output.push_str(&format!(" (request_id: {})", req_id));
452                }
453                
454                if let Some(error_details) = errors {
455                    let mut missing_fields = Vec::new();
456                    let mut other_errors = Vec::new();
457                    
458                    for detail in error_details {
459                        let field_name = detail.field.as_deref().unwrap_or("unknown");
460                        let msg_lower = detail.message.to_lowercase();
461                        
462                        // Detect missing/required field errors
463                        if msg_lower.contains("required") 
464                            || msg_lower.contains("missing")
465                            || msg_lower.contains("field is required") {
466                            missing_fields.push(field_name);
467                        } else {
468                            other_errors.push(format!(
469                                "{} on parameter `{}`",
470                                detail.message, field_name
471                            ));
472                        }
473                    }
474                    
475                    // Format missing fields
476                    if !missing_fields.is_empty() {
477                        output.push_str(&format!(
478                            "\n- Following fields are missing: {:?}",
479                            missing_fields
480                        ));
481                    }
482                    
483                    // Format other errors
484                    if !other_errors.is_empty() {
485                        output.push_str("\n- ");
486                        output.push_str(&other_errors.join("\n- "));
487                    }
488                }
489                
490                // Add suggested fix if available
491                if let Some(fix) = suggested_fix {
492                    output.push_str(&format!("\n\nSuggested fix: {}", fix));
493                }
494                
495                output
496            }
497            ComposioError::ValidationError(msg) => {
498                format!("Validation error: {}", msg)
499            }
500            _ => format!("{}", self),
501        }
502    }
503
504    /// Check if this error should be retried
505    ///
506    /// Returns true for transient errors that may succeed on retry:
507    /// - 429 (Rate Limited)
508    /// - 500 (Internal Server Error)
509    /// - 502 (Bad Gateway)
510    /// - 503 (Service Unavailable)
511    /// - 504 (Gateway Timeout)
512    /// - Network errors
513    /// - Timeout errors
514    ///
515    /// Returns false for client errors (4xx except 429) that won't succeed on retry.
516    pub fn is_retryable(&self) -> bool {
517        match self {
518            ComposioError::ApiError { status, .. } => {
519                matches!(status, 429 | 500 | 502 | 503 | 504)
520            }
521            ComposioError::NetworkError(_) => true,
522            ComposioError::Timeout(_) => true,
523            _ => false,
524        }
525    }
526}
527
528/// Find the closest matching string using Levenshtein distance
529///
530/// Returns the closest match if the distance is within a reasonable threshold.
531fn find_closest_match(target: &str, candidates: &[String]) -> Option<String> {
532    if candidates.is_empty() {
533        return None;
534    }
535
536    let target_lower = target.to_lowercase();
537    let mut best_match: Option<(String, usize)> = None;
538
539    for candidate in candidates {
540        let candidate_lower = candidate.to_lowercase();
541        let distance = levenshtein_distance(&target_lower, &candidate_lower);
542        
543        match &best_match {
544            None => best_match = Some((candidate.clone(), distance)),
545            Some((_, best_distance)) if distance < *best_distance => {
546                best_match = Some((candidate.clone(), distance));
547            }
548            _ => {}
549        }
550    }
551
552    // Only suggest if distance is reasonable (less than half the length)
553    best_match.and_then(|(matched, distance)| {
554        let threshold = target.len() / 2;
555        if distance <= threshold {
556            Some(matched)
557        } else {
558            None
559        }
560    })
561}
562
563/// Calculate Levenshtein distance between two strings
564///
565/// This is a simple implementation for fuzzy string matching.
566fn levenshtein_distance(s1: &str, s2: &str) -> usize {
567    let len1 = s1.len();
568    let len2 = s2.len();
569
570    if len1 == 0 {
571        return len2;
572    }
573    if len2 == 0 {
574        return len1;
575    }
576
577    let mut matrix = vec![vec![0; len2 + 1]; len1 + 1];
578
579    for i in 0..=len1 {
580        matrix[i][0] = i;
581    }
582    for j in 0..=len2 {
583        matrix[0][j] = j;
584    }
585
586    let s1_chars: Vec<char> = s1.chars().collect();
587    let s2_chars: Vec<char> = s2.chars().collect();
588
589    for i in 1..=len1 {
590        for j in 1..=len2 {
591            let cost = if s1_chars[i - 1] == s2_chars[j - 1] { 0 } else { 1 };
592            matrix[i][j] = std::cmp::min(
593                std::cmp::min(
594                    matrix[i - 1][j] + 1,      // deletion
595                    matrix[i][j - 1] + 1,      // insertion
596                ),
597                matrix[i - 1][j - 1] + cost,   // substitution
598            );
599        }
600    }
601
602    matrix[len1][len2]
603}
604
605#[cfg(test)]
606mod tests {
607    use super::*;
608
609    #[test]
610    fn test_api_error_display() {
611        let error = ComposioError::ApiError {
612            status: 404,
613            message: "Resource not found".to_string(),
614            code: Some("NOT_FOUND".to_string()),
615            slug: Some("resource-not-found".to_string()),
616            request_id: Some("req_123".to_string()),
617            suggested_fix: Some("Check the resource ID".to_string()),
618            errors: None,
619        };
620
621        let display = format!("{}", error);
622        assert!(display.contains("API error"));
623        assert!(display.contains("Resource not found"));
624        assert!(display.contains("404"));
625    }
626
627    #[test]
628    fn test_invalid_input_error() {
629        let error = ComposioError::InvalidInput("Invalid API key".to_string());
630        let display = format!("{}", error);
631        assert!(display.contains("Invalid input"));
632        assert!(display.contains("Invalid API key"));
633    }
634
635    #[test]
636    fn test_config_error() {
637        let error = ComposioError::ConfigError("Invalid base URL".to_string());
638        let display = format!("{}", error);
639        assert!(display.contains("Configuration error"));
640        assert!(display.contains("Invalid base URL"));
641    }
642
643    #[test]
644    fn test_serialization_error_conversion() {
645        let json_error = serde_json::from_str::<serde_json::Value>("invalid json")
646            .unwrap_err();
647        let error: ComposioError = json_error.into();
648
649        match error {
650            ComposioError::SerializationError(_) => (),
651            _ => panic!("Expected SerializationError"),
652        }
653    }
654
655    #[test]
656    fn test_is_retryable_for_rate_limit() {
657        let error = ComposioError::ApiError {
658            status: 429,
659            message: "Rate limited".to_string(),
660            code: None,
661            slug: None,
662            request_id: None,
663            suggested_fix: None,
664            errors: None,
665        };
666
667        assert!(error.is_retryable());
668    }
669
670    #[test]
671    fn test_is_retryable_for_server_errors() {
672        for status in [500, 502, 503, 504] {
673            let error = ComposioError::ApiError {
674                status,
675                message: "Server error".to_string(),
676                code: None,
677                slug: None,
678                request_id: None,
679                suggested_fix: None,
680                errors: None,
681            };
682
683            assert!(
684                error.is_retryable(),
685                "Status {} should be retryable",
686                status
687            );
688        }
689    }
690
691    #[test]
692    fn test_is_not_retryable_for_client_errors() {
693        for status in [400, 401, 403, 404] {
694            let error = ComposioError::ApiError {
695                status,
696                message: "Client error".to_string(),
697                code: None,
698                slug: None,
699                request_id: None,
700                suggested_fix: None,
701                errors: None,
702            };
703
704            assert!(
705                !error.is_retryable(),
706                "Status {} should not be retryable",
707                status
708            );
709        }
710    }
711
712    #[test]
713    fn test_serialization_error_is_not_retryable() {
714        let json_error = serde_json::from_str::<serde_json::Value>("invalid json")
715            .unwrap_err();
716        let error: ComposioError = json_error.into();
717
718        assert!(!error.is_retryable());
719    }
720
721    #[test]
722    fn test_invalid_input_not_retryable() {
723        let error = ComposioError::InvalidInput("Invalid API key".to_string());
724        assert!(!error.is_retryable());
725    }
726
727    #[test]
728    fn test_config_error_not_retryable() {
729        let error = ComposioError::ConfigError("Invalid base URL".to_string());
730        assert!(!error.is_retryable());
731    }
732
733    #[test]
734    fn test_error_detail_deserialization() {
735        let json = r#"{
736            "field": "email",
737            "message": "Invalid email format"
738        }"#;
739
740        let detail: ErrorDetail = serde_json::from_str(json).unwrap();
741        assert_eq!(detail.field, Some("email".to_string()));
742        assert_eq!(detail.message, "Invalid email format");
743    }
744
745    #[test]
746    fn test_error_response_deserialization() {
747        let json = r#"{
748            "message": "Validation failed",
749            "code": "VALIDATION_ERROR",
750            "slug": "validation-failed",
751            "status": 400,
752            "request_id": "req_abc123",
753            "suggested_fix": "Check your input parameters",
754            "errors": [
755                {
756                    "field": "user_id",
757                    "message": "User ID is required"
758                }
759            ]
760        }"#;
761
762        let response: ErrorResponse = serde_json::from_str(json).unwrap();
763        assert_eq!(response.message, "Validation failed");
764        assert_eq!(response.code, Some("VALIDATION_ERROR".to_string()));
765        assert_eq!(response.status, 400);
766        assert!(response.errors.is_some());
767        assert_eq!(response.errors.as_ref().unwrap().len(), 1);
768    }
769
770    #[test]
771    fn test_error_response_minimal_deserialization() {
772        let json = r#"{
773            "message": "Internal server error",
774            "status": 500
775        }"#;
776
777        let response: ErrorResponse = serde_json::from_str(json).unwrap();
778        assert_eq!(response.message, "Internal server error");
779        assert_eq!(response.status, 500);
780        assert!(response.code.is_none());
781        assert!(response.errors.is_none());
782    }
783
784    #[test]
785    fn test_format_validation_error_with_missing_fields() {
786        let error = ComposioError::ApiError {
787            status: 400,
788            message: "Validation failed".to_string(),
789            code: Some("VALIDATION_ERROR".to_string()),
790            slug: None,
791            request_id: Some("req_abc123".to_string()),
792            suggested_fix: Some("Provide all required fields".to_string()),
793            errors: Some(vec![
794                ErrorDetail {
795                    field: Some("user_id".to_string()),
796                    message: "Field required".to_string(),
797                },
798                ErrorDetail {
799                    field: Some("toolkit".to_string()),
800                    message: "This field is required".to_string(),
801                },
802            ]),
803        };
804
805        let formatted = error.format_validation_error();
806        
807        assert!(formatted.contains("Validation failed"));
808        assert!(formatted.contains("request_id: req_abc123"));
809        assert!(formatted.contains("Following fields are missing"));
810        assert!(formatted.contains("user_id"));
811        assert!(formatted.contains("toolkit"));
812        assert!(formatted.contains("Suggested fix: Provide all required fields"));
813    }
814
815    #[test]
816    fn test_format_validation_error_with_invalid_values() {
817        let error = ComposioError::ApiError {
818            status: 400,
819            message: "Invalid request data".to_string(),
820            code: Some("VALIDATION_ERROR".to_string()),
821            slug: None,
822            request_id: None,
823            suggested_fix: None,
824            errors: Some(vec![
825                ErrorDetail {
826                    field: Some("email".to_string()),
827                    message: "Invalid email format".to_string(),
828                },
829                ErrorDetail {
830                    field: Some("age".to_string()),
831                    message: "Must be a positive integer".to_string(),
832                },
833            ]),
834        };
835
836        let formatted = error.format_validation_error();
837        
838        assert!(formatted.contains("Invalid request data"));
839        assert!(formatted.contains("Invalid email format on parameter `email`"));
840        assert!(formatted.contains("Must be a positive integer on parameter `age`"));
841        assert!(!formatted.contains("Following fields are missing"));
842    }
843
844    #[test]
845    fn test_format_validation_error_mixed_errors() {
846        let error = ComposioError::ApiError {
847            status: 400,
848            message: "Validation failed".to_string(),
849            code: Some("VALIDATION_ERROR".to_string()),
850            slug: None,
851            request_id: Some("req_xyz789".to_string()),
852            suggested_fix: Some("Check your input parameters".to_string()),
853            errors: Some(vec![
854                ErrorDetail {
855                    field: Some("user_id".to_string()),
856                    message: "Field required".to_string(),
857                },
858                ErrorDetail {
859                    field: Some("toolkit".to_string()),
860                    message: "Invalid toolkit name".to_string(),
861                },
862                ErrorDetail {
863                    field: Some("auth_config".to_string()),
864                    message: "Missing required field".to_string(),
865                },
866            ]),
867        };
868
869        let formatted = error.format_validation_error();
870        
871        // Should have both missing fields and other errors
872        assert!(formatted.contains("Following fields are missing"));
873        assert!(formatted.contains("user_id"));
874        assert!(formatted.contains("auth_config"));
875        assert!(formatted.contains("Invalid toolkit name on parameter `toolkit`"));
876        assert!(formatted.contains("Suggested fix: Check your input parameters"));
877    }
878
879    #[test]
880    fn test_format_validation_error_no_field_details() {
881        let error = ComposioError::ApiError {
882            status: 400,
883            message: "Bad request".to_string(),
884            code: None,
885            slug: None,
886            request_id: None,
887            suggested_fix: None,
888            errors: None,
889        };
890
891        let formatted = error.format_validation_error();
892        
893        assert_eq!(formatted, "Bad request");
894    }
895
896    #[test]
897    fn test_format_validation_error_for_validation_error_type() {
898        let error = ComposioError::ValidationError("Invalid session configuration".to_string());
899        
900        let formatted = error.format_validation_error();
901        
902        assert_eq!(formatted, "Validation error: Invalid session configuration");
903    }
904
905    #[test]
906    fn test_format_validation_error_for_other_error_types() {
907        let error = ComposioError::ConfigError("Invalid base URL".to_string());
908        
909        let formatted = error.format_validation_error();
910        
911        assert!(formatted.contains("Configuration error"));
912        assert!(formatted.contains("Invalid base URL"));
913    }
914
915    #[test]
916    fn test_format_validation_error_with_unknown_field() {
917        let error = ComposioError::ApiError {
918            status: 400,
919            message: "Validation failed".to_string(),
920            code: None,
921            slug: None,
922            request_id: None,
923            suggested_fix: None,
924            errors: Some(vec![
925                ErrorDetail {
926                    field: None,
927                    message: "Unknown validation error".to_string(),
928                },
929            ]),
930        };
931
932        let formatted = error.format_validation_error();
933        
934        assert!(formatted.contains("Unknown validation error on parameter `unknown`"));
935    }
936
937    // ============================================
938    // Tests for new error types
939    // ============================================
940
941    #[test]
942    fn test_invalid_enum_with_suggestion() {
943        let valid_values = vec!["github".to_string(), "gmail".to_string(), "slack".to_string()];
944        let error = ComposioError::invalid_enum("gihub", "Toolkit", valid_values);
945
946        let display = format!("{}", error);
947        assert!(display.contains("Invalid value 'gihub' for enum 'Toolkit'"));
948        assert!(display.contains("Did you mean 'github'?"));
949    }
950
951    #[test]
952    fn test_invalid_enum_without_close_match() {
953        let valid_values = vec!["github".to_string(), "gmail".to_string(), "slack".to_string()];
954        let error = ComposioError::invalid_enum("xyz123", "Toolkit", valid_values);
955
956        let display = format!("{}", error);
957        assert!(display.contains("Invalid value 'xyz123' for enum 'Toolkit'"));
958        assert!(!display.contains("Did you mean"));
959    }
960
961    #[test]
962    fn test_multiple_connected_accounts_error() {
963        let error = ComposioError::multiple_connected_accounts("user_123", "github", 3);
964
965        let display = format!("{}", error);
966        assert!(display.contains("Multiple connected accounts found"));
967        assert!(display.contains("user_123"));
968        assert!(display.contains("github"));
969        assert!(display.contains("Please specify connected_account_id explicitly"));
970    }
971
972    #[test]
973    fn test_version_selection_error() {
974        let error = ComposioError::version_selection_error("GITHUB_CREATE_ISSUE", "1.0.0", "2.0.0");
975
976        let display = format!("{}", error);
977        assert!(display.contains("Error selecting version"));
978        assert!(display.contains("GITHUB_CREATE_ISSUE"));
979        assert!(display.contains("requested '1.0.0'"));
980        assert!(display.contains("locked '2.0.0'"));
981    }
982
983    #[test]
984    fn test_response_too_large_error() {
985        let error = ComposioError::response_too_large(10_000_000, 5_000_000);
986
987        let display = format!("{}", error);
988        assert!(display.contains("Response too large"));
989        assert!(display.contains("10000000 bytes"));
990        assert!(display.contains("5000000 bytes"));
991    }
992
993    #[test]
994    fn test_tool_not_found_error() {
995        let error = ComposioError::tool_not_found("GITHUB_INVALID_ACTION");
996
997        let display = format!("{}", error);
998        assert!(display.contains("Tool not found: GITHUB_INVALID_ACTION"));
999        assert!(display.contains("COMPOSIO_SEARCH_TOOLS"));
1000    }
1001
1002    #[test]
1003    fn test_connected_account_not_found_error() {
1004        let error = ComposioError::connected_account_not_found("ca_123456");
1005
1006        let display = format!("{}", error);
1007        assert!(display.contains("Connected account not found"));
1008        assert!(display.contains("ca_123456"));
1009    }
1010
1011    #[test]
1012    fn test_api_key_not_provided_error() {
1013        let error = ComposioError::ApiKeyNotProvided;
1014
1015        let display = format!("{}", error);
1016        assert!(display.contains("API Key not provided"));
1017        assert!(display.contains("COMPOSIO_API_KEY"));
1018    }
1019
1020    #[test]
1021    fn test_tool_version_required_error() {
1022        let error = ComposioError::ToolVersionRequired;
1023
1024        let display = format!("{}", error);
1025        assert!(display.contains("Toolkit version not specified"));
1026        assert!(display.contains("Possible fixes"));
1027        assert!(display.contains("dangerously_skip_version_check"));
1028    }
1029
1030    #[test]
1031    fn test_webhook_signature_verification_error() {
1032        let error = ComposioError::WebhookSignatureVerificationError(
1033            "Invalid signature".to_string()
1034        );
1035
1036        let display = format!("{}", error);
1037        assert!(display.contains("Webhook signature verification failed"));
1038        assert!(display.contains("Invalid signature"));
1039    }
1040
1041    #[test]
1042    fn test_timeout_is_retryable() {
1043        let error = ComposioError::Timeout("Request timed out after 30s".to_string());
1044        assert!(error.is_retryable());
1045    }
1046
1047    #[test]
1048    fn test_tool_not_found_not_retryable() {
1049        let error = ComposioError::tool_not_found("INVALID_TOOL");
1050        assert!(!error.is_retryable());
1051    }
1052
1053    #[test]
1054    fn test_invalid_enum_not_retryable() {
1055        let error = ComposioError::invalid_enum(
1056            "invalid",
1057            "TestEnum",
1058            vec!["valid1".to_string(), "valid2".to_string()]
1059        );
1060        assert!(!error.is_retryable());
1061    }
1062
1063    #[test]
1064    fn test_levenshtein_distance() {
1065        use super::levenshtein_distance;
1066
1067        assert_eq!(levenshtein_distance("", ""), 0);
1068        assert_eq!(levenshtein_distance("abc", "abc"), 0);
1069        assert_eq!(levenshtein_distance("abc", "ab"), 1);
1070        assert_eq!(levenshtein_distance("abc", "abcd"), 1);
1071        assert_eq!(levenshtein_distance("github", "gihub"), 1);
1072        assert_eq!(levenshtein_distance("slack", "slak"), 1);
1073    }
1074
1075    #[test]
1076    fn test_find_closest_match() {
1077        use super::find_closest_match;
1078
1079        let candidates = vec![
1080            "github".to_string(),
1081            "gmail".to_string(),
1082            "slack".to_string(),
1083        ];
1084
1085        assert_eq!(
1086            find_closest_match("gihub", &candidates),
1087            Some("github".to_string())
1088        );
1089        assert_eq!(
1090            find_closest_match("gmial", &candidates),
1091            Some("gmail".to_string())
1092        );
1093        assert_eq!(
1094            find_closest_match("slak", &candidates),
1095            Some("slack".to_string())
1096        );
1097        
1098        // No close match
1099        assert_eq!(find_closest_match("xyz123", &candidates), None);
1100        
1101        // Empty candidates
1102        assert_eq!(find_closest_match("test", &[]), None);
1103    }
1104
1105    #[test]
1106    fn test_no_items_found_error() {
1107        let error = ComposioError::NoItemsFound("No toolkits found for query".to_string());
1108
1109        let display = format!("{}", error);
1110        assert!(display.contains("No items found"));
1111        assert!(display.contains("No toolkits found"));
1112    }
1113
1114    #[test]
1115    fn test_invalid_trigger_filters_error() {
1116        let error = ComposioError::InvalidTriggerFilters(
1117            "Invalid filter: unknown_field".to_string()
1118        );
1119
1120        let display = format!("{}", error);
1121        assert!(display.contains("Invalid trigger filters"));
1122        assert!(display.contains("unknown_field"));
1123    }
1124
1125    #[test]
1126    fn test_invalid_modifier_error() {
1127        let error = ComposioError::InvalidModifier(
1128            "Modifier 'before_execute' not found".to_string()
1129        );
1130
1131        let display = format!("{}", error);
1132        assert!(display.contains("Invalid modifier"));
1133        assert!(display.contains("before_execute"));
1134    }
1135}