agent_tui/
error.rs

1//! CLI errors with structured context for AI agents.
2//!
3//! These errors provide semantic codes, categories, and actionable suggestions
4//! to enable programmatic error handling and UNIX sysexits.h-compliant exit codes.
5
6use std::io;
7
8use crate::ipc::error_codes::{self, ErrorCategory};
9use serde_json::{Value, json};
10use thiserror::Error;
11
12/// Attach mode errors with structured context.
13#[derive(Error, Debug)]
14pub enum AttachError {
15    #[error("Terminal error: {0}")]
16    Terminal(#[from] io::Error),
17
18    #[error("PTY write failed: {0}")]
19    PtyWrite(String),
20
21    #[error("PTY read failed: {0}")]
22    PtyRead(String),
23
24    #[error("Event read failed")]
25    EventRead,
26}
27
28impl AttachError {
29    /// Returns the JSON-RPC error code for this error.
30    pub fn code(&self) -> i32 {
31        match self {
32            AttachError::Terminal(_) => error_codes::PTY_ERROR,
33            AttachError::PtyWrite(_) => error_codes::PTY_ERROR,
34            AttachError::PtyRead(_) => error_codes::PTY_ERROR,
35            AttachError::EventRead => error_codes::PTY_ERROR,
36        }
37    }
38
39    /// Returns the error category for programmatic handling.
40    pub fn category(&self) -> ErrorCategory {
41        ErrorCategory::External
42    }
43
44    /// Returns structured context about the error for debugging.
45    pub fn context(&self) -> Value {
46        match self {
47            AttachError::Terminal(e) => json!({
48                "operation": "terminal",
49                "reason": e.to_string()
50            }),
51            AttachError::PtyWrite(reason) => json!({
52                "operation": "pty_write",
53                "reason": reason
54            }),
55            AttachError::PtyRead(reason) => json!({
56                "operation": "pty_read",
57                "reason": reason
58            }),
59            AttachError::EventRead => json!({
60                "operation": "event_read",
61                "reason": "Failed to read terminal events"
62            }),
63        }
64    }
65
66    /// Returns a helpful suggestion for resolving the error.
67    pub fn suggestion(&self) -> String {
68        match self {
69            AttachError::Terminal(_) => {
70                "Terminal mode error. Try restarting your terminal.".to_string()
71            }
72            AttachError::PtyWrite(_) => {
73                "Failed to send input to session. The session may have ended. Run 'sessions' to check status."
74                    .to_string()
75            }
76            AttachError::PtyRead(_) => {
77                "Failed to read from session. The session may have ended. Run 'sessions' to check status."
78                    .to_string()
79            }
80            AttachError::EventRead => {
81                "Failed to read terminal events. Try restarting your terminal.".to_string()
82            }
83        }
84    }
85
86    /// Returns whether this error is potentially transient and may succeed on retry.
87    pub fn is_retryable(&self) -> bool {
88        matches!(self, AttachError::PtyWrite(_) | AttachError::PtyRead(_))
89    }
90
91    /// Converts to UNIX sysexits.h-compliant exit code.
92    pub fn exit_code(&self) -> i32 {
93        match self.category() {
94            ErrorCategory::InvalidInput => 64, // EX_USAGE
95            ErrorCategory::NotFound => 69,     // EX_UNAVAILABLE
96            ErrorCategory::Busy => 73,         // EX_CANTCREAT
97            ErrorCategory::External => 74,     // EX_IOERR
98            ErrorCategory::Internal => 74,     // EX_IOERR
99            ErrorCategory::Timeout => 75,      // EX_TEMPFAIL
100        }
101    }
102
103    /// Returns structured JSON representation of this error.
104    pub fn to_json(&self) -> Value {
105        json!({
106            "code": self.code(),
107            "message": self.to_string(),
108            "category": self.category().as_str(),
109            "retryable": self.is_retryable(),
110            "context": self.context(),
111            "suggestion": self.suggestion()
112        })
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    #[test]
121    fn test_attach_error_code() {
122        let err = AttachError::PtyWrite("broken pipe".into());
123        assert_eq!(err.code(), error_codes::PTY_ERROR);
124
125        let err = AttachError::PtyRead("timeout".into());
126        assert_eq!(err.code(), error_codes::PTY_ERROR);
127
128        let err = AttachError::EventRead;
129        assert_eq!(err.code(), error_codes::PTY_ERROR);
130    }
131
132    #[test]
133    fn test_attach_error_category() {
134        let err = AttachError::PtyWrite("x".into());
135        assert_eq!(err.category(), ErrorCategory::External);
136
137        let err = AttachError::EventRead;
138        assert_eq!(err.category(), ErrorCategory::External);
139    }
140
141    #[test]
142    fn test_attach_error_context() {
143        let err = AttachError::PtyWrite("broken pipe".into());
144        let ctx = err.context();
145        assert_eq!(ctx["operation"], "pty_write");
146        assert_eq!(ctx["reason"], "broken pipe");
147
148        let err = AttachError::PtyRead("timeout".into());
149        let ctx = err.context();
150        assert_eq!(ctx["operation"], "pty_read");
151        assert_eq!(ctx["reason"], "timeout");
152    }
153
154    #[test]
155    fn test_attach_error_suggestion() {
156        let err = AttachError::PtyWrite("x".into());
157        assert!(err.suggestion().contains("session"));
158
159        let err = AttachError::EventRead;
160        assert!(err.suggestion().contains("terminal"));
161    }
162
163    #[test]
164    fn test_attach_error_is_retryable() {
165        assert!(AttachError::PtyWrite("x".into()).is_retryable());
166        assert!(AttachError::PtyRead("x".into()).is_retryable());
167        assert!(!AttachError::EventRead.is_retryable());
168    }
169
170    #[test]
171    fn test_attach_error_exit_code() {
172        let err = AttachError::PtyWrite("x".into());
173        assert_eq!(err.exit_code(), 74); // EX_IOERR
174    }
175
176    #[test]
177    fn test_attach_error_to_json() {
178        let err = AttachError::PtyRead("connection reset".into());
179        let json = err.to_json();
180        assert_eq!(json["code"], error_codes::PTY_ERROR);
181        assert_eq!(json["category"], "external");
182        assert_eq!(json["retryable"], true);
183        assert!(
184            json["context"]["operation"]
185                .as_str()
186                .unwrap()
187                .contains("pty_read")
188        );
189    }
190}