Skip to main content

atm_core/
error.rs

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    /// Return the captured backtrace when one is available.
145    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}