1use std::io;
2use std::path::PathBuf;
3use thiserror::Error;
4
5#[derive(Error, Debug)]
10pub enum VibeTicketError {
11 #[error("I/O error: {0}")]
13 Io(#[from] io::Error),
14
15 #[error("YAML error: {0}")]
17 Yaml(#[from] serde_yaml::Error),
18
19 #[error("JSON error: {0}")]
21 Json(#[from] serde_json::Error),
22
23 #[error("Git error: {0}")]
25 Git(#[from] git2::Error),
26
27 #[error("Configuration error: {0}")]
29 Config(#[from] config::ConfigError),
30
31 #[error("Ticket not found: {id}")]
33 TicketNotFound { id: String },
34
35 #[error("Task not found: {id}")]
37 TaskNotFound { id: String },
38
39 #[error("Invalid ticket status: {status}")]
41 InvalidStatus { status: String },
42
43 #[error("Invalid priority: {priority}")]
45 InvalidPriority { priority: String },
46
47 #[error("Project not initialized. Run 'vibe-ticket init' first")]
49 ProjectNotInitialized,
50
51 #[error("Template not found: {0}")]
53 TemplateNotFound(String),
54
55 #[error("Missing required field: {0}")]
57 MissingRequiredField(String),
58
59 #[error("No tickets found")]
61 NoTicketsFound,
62
63 #[error("Project already initialized at {}", path.display())]
65 ProjectAlreadyInitialized { path: PathBuf },
66
67 #[error("No active ticket. Use 'vibe-ticket start <id>' to start working on a ticket")]
69 NoActiveTicket,
70
71 #[error("Multiple active tickets found. This should not happen")]
73 MultipleActiveTickets,
74
75 #[error("Invalid slug format: {slug}. Slugs must be lowercase alphanumeric with hyphens")]
77 InvalidSlug { slug: String },
78
79 #[error("Ticket with slug '{slug}' already exists")]
81 DuplicateTicket { slug: String },
82
83 #[error("File operation failed for {}: {message}", path.display())]
85 FileOperation { path: PathBuf, message: String },
86
87 #[error("Permission denied: {message}")]
89 PermissionDenied { message: String },
90
91 #[error("Template error: {0}")]
93 Template(#[from] tera::Error),
94
95 #[error("Interactive input error: {0}")]
97 Dialoguer(#[from] dialoguer::Error),
98
99 #[error("UUID error: {0}")]
101 Uuid(#[from] uuid::Error),
102
103 #[error("Specification not found: {id}")]
105 SpecNotFound { id: String },
106
107 #[error("No active specification. Use 'vibe-ticket spec activate <id>' to set active spec")]
109 NoActiveSpec,
110
111 #[error("Invalid input: {0}")]
113 InvalidInput(String),
114
115 #[error("{0}")]
117 Custom(String),
118 #[error("Parse error: {0}")]
120 ParseError(String),
121
122 #[error("Serialization error: {0}")]
124 SerializationError(String),
125}
126
127pub type Result<T> = std::result::Result<T, VibeTicketError>;
129
130impl VibeTicketError {
131 pub fn custom(msg: impl Into<String>) -> Self {
133 Self::Custom(msg.into())
134 }
135
136 #[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 #[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 #[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 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 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 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 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 #[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
234pub trait ErrorContext<T> {
236 fn context(self, msg: &str) -> Result<T>;
238
239 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}