use rust_decimal::Decimal;
use thiserror::Error;
use ironflow_artifacts::error::ArtifactError;
use ironflow_core::error::OperationError;
use ironflow_store::error::StoreError;
pub const RUN_BUDGET_EXCEEDED_CODE: &str = "RUN_BUDGET_EXCEEDED";
pub const MONTHLY_BUDGET_EXCEEDED_CODE: &str = "MONTHLY_BUDGET_EXCEEDED";
pub const HANDLER_VERSION_MISMATCH_CODE: &str = "HANDLER_VERSION_MISMATCH";
#[derive(Debug, Error)]
pub enum EngineError {
#[error("operation failed: {0}")]
Operation(#[from] OperationError),
#[error("store error: {0}")]
Store(#[from] StoreError),
#[error("invalid workflow: {0}")]
InvalidWorkflow(String),
#[error("step config error: {0}")]
StepConfig(String),
#[error("serialization error: {0}")]
Serialization(#[from] serde_json::Error),
#[error(
"{RUN_BUDGET_EXCEEDED_CODE}: run {run_id} would exceed its cost cap \
(spent {spent_usd} USD + next step {step_budget_usd} USD > cap {limit_usd} USD)"
)]
RunBudgetExceeded {
run_id: uuid::Uuid,
limit_usd: Decimal,
spent_usd: Decimal,
step_budget_usd: Decimal,
},
#[error(
"{MONTHLY_BUDGET_EXCEEDED_CODE}: monthly cost quota exhausted \
({spent_usd} USD spent of {limit_usd} USD)"
)]
MonthlyBudgetExceeded {
limit_usd: Decimal,
spent_usd: Decimal,
},
#[error("step {step:?} declared output {pattern:?} but no file matched")]
MissingArtifact {
step: String,
pattern: String,
},
#[error("no artifact {name:?} produced by step {step:?} before this point")]
ArtifactNotFound {
step: String,
name: String,
},
#[error("artifact storage is not configured: {0}")]
ArtifactsUnavailable(String),
#[error("artifact storage error: {0}")]
Artifact(#[from] ArtifactError),
#[error("approval required for run {run_id}, step {step_id}: {message}")]
ApprovalRequired {
run_id: uuid::Uuid,
step_id: uuid::Uuid,
message: String,
},
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn invalid_workflow_display() {
let err = EngineError::InvalidWorkflow("unknown-handler".to_string());
assert!(err.to_string().contains("invalid workflow"));
assert!(err.to_string().contains("unknown-handler"));
}
#[test]
fn step_config_display() {
let err = EngineError::StepConfig("bad shell config".to_string());
assert!(err.to_string().contains("step config error"));
assert!(err.to_string().contains("bad shell config"));
}
#[test]
fn store_error_from_conversion() {
let store_err = StoreError::RunNotFound(uuid::Uuid::nil());
let engine_err = EngineError::from(store_err);
assert!(engine_err.to_string().contains("store error"));
}
#[test]
fn run_budget_exceeded_display_carries_code_and_amounts() {
let err = EngineError::RunBudgetExceeded {
run_id: uuid::Uuid::nil(),
limit_usd: Decimal::new(200, 2),
spent_usd: Decimal::new(180, 2),
step_budget_usd: Decimal::new(50, 2),
};
let msg = err.to_string();
assert!(msg.contains(RUN_BUDGET_EXCEEDED_CODE));
assert!(msg.contains("2.00"));
assert!(msg.contains("1.80"));
assert!(msg.contains("0.50"));
}
#[test]
fn monthly_budget_exceeded_display_carries_code_and_amounts() {
let err = EngineError::MonthlyBudgetExceeded {
limit_usd: Decimal::new(10000, 2),
spent_usd: Decimal::new(10500, 2),
};
let msg = err.to_string();
assert!(msg.contains(MONTHLY_BUDGET_EXCEEDED_CODE));
assert!(msg.contains("100.00"));
assert!(msg.contains("105.00"));
}
#[test]
fn missing_artifact_display_names_the_step_and_pattern() {
let err = EngineError::MissingArtifact {
step: "build".to_string(),
pattern: "target/report.html".to_string(),
};
let msg = err.to_string();
assert!(msg.contains("\"build\""));
assert!(msg.contains("target/report.html"));
}
#[test]
fn artifact_not_found_display_names_the_producer() {
let err = EngineError::ArtifactNotFound {
step: "build".to_string(),
name: "report.html".to_string(),
};
let msg = err.to_string();
assert!(msg.contains("\"build\""));
assert!(msg.contains("report.html"));
}
#[test]
fn artifacts_unavailable_display() {
let err = EngineError::ArtifactsUnavailable("no blob store".to_string());
assert!(err.to_string().contains("not configured"));
}
#[test]
fn artifact_error_from_conversion() {
let engine_err = EngineError::from(ArtifactError::NotFound("a/b".to_string()));
assert!(engine_err.to_string().contains("artifact storage error"));
}
#[test]
fn serialization_error_from_conversion() {
let serde_err = serde_json::from_str::<String>("not json").unwrap_err();
let engine_err = EngineError::from(serde_err);
assert!(engine_err.to_string().contains("serialization error"));
}
}