1use crate::ipc::error_codes::{self, ErrorCategory};
7use crate::terminal::PtyError;
8use serde_json::{Value, json};
9use thiserror::Error;
10
11#[derive(Error, Debug)]
13pub enum SessionError {
14 #[error("Session not found: {0}")]
15 NotFound(String),
16 #[error("No active session")]
17 NoActiveSession,
18 #[error("PTY error: {0}")]
19 Pty(#[from] PtyError),
20 #[error("Element not found: {0}")]
21 ElementNotFound(String),
22 #[error("Element {element_ref} is a {actual} not a {expected}")]
23 WrongElementType {
24 element_ref: String,
25 actual: String,
26 expected: String,
27 },
28 #[error("Invalid key: {0}")]
29 InvalidKey(String),
30 #[error("Session limit reached: maximum {0} sessions allowed")]
31 LimitReached(usize),
32 #[error("Persistence error during {operation}: {reason}")]
33 Persistence { operation: String, reason: String },
34}
35
36impl SessionError {
37 pub fn code(&self) -> i32 {
39 match self {
40 SessionError::NotFound(_) => error_codes::SESSION_NOT_FOUND,
41 SessionError::NoActiveSession => error_codes::NO_ACTIVE_SESSION,
42 SessionError::ElementNotFound(_) => error_codes::ELEMENT_NOT_FOUND,
43 SessionError::WrongElementType { .. } => error_codes::WRONG_ELEMENT_TYPE,
44 SessionError::InvalidKey(_) => error_codes::INVALID_KEY,
45 SessionError::LimitReached(_) => error_codes::SESSION_LIMIT,
46 SessionError::Pty(_) => error_codes::PTY_ERROR,
47 SessionError::Persistence { .. } => error_codes::PERSISTENCE_ERROR,
48 }
49 }
50
51 pub fn category(&self) -> ErrorCategory {
53 error_codes::category_for_code(self.code())
54 }
55
56 pub fn context(&self) -> Value {
58 match self {
59 SessionError::NotFound(id) => json!({ "session_id": id }),
60 SessionError::NoActiveSession => json!({}),
61 SessionError::ElementNotFound(element_ref) => json!({ "element_ref": element_ref }),
62 SessionError::WrongElementType {
63 element_ref,
64 actual,
65 expected,
66 } => {
67 json!({
68 "element_ref": element_ref,
69 "actual_type": actual,
70 "expected_type": expected
71 })
72 }
73 SessionError::InvalidKey(key) => json!({ "key": key }),
74 SessionError::LimitReached(max) => json!({ "max_sessions": max }),
75 SessionError::Pty(pty_err) => pty_err.context(),
76 SessionError::Persistence { operation, reason } => {
77 json!({ "operation": operation, "reason": reason })
78 }
79 }
80 }
81
82 pub fn suggestion(&self) -> String {
84 match self {
85 SessionError::NotFound(_) | SessionError::NoActiveSession => {
86 "Run 'sessions' to list active sessions or 'spawn <cmd>' to start a new one."
87 .to_string()
88 }
89 SessionError::ElementNotFound(element_ref) => {
90 format!(
91 "Element '{}' not found. Run 'snapshot -i' to see current elements and their refs.",
92 element_ref
93 )
94 }
95 SessionError::WrongElementType {
96 element_ref,
97 actual,
98 ..
99 } => suggest_command_for_type(actual, element_ref),
100 SessionError::InvalidKey(_) => {
101 "Supported keys: Enter, Tab, Escape, Backspace, Delete, ArrowUp/Down/Left/Right, Home, End, PageUp/Down, F1-F12. Modifiers: Ctrl+, Alt+, Shift+".to_string()
102 }
103 SessionError::LimitReached(_) => {
104 "Kill unused sessions with 'kill <session_id>' or increase limit with AGENT_TUI_MAX_SESSIONS env var.".to_string()
105 }
106 SessionError::Pty(pty_err) => pty_err.suggestion(),
107 SessionError::Persistence { .. } => {
108 "Persistence error is non-fatal. Session continues to operate normally.".to_string()
109 }
110 }
111 }
112
113 pub fn is_retryable(&self) -> bool {
115 match self {
116 SessionError::Pty(pty_err) => pty_err.is_retryable(),
117 SessionError::Persistence { .. } => true,
118 _ => error_codes::is_retryable(self.code()),
119 }
120 }
121}
122
123#[derive(Error, Debug)]
125pub enum DaemonError {
126 #[error("Failed to bind socket: {0}")]
127 SocketBind(String),
128 #[error("Another daemon instance is already running")]
129 AlreadyRunning,
130 #[error("Failed to acquire lock: {0}")]
131 LockFailed(String),
132 #[error("Failed to setup signal handler: {0}")]
133 SignalSetup(String),
134 #[error("Failed to create thread pool: {0}")]
135 ThreadPool(String),
136}
137
138impl DaemonError {
139 pub fn code(&self) -> i32 {
141 error_codes::DAEMON_ERROR
142 }
143
144 pub fn category(&self) -> ErrorCategory {
146 ErrorCategory::External
147 }
148
149 pub fn context(&self) -> Value {
151 match self {
152 DaemonError::SocketBind(reason) => {
153 json!({ "operation": "socket_bind", "reason": reason })
154 }
155 DaemonError::AlreadyRunning => {
156 json!({ "operation": "startup", "reason": "another instance running" })
157 }
158 DaemonError::LockFailed(reason) => json!({ "operation": "lock", "reason": reason }),
159 DaemonError::SignalSetup(reason) => {
160 json!({ "operation": "signal_setup", "reason": reason })
161 }
162 DaemonError::ThreadPool(reason) => {
163 json!({ "operation": "thread_pool", "reason": reason })
164 }
165 }
166 }
167
168 pub fn suggestion(&self) -> String {
170 match self {
171 DaemonError::SocketBind(_) => {
172 "Check if the socket directory is writable. Try: rm /tmp/agent-tui.sock".to_string()
173 }
174 DaemonError::AlreadyRunning => {
175 "Another daemon is running. Use 'agent-tui sessions' to connect or kill existing daemon.".to_string()
176 }
177 DaemonError::LockFailed(_) => {
178 "Lock file issue. Try removing the lock file: rm /tmp/agent-tui.sock.lock".to_string()
179 }
180 DaemonError::SignalSetup(_) => {
181 "Signal handler setup failed. Check system signal configuration.".to_string()
182 }
183 DaemonError::ThreadPool(_) => {
184 "Thread pool creation failed. Check system thread limits (ulimit -u).".to_string()
185 }
186 }
187 }
188
189 pub fn is_retryable(&self) -> bool {
191 matches!(self, DaemonError::LockFailed(_))
192 }
193}
194
195#[derive(Error, Debug)]
197pub enum DomainError {
198 #[error("Session not found: {session_id}")]
199 SessionNotFound { session_id: String },
200
201 #[error("No active session")]
202 NoActiveSession,
203
204 #[error("Element not found: {element_ref}")]
205 ElementNotFound {
206 element_ref: String,
207 session_id: Option<String>,
208 },
209
210 #[error("Element {element_ref} is a {actual} not a {expected}")]
211 WrongElementType {
212 element_ref: String,
213 actual: String,
214 expected: String,
215 },
216
217 #[error("Invalid key: {key}")]
218 InvalidKey { key: String },
219
220 #[error("Session limit reached: maximum {max} sessions allowed")]
221 SessionLimitReached { max: usize },
222
223 #[error("Lock timeout{}", session_id.as_ref().map(|id| format!(" for session: {}", id)).unwrap_or_default())]
224 LockTimeout { session_id: Option<String> },
225
226 #[error("PTY error during {operation}: {reason}")]
227 PtyError { operation: String, reason: String },
228
229 #[error("Timeout waiting for: {condition}")]
230 WaitTimeout {
231 condition: String,
232 elapsed_ms: u64,
233 timeout_ms: u64,
234 },
235
236 #[error("Command not found: {command}")]
237 CommandNotFound { command: String },
238
239 #[error("Permission denied: {command}")]
240 PermissionDenied { command: String },
241
242 #[error("{message}")]
243 Generic { message: String },
244}
245
246impl DomainError {
247 pub fn code(&self) -> i32 {
249 match self {
250 DomainError::SessionNotFound { .. } => error_codes::SESSION_NOT_FOUND,
251 DomainError::NoActiveSession => error_codes::NO_ACTIVE_SESSION,
252 DomainError::ElementNotFound { .. } => error_codes::ELEMENT_NOT_FOUND,
253 DomainError::WrongElementType { .. } => error_codes::WRONG_ELEMENT_TYPE,
254 DomainError::InvalidKey { .. } => error_codes::INVALID_KEY,
255 DomainError::SessionLimitReached { .. } => error_codes::SESSION_LIMIT,
256 DomainError::LockTimeout { .. } => error_codes::LOCK_TIMEOUT,
257 DomainError::PtyError { .. } => error_codes::PTY_ERROR,
258 DomainError::WaitTimeout { .. } => error_codes::WAIT_TIMEOUT,
259 DomainError::CommandNotFound { .. } => error_codes::COMMAND_NOT_FOUND,
260 DomainError::PermissionDenied { .. } => error_codes::PERMISSION_DENIED,
261 DomainError::Generic { .. } => error_codes::GENERIC_ERROR,
262 }
263 }
264
265 pub fn category(&self) -> ErrorCategory {
267 error_codes::category_for_code(self.code())
268 }
269
270 pub fn context(&self) -> Value {
272 match self {
273 DomainError::SessionNotFound { session_id } => {
274 json!({ "session_id": session_id })
275 }
276 DomainError::NoActiveSession => json!({}),
277 DomainError::ElementNotFound {
278 element_ref,
279 session_id,
280 } => {
281 let mut ctx = json!({ "element_ref": element_ref });
282 if let Some(sid) = session_id {
283 ctx["session_id"] = json!(sid);
284 }
285 ctx
286 }
287 DomainError::WrongElementType {
288 element_ref,
289 actual,
290 expected,
291 } => {
292 json!({
293 "element_ref": element_ref,
294 "actual_type": actual,
295 "expected_type": expected
296 })
297 }
298 DomainError::InvalidKey { key } => {
299 json!({ "key": key })
300 }
301 DomainError::SessionLimitReached { max } => {
302 json!({ "max_sessions": max })
303 }
304 DomainError::LockTimeout { session_id } => match session_id {
305 Some(id) => json!({ "session_id": id }),
306 None => json!({}),
307 },
308 DomainError::PtyError { operation, reason } => {
309 json!({
310 "operation": operation,
311 "reason": reason
312 })
313 }
314 DomainError::WaitTimeout {
315 condition,
316 elapsed_ms,
317 timeout_ms,
318 } => {
319 json!({
320 "condition": condition,
321 "elapsed_ms": elapsed_ms,
322 "timeout_ms": timeout_ms
323 })
324 }
325 DomainError::CommandNotFound { command } => {
326 json!({ "command": command })
327 }
328 DomainError::PermissionDenied { command } => {
329 json!({ "command": command })
330 }
331 DomainError::Generic { message } => {
332 json!({ "message": message })
333 }
334 }
335 }
336
337 pub fn suggestion(&self) -> String {
339 match self {
340 DomainError::SessionNotFound { .. } | DomainError::NoActiveSession => {
341 "Run 'sessions' to list active sessions or 'spawn <cmd>' to start a new one."
342 .to_string()
343 }
344 DomainError::ElementNotFound { element_ref, .. } => {
345 format!(
346 "Element '{}' not found. Run 'snapshot -i' to see current elements and their refs.",
347 element_ref
348 )
349 }
350 DomainError::WrongElementType {
351 element_ref,
352 actual,
353 ..
354 } => suggest_command_for_type(actual, element_ref),
355 DomainError::InvalidKey { .. } => {
356 "Supported keys: Enter, Tab, Escape, Backspace, Delete, ArrowUp/Down/Left/Right, Home, End, PageUp/Down, F1-F12. Modifiers: Ctrl+, Alt+, Shift+".to_string()
357 }
358 DomainError::SessionLimitReached { .. } => {
359 "Kill unused sessions with 'kill <session_id>' or increase limit with AGENT_TUI_MAX_SESSIONS env var.".to_string()
360 }
361 DomainError::LockTimeout { .. } => {
362 "Session is busy. Try again in a moment, or run 'sessions' to check session status."
363 .to_string()
364 }
365 DomainError::PtyError { .. } => {
366 "Terminal communication error. The session may have ended. Run 'sessions' to check status.".to_string()
367 }
368 DomainError::WaitTimeout { condition, .. } => {
369 format!(
370 "Condition '{}' not met. The app may still be loading. Try 'wait --stable' or increase timeout with '-t'.",
371 condition
372 )
373 }
374 DomainError::CommandNotFound { command } => {
375 format!(
376 "Command '{}' not found. Check if the command exists and is in PATH.",
377 command
378 )
379 }
380 DomainError::PermissionDenied { command } => {
381 format!(
382 "Cannot execute '{}'. Check file permissions.",
383 command
384 )
385 }
386 DomainError::Generic { .. } => {
387 "Run 'snapshot -i' to see current screen state.".to_string()
388 }
389 }
390 }
391}
392
393fn suggest_command_for_type(element_type: &str, element_ref: &str) -> String {
394 let hint = match element_type {
395 "button" | "menuitem" | "listitem" => format!("Try: click {}", element_ref),
396 "checkbox" | "radio" => format!("Try: toggle {} or click {}", element_ref, element_ref),
397 "input" => format!("Try: fill {} <value>", element_ref),
398 "select" => format!("Try: select {} <option>", element_ref),
399 _ => "Run 'snapshot -i' to see element types.".to_string(),
400 };
401 hint
402}
403
404impl From<SessionError> for DomainError {
405 fn from(err: SessionError) -> Self {
406 match err {
407 SessionError::NotFound(id) => DomainError::SessionNotFound { session_id: id },
408 SessionError::NoActiveSession => DomainError::NoActiveSession,
409 SessionError::ElementNotFound(element_ref) => DomainError::ElementNotFound {
410 element_ref,
411 session_id: None,
412 },
413 SessionError::WrongElementType {
414 element_ref,
415 actual,
416 expected,
417 } => DomainError::WrongElementType {
418 element_ref,
419 actual,
420 expected,
421 },
422 SessionError::InvalidKey(key) => DomainError::InvalidKey { key },
423 SessionError::LimitReached(max) => DomainError::SessionLimitReached { max },
424 SessionError::Pty(pty_err) => DomainError::PtyError {
425 operation: pty_err.operation().to_string(),
426 reason: pty_err.reason().to_string(),
427 },
428 SessionError::Persistence { operation, reason } => DomainError::Generic {
429 message: format!("Persistence error during {}: {}", operation, reason),
430 },
431 }
432 }
433}
434
435#[cfg(test)]
436mod tests {
437 use super::*;
438
439 #[test]
440 fn test_session_not_found_code() {
441 let err = DomainError::SessionNotFound {
442 session_id: "abc123".into(),
443 };
444 assert_eq!(err.code(), error_codes::SESSION_NOT_FOUND);
445 }
446
447 #[test]
448 fn test_element_not_found_category() {
449 let err = DomainError::ElementNotFound {
450 element_ref: "@btn1".into(),
451 session_id: None,
452 };
453 assert_eq!(err.category(), ErrorCategory::NotFound);
454 }
455
456 #[test]
457 fn test_lock_timeout_is_retryable() {
458 let err = DomainError::LockTimeout {
459 session_id: Some("abc".into()),
460 };
461 assert!(error_codes::is_retryable(err.code()));
462 }
463
464 #[test]
465 fn test_element_not_found_not_retryable() {
466 let err = DomainError::ElementNotFound {
467 element_ref: "@btn1".into(),
468 session_id: None,
469 };
470 assert!(!error_codes::is_retryable(err.code()));
471 }
472
473 #[test]
474 fn test_context_includes_element_ref() {
475 let err = DomainError::ElementNotFound {
476 element_ref: "@btn5".into(),
477 session_id: Some("sess1".into()),
478 };
479 let ctx = err.context();
480 assert_eq!(ctx["element_ref"], "@btn5");
481 assert_eq!(ctx["session_id"], "sess1");
482 }
483
484 #[test]
485 fn test_wrong_element_type_context() {
486 let err = DomainError::WrongElementType {
487 element_ref: "@el1".into(),
488 actual: "button".into(),
489 expected: "input".into(),
490 };
491 let ctx = err.context();
492 assert_eq!(ctx["element_ref"], "@el1");
493 assert_eq!(ctx["actual_type"], "button");
494 assert_eq!(ctx["expected_type"], "input");
495 }
496
497 #[test]
498 fn test_suggestion_for_button() {
499 let err = DomainError::WrongElementType {
500 element_ref: "@btn1".into(),
501 actual: "button".into(),
502 expected: "input".into(),
503 };
504 assert!(err.suggestion().contains("click @btn1"));
505 }
506
507 #[test]
508 fn test_from_session_error() {
509 let session_err = SessionError::NotFound("test123".into());
510 let domain_err: DomainError = session_err.into();
511 assert_eq!(domain_err.code(), error_codes::SESSION_NOT_FOUND);
512 }
513
514 #[test]
515 fn test_display_session_not_found() {
516 let err = DomainError::SessionNotFound {
517 session_id: "abc".into(),
518 };
519 assert_eq!(err.to_string(), "Session not found: abc");
520 }
521
522 #[test]
523 fn test_display_wrong_element_type() {
524 let err = DomainError::WrongElementType {
525 element_ref: "@el1".into(),
526 actual: "button".into(),
527 expected: "input".into(),
528 };
529 assert_eq!(err.to_string(), "Element @el1 is a button not a input");
530 }
531
532 #[test]
534 fn test_session_error_not_found_code() {
535 let err = SessionError::NotFound("abc123".into());
536 assert_eq!(err.code(), error_codes::SESSION_NOT_FOUND);
537 }
538
539 #[test]
540 fn test_session_error_no_active_session_code() {
541 let err = SessionError::NoActiveSession;
542 assert_eq!(err.code(), error_codes::NO_ACTIVE_SESSION);
543 }
544
545 #[test]
546 fn test_session_error_element_not_found_code() {
547 let err = SessionError::ElementNotFound("@btn1".into());
548 assert_eq!(err.code(), error_codes::ELEMENT_NOT_FOUND);
549 }
550
551 #[test]
552 fn test_session_error_invalid_key_code() {
553 let err = SessionError::InvalidKey("BadKey".into());
554 assert_eq!(err.code(), error_codes::INVALID_KEY);
555 }
556
557 #[test]
558 fn test_session_error_limit_reached_code() {
559 let err = SessionError::LimitReached(16);
560 assert_eq!(err.code(), error_codes::SESSION_LIMIT);
561 }
562
563 #[test]
564 fn test_session_error_category() {
565 let err = SessionError::NotFound("abc".into());
566 assert_eq!(err.category(), ErrorCategory::NotFound);
567
568 let err = SessionError::InvalidKey("x".into());
569 assert_eq!(err.category(), ErrorCategory::InvalidInput);
570
571 let err = SessionError::LimitReached(10);
572 assert_eq!(err.category(), ErrorCategory::Busy);
573 }
574
575 #[test]
576 fn test_session_error_context() {
577 let err = SessionError::NotFound("sess123".into());
578 let ctx = err.context();
579 assert_eq!(ctx["session_id"], "sess123");
580
581 let err = SessionError::ElementNotFound("@btn5".into());
582 let ctx = err.context();
583 assert_eq!(ctx["element_ref"], "@btn5");
584
585 let err = SessionError::LimitReached(16);
586 let ctx = err.context();
587 assert_eq!(ctx["max_sessions"], 16);
588 }
589
590 #[test]
591 fn test_session_error_suggestion() {
592 let err = SessionError::NotFound("x".into());
593 assert!(err.suggestion().contains("sessions"));
594
595 let err = SessionError::ElementNotFound("@btn1".into());
596 assert!(err.suggestion().contains("snapshot"));
597
598 let err = SessionError::InvalidKey("x".into());
599 assert!(err.suggestion().contains("Enter"));
600 }
601
602 #[test]
603 fn test_session_error_is_retryable() {
604 assert!(!SessionError::NotFound("x".into()).is_retryable());
605 assert!(!SessionError::NoActiveSession.is_retryable());
606 assert!(!SessionError::ElementNotFound("x".into()).is_retryable());
607 assert!(!SessionError::InvalidKey("x".into()).is_retryable());
608 }
609
610 #[test]
612 fn test_session_error_persistence_code() {
613 let err = SessionError::Persistence {
614 operation: "save".into(),
615 reason: "disk full".into(),
616 };
617 assert_eq!(err.code(), error_codes::PERSISTENCE_ERROR);
618 }
619
620 #[test]
621 fn test_session_error_persistence_context() {
622 let err = SessionError::Persistence {
623 operation: "write_json".into(),
624 reason: "permission denied".into(),
625 };
626 let ctx = err.context();
627 assert_eq!(ctx["operation"], "write_json");
628 assert_eq!(ctx["reason"], "permission denied");
629 }
630
631 #[test]
632 fn test_session_error_persistence_is_retryable() {
633 let err = SessionError::Persistence {
634 operation: "save".into(),
635 reason: "disk full".into(),
636 };
637 assert!(err.is_retryable());
638 }
639
640 #[test]
641 fn test_session_error_persistence_display() {
642 let err = SessionError::Persistence {
643 operation: "write".into(),
644 reason: "disk full".into(),
645 };
646 assert_eq!(err.to_string(), "Persistence error during write: disk full");
647 }
648
649 #[test]
651 fn test_daemon_error_socket_bind() {
652 let err = DaemonError::SocketBind("address in use".into());
653 assert_eq!(err.code(), error_codes::DAEMON_ERROR);
654 assert_eq!(err.category(), ErrorCategory::External);
655 assert!(err.suggestion().contains("socket"));
656 }
657
658 #[test]
659 fn test_daemon_error_already_running() {
660 let err = DaemonError::AlreadyRunning;
661 assert_eq!(err.code(), error_codes::DAEMON_ERROR);
662 assert!(err.suggestion().contains("Another daemon"));
663 }
664
665 #[test]
666 fn test_daemon_error_lock_failed() {
667 let err = DaemonError::LockFailed("permission denied".into());
668 assert_eq!(err.code(), error_codes::DAEMON_ERROR);
669 assert!(err.is_retryable());
670 }
671
672 #[test]
673 fn test_daemon_error_not_retryable() {
674 assert!(!DaemonError::SocketBind("x".into()).is_retryable());
675 assert!(!DaemonError::AlreadyRunning.is_retryable());
676 assert!(!DaemonError::SignalSetup("x".into()).is_retryable());
677 assert!(!DaemonError::ThreadPool("x".into()).is_retryable());
678 }
679
680 #[test]
681 fn test_daemon_error_context() {
682 let err = DaemonError::SocketBind("address in use".into());
683 let ctx = err.context();
684 assert_eq!(ctx["operation"], "socket_bind");
685 assert_eq!(ctx["reason"], "address in use");
686 }
687
688 #[test]
689 fn test_daemon_error_display() {
690 let err = DaemonError::AlreadyRunning;
691 assert_eq!(
692 err.to_string(),
693 "Another daemon instance is already running"
694 );
695 }
696
697 #[test]
699 fn test_pty_error_conversion_preserves_context() {
700 let pty_err = PtyError::Write("broken pipe".into());
701 let session_err = SessionError::Pty(pty_err);
702 let domain_err: DomainError = session_err.into();
703
704 match domain_err {
705 DomainError::PtyError { operation, reason } => {
706 assert_eq!(operation, "write");
707 assert_eq!(reason, "broken pipe");
708 }
709 _ => panic!("Expected PtyError variant"),
710 }
711 }
712}