1use serde::Serialize;
7use thiserror::Error;
8
9use crate::context::ContextState;
10use crate::gates::StopReason;
11use crate::invariant::InvariantClass;
12
13#[derive(Debug, Error, Serialize)]
18pub enum ConvergeError {
19 #[error("budget exhausted: {kind}")]
21 BudgetExhausted { kind: String },
22
23 #[error("{class:?} invariant '{name}' violated: {reason}")]
25 InvariantViolation {
26 name: String,
28 class: InvariantClass,
30 reason: String,
32 context: Box<ContextState>,
34 },
35
36 #[error("agent failed: {agent_id}")]
38 AgentFailed { agent_id: String },
39
40 #[error(
42 "conflict detected for fact '{id}': existing content '{existing}' vs new content '{new}'"
43 )]
44 Conflict {
45 id: String,
47 existing: String,
49 new: String,
51 context: Box<ContextState>,
53 },
54}
55
56impl ConvergeError {
57 #[must_use]
59 pub fn context(&self) -> Option<&ContextState> {
60 match self {
61 Self::InvariantViolation { context, .. } | Self::Conflict { context, .. } => {
62 Some(context)
63 }
64 Self::BudgetExhausted { .. } | Self::AgentFailed { .. } => None,
65 }
66 }
67
68 #[must_use]
70 pub fn stop_reason(&self) -> StopReason {
71 match self {
72 Self::BudgetExhausted { kind } => StopReason::Error {
73 message: format!("budget exhausted: {kind}"),
74 category: crate::gates::ErrorCategory::Resource,
75 },
76 Self::InvariantViolation {
77 name,
78 class,
79 reason,
80 ..
81 } => StopReason::invariant_violated(*class, name.clone(), reason.clone()),
82 Self::AgentFailed { agent_id } => StopReason::AgentRefused {
83 agent_id: agent_id.clone(),
84 reason: "agent execution failed".to_string(),
85 },
86 Self::Conflict {
87 id, existing, new, ..
88 } => StopReason::Error {
89 message: format!("conflict for fact '{id}': existing '{existing}' vs new '{new}'"),
90 category: crate::gates::ErrorCategory::Internal,
91 },
92 }
93 }
94}
95
96#[cfg(test)]
97mod tests {
98 use super::*;
99
100 fn empty_context() -> ContextState {
101 ContextState::default()
102 }
103
104 #[test]
105 fn budget_exhausted_display() {
106 let err = ConvergeError::BudgetExhausted {
107 kind: "cycles".into(),
108 };
109 assert_eq!(err.to_string(), "budget exhausted: cycles");
110 }
111
112 #[test]
113 fn budget_exhausted_has_no_context() {
114 let err = ConvergeError::BudgetExhausted {
115 kind: "tokens".into(),
116 };
117 assert!(err.context().is_none());
118 }
119
120 #[test]
121 fn agent_failed_display() {
122 let err = ConvergeError::AgentFailed {
123 agent_id: "agent-x".into(),
124 };
125 assert_eq!(err.to_string(), "agent failed: agent-x");
126 }
127
128 #[test]
129 fn agent_failed_has_no_context() {
130 let err = ConvergeError::AgentFailed {
131 agent_id: "a".into(),
132 };
133 assert!(err.context().is_none());
134 }
135
136 #[test]
137 fn invariant_violation_has_context() {
138 let err = ConvergeError::InvariantViolation {
139 name: "no_empty".into(),
140 class: InvariantClass::Structural,
141 reason: "empty found".into(),
142 context: Box::new(empty_context()),
143 };
144 assert!(err.context().is_some());
145 }
146
147 #[test]
148 fn invariant_violation_display() {
149 let err = ConvergeError::InvariantViolation {
150 name: "no_empty".into(),
151 class: InvariantClass::Semantic,
152 reason: "bad".into(),
153 context: Box::new(empty_context()),
154 };
155 assert_eq!(
156 err.to_string(),
157 "Semantic invariant 'no_empty' violated: bad"
158 );
159 }
160
161 #[test]
162 fn conflict_has_context() {
163 let err = ConvergeError::Conflict {
164 id: "fact-1".into(),
165 existing: "old".into(),
166 new: "new".into(),
167 context: Box::new(empty_context()),
168 };
169 assert!(err.context().is_some());
170 }
171
172 #[test]
173 fn conflict_display() {
174 let err = ConvergeError::Conflict {
175 id: "f".into(),
176 existing: "a".into(),
177 new: "b".into(),
178 context: Box::new(empty_context()),
179 };
180 assert!(err.to_string().contains("conflict detected for fact 'f'"));
181 }
182
183 #[test]
184 fn stop_reason_budget_exhausted() {
185 let err = ConvergeError::BudgetExhausted {
186 kind: "time".into(),
187 };
188 let reason = err.stop_reason();
189 assert!(matches!(reason, StopReason::Error { .. }));
190 }
191
192 #[test]
193 fn stop_reason_invariant_violated() {
194 let err = ConvergeError::InvariantViolation {
195 name: "inv".into(),
196 class: InvariantClass::Acceptance,
197 reason: "fail".into(),
198 context: Box::new(empty_context()),
199 };
200 let reason = err.stop_reason();
201 assert!(matches!(reason, StopReason::InvariantViolated { .. }));
202 }
203
204 #[test]
205 fn stop_reason_agent_refused() {
206 let err = ConvergeError::AgentFailed {
207 agent_id: "bot".into(),
208 };
209 let reason = err.stop_reason();
210 assert!(matches!(reason, StopReason::AgentRefused { .. }));
211 }
212
213 #[test]
214 fn stop_reason_conflict() {
215 let err = ConvergeError::Conflict {
216 id: "x".into(),
217 existing: "old".into(),
218 new: "new".into(),
219 context: Box::new(empty_context()),
220 };
221 let reason = err.stop_reason();
222 assert!(matches!(reason, StopReason::Error { .. }));
223 }
224}