rustapi_core/
error.rs

1//! Error types for RustAPI
2//!
3//! This module provides structured error handling with environment-aware
4//! error masking for production safety.
5//!
6//! # Error Response Format
7//!
8//! All errors are returned as JSON with a consistent structure:
9//!
10//! ```json
11//! {
12//!   "error": {
13//!     "type": "not_found",
14//!     "message": "User not found",
15//!     "fields": null
16//!   },
17//!   "error_id": "err_a1b2c3d4e5f6"
18//! }
19//! ```
20//!
21//! # Environment-Aware Error Masking
22//!
23//! In production mode (`RUSTAPI_ENV=production`), internal server errors (5xx)
24//! are masked to prevent information leakage:
25//!
26//! - **Production**: Generic "An internal error occurred" message
27//! - **Development**: Full error details for debugging
28//!
29//! Validation errors always include field details regardless of environment.
30//!
31//! # Example
32//!
33//! ```rust,ignore
34//! use rustapi_core::{ApiError, Result};
35//! use http::StatusCode;
36//!
37//! async fn get_user(id: i64) -> Result<Json<User>> {
38//!     let user = db.find_user(id)
39//!         .ok_or_else(|| ApiError::not_found("User not found"))?;
40//!     Ok(Json(user))
41//! }
42//!
43//! // Create custom errors
44//! let error = ApiError::new(StatusCode::CONFLICT, "duplicate", "Email already exists");
45//!
46//! // Convenience constructors
47//! let bad_request = ApiError::bad_request("Invalid input");
48//! let unauthorized = ApiError::unauthorized("Invalid token");
49//! let forbidden = ApiError::forbidden("Access denied");
50//! let not_found = ApiError::not_found("Resource not found");
51//! let internal = ApiError::internal("Something went wrong");
52//! ```
53//!
54//! # Error ID Correlation
55//!
56//! Every error response includes a unique `error_id` (format: `err_{uuid}`) that
57//! appears in both the response and server logs, enabling easy correlation for
58//! debugging.
59
60use http::StatusCode;
61use serde::Serialize;
62use std::fmt;
63use std::sync::OnceLock;
64use uuid::Uuid;
65
66/// Result type alias for RustAPI operations
67pub type Result<T, E = ApiError> = std::result::Result<T, E>;
68
69/// Environment configuration for error handling behavior
70///
71/// Controls whether internal error details are exposed in API responses.
72/// In production, internal details are masked to prevent information leakage.
73/// In development, full error details are shown for debugging.
74///
75/// # Example
76///
77/// ```
78/// use rustapi_core::Environment;
79///
80/// let dev = Environment::Development;
81/// assert!(dev.is_development());
82/// assert!(!dev.is_production());
83///
84/// let prod = Environment::Production;
85/// assert!(prod.is_production());
86/// assert!(!prod.is_development());
87/// ```
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
89pub enum Environment {
90    /// Development mode - shows full error details in responses
91    #[default]
92    Development,
93    /// Production mode - masks internal error details in responses
94    Production,
95}
96
97impl Environment {
98    /// Detect environment from `RUSTAPI_ENV` environment variable
99    ///
100    /// Returns `Production` if `RUSTAPI_ENV` is set to "production" or "prod" (case-insensitive).
101    /// Returns `Development` for all other values or if the variable is not set.
102    ///
103    /// # Example
104    ///
105    /// ```bash
106    /// # Production mode
107    /// RUSTAPI_ENV=production cargo run
108    /// RUSTAPI_ENV=prod cargo run
109    ///
110    /// # Development mode (default)
111    /// RUSTAPI_ENV=development cargo run
112    /// cargo run  # No env var set
113    /// ```
114    pub fn from_env() -> Self {
115        match std::env::var("RUSTAPI_ENV")
116            .map(|s| s.to_lowercase())
117            .as_deref()
118        {
119            Ok("production") | Ok("prod") => Environment::Production,
120            _ => Environment::Development,
121        }
122    }
123
124    /// Check if this is production environment
125    pub fn is_production(&self) -> bool {
126        matches!(self, Environment::Production)
127    }
128
129    /// Check if this is development environment
130    pub fn is_development(&self) -> bool {
131        matches!(self, Environment::Development)
132    }
133}
134
135impl fmt::Display for Environment {
136    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
137        match self {
138            Environment::Development => write!(f, "development"),
139            Environment::Production => write!(f, "production"),
140        }
141    }
142}
143
144/// Global environment setting, cached on first access
145static ENVIRONMENT: OnceLock<Environment> = OnceLock::new();
146
147/// Get the current environment (cached)
148///
149/// This function caches the environment on first call for performance.
150/// The environment is detected from the `RUSTAPI_ENV` environment variable.
151pub fn get_environment() -> Environment {
152    *ENVIRONMENT.get_or_init(Environment::from_env)
153}
154
155/// Set the environment explicitly (for testing purposes)
156///
157/// Note: This only works if the environment hasn't been accessed yet.
158/// Returns `Ok(())` if successful, `Err(env)` if already set.
159#[cfg(test)]
160#[allow(dead_code)]
161pub fn set_environment_for_test(env: Environment) -> Result<(), Environment> {
162    ENVIRONMENT.set(env)
163}
164
165/// Generate a unique error ID using UUID v4 format
166///
167/// Returns a string in the format `err_{uuid}` where uuid is a 32-character
168/// hexadecimal string (UUID v4 simple format).
169///
170/// # Example
171///
172/// ```rust,ignore
173/// use rustapi_core::error::generate_error_id;
174///
175/// let id = generate_error_id();
176/// assert!(id.starts_with("err_"));
177/// assert_eq!(id.len(), 36); // "err_" (4) + uuid (32)
178/// ```
179pub fn generate_error_id() -> String {
180    format!("err_{}", Uuid::new_v4().simple())
181}
182
183/// Standard API error type
184///
185/// Provides structured error responses following a consistent JSON format.
186///
187/// # Example
188///
189/// ```
190/// use rustapi_core::ApiError;
191/// use http::StatusCode;
192///
193/// // Create a custom error
194/// let error = ApiError::new(StatusCode::CONFLICT, "duplicate", "Email already exists");
195/// assert_eq!(error.status, StatusCode::CONFLICT);
196/// assert_eq!(error.error_type, "duplicate");
197///
198/// // Use convenience constructors
199/// let not_found = ApiError::not_found("User not found");
200/// assert_eq!(not_found.status, StatusCode::NOT_FOUND);
201///
202/// let bad_request = ApiError::bad_request("Invalid input");
203/// assert_eq!(bad_request.status, StatusCode::BAD_REQUEST);
204/// ```
205#[derive(Debug, Clone)]
206pub struct ApiError {
207    /// HTTP status code
208    pub status: StatusCode,
209    /// Error type identifier
210    pub error_type: String,
211    /// Human-readable error message
212    pub message: String,
213    /// Optional field-level validation errors
214    pub fields: Option<Vec<FieldError>>,
215    /// Internal details (hidden in production)
216    pub(crate) internal: Option<String>,
217}
218
219/// Field-level validation error
220#[derive(Debug, Clone, Serialize)]
221pub struct FieldError {
222    /// Field name (supports nested: "address.city")
223    pub field: String,
224    /// Error code (e.g., "email", "length", "required")
225    pub code: String,
226    /// Human-readable message
227    pub message: String,
228}
229
230impl ApiError {
231    /// Create a new API error
232    pub fn new(
233        status: StatusCode,
234        error_type: impl Into<String>,
235        message: impl Into<String>,
236    ) -> Self {
237        Self {
238            status,
239            error_type: error_type.into(),
240            message: message.into(),
241            fields: None,
242            internal: None,
243        }
244    }
245
246    /// Create a validation error with field details
247    pub fn validation(fields: Vec<FieldError>) -> Self {
248        Self {
249            status: StatusCode::UNPROCESSABLE_ENTITY,
250            error_type: "validation_error".to_string(),
251            message: "Request validation failed".to_string(),
252            fields: Some(fields),
253            internal: None,
254        }
255    }
256
257    /// Create a 400 Bad Request error
258    pub fn bad_request(message: impl Into<String>) -> Self {
259        Self::new(StatusCode::BAD_REQUEST, "bad_request", message)
260    }
261
262    /// Create a 401 Unauthorized error
263    pub fn unauthorized(message: impl Into<String>) -> Self {
264        Self::new(StatusCode::UNAUTHORIZED, "unauthorized", message)
265    }
266
267    /// Create a 403 Forbidden error
268    pub fn forbidden(message: impl Into<String>) -> Self {
269        Self::new(StatusCode::FORBIDDEN, "forbidden", message)
270    }
271
272    /// Create a 404 Not Found error
273    pub fn not_found(message: impl Into<String>) -> Self {
274        Self::new(StatusCode::NOT_FOUND, "not_found", message)
275    }
276
277    /// Create a 409 Conflict error
278    pub fn conflict(message: impl Into<String>) -> Self {
279        Self::new(StatusCode::CONFLICT, "conflict", message)
280    }
281
282    /// Create a 500 Internal Server Error
283    pub fn internal(message: impl Into<String>) -> Self {
284        Self::new(StatusCode::INTERNAL_SERVER_ERROR, "internal_error", message)
285    }
286
287    /// Add internal details (for logging, hidden from response in prod)
288    pub fn with_internal(mut self, details: impl Into<String>) -> Self {
289        self.internal = Some(details.into());
290        self
291    }
292}
293
294impl fmt::Display for ApiError {
295    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
296        write!(f, "{}: {}", self.error_type, self.message)
297    }
298}
299
300impl std::error::Error for ApiError {}
301
302/// JSON representation of API error response
303#[derive(Serialize)]
304pub struct ErrorResponse {
305    pub error: ErrorBody,
306    /// Unique error ID for log correlation (format: err_{uuid})
307    pub error_id: String,
308    #[serde(skip_serializing_if = "Option::is_none")]
309    pub request_id: Option<String>,
310}
311
312#[derive(Serialize)]
313pub struct ErrorBody {
314    #[serde(rename = "type")]
315    pub error_type: String,
316    pub message: String,
317    #[serde(skip_serializing_if = "Option::is_none")]
318    pub fields: Option<Vec<FieldError>>,
319}
320
321impl ErrorResponse {
322    /// Create an ErrorResponse from an ApiError with environment-aware masking
323    ///
324    /// In production mode:
325    /// - Internal server errors (5xx) show generic messages
326    /// - Validation errors always include field details
327    /// - Client errors (4xx) show their messages
328    ///
329    /// In development mode:
330    /// - All error details are shown
331    pub fn from_api_error(err: ApiError, env: Environment) -> Self {
332        let error_id = generate_error_id();
333
334        // Always log the full error details with error_id for correlation
335        if err.status.is_server_error() {
336            tracing::error!(
337                error_id = %error_id,
338                error_type = %err.error_type,
339                message = %err.message,
340                status = %err.status.as_u16(),
341                internal = ?err.internal,
342                environment = %env,
343                "Server error occurred"
344            );
345        } else if err.status.is_client_error() {
346            tracing::warn!(
347                error_id = %error_id,
348                error_type = %err.error_type,
349                message = %err.message,
350                status = %err.status.as_u16(),
351                environment = %env,
352                "Client error occurred"
353            );
354        } else {
355            tracing::info!(
356                error_id = %error_id,
357                error_type = %err.error_type,
358                message = %err.message,
359                status = %err.status.as_u16(),
360                environment = %env,
361                "Error response generated"
362            );
363        }
364
365        // Determine the message and fields based on environment and error type
366        let (message, fields) = if env.is_production() && err.status.is_server_error() {
367            // In production, mask internal server error details
368            // But preserve validation error fields (they're always shown per requirement 3.5)
369            let masked_message = "An internal error occurred".to_string();
370            // Validation errors keep their fields even in production
371            let fields = if err.error_type == "validation_error" {
372                err.fields
373            } else {
374                None
375            };
376            (masked_message, fields)
377        } else {
378            // In development or for non-5xx errors, show full details
379            (err.message, err.fields)
380        };
381
382        Self {
383            error: ErrorBody {
384                error_type: err.error_type,
385                message,
386                fields,
387            },
388            error_id,
389            request_id: None,
390        }
391    }
392}
393
394impl From<ApiError> for ErrorResponse {
395    fn from(err: ApiError) -> Self {
396        // Use the cached environment
397        let env = get_environment();
398        Self::from_api_error(err, env)
399    }
400}
401
402// Conversion from common error types
403impl From<serde_json::Error> for ApiError {
404    fn from(err: serde_json::Error) -> Self {
405        ApiError::bad_request(format!("Invalid JSON: {}", err))
406    }
407}
408
409impl From<std::io::Error> for ApiError {
410    fn from(err: std::io::Error) -> Self {
411        ApiError::internal("I/O error").with_internal(err.to_string())
412    }
413}
414
415impl From<hyper::Error> for ApiError {
416    fn from(err: hyper::Error) -> Self {
417        ApiError::internal("HTTP error").with_internal(err.to_string())
418    }
419}
420
421impl From<rustapi_validate::ValidationError> for ApiError {
422    fn from(err: rustapi_validate::ValidationError) -> Self {
423        let fields = err
424            .fields
425            .into_iter()
426            .map(|f| FieldError {
427                field: f.field,
428                code: f.code,
429                message: f.message,
430            })
431            .collect();
432
433        ApiError::validation(fields)
434    }
435}
436
437impl ApiError {
438    /// Create a validation error from a ValidationError
439    pub fn from_validation_error(err: rustapi_validate::ValidationError) -> Self {
440        err.into()
441    }
442
443    /// Create a 503 Service Unavailable error
444    pub fn service_unavailable(message: impl Into<String>) -> Self {
445        Self::new(
446            StatusCode::SERVICE_UNAVAILABLE,
447            "service_unavailable",
448            message,
449        )
450    }
451}
452
453// SQLx error conversion (feature-gated)
454#[cfg(feature = "sqlx")]
455impl From<sqlx::Error> for ApiError {
456    fn from(err: sqlx::Error) -> Self {
457        match &err {
458            // Pool timeout or connection acquisition failure → 503
459            sqlx::Error::PoolTimedOut => {
460                ApiError::service_unavailable("Database connection pool exhausted")
461                    .with_internal(err.to_string())
462            }
463
464            // Pool closed → 503
465            sqlx::Error::PoolClosed => {
466                ApiError::service_unavailable("Database connection pool is closed")
467                    .with_internal(err.to_string())
468            }
469
470            // Row not found → 404
471            sqlx::Error::RowNotFound => ApiError::not_found("Resource not found"),
472
473            // Database-specific errors need deeper inspection
474            sqlx::Error::Database(db_err) => {
475                // Check for unique constraint violation
476                // PostgreSQL: 23505, MySQL: 1062, SQLite: 2067
477                if let Some(code) = db_err.code() {
478                    let code_str = code.as_ref();
479                    if code_str == "23505" || code_str == "1062" || code_str == "2067" {
480                        return ApiError::conflict("Resource already exists")
481                            .with_internal(db_err.to_string());
482                    }
483
484                    // Foreign key violation
485                    // PostgreSQL: 23503, MySQL: 1452, SQLite: 787
486                    if code_str == "23503" || code_str == "1452" || code_str == "787" {
487                        return ApiError::bad_request("Referenced resource does not exist")
488                            .with_internal(db_err.to_string());
489                    }
490
491                    // Check constraint violation
492                    // PostgreSQL: 23514
493                    if code_str == "23514" {
494                        return ApiError::bad_request("Data validation failed")
495                            .with_internal(db_err.to_string());
496                    }
497                }
498
499                // Generic database error
500                ApiError::internal("Database error").with_internal(db_err.to_string())
501            }
502
503            // Connection errors → 503
504            sqlx::Error::Io(_) => ApiError::service_unavailable("Database connection error")
505                .with_internal(err.to_string()),
506
507            // TLS errors → 503
508            sqlx::Error::Tls(_) => {
509                ApiError::service_unavailable("Database TLS error").with_internal(err.to_string())
510            }
511
512            // Protocol errors → 500
513            sqlx::Error::Protocol(_) => {
514                ApiError::internal("Database protocol error").with_internal(err.to_string())
515            }
516
517            // Type/decode errors → 500
518            sqlx::Error::TypeNotFound { .. } => {
519                ApiError::internal("Database type error").with_internal(err.to_string())
520            }
521
522            sqlx::Error::ColumnNotFound(_) => {
523                ApiError::internal("Database column not found").with_internal(err.to_string())
524            }
525
526            sqlx::Error::ColumnIndexOutOfBounds { .. } => {
527                ApiError::internal("Database column index error").with_internal(err.to_string())
528            }
529
530            sqlx::Error::ColumnDecode { .. } => {
531                ApiError::internal("Database decode error").with_internal(err.to_string())
532            }
533
534            // Configuration errors → 500
535            sqlx::Error::Configuration(_) => {
536                ApiError::internal("Database configuration error").with_internal(err.to_string())
537            }
538
539            // Migration errors → 500
540            sqlx::Error::Migrate(_) => {
541                ApiError::internal("Database migration error").with_internal(err.to_string())
542            }
543
544            // Any other errors → 500
545            _ => ApiError::internal("Database error").with_internal(err.to_string()),
546        }
547    }
548}
549
550#[cfg(test)]
551mod tests {
552    use super::*;
553    use proptest::prelude::*;
554    use std::collections::HashSet;
555
556    // **Feature: phase4-ergonomics-v1, Property 6: Error ID Uniqueness**
557    //
558    // For any sequence of N errors generated by the system, all N error IDs
559    // should be unique. The error ID should appear in both the HTTP response
560    // and the corresponding log entry.
561    //
562    // **Validates: Requirements 3.3**
563    proptest! {
564        #![proptest_config(ProptestConfig::with_cases(100))]
565
566        #[test]
567        fn prop_error_id_uniqueness(
568            // Generate a random number of errors between 10 and 200
569            num_errors in 10usize..200,
570        ) {
571            // Generate N error IDs
572            let error_ids: Vec<String> = (0..num_errors)
573                .map(|_| generate_error_id())
574                .collect();
575
576            // Collect into a HashSet to check uniqueness
577            let unique_ids: HashSet<&String> = error_ids.iter().collect();
578
579            // All IDs should be unique
580            prop_assert_eq!(
581                unique_ids.len(),
582                error_ids.len(),
583                "Generated {} error IDs but only {} were unique",
584                error_ids.len(),
585                unique_ids.len()
586            );
587
588            // All IDs should follow the format err_{uuid}
589            for id in &error_ids {
590                prop_assert!(
591                    id.starts_with("err_"),
592                    "Error ID '{}' does not start with 'err_'",
593                    id
594                );
595
596                // The UUID part should be 32 hex characters (simple format)
597                let uuid_part = &id[4..];
598                prop_assert_eq!(
599                    uuid_part.len(),
600                    32,
601                    "UUID part '{}' should be 32 characters, got {}",
602                    uuid_part,
603                    uuid_part.len()
604                );
605
606                // All characters should be valid hex
607                prop_assert!(
608                    uuid_part.chars().all(|c| c.is_ascii_hexdigit()),
609                    "UUID part '{}' contains non-hex characters",
610                    uuid_part
611                );
612            }
613        }
614    }
615
616    // **Feature: phase4-ergonomics-v1, Property 6: Error ID in Response**
617    //
618    // For any ApiError converted to ErrorResponse, the error_id field should
619    // be present and follow the correct format.
620    //
621    // **Validates: Requirements 3.3**
622    proptest! {
623        #![proptest_config(ProptestConfig::with_cases(100))]
624
625        #[test]
626        fn prop_error_response_contains_error_id(
627            error_type in "[a-z_]{1,20}",
628            message in "[a-zA-Z0-9 ]{1,100}",
629        ) {
630            let api_error = ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, error_type, message);
631            let error_response = ErrorResponse::from(api_error);
632
633            // error_id should be present and follow format
634            prop_assert!(
635                error_response.error_id.starts_with("err_"),
636                "Error ID '{}' does not start with 'err_'",
637                error_response.error_id
638            );
639
640            let uuid_part = &error_response.error_id[4..];
641            prop_assert_eq!(uuid_part.len(), 32);
642            prop_assert!(uuid_part.chars().all(|c| c.is_ascii_hexdigit()));
643        }
644    }
645
646    #[test]
647    fn test_error_id_format() {
648        let error_id = generate_error_id();
649
650        // Should start with "err_"
651        assert!(error_id.starts_with("err_"));
652
653        // Total length should be 4 (prefix) + 32 (uuid simple format) = 36
654        assert_eq!(error_id.len(), 36);
655
656        // UUID part should be valid hex
657        let uuid_part = &error_id[4..];
658        assert!(uuid_part.chars().all(|c| c.is_ascii_hexdigit()));
659    }
660
661    #[test]
662    fn test_error_response_includes_error_id() {
663        let api_error = ApiError::bad_request("test error");
664        let error_response = ErrorResponse::from(api_error);
665
666        // error_id should be present
667        assert!(error_response.error_id.starts_with("err_"));
668        assert_eq!(error_response.error_id.len(), 36);
669    }
670
671    #[test]
672    fn test_error_id_in_json_serialization() {
673        let api_error = ApiError::internal("test error");
674        let error_response = ErrorResponse::from(api_error);
675
676        let json = serde_json::to_string(&error_response).unwrap();
677
678        // JSON should contain error_id field
679        assert!(json.contains("\"error_id\":"));
680        assert!(json.contains("err_"));
681    }
682
683    #[test]
684    fn test_multiple_error_ids_are_unique() {
685        let ids: Vec<String> = (0..1000).map(|_| generate_error_id()).collect();
686        let unique: HashSet<_> = ids.iter().collect();
687
688        assert_eq!(ids.len(), unique.len(), "All error IDs should be unique");
689    }
690
691    // **Feature: phase4-ergonomics-v1, Property 4: Production Error Masking**
692    //
693    // For any internal error (5xx) when RUSTAPI_ENV=production, the response body
694    // should contain only a generic error message and error ID, without stack traces,
695    // internal details, or sensitive information.
696    //
697    // **Validates: Requirements 3.1**
698    proptest! {
699        #![proptest_config(ProptestConfig::with_cases(100))]
700
701        #[test]
702        fn prop_production_error_masking(
703            // Generate random error messages that could contain sensitive info
704            // Use longer strings to avoid false positives where short strings appear in masked message
705            sensitive_message in "[a-zA-Z0-9_]{10,200}",
706            internal_details in "[a-zA-Z0-9_]{10,200}",
707            // Generate random 5xx status codes
708            status_code in prop::sample::select(vec![500u16, 501, 502, 503, 504, 505]),
709        ) {
710            // Create an internal error with potentially sensitive details
711            let api_error = ApiError::new(
712                StatusCode::from_u16(status_code).unwrap(),
713                "internal_error",
714                sensitive_message.clone()
715            ).with_internal(internal_details.clone());
716
717            // Convert to ErrorResponse in production mode
718            let error_response = ErrorResponse::from_api_error(api_error, Environment::Production);
719
720            // The message should be masked to a generic message
721            prop_assert_eq!(
722                &error_response.error.message,
723                "An internal error occurred",
724                "Production 5xx error should have masked message, got: {}",
725                &error_response.error.message
726            );
727
728            // The original sensitive message should NOT appear in the response
729            // (only check if the message is long enough to be meaningful)
730            if sensitive_message.len() >= 10 {
731                prop_assert!(
732                    !error_response.error.message.contains(&sensitive_message),
733                    "Production error response should not contain original message"
734                );
735            }
736
737            // Internal details should NOT appear anywhere in the serialized response
738            let json = serde_json::to_string(&error_response).unwrap();
739            if internal_details.len() >= 10 {
740                prop_assert!(
741                    !json.contains(&internal_details),
742                    "Production error response should not contain internal details"
743                );
744            }
745
746            // Error ID should still be present
747            prop_assert!(
748                error_response.error_id.starts_with("err_"),
749                "Error ID should be present in production error response"
750            );
751        }
752    }
753
754    // **Feature: phase4-ergonomics-v1, Property 5: Development Error Details**
755    //
756    // For any error when RUSTAPI_ENV=development, the response body should contain
757    // detailed error information including the original error message and any
758    // available context.
759    //
760    // **Validates: Requirements 3.2**
761    proptest! {
762        #![proptest_config(ProptestConfig::with_cases(100))]
763
764        #[test]
765        fn prop_development_error_details(
766            // Generate random error messages
767            error_message in "[a-zA-Z0-9 ]{1,100}",
768            error_type in "[a-z_]{1,20}",
769            // Generate random status codes (both 4xx and 5xx)
770            status_code in prop::sample::select(vec![400u16, 401, 403, 404, 500, 502, 503]),
771        ) {
772            // Create an error with details
773            let api_error = ApiError::new(
774                StatusCode::from_u16(status_code).unwrap(),
775                error_type.clone(),
776                error_message.clone()
777            );
778
779            // Convert to ErrorResponse in development mode
780            let error_response = ErrorResponse::from_api_error(api_error, Environment::Development);
781
782            // The original message should be preserved
783            prop_assert_eq!(
784                error_response.error.message,
785                error_message,
786                "Development error should preserve original message"
787            );
788
789            // The error type should be preserved
790            prop_assert_eq!(
791                error_response.error.error_type,
792                error_type,
793                "Development error should preserve error type"
794            );
795
796            // Error ID should be present
797            prop_assert!(
798                error_response.error_id.starts_with("err_"),
799                "Error ID should be present in development error response"
800            );
801        }
802    }
803
804    // **Feature: phase4-ergonomics-v1, Property 7: Validation Error Field Details**
805    //
806    // For any validation error in any environment (production or development),
807    // the response should include field-level error details with field name,
808    // error code, and message.
809    //
810    // **Validates: Requirements 3.5**
811    proptest! {
812        #![proptest_config(ProptestConfig::with_cases(100))]
813
814        #[test]
815        fn prop_validation_error_field_details(
816            // Generate random field errors
817            field_name in "[a-z_]{1,20}",
818            field_code in "[a-z_]{1,15}",
819            field_message in "[a-zA-Z0-9 ]{1,50}",
820            // Test in both environments
821            is_production in proptest::bool::ANY,
822        ) {
823            let env = if is_production {
824                Environment::Production
825            } else {
826                Environment::Development
827            };
828
829            // Create a validation error with field details
830            let field_error = FieldError {
831                field: field_name.clone(),
832                code: field_code.clone(),
833                message: field_message.clone(),
834            };
835            let api_error = ApiError::validation(vec![field_error]);
836
837            // Convert to ErrorResponse
838            let error_response = ErrorResponse::from_api_error(api_error, env);
839
840            // Fields should always be present for validation errors
841            prop_assert!(
842                error_response.error.fields.is_some(),
843                "Validation error should always include fields in {} mode",
844                env
845            );
846
847            let fields = error_response.error.fields.as_ref().unwrap();
848            prop_assert_eq!(
849                fields.len(),
850                1,
851                "Should have exactly one field error"
852            );
853
854            let field = &fields[0];
855
856            // Field name should be preserved
857            prop_assert_eq!(
858                &field.field,
859                &field_name,
860                "Field name should be preserved in {} mode",
861                env
862            );
863
864            // Field code should be preserved
865            prop_assert_eq!(
866                &field.code,
867                &field_code,
868                "Field code should be preserved in {} mode",
869                env
870            );
871
872            // Field message should be preserved
873            prop_assert_eq!(
874                &field.message,
875                &field_message,
876                "Field message should be preserved in {} mode",
877                env
878            );
879
880            // Verify JSON serialization includes all field details
881            let json = serde_json::to_string(&error_response).unwrap();
882            prop_assert!(
883                json.contains(&field_name),
884                "JSON should contain field name in {} mode",
885                env
886            );
887            prop_assert!(
888                json.contains(&field_code),
889                "JSON should contain field code in {} mode",
890                env
891            );
892            prop_assert!(
893                json.contains(&field_message),
894                "JSON should contain field message in {} mode",
895                env
896            );
897        }
898    }
899
900    // Unit tests for Environment enum
901    // Note: These tests verify the Environment::from_env() logic by testing the parsing
902    // directly rather than modifying global environment variables (which causes race conditions
903    // in parallel test execution).
904
905    #[test]
906    fn test_environment_from_env_production() {
907        // Test the parsing logic directly by simulating what from_env() does
908        // This avoids race conditions with parallel tests
909
910        // Test "production" variants
911        assert!(matches!(
912            match "production".to_lowercase().as_str() {
913                "production" | "prod" => Environment::Production,
914                _ => Environment::Development,
915            },
916            Environment::Production
917        ));
918
919        assert!(matches!(
920            match "prod".to_lowercase().as_str() {
921                "production" | "prod" => Environment::Production,
922                _ => Environment::Development,
923            },
924            Environment::Production
925        ));
926
927        assert!(matches!(
928            match "PRODUCTION".to_lowercase().as_str() {
929                "production" | "prod" => Environment::Production,
930                _ => Environment::Development,
931            },
932            Environment::Production
933        ));
934
935        assert!(matches!(
936            match "PROD".to_lowercase().as_str() {
937                "production" | "prod" => Environment::Production,
938                _ => Environment::Development,
939            },
940            Environment::Production
941        ));
942    }
943
944    #[test]
945    fn test_environment_from_env_development() {
946        // Test the parsing logic directly by simulating what from_env() does
947        // This avoids race conditions with parallel tests
948
949        // Test "development" and other variants that should default to Development
950        assert!(matches!(
951            match "development".to_lowercase().as_str() {
952                "production" | "prod" => Environment::Production,
953                _ => Environment::Development,
954            },
955            Environment::Development
956        ));
957
958        assert!(matches!(
959            match "dev".to_lowercase().as_str() {
960                "production" | "prod" => Environment::Production,
961                _ => Environment::Development,
962            },
963            Environment::Development
964        ));
965
966        assert!(matches!(
967            match "test".to_lowercase().as_str() {
968                "production" | "prod" => Environment::Production,
969                _ => Environment::Development,
970            },
971            Environment::Development
972        ));
973
974        assert!(matches!(
975            match "anything_else".to_lowercase().as_str() {
976                "production" | "prod" => Environment::Production,
977                _ => Environment::Development,
978            },
979            Environment::Development
980        ));
981    }
982
983    #[test]
984    fn test_environment_default_is_development() {
985        // Test that the default is Development
986        assert_eq!(Environment::default(), Environment::Development);
987    }
988
989    #[test]
990    fn test_environment_display() {
991        assert_eq!(format!("{}", Environment::Development), "development");
992        assert_eq!(format!("{}", Environment::Production), "production");
993    }
994
995    #[test]
996    fn test_environment_is_methods() {
997        assert!(Environment::Production.is_production());
998        assert!(!Environment::Production.is_development());
999        assert!(Environment::Development.is_development());
1000        assert!(!Environment::Development.is_production());
1001    }
1002
1003    #[test]
1004    fn test_production_masks_5xx_errors() {
1005        let error =
1006            ApiError::internal("Sensitive database connection string: postgres://user:pass@host");
1007        let response = ErrorResponse::from_api_error(error, Environment::Production);
1008
1009        assert_eq!(response.error.message, "An internal error occurred");
1010        assert!(!response.error.message.contains("postgres"));
1011    }
1012
1013    #[test]
1014    fn test_production_shows_4xx_errors() {
1015        let error = ApiError::bad_request("Invalid email format");
1016        let response = ErrorResponse::from_api_error(error, Environment::Production);
1017
1018        // 4xx errors should show their message even in production
1019        assert_eq!(response.error.message, "Invalid email format");
1020    }
1021
1022    #[test]
1023    fn test_development_shows_all_errors() {
1024        let error = ApiError::internal("Detailed error: connection refused to 192.168.1.1:5432");
1025        let response = ErrorResponse::from_api_error(error, Environment::Development);
1026
1027        assert_eq!(
1028            response.error.message,
1029            "Detailed error: connection refused to 192.168.1.1:5432"
1030        );
1031    }
1032
1033    #[test]
1034    fn test_validation_errors_always_show_fields() {
1035        let fields = vec![
1036            FieldError {
1037                field: "email".to_string(),
1038                code: "invalid_format".to_string(),
1039                message: "Invalid email format".to_string(),
1040            },
1041            FieldError {
1042                field: "age".to_string(),
1043                code: "min".to_string(),
1044                message: "Must be at least 18".to_string(),
1045            },
1046        ];
1047
1048        let error = ApiError::validation(fields.clone());
1049
1050        // Test in production
1051        let prod_response = ErrorResponse::from_api_error(error.clone(), Environment::Production);
1052        assert!(prod_response.error.fields.is_some());
1053        let prod_fields = prod_response.error.fields.unwrap();
1054        assert_eq!(prod_fields.len(), 2);
1055        assert_eq!(prod_fields[0].field, "email");
1056        assert_eq!(prod_fields[1].field, "age");
1057
1058        // Test in development
1059        let dev_response = ErrorResponse::from_api_error(error, Environment::Development);
1060        assert!(dev_response.error.fields.is_some());
1061        let dev_fields = dev_response.error.fields.unwrap();
1062        assert_eq!(dev_fields.len(), 2);
1063    }
1064}