version_migrate/
errors.rs

1//! Error types for migration operations.
2
3use std::fmt;
4use thiserror::Error;
5
6/// File I/O operation kind.
7///
8/// Identifies the specific type of I/O operation that failed.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum IoOperationKind {
11    /// Reading from a file
12    Read,
13    /// Writing to a file
14    Write,
15    /// Creating a new file
16    Create,
17    /// Deleting a file
18    Delete,
19    /// Renaming/moving a file
20    Rename,
21    /// Creating a directory
22    CreateDir,
23    /// Reading directory contents
24    ReadDir,
25    /// Syncing file contents to disk
26    Sync,
27}
28
29impl fmt::Display for IoOperationKind {
30    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
31        match self {
32            Self::Read => write!(f, "read"),
33            Self::Write => write!(f, "write"),
34            Self::Create => write!(f, "create"),
35            Self::Delete => write!(f, "delete"),
36            Self::Rename => write!(f, "rename"),
37            Self::CreateDir => write!(f, "create directory"),
38            Self::ReadDir => write!(f, "read directory"),
39            Self::Sync => write!(f, "sync"),
40        }
41    }
42}
43
44/// Format I/O error message with operation, path, context, and error details.
45fn format_io_error(
46    operation: &IoOperationKind,
47    path: &str,
48    context: &Option<String>,
49    error: &str,
50) -> String {
51    if let Some(ctx) = context {
52        format!("Failed to {} {} at '{}': {}", operation, ctx, path, error)
53    } else {
54        format!("Failed to {} file at '{}': {}", operation, path, error)
55    }
56}
57
58/// Error types that can occur during migration operations.
59#[derive(Error, Debug)]
60#[non_exhaustive]
61pub enum MigrationError {
62    /// Failed to deserialize the data.
63    #[error("Failed to deserialize: {0}")]
64    DeserializationError(String),
65
66    /// Failed to serialize the data.
67    #[error("Failed to serialize: {0}")]
68    SerializationError(String),
69
70    /// The requested entity type was not found in the migrator.
71    #[error("Entity '{0}' not found")]
72    EntityNotFound(String),
73
74    /// No migration path is defined for the given entity and version.
75    #[error("No migration path defined for entity '{entity}' version '{version}'")]
76    MigrationPathNotDefined {
77        /// The entity name.
78        entity: String,
79        /// The version that has no migration path.
80        version: String,
81    },
82
83    /// A migration step failed during execution.
84    #[error("Migration failed from '{from}' to '{to}': {error}")]
85    MigrationStepFailed {
86        /// The source version.
87        from: String,
88        /// The target version.
89        to: String,
90        /// The error message.
91        error: String,
92    },
93
94    /// A circular migration path was detected.
95    #[error("Circular migration path detected in entity '{entity}': {path}")]
96    CircularMigrationPath {
97        /// The entity name.
98        entity: String,
99        /// The path that forms a cycle.
100        path: String,
101    },
102
103    /// Version ordering is invalid (not following semver rules).
104    #[error("Invalid version order in entity '{entity}': '{from}' -> '{to}' (versions must increase according to semver)")]
105    InvalidVersionOrder {
106        /// The entity name.
107        entity: String,
108        /// The source version.
109        from: String,
110        /// The target version.
111        to: String,
112    },
113
114    /// File I/O error with detailed operation context.
115    ///
116    /// Provides specific information about which I/O operation failed,
117    /// along with optional context (e.g., "temporary file", "after 3 retries").
118    #[error("{}", format_io_error(.operation, .path, .context, .error))]
119    IoError {
120        /// The I/O operation that failed.
121        operation: IoOperationKind,
122        /// The file path where the error occurred.
123        path: String,
124        /// Additional context (e.g., "temporary file", "after 3 retries").
125        context: Option<String>,
126        /// The underlying I/O error message.
127        error: String,
128    },
129
130    /// File locking error.
131    #[error("Failed to acquire file lock for '{path}': {error}")]
132    LockError {
133        /// The file path.
134        path: String,
135        /// The error message.
136        error: String,
137    },
138
139    /// TOML parsing error.
140    #[error("Failed to parse TOML: {0}")]
141    TomlParseError(String),
142
143    /// TOML serialization error.
144    #[error("Failed to serialize to TOML: {0}")]
145    TomlSerializeError(String),
146
147    /// Failed to find home directory.
148    #[error("Cannot determine home directory")]
149    HomeDirNotFound,
150
151    /// Failed to resolve path.
152    #[error("Failed to resolve path: {0}")]
153    PathResolution(String),
154
155    /// Failed to encode filename.
156    #[error("Failed to encode filename for ID '{id}': {reason}")]
157    FilenameEncoding {
158        /// The entity ID that failed to encode.
159        id: String,
160        /// The reason for the encoding failure.
161        reason: String,
162    },
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    #[test]
170    fn test_error_display_deserialization() {
171        let err = MigrationError::DeserializationError("invalid JSON".to_string());
172        let display = format!("{}", err);
173        assert!(display.contains("Failed to deserialize"));
174        assert!(display.contains("invalid JSON"));
175    }
176
177    #[test]
178    fn test_error_display_serialization() {
179        let err = MigrationError::SerializationError("invalid data".to_string());
180        let display = format!("{}", err);
181        assert!(display.contains("Failed to serialize"));
182        assert!(display.contains("invalid data"));
183    }
184
185    #[test]
186    fn test_error_display_entity_not_found() {
187        let err = MigrationError::EntityNotFound("user".to_string());
188        let display = format!("{}", err);
189        assert!(display.contains("Entity 'user' not found"));
190    }
191
192    #[test]
193    fn test_error_display_migration_path_not_defined() {
194        let err = MigrationError::MigrationPathNotDefined {
195            entity: "task".to_string(),
196            version: "2.0.0".to_string(),
197        };
198        let display = format!("{}", err);
199        assert!(display.contains("No migration path defined"));
200        assert!(display.contains("task"));
201        assert!(display.contains("2.0.0"));
202    }
203
204    #[test]
205    fn test_error_display_migration_step_failed() {
206        let err = MigrationError::MigrationStepFailed {
207            from: "1.0.0".to_string(),
208            to: "2.0.0".to_string(),
209            error: "field missing".to_string(),
210        };
211        let display = format!("{}", err);
212        assert!(display.contains("Migration failed"));
213        assert!(display.contains("1.0.0"));
214        assert!(display.contains("2.0.0"));
215        assert!(display.contains("field missing"));
216    }
217
218    #[test]
219    fn test_error_debug() {
220        let err = MigrationError::EntityNotFound("test".to_string());
221        let debug = format!("{:?}", err);
222        assert!(debug.contains("EntityNotFound"));
223    }
224
225    #[test]
226    fn test_error_is_std_error() {
227        let err = MigrationError::DeserializationError("test".to_string());
228        // MigrationError should implement std::error::Error
229        let _: &dyn std::error::Error = &err;
230    }
231
232    #[test]
233    fn test_error_display_circular_migration_path() {
234        let err = MigrationError::CircularMigrationPath {
235            entity: "task".to_string(),
236            path: "1.0.0 -> 2.0.0 -> 1.0.0".to_string(),
237        };
238        let display = format!("{}", err);
239        assert!(display.contains("Circular migration path"));
240        assert!(display.contains("task"));
241        assert!(display.contains("1.0.0 -> 2.0.0 -> 1.0.0"));
242    }
243
244    #[test]
245    fn test_error_display_invalid_version_order() {
246        let err = MigrationError::InvalidVersionOrder {
247            entity: "task".to_string(),
248            from: "2.0.0".to_string(),
249            to: "1.0.0".to_string(),
250        };
251        let display = format!("{}", err);
252        assert!(display.contains("Invalid version order"));
253        assert!(display.contains("task"));
254        assert!(display.contains("2.0.0"));
255        assert!(display.contains("1.0.0"));
256        assert!(display.contains("must increase"));
257    }
258
259    #[test]
260    fn test_error_display_io_error_without_context() {
261        let err = MigrationError::IoError {
262            operation: IoOperationKind::Read,
263            path: "/path/to/file.toml".to_string(),
264            context: None,
265            error: "Permission denied".to_string(),
266        };
267        let display = format!("{}", err);
268        assert!(display.contains("Failed to read"));
269        assert!(display.contains("/path/to/file.toml"));
270        assert!(display.contains("Permission denied"));
271    }
272
273    #[test]
274    fn test_error_display_io_error_with_context() {
275        let err = MigrationError::IoError {
276            operation: IoOperationKind::Write,
277            path: "/path/to/tmp.toml".to_string(),
278            context: Some("temporary file".to_string()),
279            error: "Disk full".to_string(),
280        };
281        let display = format!("{}", err);
282        assert!(display.contains("Failed to write"));
283        assert!(display.contains("temporary file"));
284        assert!(display.contains("/path/to/tmp.toml"));
285        assert!(display.contains("Disk full"));
286    }
287
288    #[test]
289    fn test_error_display_io_error_rename_with_retries() {
290        let err = MigrationError::IoError {
291            operation: IoOperationKind::Rename,
292            path: "/path/to/file.toml".to_string(),
293            context: Some("after 3 retries".to_string()),
294            error: "Resource temporarily unavailable".to_string(),
295        };
296        let display = format!("{}", err);
297        assert!(display.contains("Failed to rename"));
298        assert!(display.contains("after 3 retries"));
299        assert!(display.contains("/path/to/file.toml"));
300        assert!(display.contains("Resource temporarily unavailable"));
301    }
302
303    #[test]
304    fn test_io_operation_kind_display() {
305        assert_eq!(IoOperationKind::Read.to_string(), "read");
306        assert_eq!(IoOperationKind::Write.to_string(), "write");
307        assert_eq!(IoOperationKind::Create.to_string(), "create");
308        assert_eq!(IoOperationKind::Delete.to_string(), "delete");
309        assert_eq!(IoOperationKind::Rename.to_string(), "rename");
310        assert_eq!(IoOperationKind::CreateDir.to_string(), "create directory");
311        assert_eq!(IoOperationKind::ReadDir.to_string(), "read directory");
312        assert_eq!(IoOperationKind::Sync.to_string(), "sync");
313    }
314}