Skip to main content

sc/
error.rs

1//! Error types for SaveContext CLI.
2//!
3//! Provides structured error handling with:
4//! - Machine-readable error codes (`ErrorCode`)
5//! - Category-based exit codes (2=db, 3=not_found, 4=validation, etc.)
6//! - Retryability flags for agent self-correction
7//! - Context-aware recovery hints
8//! - Structured JSON output for piped / non-TTY consumers
9
10use std::path::PathBuf;
11use thiserror::Error;
12
13/// Result type alias for SaveContext operations.
14pub type Result<T> = std::result::Result<T, Error>;
15
16// ── Error Code ────────────────────────────────────────────────
17
18/// Machine-readable error codes grouped by category.
19///
20/// Each code maps to a SCREAMING_SNAKE string and a category-based
21/// exit code. Agents match on the string; shell scripts on the exit code.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum ErrorCode {
24    // Database (exit 2)
25    NotInitialized,
26    AlreadyInitialized,
27    DatabaseError,
28
29    // Not Found (exit 3)
30    SessionNotFound,
31    IssueNotFound,
32    CheckpointNotFound,
33    ProjectNotFound,
34    NoActiveSession,
35    AmbiguousId,
36
37    // Validation (exit 4)
38    InvalidStatus,
39    InvalidType,
40    InvalidPriority,
41    InvalidArgument,
42    InvalidSessionStatus,
43    RequiredField,
44
45    // Dependency (exit 5)
46    CycleDetected,
47    HasDependents,
48
49    // Sync (exit 6)
50    SyncError,
51
52    // Config (exit 7)
53    ConfigError,
54
55    // I/O (exit 8)
56    IoError,
57    JsonError,
58
59    // Embedding (exit 9)
60    EmbeddingError,
61
62    // Internal (exit 1)
63    InternalError,
64}
65
66impl ErrorCode {
67    /// Machine-readable SCREAMING_SNAKE code string.
68    #[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    /// Category-based exit code (1-9).
98    #[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    /// Whether an agent should retry with corrected input.
124    ///
125    /// True for validation errors (wrong status, type, priority) and
126    /// ambiguous IDs. False for not-found, I/O, or internal errors.
127    #[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// ── Error Enum ────────────────────────────────────────────────
144
145/// Errors that can occur in SaveContext CLI operations.
146#[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        /// (short_id, name, status) of recent resumable sessions.
166        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        /// (path, name) of known projects for hint display.
191        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    /// Map this error to its structured `ErrorCode`.
218    #[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    /// Category-based exit code, delegating to the `ErrorCode`.
250    #[must_use]
251    pub const fn exit_code(&self) -> u8 {
252        self.error_code().exit_code()
253    }
254
255    /// Context-aware recovery hint for agents and humans.
256    ///
257    /// Returns `None` if no actionable suggestion exists.
258    #[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                // Check for validation-style messages and add synonym hints
335                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    /// Structured JSON representation for machine consumption.
363    ///
364    /// Includes error code, message, retryability, exit code, and
365    /// optional recovery hint. Agents parse this instead of stderr text.
366    #[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}