vibe_ticket/
error.rs

1use std::io;
2use std::path::PathBuf;
3use thiserror::Error;
4
5/// Main error type for vibe-ticket
6///
7/// This enum represents all possible errors that can occur in the application.
8/// Using thiserror for automatic Error trait implementation.
9#[derive(Error, Debug)]
10pub enum VibeTicketError {
11    /// I/O related errors
12    #[error("I/O error: {0}")]
13    Io(#[from] io::Error),
14
15    /// YAML serialization/deserialization errors
16    #[error("YAML error: {0}")]
17    Yaml(#[from] serde_yaml::Error),
18
19    /// JSON serialization/deserialization errors
20    #[error("JSON error: {0}")]
21    Json(#[from] serde_json::Error),
22
23    /// Git operation errors
24    #[error("Git error: {0}")]
25    Git(#[from] git2::Error),
26
27    /// Configuration errors
28    #[error("Configuration error: {0}")]
29    Config(#[from] config::ConfigError),
30
31    /// Ticket not found
32    #[error("Ticket not found: {id}")]
33    TicketNotFound { id: String },
34
35    /// Task not found
36    #[error("Task not found: {id}")]
37    TaskNotFound { id: String },
38
39    /// Invalid ticket status
40    #[error("Invalid ticket status: {status}")]
41    InvalidStatus { status: String },
42
43    /// Invalid priority
44    #[error("Invalid priority: {priority}")]
45    InvalidPriority { priority: String },
46
47    /// Project not initialized
48    #[error("Project not initialized. Run 'vibe-ticket init' first")]
49    ProjectNotInitialized,
50
51    /// Template not found
52    #[error("Template not found: {0}")]
53    TemplateNotFound(String),
54
55    /// Missing required field in template
56    #[error("Missing required field: {0}")]
57    MissingRequiredField(String),
58
59    /// No tickets found
60    #[error("No tickets found")]
61    NoTicketsFound,
62
63    /// Project already initialized
64    #[error("Project already initialized at {}", path.display())]
65    ProjectAlreadyInitialized { path: PathBuf },
66
67    /// No active ticket
68    #[error("No active ticket. Use 'vibe-ticket start <id>' to start working on a ticket")]
69    NoActiveTicket,
70
71    /// Multiple active tickets
72    #[error("Multiple active tickets found. This should not happen")]
73    MultipleActiveTickets,
74
75    /// Invalid slug format
76    #[error("Invalid slug format: {slug}. Slugs must be lowercase alphanumeric with hyphens")]
77    InvalidSlug { slug: String },
78
79    /// Duplicate ticket
80    #[error("Ticket with slug '{slug}' already exists")]
81    DuplicateTicket { slug: String },
82
83    /// File operation error
84    #[error("File operation failed for {}: {message}", path.display())]
85    FileOperation { path: PathBuf, message: String },
86
87    /// Permission denied
88    #[error("Permission denied: {message}")]
89    PermissionDenied { message: String },
90
91    /// Template error
92    #[error("Template error: {0}")]
93    Template(#[from] tera::Error),
94
95    /// Dialoguer error (for interactive mode)
96    #[error("Interactive input error: {0}")]
97    Dialoguer(#[from] dialoguer::Error),
98
99    /// UUID parsing error
100    #[error("UUID error: {0}")]
101    Uuid(#[from] uuid::Error),
102
103    /// Specification not found
104    #[error("Specification not found: {id}")]
105    SpecNotFound { id: String },
106
107    /// No active specification
108    #[error("No active specification. Use 'vibe-ticket spec activate <id>' to set active spec")]
109    NoActiveSpec,
110
111    /// Invalid input
112    #[error("Invalid input: {0}")]
113    InvalidInput(String),
114
115    /// Generic error with custom message
116    #[error("{0}")]
117    Custom(String),
118    /// Parse error for data formats
119    #[error("Parse error: {0}")]
120    ParseError(String),
121
122    /// Serialization error for data formats
123    #[error("Serialization error: {0}")]
124    SerializationError(String),
125}
126
127/// Result type alias for vibe-ticket operations
128pub type Result<T> = std::result::Result<T, VibeTicketError>;
129
130impl VibeTicketError {
131    /// Creates a custom error with the given message
132    pub fn custom(msg: impl Into<String>) -> Self {
133        Self::Custom(msg.into())
134    }
135
136    /// Returns true if this error is recoverable
137    #[must_use]
138    pub const fn is_recoverable(&self) -> bool {
139        matches!(
140            self,
141            Self::TicketNotFound { .. }
142                | Self::TaskNotFound { .. }
143                | Self::NoActiveTicket
144                | Self::InvalidSlug { .. }
145        )
146    }
147
148    /// Returns true if this error is a configuration issue
149    #[must_use]
150    pub const fn is_config_error(&self) -> bool {
151        matches!(
152            self,
153            Self::Config(_) | Self::ProjectNotInitialized | Self::ProjectAlreadyInitialized { .. }
154        )
155    }
156
157    /// Returns a user-friendly error message
158    #[must_use]
159    pub fn user_message(&self) -> String {
160        match self {
161            Self::Io(e) if e.kind() == io::ErrorKind::NotFound => {
162                "File or directory not found".to_string()
163            },
164            Self::Io(e) if e.kind() == io::ErrorKind::PermissionDenied => {
165                "Permission denied. Check file permissions".to_string()
166            },
167            Self::Git(e) => format!("Git operation failed: {}", e.message()),
168            _ => self.to_string(),
169        }
170    }
171
172    /// Creates a serialization error with consistent formatting
173    pub fn serialization_error(format: &str, error: impl std::fmt::Display) -> Self {
174        Self::custom(format!("Failed to serialize to {format}: {error}"))
175    }
176
177    /// Creates a deserialization error with consistent formatting
178    pub fn deserialization_error(format: &str, error: impl std::fmt::Display) -> Self {
179        Self::custom(format!("Failed to deserialize from {format}: {error}"))
180    }
181
182    /// Creates an I/O error with consistent formatting
183    pub fn io_error(
184        operation: &str,
185        path: &std::path::Path,
186        error: impl std::fmt::Display,
187    ) -> Self {
188        Self::custom(format!(
189            "Failed to {} {}: {}",
190            operation,
191            path.display(),
192            error
193        ))
194    }
195
196    /// Creates a parsing error with consistent formatting
197    pub fn parse_error(type_name: &str, value: &str, error: impl std::fmt::Display) -> Self {
198        Self::custom(format!("Failed to parse '{value}' as {type_name}: {error}"))
199    }
200
201    /// Returns suggested actions for the error
202    #[must_use]
203    pub fn suggestions(&self) -> Vec<String> {
204        match self {
205            Self::ProjectNotInitialized => vec![
206                "Run 'vibe-ticket init' to initialize the project".to_string(),
207                "Make sure you're in the correct directory".to_string(),
208            ],
209            Self::NoActiveTicket => vec![
210                "Run 'vibe-ticket list' to see available tickets".to_string(),
211                "Run 'vibe-ticket start <id>' to start working on a ticket".to_string(),
212            ],
213            Self::InvalidSlug { .. } => vec![
214                "Use lowercase letters, numbers, and hyphens only".to_string(),
215                "Example: 'fix-login-bug' or 'feature-123'".to_string(),
216            ],
217            Self::DuplicateTicket { slug } => vec![
218                format!("Use a different slug or check existing ticket '{}'", slug),
219                "Run 'vibe-ticket list' to see all tickets".to_string(),
220            ],
221            Self::NoActiveSpec => vec![
222                "Run 'vibe-ticket spec list' to see available specifications".to_string(),
223                "Run 'vibe-ticket spec activate <id>' to set an active specification".to_string(),
224            ],
225            Self::SpecNotFound { id } => vec![
226                format!("Check if specification '{}' exists", id),
227                "Run 'vibe-ticket spec list' to see all specifications".to_string(),
228            ],
229            _ => vec![],
230        }
231    }
232}
233
234/// Error context extension trait
235pub trait ErrorContext<T> {
236    /// Adds context to the error
237    fn context(self, msg: &str) -> Result<T>;
238
239    /// Adds context with a lazy message
240    fn with_context<F>(self, f: F) -> Result<T>
241    where
242        F: FnOnce() -> String;
243}
244
245impl<T, E> ErrorContext<T> for std::result::Result<T, E>
246where
247    E: Into<VibeTicketError>,
248{
249    fn context(self, msg: &str) -> Result<T> {
250        self.map_err(|e| {
251            let base_error = e.into();
252            VibeTicketError::Custom(format!("{msg}: {base_error}"))
253        })
254    }
255
256    fn with_context<F>(self, f: F) -> Result<T>
257    where
258        F: FnOnce() -> String,
259    {
260        self.map_err(|e| {
261            let base_error = e.into();
262            VibeTicketError::Custom(format!("{}: {}", f(), base_error))
263        })
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    #[test]
272    fn test_error_display() {
273        let err = VibeTicketError::TicketNotFound {
274            id: "123".to_string(),
275        };
276        assert_eq!(err.to_string(), "Ticket not found: 123");
277    }
278
279    #[test]
280    fn test_is_recoverable() {
281        assert!(VibeTicketError::NoActiveTicket.is_recoverable());
282        assert!(!VibeTicketError::ProjectNotInitialized.is_recoverable());
283    }
284
285    #[test]
286    fn test_suggestions() {
287        let err = VibeTicketError::ProjectNotInitialized;
288        let suggestions = err.suggestions();
289        assert!(!suggestions.is_empty());
290        assert!(suggestions[0].contains("vibe-ticket init"));
291    }
292}