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    // Skills / Download (exit 10)
63    SkillInstallError,
64    DownloadError,
65
66    // Remote (exit 11)
67    RemoteError,
68
69    // Internal (exit 1)
70    InternalError,
71}
72
73impl ErrorCode {
74    /// Machine-readable SCREAMING_SNAKE code string.
75    #[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    /// Category-based exit code (1-9).
108    #[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    /// Whether an agent should retry with corrected input.
136    ///
137    /// True for validation errors (wrong status, type, priority) and
138    /// ambiguous IDs. False for not-found, I/O, or internal errors.
139    #[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// ── Error Enum ────────────────────────────────────────────────
156
157/// Errors that can occur in SaveContext CLI operations.
158#[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        /// (short_id, name, status) of recent resumable sessions.
178        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        /// (path, name) of known projects for hint display.
203        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    /// Map this error to its structured `ErrorCode`.
239    #[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    /// Category-based exit code, delegating to the `ErrorCode`.
274    #[must_use]
275    pub const fn exit_code(&self) -> u8 {
276        self.error_code().exit_code()
277    }
278
279    /// Context-aware recovery hint for agents and humans.
280    ///
281    /// Returns `None` if no actionable suggestion exists.
282    #[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                // Check for validation-style messages and add synonym hints
359                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    /// Structured JSON representation for machine consumption.
404    ///
405    /// Includes error code, message, retryability, exit code, and
406    /// optional recovery hint. Agents parse this instead of stderr text.
407    #[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}