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            crate::trace_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            crate::trace_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            crate::trace_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<crate::json::JsonError> for ApiError {
410    fn from(err: crate::json::JsonError) -> Self {
411        ApiError::bad_request(format!("Invalid JSON: {}", err))
412    }
413}
414
415impl From<std::io::Error> for ApiError {
416    fn from(err: std::io::Error) -> Self {
417        ApiError::internal("I/O error").with_internal(err.to_string())
418    }
419}
420
421impl From<hyper::Error> for ApiError {
422    fn from(err: hyper::Error) -> Self {
423        ApiError::internal("HTTP error").with_internal(err.to_string())
424    }
425}
426
427impl From<rustapi_validate::ValidationError> for ApiError {
428    fn from(err: rustapi_validate::ValidationError) -> Self {
429        let fields = err
430            .fields
431            .into_iter()
432            .map(|f| FieldError {
433                field: f.field,
434                code: f.code,
435                message: f.message,
436            })
437            .collect();
438
439        ApiError::validation(fields)
440    }
441}
442
443impl ApiError {
444    /// Create a validation error from a ValidationError
445    pub fn from_validation_error(err: rustapi_validate::ValidationError) -> Self {
446        err.into()
447    }
448
449    /// Create a 503 Service Unavailable error
450    pub fn service_unavailable(message: impl Into<String>) -> Self {
451        Self::new(
452            StatusCode::SERVICE_UNAVAILABLE,
453            "service_unavailable",
454            message,
455        )
456    }
457}
458
459// SQLx error conversion (feature-gated)
460#[cfg(feature = "sqlx")]
461impl From<sqlx::Error> for ApiError {
462    fn from(err: sqlx::Error) -> Self {
463        match &err {
464            // Pool timeout or connection acquisition failure → 503
465            sqlx::Error::PoolTimedOut => {
466                ApiError::service_unavailable("Database connection pool exhausted")
467                    .with_internal(err.to_string())
468            }
469
470            // Pool closed → 503
471            sqlx::Error::PoolClosed => {
472                ApiError::service_unavailable("Database connection pool is closed")
473                    .with_internal(err.to_string())
474            }
475
476            // Row not found → 404
477            sqlx::Error::RowNotFound => ApiError::not_found("Resource not found"),
478
479            // Database-specific errors need deeper inspection
480            sqlx::Error::Database(db_err) => {
481                // Check for unique constraint violation
482                // PostgreSQL: 23505, MySQL: 1062, SQLite: 2067
483                if let Some(code) = db_err.code() {
484                    let code_str = code.as_ref();
485                    if code_str == "23505" || code_str == "1062" || code_str == "2067" {
486                        return ApiError::conflict("Resource already exists")
487                            .with_internal(db_err.to_string());
488                    }
489
490                    // Foreign key violation
491                    // PostgreSQL: 23503, MySQL: 1452, SQLite: 787
492                    if code_str == "23503" || code_str == "1452" || code_str == "787" {
493                        return ApiError::bad_request("Referenced resource does not exist")
494                            .with_internal(db_err.to_string());
495                    }
496
497                    // Check constraint violation
498                    // PostgreSQL: 23514
499                    if code_str == "23514" {
500                        return ApiError::bad_request("Data validation failed")
501                            .with_internal(db_err.to_string());
502                    }
503                }
504
505                // Generic database error
506                ApiError::internal("Database error").with_internal(db_err.to_string())
507            }
508
509            // Connection errors → 503
510            sqlx::Error::Io(_) => ApiError::service_unavailable("Database connection error")
511                .with_internal(err.to_string()),
512
513            // TLS errors → 503
514            sqlx::Error::Tls(_) => {
515                ApiError::service_unavailable("Database TLS error").with_internal(err.to_string())
516            }
517
518            // Protocol errors → 500
519            sqlx::Error::Protocol(_) => {
520                ApiError::internal("Database protocol error").with_internal(err.to_string())
521            }
522
523            // Type/decode errors → 500
524            sqlx::Error::TypeNotFound { .. } => {
525                ApiError::internal("Database type error").with_internal(err.to_string())
526            }
527
528            sqlx::Error::ColumnNotFound(_) => {
529                ApiError::internal("Database column not found").with_internal(err.to_string())
530            }
531
532            sqlx::Error::ColumnIndexOutOfBounds { .. } => {
533                ApiError::internal("Database column index error").with_internal(err.to_string())
534            }
535
536            sqlx::Error::ColumnDecode { .. } => {
537                ApiError::internal("Database decode error").with_internal(err.to_string())
538            }
539
540            // Configuration errors → 500
541            sqlx::Error::Configuration(_) => {
542                ApiError::internal("Database configuration error").with_internal(err.to_string())
543            }
544
545            // Migration errors → 500
546            sqlx::Error::Migrate(_) => {
547                ApiError::internal("Database migration error").with_internal(err.to_string())
548            }
549
550            // Any other errors → 500
551            _ => ApiError::internal("Database error").with_internal(err.to_string()),
552        }
553    }
554}
555
556#[cfg(test)]
557mod tests {
558    use super::*;
559    use proptest::prelude::*;
560    use std::collections::HashSet;
561
562    // **Feature: phase4-ergonomics-v1, Property 6: Error ID Uniqueness**
563    //
564    // For any sequence of N errors generated by the system, all N error IDs
565    // should be unique. The error ID should appear in both the HTTP response
566    // and the corresponding log entry.
567    //
568    // **Validates: Requirements 3.3**
569    proptest! {
570        #![proptest_config(ProptestConfig::with_cases(100))]
571
572        #[test]
573        fn prop_error_id_uniqueness(
574            // Generate a random number of errors between 10 and 200
575            num_errors in 10usize..200,
576        ) {
577            // Generate N error IDs
578            let error_ids: Vec<String> = (0..num_errors)
579                .map(|_| generate_error_id())
580                .collect();
581
582            // Collect into a HashSet to check uniqueness
583            let unique_ids: HashSet<&String> = error_ids.iter().collect();
584
585            // All IDs should be unique
586            prop_assert_eq!(
587                unique_ids.len(),
588                error_ids.len(),
589                "Generated {} error IDs but only {} were unique",
590                error_ids.len(),
591                unique_ids.len()
592            );
593
594            // All IDs should follow the format err_{uuid}
595            for id in &error_ids {
596                prop_assert!(
597                    id.starts_with("err_"),
598                    "Error ID '{}' does not start with 'err_'",
599                    id
600                );
601
602                // The UUID part should be 32 hex characters (simple format)
603                let uuid_part = &id[4..];
604                prop_assert_eq!(
605                    uuid_part.len(),
606                    32,
607                    "UUID part '{}' should be 32 characters, got {}",
608                    uuid_part,
609                    uuid_part.len()
610                );
611
612                // All characters should be valid hex
613                prop_assert!(
614                    uuid_part.chars().all(|c| c.is_ascii_hexdigit()),
615                    "UUID part '{}' contains non-hex characters",
616                    uuid_part
617                );
618            }
619        }
620    }
621
622    // **Feature: phase4-ergonomics-v1, Property 6: Error ID in Response**
623    //
624    // For any ApiError converted to ErrorResponse, the error_id field should
625    // be present and follow the correct format.
626    //
627    // **Validates: Requirements 3.3**
628    proptest! {
629        #![proptest_config(ProptestConfig::with_cases(100))]
630
631        #[test]
632        fn prop_error_response_contains_error_id(
633            error_type in "[a-z_]{1,20}",
634            message in "[a-zA-Z0-9 ]{1,100}",
635        ) {
636            let api_error = ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, error_type, message);
637            let error_response = ErrorResponse::from(api_error);
638
639            // error_id should be present and follow format
640            prop_assert!(
641                error_response.error_id.starts_with("err_"),
642                "Error ID '{}' does not start with 'err_'",
643                error_response.error_id
644            );
645
646            let uuid_part = &error_response.error_id[4..];
647            prop_assert_eq!(uuid_part.len(), 32);
648            prop_assert!(uuid_part.chars().all(|c| c.is_ascii_hexdigit()));
649        }
650    }
651
652    #[test]
653    fn test_error_id_format() {
654        let error_id = generate_error_id();
655
656        // Should start with "err_"
657        assert!(error_id.starts_with("err_"));
658
659        // Total length should be 4 (prefix) + 32 (uuid simple format) = 36
660        assert_eq!(error_id.len(), 36);
661
662        // UUID part should be valid hex
663        let uuid_part = &error_id[4..];
664        assert!(uuid_part.chars().all(|c| c.is_ascii_hexdigit()));
665    }
666
667    #[test]
668    fn test_error_response_includes_error_id() {
669        let api_error = ApiError::bad_request("test error");
670        let error_response = ErrorResponse::from(api_error);
671
672        // error_id should be present
673        assert!(error_response.error_id.starts_with("err_"));
674        assert_eq!(error_response.error_id.len(), 36);
675    }
676
677    #[test]
678    fn test_error_id_in_json_serialization() {
679        let api_error = ApiError::internal("test error");
680        let error_response = ErrorResponse::from(api_error);
681
682        let json = serde_json::to_string(&error_response).unwrap();
683
684        // JSON should contain error_id field
685        assert!(json.contains("\"error_id\":"));
686        assert!(json.contains("err_"));
687    }
688
689    #[test]
690    fn test_multiple_error_ids_are_unique() {
691        let ids: Vec<String> = (0..1000).map(|_| generate_error_id()).collect();
692        let unique: HashSet<_> = ids.iter().collect();
693
694        assert_eq!(ids.len(), unique.len(), "All error IDs should be unique");
695    }
696
697    // **Feature: phase4-ergonomics-v1, Property 4: Production Error Masking**
698    //
699    // For any internal error (5xx) when RUSTAPI_ENV=production, the response body
700    // should contain only a generic error message and error ID, without stack traces,
701    // internal details, or sensitive information.
702    //
703    // **Validates: Requirements 3.1**
704    proptest! {
705        #![proptest_config(ProptestConfig::with_cases(100))]
706
707        #[test]
708        fn prop_production_error_masking(
709            // Generate random error messages that could contain sensitive info
710            // Use longer strings to avoid false positives where short strings appear in masked message
711            sensitive_message in "[a-zA-Z0-9_]{10,200}",
712            internal_details in "[a-zA-Z0-9_]{10,200}",
713            // Generate random 5xx status codes
714            status_code in prop::sample::select(vec![500u16, 501, 502, 503, 504, 505]),
715        ) {
716            // Create an internal error with potentially sensitive details
717            let api_error = ApiError::new(
718                StatusCode::from_u16(status_code).unwrap(),
719                "internal_error",
720                sensitive_message.clone()
721            ).with_internal(internal_details.clone());
722
723            // Convert to ErrorResponse in production mode
724            let error_response = ErrorResponse::from_api_error(api_error, Environment::Production);
725
726            // The message should be masked to a generic message
727            prop_assert_eq!(
728                &error_response.error.message,
729                "An internal error occurred",
730                "Production 5xx error should have masked message, got: {}",
731                &error_response.error.message
732            );
733
734            // The original sensitive message should NOT appear in the response
735            // (only check if the message is long enough to be meaningful)
736            if sensitive_message.len() >= 10 {
737                prop_assert!(
738                    !error_response.error.message.contains(&sensitive_message),
739                    "Production error response should not contain original message"
740                );
741            }
742
743            // Internal details should NOT appear anywhere in the serialized response
744            let json = serde_json::to_string(&error_response).unwrap();
745            if internal_details.len() >= 10 {
746                prop_assert!(
747                    !json.contains(&internal_details),
748                    "Production error response should not contain internal details"
749                );
750            }
751
752            // Error ID should still be present
753            prop_assert!(
754                error_response.error_id.starts_with("err_"),
755                "Error ID should be present in production error response"
756            );
757        }
758    }
759
760    // **Feature: phase4-ergonomics-v1, Property 5: Development Error Details**
761    //
762    // For any error when RUSTAPI_ENV=development, the response body should contain
763    // detailed error information including the original error message and any
764    // available context.
765    //
766    // **Validates: Requirements 3.2**
767    proptest! {
768        #![proptest_config(ProptestConfig::with_cases(100))]
769
770        #[test]
771        fn prop_development_error_details(
772            // Generate random error messages
773            error_message in "[a-zA-Z0-9 ]{1,100}",
774            error_type in "[a-z_]{1,20}",
775            // Generate random status codes (both 4xx and 5xx)
776            status_code in prop::sample::select(vec![400u16, 401, 403, 404, 500, 502, 503]),
777        ) {
778            // Create an error with details
779            let api_error = ApiError::new(
780                StatusCode::from_u16(status_code).unwrap(),
781                error_type.clone(),
782                error_message.clone()
783            );
784
785            // Convert to ErrorResponse in development mode
786            let error_response = ErrorResponse::from_api_error(api_error, Environment::Development);
787
788            // The original message should be preserved
789            prop_assert_eq!(
790                error_response.error.message,
791                error_message,
792                "Development error should preserve original message"
793            );
794
795            // The error type should be preserved
796            prop_assert_eq!(
797                error_response.error.error_type,
798                error_type,
799                "Development error should preserve error type"
800            );
801
802            // Error ID should be present
803            prop_assert!(
804                error_response.error_id.starts_with("err_"),
805                "Error ID should be present in development error response"
806            );
807        }
808    }
809
810    // **Feature: phase4-ergonomics-v1, Property 7: Validation Error Field Details**
811    //
812    // For any validation error in any environment (production or development),
813    // the response should include field-level error details with field name,
814    // error code, and message.
815    //
816    // **Validates: Requirements 3.5**
817    proptest! {
818        #![proptest_config(ProptestConfig::with_cases(100))]
819
820        #[test]
821        fn prop_validation_error_field_details(
822            // Generate random field errors
823            field_name in "[a-z_]{1,20}",
824            field_code in "[a-z_]{1,15}",
825            field_message in "[a-zA-Z0-9 ]{1,50}",
826            // Test in both environments
827            is_production in proptest::bool::ANY,
828        ) {
829            let env = if is_production {
830                Environment::Production
831            } else {
832                Environment::Development
833            };
834
835            // Create a validation error with field details
836            let field_error = FieldError {
837                field: field_name.clone(),
838                code: field_code.clone(),
839                message: field_message.clone(),
840            };
841            let api_error = ApiError::validation(vec![field_error]);
842
843            // Convert to ErrorResponse
844            let error_response = ErrorResponse::from_api_error(api_error, env);
845
846            // Fields should always be present for validation errors
847            prop_assert!(
848                error_response.error.fields.is_some(),
849                "Validation error should always include fields in {} mode",
850                env
851            );
852
853            let fields = error_response.error.fields.as_ref().unwrap();
854            prop_assert_eq!(
855                fields.len(),
856                1,
857                "Should have exactly one field error"
858            );
859
860            let field = &fields[0];
861
862            // Field name should be preserved
863            prop_assert_eq!(
864                &field.field,
865                &field_name,
866                "Field name should be preserved in {} mode",
867                env
868            );
869
870            // Field code should be preserved
871            prop_assert_eq!(
872                &field.code,
873                &field_code,
874                "Field code should be preserved in {} mode",
875                env
876            );
877
878            // Field message should be preserved
879            prop_assert_eq!(
880                &field.message,
881                &field_message,
882                "Field message should be preserved in {} mode",
883                env
884            );
885
886            // Verify JSON serialization includes all field details
887            let json = serde_json::to_string(&error_response).unwrap();
888            prop_assert!(
889                json.contains(&field_name),
890                "JSON should contain field name in {} mode",
891                env
892            );
893            prop_assert!(
894                json.contains(&field_code),
895                "JSON should contain field code in {} mode",
896                env
897            );
898            prop_assert!(
899                json.contains(&field_message),
900                "JSON should contain field message in {} mode",
901                env
902            );
903        }
904    }
905
906    // Unit tests for Environment enum
907    // Note: These tests verify the Environment::from_env() logic by testing the parsing
908    // directly rather than modifying global environment variables (which causes race conditions
909    // in parallel test execution).
910
911    #[test]
912    fn test_environment_from_env_production() {
913        // Test the parsing logic directly by simulating what from_env() does
914        // This avoids race conditions with parallel tests
915
916        // Test "production" variants
917        assert!(matches!(
918            match "production".to_lowercase().as_str() {
919                "production" | "prod" => Environment::Production,
920                _ => Environment::Development,
921            },
922            Environment::Production
923        ));
924
925        assert!(matches!(
926            match "prod".to_lowercase().as_str() {
927                "production" | "prod" => Environment::Production,
928                _ => Environment::Development,
929            },
930            Environment::Production
931        ));
932
933        assert!(matches!(
934            match "PRODUCTION".to_lowercase().as_str() {
935                "production" | "prod" => Environment::Production,
936                _ => Environment::Development,
937            },
938            Environment::Production
939        ));
940
941        assert!(matches!(
942            match "PROD".to_lowercase().as_str() {
943                "production" | "prod" => Environment::Production,
944                _ => Environment::Development,
945            },
946            Environment::Production
947        ));
948    }
949
950    #[test]
951    fn test_environment_from_env_development() {
952        // Test the parsing logic directly by simulating what from_env() does
953        // This avoids race conditions with parallel tests
954
955        // Test "development" and other variants that should default to Development
956        assert!(matches!(
957            match "development".to_lowercase().as_str() {
958                "production" | "prod" => Environment::Production,
959                _ => Environment::Development,
960            },
961            Environment::Development
962        ));
963
964        assert!(matches!(
965            match "dev".to_lowercase().as_str() {
966                "production" | "prod" => Environment::Production,
967                _ => Environment::Development,
968            },
969            Environment::Development
970        ));
971
972        assert!(matches!(
973            match "test".to_lowercase().as_str() {
974                "production" | "prod" => Environment::Production,
975                _ => Environment::Development,
976            },
977            Environment::Development
978        ));
979
980        assert!(matches!(
981            match "anything_else".to_lowercase().as_str() {
982                "production" | "prod" => Environment::Production,
983                _ => Environment::Development,
984            },
985            Environment::Development
986        ));
987    }
988
989    #[test]
990    fn test_environment_default_is_development() {
991        // Test that the default is Development
992        assert_eq!(Environment::default(), Environment::Development);
993    }
994
995    #[test]
996    fn test_environment_display() {
997        assert_eq!(format!("{}", Environment::Development), "development");
998        assert_eq!(format!("{}", Environment::Production), "production");
999    }
1000
1001    #[test]
1002    fn test_environment_is_methods() {
1003        assert!(Environment::Production.is_production());
1004        assert!(!Environment::Production.is_development());
1005        assert!(Environment::Development.is_development());
1006        assert!(!Environment::Development.is_production());
1007    }
1008
1009    #[test]
1010    fn test_production_masks_5xx_errors() {
1011        let error =
1012            ApiError::internal("Sensitive database connection string: postgres://user:pass@host");
1013        let response = ErrorResponse::from_api_error(error, Environment::Production);
1014
1015        assert_eq!(response.error.message, "An internal error occurred");
1016        assert!(!response.error.message.contains("postgres"));
1017    }
1018
1019    #[test]
1020    fn test_production_shows_4xx_errors() {
1021        let error = ApiError::bad_request("Invalid email format");
1022        let response = ErrorResponse::from_api_error(error, Environment::Production);
1023
1024        // 4xx errors should show their message even in production
1025        assert_eq!(response.error.message, "Invalid email format");
1026    }
1027
1028    #[test]
1029    fn test_development_shows_all_errors() {
1030        let error = ApiError::internal("Detailed error: connection refused to 192.168.1.1:5432");
1031        let response = ErrorResponse::from_api_error(error, Environment::Development);
1032
1033        assert_eq!(
1034            response.error.message,
1035            "Detailed error: connection refused to 192.168.1.1:5432"
1036        );
1037    }
1038
1039    #[test]
1040    fn test_validation_errors_always_show_fields() {
1041        let fields = vec![
1042            FieldError {
1043                field: "email".to_string(),
1044                code: "invalid_format".to_string(),
1045                message: "Invalid email format".to_string(),
1046            },
1047            FieldError {
1048                field: "age".to_string(),
1049                code: "min".to_string(),
1050                message: "Must be at least 18".to_string(),
1051            },
1052        ];
1053
1054        let error = ApiError::validation(fields.clone());
1055
1056        // Test in production
1057        let prod_response = ErrorResponse::from_api_error(error.clone(), Environment::Production);
1058        assert!(prod_response.error.fields.is_some());
1059        let prod_fields = prod_response.error.fields.unwrap();
1060        assert_eq!(prod_fields.len(), 2);
1061        assert_eq!(prod_fields[0].field, "email");
1062        assert_eq!(prod_fields[1].field, "age");
1063
1064        // Test in development
1065        let dev_response = ErrorResponse::from_api_error(error, Environment::Development);
1066        assert!(dev_response.error.fields.is_some());
1067        let dev_fields = dev_response.error.fields.unwrap();
1068        assert_eq!(dev_fields.len(), 2);
1069    }
1070}