Skip to main content

atd_protocol/
error.rs

1use thiserror::Error;
2
3#[derive(Debug, Error)]
4#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
5#[non_exhaustive]
6pub enum AtdError {
7    #[error("tool not found: {tool_id}")]
8    ToolNotFound {
9        tool_id: String,
10        suggestions: Vec<String>,
11    },
12
13    #[error("invalid arguments for {tool_id}: field `{field}` — {reason}")]
14    InvalidArguments {
15        tool_id: String,
16        field: String,
17        reason: String,
18    },
19
20    #[error("capability denied for {tool_id}: required={required:?} granted={granted:?}")]
21    CapabilityDenied {
22        tool_id: String,
23        required: Vec<String>,
24        granted: Vec<String>,
25    },
26
27    #[error("no binding available for {tool_id}: tried={tried:?} ({reason})")]
28    BindingUnavailable {
29        tool_id: String,
30        tried: Vec<String>,
31        reason: String,
32    },
33
34    #[error("tool execution failed: {tool_id}")]
35    // `inner` is a boxed trait object that JsonSchema cannot describe;
36    // skip this variant entirely in the generated schema.
37    #[cfg_attr(feature = "schema", schemars(skip))]
38    ToolExecutionFailed {
39        tool_id: String,
40        #[source]
41        inner: Box<dyn std::error::Error + Send + Sync>,
42    },
43
44    #[error("timed out calling {tool_id} after {after_ms}ms")]
45    Timeout { tool_id: String, after_ms: u64 },
46
47    #[error("server unreachable: {0}")]
48    #[cfg_attr(feature = "schema", schemars(skip))]
49    ServerUnreachable(#[from] std::io::Error),
50
51    #[error("not implemented: {feature}")]
52    NotImplemented { feature: String },
53
54    #[error("protocol error: expected {expected}, got {got}")]
55    ProtocolError { expected: String, got: String },
56
57    /// SP-pagination-v1 §4.8 — `AtdClient::call_all` hit either `max_pages`
58    /// or `max_total_bytes` before exhausting cursors. Callers can decide
59    /// whether to treat partial as success.
60    #[error("pagination limit exceeded: fetched {pages_fetched} pages / {bytes_fetched} bytes")]
61    #[cfg_attr(feature = "schema", schemars(skip))]
62    PaginationLimitExceeded {
63        pages_fetched: u32,
64        bytes_fetched: usize,
65    },
66
67    /// SP-pagination-v1 §4.8 — `MergePolicy` couldn't combine pages
68    /// (e.g., `ConcatArray` but a page wasn't an array; `ConcatField`
69    /// but the named field was missing).
70    #[error("page merge failed: {reason}")]
71    MergeFailed { reason: String },
72}
73
74impl AtdError {
75    pub fn is_retryable(&self) -> bool {
76        matches!(
77            self,
78            AtdError::Timeout { .. }
79                | AtdError::ServerUnreachable(_)
80                | AtdError::BindingUnavailable { .. }
81        )
82    }
83
84    pub fn suggest_fix(&self) -> Option<String> {
85        match self {
86            AtdError::ToolNotFound { suggestions, .. } if !suggestions.is_empty() => {
87                Some(format!("did you mean '{}'?", suggestions[0]))
88            }
89            AtdError::ToolNotFound { .. } => {
90                Some("try `atd list --query <keyword>` to find available tools".into())
91            }
92            AtdError::CapabilityDenied { tool_id, .. } => Some(format!(
93                "run `atd allow {tool_id}` to grant for this session"
94            )),
95            AtdError::ServerUnreachable(_) => {
96                Some("is the ANOS daemon running? try `anos daemon status`".into())
97            }
98            AtdError::Timeout { tool_id, .. } => {
99                Some(format!("increase timeout or retry; tool_id={tool_id}"))
100            }
101            _ => None,
102        }
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    #[test]
111    fn tool_not_found_suggests_candidate() {
112        let e = AtdError::ToolNotFound {
113            tool_id: "fs.red".into(),
114            suggestions: vec!["fs.read".into()],
115        };
116        assert_eq!(e.suggest_fix().unwrap(), "did you mean 'fs.read'?");
117        assert!(!e.is_retryable());
118    }
119
120    #[test]
121    fn tool_not_found_without_suggestions_hints_discovery() {
122        let e = AtdError::ToolNotFound {
123            tool_id: "xx".into(),
124            suggestions: vec![],
125        };
126        assert!(e.suggest_fix().unwrap().contains("atd list"));
127    }
128
129    #[test]
130    fn timeout_is_retryable() {
131        let e = AtdError::Timeout {
132            tool_id: "fs.read".into(),
133            after_ms: 5000,
134        };
135        assert!(e.is_retryable());
136    }
137
138    #[test]
139    fn io_error_converts_to_server_unreachable() {
140        let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "no");
141        let e: AtdError = io_err.into();
142        assert!(matches!(e, AtdError::ServerUnreachable(_)));
143        assert!(e.is_retryable());
144    }
145
146    #[test]
147    fn display_includes_tool_id() {
148        let e = AtdError::InvalidArguments {
149            tool_id: "fs.read".into(),
150            field: "path".into(),
151            reason: "must be string".into(),
152        };
153        let s = format!("{e}");
154        assert!(s.contains("fs.read"));
155        assert!(s.contains("path"));
156    }
157}