1use std::path::PathBuf;
11use thiserror::Error;
12
13pub type Result<T> = std::result::Result<T, Error>;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum ErrorCode {
24 NotInitialized,
26 AlreadyInitialized,
27 DatabaseError,
28
29 SessionNotFound,
31 IssueNotFound,
32 CheckpointNotFound,
33 ProjectNotFound,
34 NoActiveSession,
35 AmbiguousId,
36
37 InvalidStatus,
39 InvalidType,
40 InvalidPriority,
41 InvalidArgument,
42 InvalidSessionStatus,
43 RequiredField,
44
45 CycleDetected,
47 HasDependents,
48
49 SyncError,
51
52 ConfigError,
54
55 IoError,
57 JsonError,
58
59 EmbeddingError,
61
62 InternalError,
64}
65
66impl ErrorCode {
67 #[must_use]
69 pub const fn as_str(&self) -> &str {
70 match self {
71 Self::NotInitialized => "NOT_INITIALIZED",
72 Self::AlreadyInitialized => "ALREADY_INITIALIZED",
73 Self::DatabaseError => "DATABASE_ERROR",
74 Self::SessionNotFound => "SESSION_NOT_FOUND",
75 Self::IssueNotFound => "ISSUE_NOT_FOUND",
76 Self::CheckpointNotFound => "CHECKPOINT_NOT_FOUND",
77 Self::ProjectNotFound => "PROJECT_NOT_FOUND",
78 Self::NoActiveSession => "NO_ACTIVE_SESSION",
79 Self::AmbiguousId => "AMBIGUOUS_ID",
80 Self::InvalidStatus => "INVALID_STATUS",
81 Self::InvalidType => "INVALID_TYPE",
82 Self::InvalidPriority => "INVALID_PRIORITY",
83 Self::InvalidArgument => "INVALID_ARGUMENT",
84 Self::InvalidSessionStatus => "INVALID_SESSION_STATUS",
85 Self::RequiredField => "REQUIRED_FIELD",
86 Self::CycleDetected => "CYCLE_DETECTED",
87 Self::HasDependents => "HAS_DEPENDENTS",
88 Self::SyncError => "SYNC_ERROR",
89 Self::ConfigError => "CONFIG_ERROR",
90 Self::IoError => "IO_ERROR",
91 Self::JsonError => "JSON_ERROR",
92 Self::EmbeddingError => "EMBEDDING_ERROR",
93 Self::InternalError => "INTERNAL_ERROR",
94 }
95 }
96
97 #[must_use]
99 pub const fn exit_code(&self) -> u8 {
100 match self {
101 Self::InternalError => 1,
102 Self::NotInitialized | Self::AlreadyInitialized | Self::DatabaseError => 2,
103 Self::SessionNotFound
104 | Self::IssueNotFound
105 | Self::CheckpointNotFound
106 | Self::ProjectNotFound
107 | Self::NoActiveSession
108 | Self::AmbiguousId => 3,
109 Self::InvalidStatus
110 | Self::InvalidType
111 | Self::InvalidPriority
112 | Self::InvalidArgument
113 | Self::InvalidSessionStatus
114 | Self::RequiredField => 4,
115 Self::CycleDetected | Self::HasDependents => 5,
116 Self::SyncError => 6,
117 Self::ConfigError => 7,
118 Self::IoError | Self::JsonError => 8,
119 Self::EmbeddingError => 9,
120 }
121 }
122
123 #[must_use]
128 pub const fn is_retryable(&self) -> bool {
129 matches!(
130 self,
131 Self::InvalidStatus
132 | Self::InvalidType
133 | Self::InvalidPriority
134 | Self::InvalidArgument
135 | Self::InvalidSessionStatus
136 | Self::RequiredField
137 | Self::AmbiguousId
138 | Self::DatabaseError
139 )
140 }
141}
142
143#[derive(Error, Debug)]
147pub enum Error {
148 #[error("Not initialized: run `sc init` first")]
149 NotInitialized,
150
151 #[error("Already initialized at {path}")]
152 AlreadyInitialized { path: PathBuf },
153
154 #[error("Session not found: {id}")]
155 SessionNotFound { id: String },
156
157 #[error("Session not found: {id} (did you mean: {}?)", similar.join(", "))]
158 SessionNotFoundSimilar { id: String, similar: Vec<String> },
159
160 #[error("No active session")]
161 NoActiveSession,
162
163 #[error("No active session (recent sessions available)")]
164 NoActiveSessionWithRecent {
165 recent: Vec<(String, String, String)>,
167 },
168
169 #[error("Invalid session status: expected {expected}, got {actual}")]
170 InvalidSessionStatus { expected: String, actual: String },
171
172 #[error("Issue not found: {id}")]
173 IssueNotFound { id: String },
174
175 #[error("Issue not found: {id} (did you mean: {}?)", similar.join(", "))]
176 IssueNotFoundSimilar { id: String, similar: Vec<String> },
177
178 #[error("Checkpoint not found: {id}")]
179 CheckpointNotFound { id: String },
180
181 #[error("Checkpoint not found: {id} (did you mean: {}?)", similar.join(", "))]
182 CheckpointNotFoundSimilar { id: String, similar: Vec<String> },
183
184 #[error("Project not found: {id}")]
185 ProjectNotFound { id: String },
186
187 #[error("No project found for current directory: {cwd}")]
188 NoProjectForDirectory {
189 cwd: String,
190 available: Vec<(String, String)>,
192 },
193
194 #[error("Database error: {0}")]
195 Database(#[from] rusqlite::Error),
196
197 #[error("IO error: {0}")]
198 Io(#[from] std::io::Error),
199
200 #[error("JSON error: {0}")]
201 Json(#[from] serde_json::Error),
202
203 #[error("Invalid argument: {0}")]
204 InvalidArgument(String),
205
206 #[error("Configuration error: {0}")]
207 Config(String),
208
209 #[error("Embedding error: {0}")]
210 Embedding(String),
211
212 #[error("{0}")]
213 Other(String),
214}
215
216impl Error {
217 #[must_use]
219 pub const fn error_code(&self) -> ErrorCode {
220 match self {
221 Self::NotInitialized => ErrorCode::NotInitialized,
222 Self::AlreadyInitialized { .. } => ErrorCode::AlreadyInitialized,
223 Self::Database(_) => ErrorCode::DatabaseError,
224 Self::SessionNotFound { .. } | Self::SessionNotFoundSimilar { .. } => {
225 ErrorCode::SessionNotFound
226 }
227 Self::IssueNotFound { .. } | Self::IssueNotFoundSimilar { .. } => {
228 ErrorCode::IssueNotFound
229 }
230 Self::CheckpointNotFound { .. } | Self::CheckpointNotFoundSimilar { .. } => {
231 ErrorCode::CheckpointNotFound
232 }
233 Self::ProjectNotFound { .. } | Self::NoProjectForDirectory { .. } => {
234 ErrorCode::ProjectNotFound
235 }
236 Self::NoActiveSession | Self::NoActiveSessionWithRecent { .. } => {
237 ErrorCode::NoActiveSession
238 }
239 Self::InvalidSessionStatus { .. } => ErrorCode::InvalidSessionStatus,
240 Self::InvalidArgument(_) => ErrorCode::InvalidArgument,
241 Self::Config(_) => ErrorCode::ConfigError,
242 Self::Embedding(_) => ErrorCode::EmbeddingError,
243 Self::Io(_) => ErrorCode::IoError,
244 Self::Json(_) => ErrorCode::JsonError,
245 Self::Other(_) => ErrorCode::InternalError,
246 }
247 }
248
249 #[must_use]
251 pub const fn exit_code(&self) -> u8 {
252 self.error_code().exit_code()
253 }
254
255 #[must_use]
259 pub fn hint(&self) -> Option<String> {
260 match self {
261 Self::NotInitialized => Some("Run `sc init` to initialize the database".to_string()),
262
263 Self::AlreadyInitialized { path } => Some(format!(
264 "Database already exists at {}. Use `--force` to reinitialize.",
265 path.display()
266 )),
267
268 Self::NoActiveSession => Some(
269 "No session bound to this terminal.\n \
270 Resume: sc session resume <session-id>\n \
271 Start: sc session start \"session name\""
272 .to_string(),
273 ),
274
275 Self::NoActiveSessionWithRecent { recent } => {
276 let mut hint = String::from("Recent sessions you can resume:\n");
277 for (id, name, status) in recent {
278 hint.push_str(&format!(" {id} \"{name}\" ({status})\n"));
279 }
280 hint.push_str(" Resume: sc session resume <session-id>\n");
281 hint.push_str(" Start: sc session start \"session name\"");
282 Some(hint)
283 }
284
285 Self::SessionNotFound { id } => Some(format!(
286 "No session with ID '{id}'. Use `sc session list` to see available sessions."
287 )),
288 Self::SessionNotFoundSimilar { similar, .. } => {
289 Some(format!("Did you mean: {}?", similar.join(", ")))
290 }
291
292 Self::IssueNotFound { id } => Some(format!(
293 "No issue with ID '{id}'. Use `sc issue list` to see available issues."
294 )),
295 Self::IssueNotFoundSimilar { similar, .. } => {
296 Some(format!("Did you mean: {}?", similar.join(", ")))
297 }
298
299 Self::CheckpointNotFound { id } => Some(format!(
300 "No checkpoint with ID '{id}'. Use `sc checkpoint list` to see available checkpoints."
301 )),
302 Self::CheckpointNotFoundSimilar { similar, .. } => {
303 Some(format!("Did you mean: {}?", similar.join(", ")))
304 }
305
306 Self::ProjectNotFound { id } => Some(format!(
307 "No project with ID '{id}'. Use `sc project list` to see available projects."
308 )),
309
310 Self::NoProjectForDirectory { cwd, available } => {
311 let mut hint = format!("No project registered for '{cwd}'.\n");
312 if available.is_empty() {
313 hint.push_str(" No projects exist yet.\n");
314 hint.push_str(&format!(" Create one: sc project create {cwd}"));
315 } else {
316 hint.push_str(" Known projects:\n");
317 for (path, name) in available.iter().take(5) {
318 hint.push_str(&format!(" {path} \"{name}\"\n"));
319 }
320 if available.len() > 5 {
321 hint.push_str(&format!(" ... and {} more\n", available.len() - 5));
322 }
323 hint.push_str(&format!(" Create one: sc project create {cwd}"));
324 }
325 Some(hint)
326 }
327
328 Self::InvalidSessionStatus { expected, actual } => Some(format!(
329 "Session is '{actual}' but needs to be '{expected}'. \
330 Use `sc session list` to check session states."
331 )),
332
333 Self::InvalidArgument(msg) => {
334 if msg.contains("status") {
336 Some(
337 "Valid statuses: backlog, open, in_progress, blocked, closed, deferred. \
338 Synonyms: done→closed, wip→in_progress, todo→open"
339 .to_string(),
340 )
341 } else if msg.contains("type") {
342 Some(
343 "Valid types: task, bug, feature, epic, chore. \
344 Synonyms: story→feature, defect→bug, cleanup→chore"
345 .to_string(),
346 )
347 } else if msg.contains("priority") {
348 Some(
349 "Valid priorities: 0-4, P0-P4, or names: critical, high, medium, low, backlog"
350 .to_string(),
351 )
352 } else {
353 None
354 }
355 }
356
357 Self::Database(_) | Self::Io(_) | Self::Json(_) | Self::Config(_)
358 | Self::Embedding(_) | Self::Other(_) => None,
359 }
360 }
361
362 #[must_use]
367 pub fn to_structured_json(&self) -> serde_json::Value {
368 let code = self.error_code();
369 let mut obj = serde_json::json!({
370 "error": {
371 "code": code.as_str(),
372 "message": self.to_string(),
373 "retryable": code.is_retryable(),
374 "exit_code": code.exit_code(),
375 }
376 });
377
378 if let Some(hint) = self.hint() {
379 obj["error"]["hint"] = serde_json::Value::String(hint);
380 }
381
382 obj
383 }
384}