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 SkillInstallError,
64 DownloadError,
65
66 RemoteError,
68
69 InternalError,
71}
72
73impl ErrorCode {
74 #[must_use]
76 pub const fn as_str(&self) -> &str {
77 match self {
78 Self::NotInitialized => "NOT_INITIALIZED",
79 Self::AlreadyInitialized => "ALREADY_INITIALIZED",
80 Self::DatabaseError => "DATABASE_ERROR",
81 Self::SessionNotFound => "SESSION_NOT_FOUND",
82 Self::IssueNotFound => "ISSUE_NOT_FOUND",
83 Self::CheckpointNotFound => "CHECKPOINT_NOT_FOUND",
84 Self::ProjectNotFound => "PROJECT_NOT_FOUND",
85 Self::NoActiveSession => "NO_ACTIVE_SESSION",
86 Self::AmbiguousId => "AMBIGUOUS_ID",
87 Self::InvalidStatus => "INVALID_STATUS",
88 Self::InvalidType => "INVALID_TYPE",
89 Self::InvalidPriority => "INVALID_PRIORITY",
90 Self::InvalidArgument => "INVALID_ARGUMENT",
91 Self::InvalidSessionStatus => "INVALID_SESSION_STATUS",
92 Self::RequiredField => "REQUIRED_FIELD",
93 Self::CycleDetected => "CYCLE_DETECTED",
94 Self::HasDependents => "HAS_DEPENDENTS",
95 Self::SyncError => "SYNC_ERROR",
96 Self::ConfigError => "CONFIG_ERROR",
97 Self::IoError => "IO_ERROR",
98 Self::JsonError => "JSON_ERROR",
99 Self::EmbeddingError => "EMBEDDING_ERROR",
100 Self::SkillInstallError => "SKILL_INSTALL_ERROR",
101 Self::DownloadError => "DOWNLOAD_ERROR",
102 Self::RemoteError => "REMOTE_ERROR",
103 Self::InternalError => "INTERNAL_ERROR",
104 }
105 }
106
107 #[must_use]
109 pub const fn exit_code(&self) -> u8 {
110 match self {
111 Self::InternalError => 1,
112 Self::NotInitialized | Self::AlreadyInitialized | Self::DatabaseError => 2,
113 Self::SessionNotFound
114 | Self::IssueNotFound
115 | Self::CheckpointNotFound
116 | Self::ProjectNotFound
117 | Self::NoActiveSession
118 | Self::AmbiguousId => 3,
119 Self::InvalidStatus
120 | Self::InvalidType
121 | Self::InvalidPriority
122 | Self::InvalidArgument
123 | Self::InvalidSessionStatus
124 | Self::RequiredField => 4,
125 Self::CycleDetected | Self::HasDependents => 5,
126 Self::SyncError => 6,
127 Self::ConfigError => 7,
128 Self::IoError | Self::JsonError => 8,
129 Self::EmbeddingError => 9,
130 Self::SkillInstallError | Self::DownloadError => 10,
131 Self::RemoteError => 11,
132 }
133 }
134
135 #[must_use]
140 pub const fn is_retryable(&self) -> bool {
141 matches!(
142 self,
143 Self::InvalidStatus
144 | Self::InvalidType
145 | Self::InvalidPriority
146 | Self::InvalidArgument
147 | Self::InvalidSessionStatus
148 | Self::RequiredField
149 | Self::AmbiguousId
150 | Self::DatabaseError
151 )
152 }
153}
154
155#[derive(Error, Debug)]
159pub enum Error {
160 #[error("Not initialized: run `sc init` first")]
161 NotInitialized,
162
163 #[error("Already initialized at {path}")]
164 AlreadyInitialized { path: PathBuf },
165
166 #[error("Session not found: {id}")]
167 SessionNotFound { id: String },
168
169 #[error("Session not found: {id} (did you mean: {}?)", similar.join(", "))]
170 SessionNotFoundSimilar { id: String, similar: Vec<String> },
171
172 #[error("No active session")]
173 NoActiveSession,
174
175 #[error("No active session (recent sessions available)")]
176 NoActiveSessionWithRecent {
177 recent: Vec<(String, String, String)>,
179 },
180
181 #[error("Invalid session status: expected {expected}, got {actual}")]
182 InvalidSessionStatus { expected: String, actual: String },
183
184 #[error("Issue not found: {id}")]
185 IssueNotFound { id: String },
186
187 #[error("Issue not found: {id} (did you mean: {}?)", similar.join(", "))]
188 IssueNotFoundSimilar { id: String, similar: Vec<String> },
189
190 #[error("Checkpoint not found: {id}")]
191 CheckpointNotFound { id: String },
192
193 #[error("Checkpoint not found: {id} (did you mean: {}?)", similar.join(", "))]
194 CheckpointNotFoundSimilar { id: String, similar: Vec<String> },
195
196 #[error("Project not found: {id}")]
197 ProjectNotFound { id: String },
198
199 #[error("No project found for current directory: {cwd}")]
200 NoProjectForDirectory {
201 cwd: String,
202 available: Vec<(String, String)>,
204 },
205
206 #[error("Database error: {0}")]
207 Database(#[from] rusqlite::Error),
208
209 #[error("IO error: {0}")]
210 Io(#[from] std::io::Error),
211
212 #[error("JSON error: {0}")]
213 Json(#[from] serde_json::Error),
214
215 #[error("Invalid argument: {0}")]
216 InvalidArgument(String),
217
218 #[error("Configuration error: {0}")]
219 Config(String),
220
221 #[error("Embedding error: {0}")]
222 Embedding(String),
223
224 #[error("Skill install error: {0}")]
225 SkillInstall(String),
226
227 #[error("Download failed: {0}")]
228 Download(String),
229
230 #[error("Remote error: {0}")]
231 Remote(String),
232
233 #[error("{0}")]
234 Other(String),
235}
236
237impl Error {
238 #[must_use]
240 pub const fn error_code(&self) -> ErrorCode {
241 match self {
242 Self::NotInitialized => ErrorCode::NotInitialized,
243 Self::AlreadyInitialized { .. } => ErrorCode::AlreadyInitialized,
244 Self::Database(_) => ErrorCode::DatabaseError,
245 Self::SessionNotFound { .. } | Self::SessionNotFoundSimilar { .. } => {
246 ErrorCode::SessionNotFound
247 }
248 Self::IssueNotFound { .. } | Self::IssueNotFoundSimilar { .. } => {
249 ErrorCode::IssueNotFound
250 }
251 Self::CheckpointNotFound { .. } | Self::CheckpointNotFoundSimilar { .. } => {
252 ErrorCode::CheckpointNotFound
253 }
254 Self::ProjectNotFound { .. } | Self::NoProjectForDirectory { .. } => {
255 ErrorCode::ProjectNotFound
256 }
257 Self::NoActiveSession | Self::NoActiveSessionWithRecent { .. } => {
258 ErrorCode::NoActiveSession
259 }
260 Self::InvalidSessionStatus { .. } => ErrorCode::InvalidSessionStatus,
261 Self::InvalidArgument(_) => ErrorCode::InvalidArgument,
262 Self::Config(_) => ErrorCode::ConfigError,
263 Self::Embedding(_) => ErrorCode::EmbeddingError,
264 Self::SkillInstall(_) => ErrorCode::SkillInstallError,
265 Self::Download(_) => ErrorCode::DownloadError,
266 Self::Remote(_) => ErrorCode::RemoteError,
267 Self::Io(_) => ErrorCode::IoError,
268 Self::Json(_) => ErrorCode::JsonError,
269 Self::Other(_) => ErrorCode::InternalError,
270 }
271 }
272
273 #[must_use]
275 pub const fn exit_code(&self) -> u8 {
276 self.error_code().exit_code()
277 }
278
279 #[must_use]
283 pub fn hint(&self) -> Option<String> {
284 match self {
285 Self::NotInitialized => Some("Run `sc init` to initialize the database".to_string()),
286
287 Self::AlreadyInitialized { path } => Some(format!(
288 "Database already exists at {}. Use `--force` to reinitialize.",
289 path.display()
290 )),
291
292 Self::NoActiveSession => Some(
293 "No session bound to this terminal.\n \
294 Resume: sc session resume <session-id>\n \
295 Start: sc session start \"session name\""
296 .to_string(),
297 ),
298
299 Self::NoActiveSessionWithRecent { recent } => {
300 let mut hint = String::from("Recent sessions you can resume:\n");
301 for (id, name, status) in recent {
302 hint.push_str(&format!(" {id} \"{name}\" ({status})\n"));
303 }
304 hint.push_str(" Resume: sc session resume <session-id>\n");
305 hint.push_str(" Start: sc session start \"session name\"");
306 Some(hint)
307 }
308
309 Self::SessionNotFound { id } => Some(format!(
310 "No session with ID '{id}'. Use `sc session list` to see available sessions."
311 )),
312 Self::SessionNotFoundSimilar { similar, .. } => {
313 Some(format!("Did you mean: {}?", similar.join(", ")))
314 }
315
316 Self::IssueNotFound { id } => Some(format!(
317 "No issue with ID '{id}'. Use `sc issue list` to see available issues."
318 )),
319 Self::IssueNotFoundSimilar { similar, .. } => {
320 Some(format!("Did you mean: {}?", similar.join(", ")))
321 }
322
323 Self::CheckpointNotFound { id } => Some(format!(
324 "No checkpoint with ID '{id}'. Use `sc checkpoint list` to see available checkpoints."
325 )),
326 Self::CheckpointNotFoundSimilar { similar, .. } => {
327 Some(format!("Did you mean: {}?", similar.join(", ")))
328 }
329
330 Self::ProjectNotFound { id } => Some(format!(
331 "No project with ID '{id}'. Use `sc project list` to see available projects."
332 )),
333
334 Self::NoProjectForDirectory { cwd, available } => {
335 let mut hint = format!("No project registered for '{cwd}'.\n");
336 if available.is_empty() {
337 hint.push_str(" No projects exist yet.\n");
338 hint.push_str(&format!(" Create one: sc project create {cwd}"));
339 } else {
340 hint.push_str(" Known projects:\n");
341 for (path, name) in available.iter().take(5) {
342 hint.push_str(&format!(" {path} \"{name}\"\n"));
343 }
344 if available.len() > 5 {
345 hint.push_str(&format!(" ... and {} more\n", available.len() - 5));
346 }
347 hint.push_str(&format!(" Create one: sc project create {cwd}"));
348 }
349 Some(hint)
350 }
351
352 Self::InvalidSessionStatus { expected, actual } => Some(format!(
353 "Session is '{actual}' but needs to be '{expected}'. \
354 Use `sc session list` to check session states."
355 )),
356
357 Self::InvalidArgument(msg) => {
358 if msg.contains("status") {
360 Some(
361 "Valid statuses: backlog, open, in_progress, blocked, closed, deferred. \
362 Synonyms: done→closed, wip→in_progress, todo→open"
363 .to_string(),
364 )
365 } else if msg.contains("type") {
366 Some(
367 "Valid types: task, bug, feature, epic, chore. \
368 Synonyms: story→feature, defect→bug, cleanup→chore"
369 .to_string(),
370 )
371 } else if msg.contains("priority") {
372 Some(
373 "Valid priorities: 0-4, P0-P4, or names: critical, high, medium, low, backlog"
374 .to_string(),
375 )
376 } else {
377 None
378 }
379 }
380
381 Self::SkillInstall(_) => Some(
382 "Check your internet connection and try again. \
383 Use `sc skills status` to see installed skills."
384 .to_string(),
385 ),
386
387 Self::Download(_) => Some(
388 "Check your internet connection. The download URL may be unreachable."
389 .to_string(),
390 ),
391
392 Self::Remote(_) => Some(
393 "Check remote configuration with `sc config remote show`. \
394 Ensure SSH access works: ssh user@host sc version"
395 .to_string(),
396 ),
397
398 Self::Database(_) | Self::Io(_) | Self::Json(_) | Self::Config(_)
399 | Self::Embedding(_) | Self::Other(_) => None,
400 }
401 }
402
403 #[must_use]
408 pub fn to_structured_json(&self) -> serde_json::Value {
409 let code = self.error_code();
410 let mut obj = serde_json::json!({
411 "error": {
412 "code": code.as_str(),
413 "message": self.to_string(),
414 "retryable": code.is_retryable(),
415 "exit_code": code.exit_code(),
416 }
417 });
418
419 if let Some(hint) = self.hint() {
420 obj["error"]["hint"] = serde_json::Value::String(hint);
421 }
422
423 obj
424 }
425}