1use rust_decimal::Decimal;
4use thiserror::Error;
5
6use ironflow_artifacts::error::ArtifactError;
7use ironflow_core::error::OperationError;
8use ironflow_store::error::StoreError;
9
10pub const RUN_BUDGET_EXCEEDED_CODE: &str = "RUN_BUDGET_EXCEEDED";
12
13pub const MONTHLY_BUDGET_EXCEEDED_CODE: &str = "MONTHLY_BUDGET_EXCEEDED";
15
16pub const HANDLER_VERSION_MISMATCH_CODE: &str = "HANDLER_VERSION_MISMATCH";
18
19#[derive(Debug, Error)]
21pub enum EngineError {
22 #[error("operation failed: {0}")]
24 Operation(#[from] OperationError),
25
26 #[error("store error: {0}")]
28 Store(#[from] StoreError),
29
30 #[error("invalid workflow: {0}")]
32 InvalidWorkflow(String),
33
34 #[error("step config error: {0}")]
36 StepConfig(String),
37
38 #[error("serialization error: {0}")]
40 Serialization(#[from] serde_json::Error),
41
42 #[error(
48 "{RUN_BUDGET_EXCEEDED_CODE}: run {run_id} would exceed its cost cap \
49 (spent {spent_usd} USD + next step {step_budget_usd} USD > cap {limit_usd} USD)"
50 )]
51 RunBudgetExceeded {
52 run_id: uuid::Uuid,
54 limit_usd: Decimal,
56 spent_usd: Decimal,
58 step_budget_usd: Decimal,
60 },
61
62 #[error(
66 "{MONTHLY_BUDGET_EXCEEDED_CODE}: monthly cost quota exhausted \
67 ({spent_usd} USD spent of {limit_usd} USD)"
68 )]
69 MonthlyBudgetExceeded {
70 limit_usd: Decimal,
72 spent_usd: Decimal,
74 },
75
76 #[error("step {step:?} declared output {pattern:?} but no file matched")]
82 MissingArtifact {
83 step: String,
85 pattern: String,
87 },
88
89 #[error("no artifact {name:?} produced by step {step:?} before this point")]
91 ArtifactNotFound {
92 step: String,
94 name: String,
96 },
97
98 #[error("artifact storage is not configured: {0}")]
100 ArtifactsUnavailable(String),
101
102 #[error("artifact storage error: {0}")]
104 Artifact(#[from] ArtifactError),
105
106 #[error("approval required for run {run_id}, step {step_id}: {message}")]
108 ApprovalRequired {
109 run_id: uuid::Uuid,
111 step_id: uuid::Uuid,
113 message: String,
115 },
116}
117
118#[cfg(test)]
119mod tests {
120 use super::*;
121
122 #[test]
123 fn invalid_workflow_display() {
124 let err = EngineError::InvalidWorkflow("unknown-handler".to_string());
125 assert!(err.to_string().contains("invalid workflow"));
126 assert!(err.to_string().contains("unknown-handler"));
127 }
128
129 #[test]
130 fn step_config_display() {
131 let err = EngineError::StepConfig("bad shell config".to_string());
132 assert!(err.to_string().contains("step config error"));
133 assert!(err.to_string().contains("bad shell config"));
134 }
135
136 #[test]
137 fn store_error_from_conversion() {
138 let store_err = StoreError::RunNotFound(uuid::Uuid::nil());
139 let engine_err = EngineError::from(store_err);
140 assert!(engine_err.to_string().contains("store error"));
141 }
142
143 #[test]
144 fn run_budget_exceeded_display_carries_code_and_amounts() {
145 let err = EngineError::RunBudgetExceeded {
146 run_id: uuid::Uuid::nil(),
147 limit_usd: Decimal::new(200, 2),
148 spent_usd: Decimal::new(180, 2),
149 step_budget_usd: Decimal::new(50, 2),
150 };
151
152 let msg = err.to_string();
153 assert!(msg.contains(RUN_BUDGET_EXCEEDED_CODE));
154 assert!(msg.contains("2.00"));
155 assert!(msg.contains("1.80"));
156 assert!(msg.contains("0.50"));
157 }
158
159 #[test]
160 fn monthly_budget_exceeded_display_carries_code_and_amounts() {
161 let err = EngineError::MonthlyBudgetExceeded {
162 limit_usd: Decimal::new(10000, 2),
163 spent_usd: Decimal::new(10500, 2),
164 };
165
166 let msg = err.to_string();
167 assert!(msg.contains(MONTHLY_BUDGET_EXCEEDED_CODE));
168 assert!(msg.contains("100.00"));
169 assert!(msg.contains("105.00"));
170 }
171
172 #[test]
173 fn missing_artifact_display_names_the_step_and_pattern() {
174 let err = EngineError::MissingArtifact {
175 step: "build".to_string(),
176 pattern: "target/report.html".to_string(),
177 };
178
179 let msg = err.to_string();
180 assert!(msg.contains("\"build\""));
181 assert!(msg.contains("target/report.html"));
182 }
183
184 #[test]
185 fn artifact_not_found_display_names_the_producer() {
186 let err = EngineError::ArtifactNotFound {
187 step: "build".to_string(),
188 name: "report.html".to_string(),
189 };
190
191 let msg = err.to_string();
192 assert!(msg.contains("\"build\""));
193 assert!(msg.contains("report.html"));
194 }
195
196 #[test]
197 fn artifacts_unavailable_display() {
198 let err = EngineError::ArtifactsUnavailable("no blob store".to_string());
199 assert!(err.to_string().contains("not configured"));
200 }
201
202 #[test]
203 fn artifact_error_from_conversion() {
204 let engine_err = EngineError::from(ArtifactError::NotFound("a/b".to_string()));
205 assert!(engine_err.to_string().contains("artifact storage error"));
206 }
207
208 #[test]
209 fn serialization_error_from_conversion() {
210 let serde_err = serde_json::from_str::<String>("not json").unwrap_err();
211 let engine_err = EngineError::from(serde_err);
212 assert!(engine_err.to_string().contains("serialization error"));
213 }
214}