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 enum AtmErrorKind {
9 Config,
10 MissingDocument,
11 Address,
12 Identity,
13 DaemonUnavailable,
14 TeamNotFound,
15 AgentNotFound,
16 MailboxLock,
17 MailboxRead,
18 MailboxWrite,
19 FilePolicy,
20 Internal,
21 Validation,
22 Serialization,
23 Timeout,
24 ObservabilityEmit,
25 ObservabilityBootstrap,
26 ObservabilityQuery,
27 ObservabilityFollow,
28 ObservabilityHealth,
29}
30
31#[derive(Debug)]
32pub struct AtmError {
33 pub code: AtmErrorCode,
34 pub kind: AtmErrorKind,
35 pub message: String,
36 pub recovery: Vec<String>,
37 pub source: Option<Box<dyn StdError + Send + Sync>>,
38 pub backtrace: Backtrace,
39}
40
41impl AtmError {
42 pub fn new(kind: AtmErrorKind, message: impl Into<String>) -> Self {
43 Self::new_with_code(kind.default_code(), kind, message)
44 }
45
46 pub fn new_with_code(
47 code: AtmErrorCode,
48 kind: AtmErrorKind,
49 message: impl Into<String>,
50 ) -> Self {
51 Self {
52 code,
53 kind,
54 message: message.into(),
55 recovery: Vec::new(),
56 source: None,
57 backtrace: Backtrace::capture(),
58 }
59 }
60
61 pub fn is_config(&self) -> bool {
62 self.kind == AtmErrorKind::Config
63 }
64 pub fn is_address(&self) -> bool {
65 self.kind == AtmErrorKind::Address
66 }
67 pub fn is_missing_document(&self) -> bool {
68 self.kind == AtmErrorKind::MissingDocument
69 }
70 pub fn is_identity(&self) -> bool {
71 self.kind == AtmErrorKind::Identity
72 }
73 pub fn is_team_not_found(&self) -> bool {
74 self.kind == AtmErrorKind::TeamNotFound
75 }
76 pub fn is_daemon_unavailable(&self) -> bool {
77 self.kind == AtmErrorKind::DaemonUnavailable
78 }
79 pub fn is_agent_not_found(&self) -> bool {
80 self.kind == AtmErrorKind::AgentNotFound
81 }
82 pub fn is_mailbox_read(&self) -> bool {
83 self.kind == AtmErrorKind::MailboxRead
84 }
85 pub fn is_mailbox_lock(&self) -> bool {
86 self.kind == AtmErrorKind::MailboxLock
87 }
88 pub fn is_mailbox_write(&self) -> bool {
89 self.kind == AtmErrorKind::MailboxWrite
90 }
91 pub fn is_file_policy(&self) -> bool {
92 self.kind == AtmErrorKind::FilePolicy
93 }
94 pub fn is_internal(&self) -> bool {
95 self.kind == AtmErrorKind::Internal
96 }
97 pub fn is_validation(&self) -> bool {
98 self.kind == AtmErrorKind::Validation
99 }
100 pub fn is_serialization(&self) -> bool {
101 self.kind == AtmErrorKind::Serialization
102 }
103 pub fn is_timeout(&self) -> bool {
104 self.kind == AtmErrorKind::Timeout
105 }
106 pub fn is_observability_emit(&self) -> bool {
107 self.kind == AtmErrorKind::ObservabilityEmit
108 }
109 pub fn is_observability_bootstrap(&self) -> bool {
110 self.kind == AtmErrorKind::ObservabilityBootstrap
111 }
112 pub fn is_observability_query(&self) -> bool {
113 self.kind == AtmErrorKind::ObservabilityQuery
114 }
115 pub fn is_observability_follow(&self) -> bool {
116 self.kind == AtmErrorKind::ObservabilityFollow
117 }
118 pub fn is_observability_health(&self) -> bool {
119 self.kind == AtmErrorKind::ObservabilityHealth
120 }
121
122 pub fn with_recovery(mut self, recovery: impl Into<String>) -> Self {
123 self.recovery.push(recovery.into());
124 self
125 }
126
127 pub fn primary_recovery(&self) -> Option<&str> {
128 self.recovery.first().map(String::as_str)
129 }
130
131 pub fn with_source<E>(mut self, source: E) -> Self
132 where
133 E: StdError + Send + Sync + 'static,
134 {
135 self.source = Some(Box::new(source));
136 self
137 }
138
139 pub fn backtrace(&self) -> Option<&Backtrace> {
140 (self.backtrace.status() == BacktraceStatus::Captured).then_some(&self.backtrace)
141 }
142
143 pub fn home_directory_unavailable() -> Self {
144 Self::new_with_code(
145 AtmErrorCode::ConfigHomeUnavailable,
146 AtmErrorKind::Config,
147 "home directory is unavailable",
148 )
149 .with_recovery("Set ATM_HOME or ensure the OS home directory can be resolved.")
150 }
151
152 pub fn atm_home_unresolved(message: impl Into<String>) -> Self {
153 Self::new_with_code(
154 AtmErrorCode::AtmHomeUnresolved,
155 AtmErrorKind::Config,
156 message,
157 )
158 .with_recovery(
159 "Set ATM_HOME or ensure the OS home directory can be resolved before retrying the ATM command.",
160 )
161 }
162
163 pub fn config(message: impl Into<String>) -> Self {
164 Self::new(AtmErrorKind::Config, message).with_recovery(
165 "Check the active ATM configuration, runtime wiring, and local path settings before retrying.",
166 )
167 }
168
169 pub fn address_parse(message: impl Into<String>) -> Self {
170 Self::new(
171 AtmErrorKind::Address,
172 format!("address parse failed: {}", message.into()),
173 )
174 .with_recovery(
175 "Correct the ATM address format and retry with a valid <agent> or <agent>@<team> target.",
176 )
177 }
178
179 pub fn identity_unavailable() -> Self {
180 Self::new_with_code(
181 AtmErrorCode::IdentityUnavailable,
182 AtmErrorKind::Identity,
183 "identity is not configured",
184 )
185 .with_recovery("Set ATM_IDENTITY or provide an explicit command identity override when the command supports one.")
186 }
187
188 pub fn identity_invalid(message: impl Into<String>) -> Self {
189 Self::new_with_code(
190 AtmErrorCode::IdentityInvalid,
191 AtmErrorKind::Identity,
192 format!("caller identity is invalid: {}", message.into()),
193 )
194 .with_recovery(
195 "Set ATM_IDENTITY or provide an explicit command identity override using a valid ATM agent name.",
196 )
197 }
198
199 pub fn identity_conflict(message: impl Into<String>) -> Self {
200 Self::new_with_code(
201 AtmErrorCode::IdentityConflict,
202 AtmErrorKind::Identity,
203 message,
204 )
205 .with_recovery("Stop and report to the user immediately. Resolve the live pid conflict before retrying ATM activity.")
206 }
207
208 pub fn member_already_exists(member: &str, team: &str) -> Self {
209 Self::new_with_code(
210 AtmErrorCode::MemberAlreadyExists,
211 AtmErrorKind::Validation,
212 format!("member '{member}' already exists in team '{team}'"),
213 )
214 .with_recovery(
215 "Use `atm teams update-member` to repair metadata for an existing member instead of retrying `atm teams add-member`.",
216 )
217 }
218
219 pub fn member_not_found(member: &str, team: &str) -> Self {
220 Self::new_with_code(
221 AtmErrorCode::MemberNotFound,
222 AtmErrorKind::AgentNotFound,
223 format!("member '{member}' was not found in team '{team}'"),
224 )
225 .with_recovery(
226 "Confirm the target team/member pair, create the member with `atm teams add-member` if it is genuinely missing, or retry `atm teams update-member` against an existing member row.",
227 )
228 }
229
230 pub fn daemon_unavailable(message: impl Into<String>) -> Self {
231 Self::new_with_code(
232 AtmErrorCode::DaemonUnavailable,
233 AtmErrorKind::DaemonUnavailable,
234 message,
235 )
236 .with_recovery(
237 "Ensure the atm-daemon binary is installed, the daemon socket path is reachable, and ATM_DAEMON_BIN/ATM_HOME are set correctly before retrying.",
238 )
239 }
240
241 pub fn runtime_root_invalid(message: impl Into<String>) -> Self {
242 Self::new_with_code(
243 AtmErrorCode::RuntimeRootInvalid,
244 AtmErrorKind::DaemonUnavailable,
245 message,
246 )
247 .with_recovery(
248 "Repair ATM_HOME, the derived daemon/socket/database root, or the active working directory before retrying the ATM command.",
249 )
250 }
251
252 pub fn runtime_bootstrap_refused(message: impl Into<String>) -> Self {
253 Self::new_with_code(
254 AtmErrorCode::RuntimeBootstrapRefused,
255 AtmErrorKind::DaemonUnavailable,
256 message,
257 )
258 .with_recovery(
259 "Clear the conflicting daemon runtime override or repair the canonical ATM runtime root before retrying the ATM command.",
260 )
261 }
262
263 pub fn socket_override_forbidden(message: impl Into<String>) -> Self {
264 Self::new_with_code(
265 AtmErrorCode::SocketOverrideForbidden,
266 AtmErrorKind::Config,
267 message,
268 )
269 .with_recovery(
270 "Remove ATM_DAEMON_SOCKET; ATM always connects through the OS-user daemon endpoint.",
271 )
272 }
273
274 pub fn daemon_may_have_executed(message: impl Into<String>) -> Self {
275 Self::new_with_code(
276 AtmErrorCode::DaemonMayHaveExecuted,
277 AtmErrorKind::DaemonUnavailable,
278 message,
279 )
280 .with_recovery(
281 "Check the destination mailbox or other service-side effects before retrying this same-host ATM command.",
282 )
283 }
284
285 pub fn daemon_lifecycle_wedge(message: impl Into<String>) -> Self {
286 Self::new_with_code(
287 AtmErrorCode::DaemonLifecycleWedge,
288 AtmErrorKind::DaemonUnavailable,
289 message,
290 )
291 .with_recovery(
292 "Restart the daemon after the local IPC listener and lifecycle-control state fully stop, then inspect daemon logs for the wedged shutdown path.",
293 )
294 }
295
296 pub fn daemon_advisory_session_already_registered(message: impl Into<String>) -> Self {
297 Self::new_with_code(
298 AtmErrorCode::DaemonAdvisorySessionAlreadyRegistered,
299 AtmErrorKind::DaemonUnavailable,
300 message,
301 )
302 .with_recovery(
303 "Unregister the existing advisory session or choose a fresh session id before retrying embedded advisory activation.",
304 )
305 }
306
307 pub fn daemon_advisory_session_not_registered(message: impl Into<String>) -> Self {
308 Self::new_with_code(
309 AtmErrorCode::DaemonAdvisorySessionNotRegistered,
310 AtmErrorKind::DaemonUnavailable,
311 message,
312 )
313 .with_recovery(
314 "Register the advisory session before fetching or draining daemon-owned advisory state.",
315 )
316 }
317
318 pub fn daemon_advisory_session_cleanup_failed(message: impl Into<String>) -> Self {
319 Self::new_with_code(
320 AtmErrorCode::DaemonAdvisorySessionCleanupFailed,
321 AtmErrorKind::DaemonUnavailable,
322 message,
323 )
324 .with_recovery(
325 "Inspect advisory-session lifecycle logs, clean up any orphaned graft session, and retry only after the daemon unregister path is healthy.",
326 )
327 }
328
329 pub fn daemon_launch_gate_rejected(message: impl Into<String>) -> Self {
330 Self::new_with_code(
331 AtmErrorCode::DaemonLaunchGateRejected,
332 AtmErrorKind::DaemonUnavailable,
333 message,
334 )
335 .with_recovery(
336 "Connect to the existing daemon if it is healthy, or resolve stale ownership before retrying another daemon launch.",
337 )
338 }
339
340 pub fn daemon_serving_state_rejected(message: impl Into<String>) -> Self {
341 Self::new_with_code(
342 AtmErrorCode::DaemonServingStateRejected,
343 AtmErrorKind::DaemonUnavailable,
344 message,
345 )
346 .with_recovery(
347 "Stop launching duplicate daemons and inspect the existing runtime owner before retrying startup.",
348 )
349 }
350
351 pub fn daemon_stale_owner_recovery_failed(message: impl Into<String>) -> Self {
352 Self::new_with_code(
353 AtmErrorCode::DaemonStaleOwnerRecoveryFailed,
354 AtmErrorKind::DaemonUnavailable,
355 message,
356 )
357 .with_recovery(
358 "Inspect the recorded owner, confirm no live daemon remains, repair ownership metadata, then retry startup.",
359 )
360 }
361
362 pub fn daemon_auto_start_failed(message: impl Into<String>) -> Self {
363 Self::new_with_code(
364 AtmErrorCode::DaemonAutoStartFailed,
365 AtmErrorKind::DaemonUnavailable,
366 message,
367 )
368 .with_recovery(
369 "Inspect daemon stderr/logs, fix the startup fault, and retry only after the daemon can reach serving state.",
370 )
371 }
372
373 pub fn remote_delivery_outcome_unknown(message: impl Into<String>) -> Self {
374 Self::new_with_code(
375 AtmErrorCode::RemoteDeliveryOutcomeUnknown,
376 AtmErrorKind::DaemonUnavailable,
377 message,
378 )
379 .with_recovery(
380 "Check the destination daemon or mailbox before retrying. If local durable replay is enabled, let the daemon resume the pending handoff rather than guessing success.",
381 )
382 }
383
384 pub fn help_topic_not_found(message: impl Into<String>) -> Self {
385 Self::new_with_code(
386 AtmErrorCode::HelpTopicNotFound,
387 AtmErrorKind::Validation,
388 message,
389 )
390 .with_recovery("Use `atm help --list` to inspect available help topics and subcommands.")
391 }
392
393 pub fn test_fake_transport_injection_failed(message: impl Into<String>) -> Self {
394 Self::new_with_code(
395 AtmErrorCode::TestFakeTransportInjectionFailed,
396 AtmErrorKind::Validation,
397 message,
398 )
399 .with_recovery(
400 "Fix the test seam configuration so it uses a valid FakeClientTransport or LoopbackClientTransport instance.",
401 )
402 }
403
404 pub fn team_unavailable() -> Self {
405 Self::new_with_code(
406 AtmErrorCode::TeamUnavailable,
407 AtmErrorKind::TeamNotFound,
408 "team is not configured",
409 )
410 .with_recovery("Pass an explicit team in the address or configure a default team.")
411 }
412
413 pub fn team_invalid(message: impl Into<String>) -> Self {
414 Self::new_with_code(
415 AtmErrorCode::TeamInvalid,
416 AtmErrorKind::Validation,
417 format!("caller team is invalid: {}", message.into()),
418 )
419 .with_recovery(
420 "Set ATM_TEAM or provide an explicit --team override using a valid ATM team name.",
421 )
422 }
423
424 pub fn team_not_found(team: &str) -> Self {
425 Self::new(
426 AtmErrorKind::TeamNotFound,
427 format!("team '{team}' was not found"),
428 )
429 .with_recovery("Create the team config or target a different team.")
430 }
431
432 pub fn agent_not_found(agent: &str, team: &str) -> Self {
433 Self::new(
434 AtmErrorKind::AgentNotFound,
435 format!("agent '{agent}' was not found in team '{team}'"),
436 )
437 .with_recovery("Update the team membership or target a different recipient.")
438 }
439
440 pub fn validation(message: impl Into<String>) -> Self {
441 Self::new(AtmErrorKind::Validation, message).with_recovery(
442 "Correct the invalid ATM input or mailbox state, then retry the command with a valid target or argument.",
443 )
444 }
445
446 pub fn self_addressed_send_invalid(message: impl Into<String>) -> Self {
447 Self::new_with_code(
448 AtmErrorCode::SelfAddressedSendInvalid,
449 AtmErrorKind::Validation,
450 message,
451 )
452 .with_recovery(
453 "Target a different recipient or use a non-mutating mailbox inspection command instead of sending a message to yourself.",
454 )
455 }
456
457 pub fn empty_nudge_template_body() -> Self {
458 Self::new_with_code(
459 AtmErrorCode::EmptyNudgeTemplateBody,
460 AtmErrorKind::Validation,
461 "built-in nudge template body must be non-empty",
462 )
463 .with_recovery(
464 "Provide a non-empty template body, or use the explicit disable or clear nudge-template command instead of an empty string.",
465 )
466 }
467
468 pub fn caller_context_request_invalid(message: impl Into<String>) -> Self {
469 Self::new_with_code(
470 AtmErrorCode::CallerContextRequestInvalid,
471 AtmErrorKind::Validation,
472 message,
473 )
474 .with_recovery(
475 "Repair the CLI request-builder path so ATM daemon requests always include validated caller_identity and caller_team fields.",
476 )
477 }
478
479 pub fn missing_document(message: impl Into<String>) -> Self {
480 Self::new(AtmErrorKind::MissingDocument, message).with_recovery(
481 "Restore the missing ATM document or recreate it through the documented team-management workflow before retrying.",
482 )
483 }
484
485 pub fn file_policy(message: impl Into<String>) -> Self {
486 Self::new(AtmErrorKind::FilePolicy, message).with_recovery(
487 "Update the referenced file, path, or policy inputs so they satisfy ATM file-policy rules before retrying the command.",
488 )
489 }
490
491 pub fn mailbox_read(message: impl Into<String>) -> Self {
492 Self::new(AtmErrorKind::MailboxRead, message).with_recovery(
493 "Check ATM_HOME, mailbox file permissions, and mailbox JSON syntax before retrying the ATM command.",
494 )
495 }
496
497 pub fn mailbox_lock(message: impl Into<String>) -> Self {
498 Self::new(AtmErrorKind::MailboxLock, message).with_recovery(
499 "Retry after other ATM mailbox activity completes, or wait for the competing process to release its mailbox lock.",
500 )
501 }
502
503 pub fn mailbox_lock_read_only_filesystem(
504 operation: impl fmt::Display,
505 path: &std::path::Path,
506 ) -> Self {
507 Self::new_with_code(
508 AtmErrorCode::MailboxLockReadOnlyFilesystem,
509 AtmErrorKind::MailboxLock,
510 format!(
511 "mailbox lock {operation} failed for {}: filesystem is read-only",
512 path.display()
513 ),
514 )
515 .with_recovery(
516 "Remount the filesystem read-write or point ATM at a writable home with ATM_HOME or --home, then retry the ATM command.",
517 )
518 }
519
520 pub fn mailbox_lock_timeout(path: &std::path::Path) -> Self {
521 Self::new_with_code(
522 AtmErrorCode::MailboxLockTimeout,
523 AtmErrorKind::MailboxLock,
524 format!("timed out waiting for mailbox lock on {}", path.display()),
525 )
526 .with_recovery(
527 "Retry after the competing ATM process finishes, or investigate whether another process is holding the mailbox lock unexpectedly.",
528 )
529 }
530
531 pub fn mailbox_write(message: impl Into<String>) -> Self {
532 Self::new(AtmErrorKind::MailboxWrite, message).with_recovery(
533 "Check that the mailbox/workflow path is writable, has free space, and was not modified concurrently before retrying the ATM command.",
534 )
535 }
536
537 pub fn observability_emit(message: impl Into<String>) -> Self {
538 Self::new(AtmErrorKind::ObservabilityEmit, message).with_recovery(
539 "Verify the observability sink is writable or temporarily disable retained logging while investigating.",
540 )
541 }
542
543 pub fn observability_bootstrap(message: impl Into<String>) -> Self {
544 Self::new(AtmErrorKind::ObservabilityBootstrap, message).with_recovery(
545 "Check the configured observability backend, log directory permissions, and any local path overrides before retrying ATM commands.",
546 )
547 }
548
549 pub fn observability_query(message: impl Into<String>) -> Self {
550 Self::new(AtmErrorKind::ObservabilityQuery, message).with_recovery(
551 "Confirm retained logs exist and the observability backend supports queries for the selected sink and time range.",
552 )
553 }
554
555 pub fn observability_follow(message: impl Into<String>) -> Self {
556 Self::new(AtmErrorKind::ObservabilityFollow, message).with_recovery(
557 "Check that follow/tail is enabled for the active sink and retry with a narrower query if the stream is unavailable.",
558 )
559 }
560
561 pub fn observability_health(message: impl Into<String>) -> Self {
562 Self::new(AtmErrorKind::ObservabilityHealth, message).with_recovery(
563 "Inspect the observability backend health, file sink path, and query backend status, then rerun `atm doctor`.",
564 )
565 }
566}
567
568impl fmt::Display for AtmError {
569 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
570 write!(f, "{}", self.message)?;
571 for recovery in &self.recovery {
572 write!(f, "\n Recovery: {recovery}")?;
573 }
574 if let Some(source) = self.source() {
575 write!(f, "\n Source: {source}")?;
576 let mut current = source.source();
577 while let Some(next) = current {
578 write!(f, "\n Caused by: {next}")?;
579 current = next.source();
580 }
581 }
582 match self.backtrace() {
583 Some(backtrace) => write!(f, "\n Backtrace:\n{backtrace}")?,
584 None => write!(f, "\n Backtrace: {:?}", self.backtrace.status())?,
585 }
586 Ok(())
587 }
588}
589
590impl StdError for AtmError {
591 fn source(&self) -> Option<&(dyn StdError + 'static)> {
592 self.source
593 .as_deref()
594 .map(|source| source as &(dyn StdError + 'static))
595 }
596}
597
598impl From<serde_json::Error> for AtmError {
599 fn from(source: serde_json::Error) -> Self {
600 Self::new(AtmErrorKind::Serialization, format!("json error: {source}"))
601 .with_recovery(
602 "Inspect the JSON payload for structural errors and verify the schema matches the expected format.",
603 )
604 .with_source(source)
605 }
606}
607
608impl From<toml::de::Error> for AtmError {
609 fn from(source: toml::de::Error) -> Self {
610 Self::new(AtmErrorKind::Config, format!("toml error: {source}"))
611 .with_recovery(
612 "Inspect the TOML file for syntax errors and verify all required fields are present.",
613 )
614 .with_source(source)
615 }
616}
617
618impl AtmErrorKind {
619 const fn default_code(self) -> AtmErrorCode {
620 match self {
621 Self::Config => AtmErrorCode::ConfigParseFailed,
622 Self::MissingDocument => AtmErrorCode::ConfigTeamMissing,
623 Self::Address => AtmErrorCode::AddressParseFailed,
624 Self::Identity => AtmErrorCode::IdentityUnavailable,
625 Self::DaemonUnavailable => AtmErrorCode::DaemonUnavailable,
626 Self::TeamNotFound => AtmErrorCode::TeamNotFound,
627 Self::AgentNotFound => AtmErrorCode::AgentNotFound,
628 Self::MailboxLock => AtmErrorCode::MailboxLockFailed,
629 Self::MailboxRead => AtmErrorCode::MailboxReadFailed,
630 Self::MailboxWrite => AtmErrorCode::MailboxWriteFailed,
631 Self::FilePolicy => AtmErrorCode::FilePolicyRejected,
632 Self::Internal => AtmErrorCode::InternalError,
633 Self::Validation => AtmErrorCode::MessageValidationFailed,
634 Self::Serialization => AtmErrorCode::SerializationFailed,
635 Self::Timeout => AtmErrorCode::WaitTimeout,
636 Self::ObservabilityEmit => AtmErrorCode::ObservabilityEmitFailed,
637 Self::ObservabilityBootstrap => AtmErrorCode::ObservabilityBootstrapFailed,
638 Self::ObservabilityQuery => AtmErrorCode::ObservabilityQueryFailed,
639 Self::ObservabilityFollow => AtmErrorCode::ObservabilityFollowFailed,
640 Self::ObservabilityHealth => AtmErrorCode::ObservabilityHealthFailed,
641 }
642 }
643}
644
645#[cfg(test)]
646mod tests {
647 use super::{AtmError, AtmErrorCode, AtmErrorKind};
648 use std::error::Error;
649 use std::fmt;
650
651 #[derive(Debug)]
652 struct LeafError(&'static str);
653
654 impl fmt::Display for LeafError {
655 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
656 f.write_str(self.0)
657 }
658 }
659
660 impl Error for LeafError {}
661
662 #[derive(Debug)]
663 struct ParentError {
664 message: &'static str,
665 source: LeafError,
666 }
667
668 impl fmt::Display for ParentError {
669 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
670 f.write_str(self.message)
671 }
672 }
673
674 impl Error for ParentError {
675 fn source(&self) -> Option<&(dyn Error + 'static)> {
676 Some(&self.source)
677 }
678 }
679
680 #[test]
681 fn display_includes_recovery_source_chain_and_backtrace_label() {
682 let error = AtmError::new(AtmErrorKind::Validation, "storage contract failed")
683 .with_recovery("Repair the contract fixture.")
684 .with_source(ParentError {
685 message: "parent source",
686 source: LeafError("leaf source"),
687 });
688
689 let rendered = error.to_string();
690
691 assert!(rendered.contains("storage contract failed"));
692 assert!(rendered.contains("Recovery: Repair the contract fixture."));
693 assert!(rendered.contains("Source: parent source"));
694 assert!(rendered.contains("Caused by: leaf source"));
695 assert!(rendered.contains("Backtrace:"));
696 }
697
698 #[test]
699 fn member_not_found_uses_agent_not_found_kind() {
700 let error = AtmError::member_not_found("test-agent", "test-team");
701
702 assert_eq!(error.code, AtmErrorCode::MemberNotFound);
703 assert!(error.is_agent_not_found());
704 }
705}