1use std::backtrace::{Backtrace, BacktraceStatus};
2use std::error::Error as StdError;
3use std::fmt;
4
5pub use crate::error_codes::AtmErrorCode;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub(crate) enum AtmErrorKind {
9 Config,
10 MissingDocument,
11 Address,
12 Identity,
13 TeamNotFound,
14 AgentNotFound,
15 MailboxLock,
16 MailboxRead,
17 MailboxWrite,
18 FilePolicy,
19 Validation,
20 Serialization,
21 Timeout,
22 ObservabilityEmit,
23 ObservabilityBootstrap,
24 ObservabilityQuery,
25 ObservabilityFollow,
26 ObservabilityHealth,
27}
28
29#[derive(Debug)]
30pub struct AtmError {
31 pub code: AtmErrorCode,
32 pub(crate) kind: AtmErrorKind,
33 pub message: String,
34 pub recovery: Option<String>,
35 pub source: Option<Box<dyn StdError + Send + Sync>>,
36 pub backtrace: Backtrace,
37}
38
39impl AtmError {
40 pub(crate) fn new(kind: AtmErrorKind, message: impl Into<String>) -> Self {
41 Self::new_with_code(kind.default_code(), kind, message)
42 }
43
44 pub(crate) fn new_with_code(
45 code: AtmErrorCode,
46 kind: AtmErrorKind,
47 message: impl Into<String>,
48 ) -> Self {
49 Self {
50 code,
51 kind,
52 message: message.into(),
53 recovery: None,
54 source: None,
55 backtrace: Backtrace::capture(),
56 }
57 }
58
59 pub fn is_config(&self) -> bool {
60 self.kind == AtmErrorKind::Config
61 }
62
63 pub fn is_address(&self) -> bool {
64 self.kind == AtmErrorKind::Address
65 }
66
67 pub fn is_missing_document(&self) -> bool {
68 self.kind == AtmErrorKind::MissingDocument
69 }
70
71 pub fn is_identity(&self) -> bool {
72 self.kind == AtmErrorKind::Identity
73 }
74
75 pub fn is_team_not_found(&self) -> bool {
76 self.kind == AtmErrorKind::TeamNotFound
77 }
78
79 pub fn is_agent_not_found(&self) -> bool {
80 self.kind == AtmErrorKind::AgentNotFound
81 }
82
83 pub fn is_mailbox_read(&self) -> bool {
84 self.kind == AtmErrorKind::MailboxRead
85 }
86
87 pub fn is_mailbox_lock(&self) -> bool {
88 self.kind == AtmErrorKind::MailboxLock
89 }
90
91 pub fn is_mailbox_write(&self) -> bool {
92 self.kind == AtmErrorKind::MailboxWrite
93 }
94
95 pub fn is_file_policy(&self) -> bool {
96 self.kind == AtmErrorKind::FilePolicy
97 }
98
99 pub fn is_validation(&self) -> bool {
100 self.kind == AtmErrorKind::Validation
101 }
102
103 pub fn is_serialization(&self) -> bool {
104 self.kind == AtmErrorKind::Serialization
105 }
106
107 pub fn is_timeout(&self) -> bool {
108 self.kind == AtmErrorKind::Timeout
109 }
110
111 pub fn is_observability_emit(&self) -> bool {
112 self.kind == AtmErrorKind::ObservabilityEmit
113 }
114
115 pub fn is_observability_bootstrap(&self) -> bool {
116 self.kind == AtmErrorKind::ObservabilityBootstrap
117 }
118
119 pub fn is_observability_query(&self) -> bool {
120 self.kind == AtmErrorKind::ObservabilityQuery
121 }
122
123 pub fn is_observability_follow(&self) -> bool {
124 self.kind == AtmErrorKind::ObservabilityFollow
125 }
126
127 pub fn is_observability_health(&self) -> bool {
128 self.kind == AtmErrorKind::ObservabilityHealth
129 }
130
131 pub fn with_recovery(mut self, recovery: impl Into<String>) -> Self {
132 self.recovery = Some(recovery.into());
133 self
134 }
135
136 pub fn with_source<E>(mut self, source: E) -> Self
137 where
138 E: StdError + Send + Sync + 'static,
139 {
140 self.source = Some(Box::new(source));
141 self
142 }
143
144 pub fn backtrace(&self) -> Option<&Backtrace> {
146 (self.backtrace.status() == BacktraceStatus::Captured).then_some(&self.backtrace)
147 }
148
149 pub fn home_directory_unavailable() -> Self {
150 Self::new_with_code(
151 AtmErrorCode::ConfigHomeUnavailable,
152 AtmErrorKind::Config,
153 "home directory is unavailable",
154 )
155 .with_recovery("Set ATM_HOME or ensure the OS home directory can be resolved.")
156 }
157
158 pub fn address_parse(message: impl Into<String>) -> Self {
159 Self::new(
160 AtmErrorKind::Address,
161 format!("address parse failed: {}", message.into()),
162 )
163 .with_recovery(
164 "Correct the ATM address format and retry with a valid <agent> or <agent>@<team> target.",
165 )
166 }
167
168 pub fn identity_unavailable() -> Self {
169 Self::new_with_code(
170 AtmErrorCode::IdentityUnavailable,
171 AtmErrorKind::Identity,
172 "identity is not configured",
173 )
174 .with_recovery("Set ATM_IDENTITY or provide an explicit command identity override when the command supports one.")
175 }
176
177 pub fn team_unavailable() -> Self {
178 Self::new_with_code(
179 AtmErrorCode::TeamUnavailable,
180 AtmErrorKind::TeamNotFound,
181 "team is not configured",
182 )
183 .with_recovery("Pass an explicit team in the address or configure a default team.")
184 }
185
186 pub fn team_not_found(team: &str) -> Self {
187 Self::new(
188 AtmErrorKind::TeamNotFound,
189 format!("team '{team}' was not found"),
190 )
191 .with_recovery("Create the team config or target a different team.")
192 }
193
194 pub fn agent_not_found(agent: &str, team: &str) -> Self {
195 Self::new(
196 AtmErrorKind::AgentNotFound,
197 format!("agent '{agent}' was not found in team '{team}'"),
198 )
199 .with_recovery("Update the team membership or target a different recipient.")
200 }
201
202 pub fn validation(message: impl Into<String>) -> Self {
203 Self::new(AtmErrorKind::Validation, message).with_recovery(
204 "Correct the invalid ATM input or mailbox state, then retry the command with a valid target or argument.",
205 )
206 }
207
208 pub fn missing_document(message: impl Into<String>) -> Self {
209 Self::new(AtmErrorKind::MissingDocument, message).with_recovery(
210 "Restore the missing ATM document or recreate it through the documented team-management workflow before retrying.",
211 )
212 }
213
214 pub fn file_policy(message: impl Into<String>) -> Self {
215 Self::new(AtmErrorKind::FilePolicy, message).with_recovery(
216 "Update the referenced file, path, or policy inputs so they satisfy ATM file-policy rules before retrying the command.",
217 )
218 }
219
220 pub fn mailbox_read(message: impl Into<String>) -> Self {
221 Self::new(AtmErrorKind::MailboxRead, message).with_recovery(
222 "Check ATM_HOME, mailbox file permissions, and mailbox JSON syntax before retrying the ATM command.",
223 )
224 }
225
226 pub fn mailbox_lock(message: impl Into<String>) -> Self {
227 Self::new(AtmErrorKind::MailboxLock, message).with_recovery(
228 "Retry after other ATM mailbox activity completes, or wait for the competing process to release its mailbox lock.",
229 )
230 }
231
232 pub fn mailbox_lock_read_only_filesystem(
233 operation: impl fmt::Display,
234 path: &std::path::Path,
235 ) -> Self {
236 Self::new_with_code(
237 AtmErrorCode::MailboxLockReadOnlyFilesystem,
238 AtmErrorKind::MailboxLock,
239 format!(
240 "mailbox lock {operation} failed for {}: filesystem is read-only",
241 path.display()
242 ),
243 )
244 .with_recovery(
245 "Remount the filesystem read-write or point ATM at a writable home with ATM_HOME or --home, then retry the ATM command.",
246 )
247 }
248
249 pub fn mailbox_lock_timeout(path: &std::path::Path) -> Self {
250 Self::new_with_code(
251 AtmErrorCode::MailboxLockTimeout,
252 AtmErrorKind::MailboxLock,
253 format!(
254 "timed out waiting for mailbox lock on {}",
255 path.display()
256 ),
257 )
258 .with_recovery(
259 "Retry after the competing ATM process finishes, or investigate whether another process is holding the mailbox lock unexpectedly.",
260 )
261 }
262
263 pub fn mailbox_write(message: impl Into<String>) -> Self {
264 Self::new(AtmErrorKind::MailboxWrite, message).with_recovery(
265 "Check that the mailbox/workflow path is writable, has free space, and was not modified concurrently before retrying the ATM command.",
266 )
267 }
268
269 pub fn observability_emit(message: impl Into<String>) -> Self {
270 Self::new(AtmErrorKind::ObservabilityEmit, message).with_recovery(
271 "Verify the observability sink is writable or temporarily disable retained logging while investigating.",
272 )
273 }
274
275 pub fn observability_bootstrap(message: impl Into<String>) -> Self {
276 Self::new(AtmErrorKind::ObservabilityBootstrap, message).with_recovery(
277 "Check the configured observability backend, log directory permissions, and any local path overrides before retrying ATM commands.",
278 )
279 }
280
281 pub fn observability_query(message: impl Into<String>) -> Self {
282 Self::new(AtmErrorKind::ObservabilityQuery, message).with_recovery(
283 "Confirm retained logs exist and the observability backend supports queries for the selected sink and time range.",
284 )
285 }
286
287 pub fn observability_follow(message: impl Into<String>) -> Self {
288 Self::new(AtmErrorKind::ObservabilityFollow, message).with_recovery(
289 "Check that follow/tail is enabled for the active sink and retry with a narrower query if the stream is unavailable.",
290 )
291 }
292
293 pub fn observability_health(message: impl Into<String>) -> Self {
294 Self::new(AtmErrorKind::ObservabilityHealth, message).with_recovery(
295 "Inspect the observability backend health, file sink path, and query backend status, then rerun `atm doctor`.",
296 )
297 }
298}
299
300impl fmt::Display for AtmError {
301 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
302 write!(f, "{}", self.message)?;
303 if let Some(recovery) = &self.recovery {
304 write!(f, "\n Recovery: {recovery}")?;
305 }
306 Ok(())
307 }
308}
309
310impl StdError for AtmError {
311 fn source(&self) -> Option<&(dyn StdError + 'static)> {
312 self.source
313 .as_deref()
314 .map(|source| source as &(dyn StdError + 'static))
315 }
316}
317
318impl From<serde_json::Error> for AtmError {
319 fn from(source: serde_json::Error) -> Self {
320 Self::new(AtmErrorKind::Serialization, format!("json error: {source}"))
321 .with_recovery(
322 "Inspect the JSON payload for structural errors and verify the schema matches the expected format.",
323 )
324 .with_source(source)
325 }
326}
327
328impl From<toml::de::Error> for AtmError {
329 fn from(source: toml::de::Error) -> Self {
330 Self::new(AtmErrorKind::Config, format!("toml error: {source}"))
331 .with_recovery(
332 "Inspect the TOML file for syntax errors and verify all required fields are present.",
333 )
334 .with_source(source)
335 }
336}
337
338impl AtmErrorKind {
339 const fn default_code(self) -> AtmErrorCode {
340 match self {
341 Self::Config => AtmErrorCode::ConfigParseFailed,
342 Self::MissingDocument => AtmErrorCode::ConfigTeamMissing,
343 Self::Address => AtmErrorCode::AddressParseFailed,
344 Self::Identity => AtmErrorCode::IdentityUnavailable,
345 Self::TeamNotFound => AtmErrorCode::TeamNotFound,
346 Self::AgentNotFound => AtmErrorCode::AgentNotFound,
347 Self::MailboxLock => AtmErrorCode::MailboxLockFailed,
348 Self::MailboxRead => AtmErrorCode::MailboxReadFailed,
349 Self::MailboxWrite => AtmErrorCode::MailboxWriteFailed,
350 Self::FilePolicy => AtmErrorCode::FilePolicyRejected,
351 Self::Validation => AtmErrorCode::MessageValidationFailed,
352 Self::Serialization => AtmErrorCode::SerializationFailed,
353 Self::Timeout => AtmErrorCode::WaitTimeout,
354 Self::ObservabilityEmit => AtmErrorCode::ObservabilityEmitFailed,
355 Self::ObservabilityBootstrap => AtmErrorCode::ObservabilityBootstrapFailed,
356 Self::ObservabilityQuery => AtmErrorCode::ObservabilityQueryFailed,
357 Self::ObservabilityFollow => AtmErrorCode::ObservabilityFollowFailed,
358 Self::ObservabilityHealth => AtmErrorCode::ObservabilityHealthFailed,
359 }
360 }
361}
362
363#[cfg(test)]
364mod tests {
365 use std::backtrace::Backtrace;
366
367 use super::{AtmError, AtmErrorCode};
368
369 #[test]
370 fn observability_error_helpers_use_expected_codes() {
371 assert_eq!(
372 AtmError::observability_emit("emit failed").code,
373 AtmErrorCode::ObservabilityEmitFailed
374 );
375 assert_eq!(
376 AtmError::observability_bootstrap("bootstrap failed").code,
377 AtmErrorCode::ObservabilityBootstrapFailed
378 );
379 assert_eq!(
380 AtmError::observability_query("query failed").code,
381 AtmErrorCode::ObservabilityQueryFailed
382 );
383 assert_eq!(
384 AtmError::observability_follow("follow failed").code,
385 AtmErrorCode::ObservabilityFollowFailed
386 );
387 assert_eq!(
388 AtmError::observability_health("health failed").code,
389 AtmErrorCode::ObservabilityHealthFailed
390 );
391 }
392
393 #[test]
394 fn mailbox_write_helper_includes_recovery_guidance() {
395 let error = AtmError::mailbox_write("write failed");
396
397 assert!(error.is_mailbox_write());
398 assert!(
399 error
400 .recovery
401 .as_deref()
402 .is_some_and(|value| value.contains("writable"))
403 );
404 }
405
406 #[test]
407 fn display_remains_concise_when_backtrace_is_captured() {
408 let mut error = AtmError::validation("boom");
409 error.backtrace = Backtrace::force_capture();
410
411 let rendered = error.to_string();
412 assert!(rendered.contains("boom"));
413 assert!(!rendered.contains("Backtrace:"));
414 assert!(error.backtrace().is_some());
415 }
416
417 #[test]
418 fn display_handles_absent_backtrace() {
419 let mut error = AtmError::validation("boom");
420 error.backtrace = Backtrace::disabled();
421
422 let rendered = error.to_string();
423 assert!(rendered.contains("boom"));
424 assert!(!rendered.contains("Backtrace:"));
425 assert!(error.backtrace().is_none());
426 }
427}