use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Job {
pub job_id: Uuid,
pub tool_name: String,
pub params: serde_json::Value,
pub idempotency_key: Option<String>,
pub max_retries: u32,
#[serde(default)]
pub retry_count: u32,
}
impl Job {
pub fn new<T: Serialize>(
tool_name: impl Into<String>,
params: &T,
max_retries: u32,
) -> Result<Self, serde_json::Error> {
Ok(Self {
job_id: Uuid::new_v4(),
tool_name: tool_name.into(),
params: serde_json::to_value(params)?,
idempotency_key: None,
max_retries,
retry_count: 0,
})
}
pub fn new_idempotent<T: Serialize>(
tool_name: impl Into<String>,
params: &T,
max_retries: u32,
idempotency_key: impl Into<String>,
) -> Result<Self, serde_json::Error> {
Ok(Self {
job_id: Uuid::new_v4(),
tool_name: tool_name.into(),
params: serde_json::to_value(params)?,
idempotency_key: Some(idempotency_key.into()),
max_retries,
retry_count: 0,
})
}
pub fn can_retry(&self) -> bool {
self.retry_count < self.max_retries
}
pub fn increment_retry(&mut self) {
self.retry_count += 1;
}
}
#[derive(Debug, Serialize, Deserialize)]
pub enum JobResult {
Success {
value: serde_json::Value,
tx_hash: Option<String>,
},
Failure {
error: crate::error::ToolError,
},
}
impl Clone for JobResult {
fn clone(&self) -> Self {
match self {
Self::Success { value, tx_hash } => Self::Success {
value: value.clone(),
tx_hash: tx_hash.clone(),
},
Self::Failure { error } => {
let cloned_error = error.clone();
Self::Failure {
error: cloned_error,
}
}
}
}
}
impl JobResult {
pub fn success<T: Serialize>(value: &T) -> Result<Self, serde_json::Error> {
Ok(JobResult::Success {
value: serde_json::to_value(value)?,
tx_hash: None,
})
}
pub fn success_with_tx<T: Serialize>(
value: &T,
tx_hash: impl Into<String>,
) -> Result<Self, serde_json::Error> {
Ok(JobResult::Success {
value: serde_json::to_value(value)?,
tx_hash: Some(tx_hash.into()),
})
}
pub fn is_success(&self) -> bool {
matches!(self, JobResult::Success { .. })
}
pub fn is_retriable(&self) -> bool {
match self {
JobResult::Failure { error } => error.is_retriable(),
_ => false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_job_result_clone_trait_bound() {
fn assert_clone<T: Clone>() {}
assert_clone::<JobResult>();
assert_clone::<serde_json::Value>();
assert_clone::<Option<String>>();
assert_clone::<crate::error::ToolError>();
let success = JobResult::Success {
value: serde_json::json!({"test": "data"}),
tx_hash: Some("0x123".to_string()),
};
let _cloned_success = success.clone();
let failure = JobResult::Failure {
error: crate::error::ToolError::retriable_string("test error"),
};
let _cloned_failure = failure.clone();
}
#[test]
fn test_job_result_clone_preserves_error_source() {
use std::error::Error;
#[derive(Debug)]
struct SourceError(String);
impl std::fmt::Display for SourceError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl Error for SourceError {}
let error_with_source = crate::error::ToolError::retriable_with_source(
SourceError("original error".to_string()),
"operation failed",
);
let result = JobResult::Failure {
error: error_with_source,
};
let cloned = result.clone();
match (&result, &cloned) {
(JobResult::Failure { error: original }, JobResult::Failure { error: cloned_err }) => {
assert!(original.source().is_some());
assert!(cloned_err.source().is_some());
assert_eq!(
original.source().unwrap().to_string(),
cloned_err.source().unwrap().to_string()
);
}
_ => panic!("Expected both to be Failure variants"),
}
}
#[test]
fn test_job_creation() {
let params = serde_json::json!({"key": "value"});
let job = Job::new("test_tool", ¶ms, 3).unwrap();
assert_eq!(job.tool_name, "test_tool");
assert_eq!(job.params, params);
assert_eq!(job.max_retries, 3);
assert_eq!(job.retry_count, 0);
assert!(job.idempotency_key.is_none());
assert!(job.can_retry());
}
#[test]
fn test_job_new_when_valid_params_should_succeed() {
let string_params = "test string";
let job = Job::new("string_tool", &string_params, 5).unwrap();
assert_eq!(job.tool_name, "string_tool");
assert_eq!(job.max_retries, 5);
assert_eq!(job.retry_count, 0);
assert!(job.idempotency_key.is_none());
assert!(job.job_id != Uuid::nil());
let complex_params = serde_json::json!({
"nested": {
"array": [1, 2, 3],
"string": "test",
"bool": true,
"null": null
}
});
let job = Job::new("complex_tool", &complex_params, 0).unwrap();
assert_eq!(job.params, complex_params);
assert_eq!(job.max_retries, 0);
}
#[test]
fn test_job_new_when_zero_retries_should_not_allow_retry() {
let job = Job::new("test_tool", &serde_json::json!({}), 0).unwrap();
assert_eq!(job.max_retries, 0);
assert!(!job.can_retry()); }
#[test]
fn test_job_new_when_tool_name_is_string_slice_should_convert() {
let job = Job::new("slice_tool", &serde_json::json!({}), 1).unwrap();
assert_eq!(job.tool_name, "slice_tool");
}
#[test]
fn test_job_new_when_tool_name_is_string_should_convert() {
let tool_name = String::from("owned_tool");
let job = Job::new(tool_name, &serde_json::json!({}), 1).unwrap();
assert_eq!(job.tool_name, "owned_tool");
}
#[test]
fn test_job_with_idempotency() {
let params = serde_json::json!({"key": "value"});
let job = Job::new_idempotent("test_tool", ¶ms, 3, "test_key").unwrap();
assert_eq!(job.idempotency_key, Some("test_key".to_string()));
}
#[test]
fn test_job_new_idempotent_when_valid_params_should_succeed() {
let params = serde_json::json!({"transfer": "data"});
let job = Job::new_idempotent("idempotent_tool", ¶ms, 2, "unique_key_123").unwrap();
assert_eq!(job.tool_name, "idempotent_tool");
assert_eq!(job.params, params);
assert_eq!(job.max_retries, 2);
assert_eq!(job.retry_count, 0);
assert_eq!(job.idempotency_key, Some("unique_key_123".to_string()));
assert!(job.job_id != Uuid::nil());
assert!(job.can_retry());
}
#[test]
fn test_job_new_idempotent_when_key_is_string_slice_should_convert() {
let job = Job::new_idempotent("test_tool", &serde_json::json!({}), 1, "slice_key").unwrap();
assert_eq!(job.idempotency_key, Some("slice_key".to_string()));
}
#[test]
fn test_job_new_idempotent_when_key_is_string_should_convert() {
let key = String::from("owned_key");
let job = Job::new_idempotent("test_tool", &serde_json::json!({}), 1, key).unwrap();
assert_eq!(job.idempotency_key, Some("owned_key".to_string()));
}
#[test]
fn test_job_retry_logic() {
let params = serde_json::json!({"key": "value"});
let mut job = Job::new("test_tool", ¶ms, 2).unwrap();
assert!(job.can_retry());
job.increment_retry();
assert!(job.can_retry());
job.increment_retry();
assert!(!job.can_retry());
}
#[test]
fn test_job_can_retry_when_retry_count_equals_max_retries_should_return_false() {
let mut job = Job::new("test_tool", &serde_json::json!({}), 1).unwrap();
job.retry_count = 1; assert!(!job.can_retry());
}
#[test]
fn test_job_can_retry_when_retry_count_exceeds_max_retries_should_return_false() {
let mut job = Job::new("test_tool", &serde_json::json!({}), 1).unwrap();
job.retry_count = 2; assert!(!job.can_retry());
}
#[test]
fn test_job_increment_retry_when_called_multiple_times_should_increment() {
let mut job = Job::new("test_tool", &serde_json::json!({}), 5).unwrap();
assert_eq!(job.retry_count, 0);
job.increment_retry();
assert_eq!(job.retry_count, 1);
job.increment_retry();
assert_eq!(job.retry_count, 2);
job.increment_retry();
assert_eq!(job.retry_count, 3);
}
#[test]
fn test_job_increment_retry_when_already_at_max_should_still_increment() {
let mut job = Job::new("test_tool", &serde_json::json!({}), 1).unwrap();
job.retry_count = 1;
job.increment_retry();
assert_eq!(job.retry_count, 2); }
#[test]
fn test_job_result_creation() {
let success = JobResult::success(&"test_value").unwrap();
assert!(success.is_success());
let success_with_tx = JobResult::success_with_tx(&"test_value", "tx_hash").unwrap();
assert!(success_with_tx.is_success());
let retriable_failure = JobResult::Failure {
error: crate::error::ToolError::retriable_string("test error"),
};
assert!(retriable_failure.is_retriable());
assert!(!retriable_failure.is_success());
let permanent_failure = JobResult::Failure {
error: crate::error::ToolError::permanent_string("test error"),
};
assert!(!permanent_failure.is_retriable());
assert!(!permanent_failure.is_success());
}
#[test]
fn test_job_result_success_when_valid_value_should_create_success() {
let result = JobResult::success(&42).unwrap();
match result {
JobResult::Success {
ref value,
ref tx_hash,
} => {
assert_eq!(*value, serde_json::json!(42));
assert!(tx_hash.is_none());
}
_ => panic!("Expected Success variant"),
}
assert!(result.is_success());
assert!(!result.is_retriable());
let complex_data = serde_json::json!({
"status": "completed",
"data": {
"items": [1, 2, 3],
"metadata": {
"count": 3,
"timestamp": "2024-01-01"
}
}
});
let result = JobResult::success(&complex_data).unwrap();
match result {
JobResult::Success { value, tx_hash } => {
assert_eq!(value, complex_data);
assert!(tx_hash.is_none());
}
_ => panic!("Expected Success variant"),
}
}
#[test]
fn test_job_result_success_with_tx_when_valid_params_should_create_success() {
let data = serde_json::json!({"amount": 100, "recipient": "0xabc"});
let tx_hash = "0x123456789abcdef";
let result = JobResult::success_with_tx(&data, tx_hash).unwrap();
match result {
JobResult::Success {
ref value,
tx_hash: ref hash,
} => {
assert_eq!(*value, data);
assert_eq!(*hash, Some("0x123456789abcdef".to_string()));
}
_ => panic!("Expected Success variant"),
}
assert!(result.is_success());
assert!(!result.is_retriable());
}
#[test]
fn test_job_result_success_with_tx_when_tx_hash_is_string_slice_should_convert() {
let result = JobResult::success_with_tx(&"test", "slice_hash").unwrap();
match result {
JobResult::Success { tx_hash, .. } => {
assert_eq!(tx_hash, Some("slice_hash".to_string()));
}
_ => panic!("Expected Success variant"),
}
}
#[test]
fn test_job_result_success_with_tx_when_tx_hash_is_string_should_convert() {
let hash = String::from("owned_hash");
let result = JobResult::success_with_tx(&"test", hash).unwrap();
match result {
JobResult::Success { tx_hash, .. } => {
assert_eq!(tx_hash, Some("owned_hash".to_string()));
}
_ => panic!("Expected Success variant"),
}
}
#[test]
fn test_job_result_retriable_failure_when_error_message_should_create_retriable() {
let error_msg = "Connection timeout occurred";
let result = JobResult::Failure {
error: crate::error::ToolError::retriable_string(error_msg),
};
match result {
JobResult::Failure { ref error } => {
assert!(error.contains("Connection timeout occurred"));
assert!(result.is_retriable());
}
_ => panic!("Expected Failure variant"),
}
assert!(!result.is_success());
assert!(result.is_retriable());
}
#[test]
fn test_job_result_retriable_failure_when_error_is_string_slice_should_convert() {
let result = JobResult::Failure {
error: crate::error::ToolError::retriable_string("slice error"),
};
match result {
JobResult::Failure { error } => {
assert_eq!(
error.to_string(),
"Operation can be retried: slice error - slice error"
);
}
_ => panic!("Expected Failure variant"),
}
}
#[test]
fn test_job_result_retriable_failure_when_error_is_string_should_convert() {
let error = String::from("owned error");
let result = JobResult::Failure {
error: crate::error::ToolError::retriable_string(error),
};
match result {
JobResult::Failure { error } => {
assert_eq!(
error.to_string(),
"Operation can be retried: owned error - owned error"
);
}
_ => panic!("Expected Failure variant"),
}
}
#[test]
fn test_job_result_permanent_failure_when_error_message_should_create_permanent() {
let error_msg = "Invalid input parameters";
let result = JobResult::Failure {
error: crate::error::ToolError::permanent_string(error_msg),
};
match result {
JobResult::Failure { ref error } => {
assert!(error.contains("Invalid input parameters"));
assert!(!result.is_retriable());
}
_ => panic!("Expected Failure variant"),
}
assert!(!result.is_success());
assert!(!result.is_retriable());
}
#[test]
fn test_job_result_permanent_failure_when_error_is_string_slice_should_convert() {
let result = JobResult::Failure {
error: crate::error::ToolError::permanent_string("slice error"),
};
match result {
JobResult::Failure { error } => {
assert_eq!(
error.to_string(),
"Permanent error: slice error - slice error"
);
}
_ => panic!("Expected Failure variant"),
}
}
#[test]
fn test_job_result_permanent_failure_when_error_is_string_should_convert() {
let error = String::from("owned error");
let result = JobResult::Failure {
error: crate::error::ToolError::permanent_string(error),
};
match result {
JobResult::Failure { error } => {
assert_eq!(
error.to_string(),
"Permanent error: owned error - owned error"
);
}
_ => panic!("Expected Failure variant"),
}
}
#[test]
fn test_job_result_is_success_when_success_variant_should_return_true() {
let success = JobResult::success(&"test").unwrap();
assert!(success.is_success());
let success_with_tx = JobResult::success_with_tx(&"test", "hash").unwrap();
assert!(success_with_tx.is_success());
}
#[test]
fn test_job_result_is_success_when_failure_variant_should_return_false() {
let retriable_failure = JobResult::Failure {
error: crate::error::ToolError::retriable_string("error"),
};
assert!(!retriable_failure.is_success());
let permanent_failure = JobResult::Failure {
error: crate::error::ToolError::permanent_string("error"),
};
assert!(!permanent_failure.is_success());
}
#[test]
fn test_job_result_is_retriable_when_retriable_failure_should_return_true() {
let result = JobResult::Failure {
error: crate::error::ToolError::retriable_string("Network error"),
};
assert!(result.is_retriable());
}
#[test]
fn test_job_result_is_retriable_when_permanent_failure_should_return_false() {
let result = JobResult::Failure {
error: crate::error::ToolError::permanent_string("Invalid input"),
};
assert!(!result.is_retriable());
}
#[test]
fn test_job_result_is_retriable_when_success_should_return_false() {
let result = JobResult::success(&"test").unwrap();
assert!(!result.is_retriable());
let result_with_tx = JobResult::success_with_tx(&"test", "hash").unwrap();
assert!(!result_with_tx.is_retriable());
}
#[test]
fn test_job_clone_should_create_identical_copy() {
let original = Job::new_idempotent(
"clone_tool",
&serde_json::json!({"test": true}),
3,
"clone_key",
)
.unwrap();
let cloned = original.clone();
assert_eq!(original.job_id, cloned.job_id);
assert_eq!(original.tool_name, cloned.tool_name);
assert_eq!(original.params, cloned.params);
assert_eq!(original.idempotency_key, cloned.idempotency_key);
assert_eq!(original.max_retries, cloned.max_retries);
assert_eq!(original.retry_count, cloned.retry_count);
}
#[test]
fn test_job_result_clone_should_create_identical_copy() {
let original_success =
JobResult::success_with_tx(&serde_json::json!({"data": "test"}), "tx123").unwrap();
let cloned_success = original_success.clone();
match (&original_success, &cloned_success) {
(
JobResult::Success {
value: v1,
tx_hash: h1,
},
JobResult::Success {
value: v2,
tx_hash: h2,
},
) => {
assert_eq!(v1, v2);
assert_eq!(h1, h2);
}
_ => panic!("Expected Success variants"),
}
let original_failure = JobResult::Failure {
error: crate::error::ToolError::retriable_string("test error"),
};
let cloned_failure = original_failure.clone();
match (&original_failure, &cloned_failure) {
(JobResult::Failure { error: e1 }, JobResult::Failure { error: e2 }) => {
assert_eq!(e1, e2);
}
_ => panic!("Expected Failure variants"),
}
}
#[test]
fn test_job_serialization_and_deserialization_should_preserve_data() {
let original = Job::new_idempotent(
"serialize_tool",
&serde_json::json!({"key": "value"}),
5,
"serialize_key",
)
.unwrap();
let serialized = serde_json::to_string(&original).unwrap();
let deserialized: Job = serde_json::from_str(&serialized).unwrap();
assert_eq!(original.job_id, deserialized.job_id);
assert_eq!(original.tool_name, deserialized.tool_name);
assert_eq!(original.params, deserialized.params);
assert_eq!(original.idempotency_key, deserialized.idempotency_key);
assert_eq!(original.max_retries, deserialized.max_retries);
assert_eq!(original.retry_count, deserialized.retry_count);
}
#[test]
fn test_job_result_serialization_and_deserialization_should_preserve_data() {
let original_success =
JobResult::success_with_tx(&serde_json::json!({"amount": 100}), "hash123").unwrap();
let serialized = serde_json::to_string(&original_success).unwrap();
let deserialized: JobResult = serde_json::from_str(&serialized).unwrap();
match (&original_success, &deserialized) {
(
JobResult::Success {
value: v1,
tx_hash: h1,
},
JobResult::Success {
value: v2,
tx_hash: h2,
},
) => {
assert_eq!(v1, v2);
assert_eq!(h1, h2);
}
_ => panic!("Expected Success variants"),
}
let original_failure = JobResult::Failure {
error: crate::error::ToolError::retriable_string("Network timeout"),
};
let serialized = serde_json::to_string(&original_failure).unwrap();
let deserialized: JobResult = serde_json::from_str(&serialized).unwrap();
match (&original_failure, &deserialized) {
(JobResult::Failure { error: e1 }, JobResult::Failure { error: e2 }) => {
assert_eq!(e1, e2);
}
_ => panic!("Expected Failure variants"),
}
}
#[test]
fn test_job_default_retry_count_when_deserializing_without_field_should_be_zero() {
let json = r#"{
"job_id": "550e8400-e29b-41d4-a716-446655440000",
"tool_name": "test_tool",
"params": {"key": "value"},
"idempotency_key": null,
"max_retries": 3
}"#;
let job: Job = serde_json::from_str(json).unwrap();
assert_eq!(job.retry_count, 0); }
#[test]
fn test_job_debug_format_should_include_all_fields() {
let job = Job::new_idempotent(
"debug_tool",
&serde_json::json!({"test": "data"}),
2,
"debug_key",
)
.unwrap();
let debug_output = format!("{:?}", job);
assert!(debug_output.contains("job_id"));
assert!(debug_output.contains("debug_tool"));
assert!(debug_output.contains("test"));
assert!(debug_output.contains("debug_key"));
assert!(debug_output.contains("2")); }
#[test]
fn test_job_result_debug_format_should_include_variant_info() {
let success =
JobResult::success_with_tx(&serde_json::json!({"data": "test"}), "tx456").unwrap();
let success_debug = format!("{:?}", success);
assert!(success_debug.contains("Success"));
assert!(success_debug.contains("tx456"));
let failure = JobResult::Failure {
error: crate::error::ToolError::permanent_string("Debug error message"),
};
let failure_debug = format!("{:?}", failure);
assert!(failure_debug.contains("Failure"));
assert!(failure_debug.contains("Debug error message"));
assert!(failure_debug.contains("Permanent")); }
}
#[derive(Debug, Clone)]
pub enum TransactionStatus {
Pending,
Submitted {
hash: String,
},
Confirming {
hash: String,
confirmations: u64,
},
Confirmed {
hash: String,
block: u64,
},
Failed {
reason: String,
},
}