use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum StepStatus {
Done,
Retry,
Blocked,
}
impl StepStatus {
pub fn parse(s: &str) -> Result<Self> {
match s.trim().to_lowercase().as_str() {
"done" => Ok(StepStatus::Done),
"retry" => Ok(StepStatus::Retry),
"blocked" => Ok(StepStatus::Blocked),
_ => Err(anyhow!(
"Invalid status: {}. Expected: done, retry, or blocked",
s
)),
}
}
pub fn as_str(&self) -> &'static str {
match self {
StepStatus::Done => "done",
StepStatus::Retry => "retry",
StepStatus::Blocked => "blocked",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExpectedField {
pub name: String,
#[serde(rename = "type")]
pub field_type: FieldType,
#[serde(default = "default_true")]
pub required: bool,
pub pattern: Option<String>,
#[serde(default)]
pub enum_values: Vec<String>,
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum FieldType {
String,
Integer,
Float,
Boolean,
Json,
StringArray,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContractExpectation {
pub status: StepStatus,
#[serde(default)]
pub outputs: Vec<ExpectedField>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "action", rename_all = "snake_case")]
pub enum FailureAction {
Retry {
max_retries: u32,
#[serde(default)]
retry_target: Option<String>,
#[serde(default)]
feedback_field: Option<String>,
#[serde(default)]
on_exhausted: Option<Box<FailureAction>>,
},
Escalate {
to: String,
},
Skip,
Fail,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StepContract {
pub expects: ContractExpectation,
#[serde(default)]
pub on_failure: Option<FailureAction>,
}
#[derive(Debug, Clone)]
pub struct ParsedOutput {
pub status: StepStatus,
pub fields: HashMap<String, serde_json::Value>,
pub raw_output: String,
}
pub struct ContractParser;
impl ContractParser {
pub fn parse(output: &str, contract: &StepContract) -> Result<ParsedOutput> {
let status = Self::extract_status(output)?;
let fields = Self::parse_fields(output)?;
if status == contract.expects.status {
Self::validate_fields(&fields, &contract.expects.outputs)?;
}
Ok(ParsedOutput {
status,
fields,
raw_output: output.to_string(),
})
}
fn extract_status(output: &str) -> Result<StepStatus> {
for line in output.lines() {
let line = line.trim();
if let Some(status_str) = line.strip_prefix("STATUS:") {
return StepStatus::parse(status_str.trim());
}
}
Err(anyhow!(
"Missing STATUS field. Expected: STATUS: done|retry|blocked\n\nOutput:\n{}",
output
))
}
fn parse_fields(output: &str) -> Result<HashMap<String, serde_json::Value>> {
let mut fields = HashMap::new();
for line in output.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some(pos) = line.find(':') {
let key = line[..pos].trim();
let value = line[pos + 1..].trim();
if key == "STATUS" {
continue;
}
let parsed_value = if (value.starts_with('[') && value.ends_with(']'))
|| (value.starts_with('{') && value.ends_with('}'))
{
serde_json::from_str(value)
.unwrap_or_else(|_| serde_json::Value::String(value.to_string()))
} else {
serde_json::Value::String(value.to_string())
};
fields.insert(key.to_string(), parsed_value);
}
}
Ok(fields)
}
fn validate_fields(
fields: &HashMap<String, serde_json::Value>,
expected: &[ExpectedField],
) -> Result<()> {
let mut errors = Vec::new();
for field_def in expected {
let field_name = &field_def.name;
match fields.get(field_name) {
Some(value) => {
if let Err(e) = Self::validate_type(value, &field_def.field_type) {
errors.push(format!(
"Field '{}' type mismatch: expected {}, got error: {}",
field_name,
format!("{:?}", field_def.field_type).to_lowercase(),
e
));
}
if let Some(pattern) = &field_def.pattern {
let value_str = match value {
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
};
let regex = regex::Regex::new(pattern).context("Invalid pattern regex")?;
if !regex.is_match(&value_str) {
errors.push(format!(
"Field '{}' value '{}' does not match pattern '{}'",
field_name, value_str, pattern
));
}
}
if !field_def.enum_values.is_empty() {
let value_str = match value {
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
};
if !field_def.enum_values.contains(&value_str) {
errors.push(format!(
"Field '{}' value '{}' not in allowed values: {:?}",
field_name, value_str, field_def.enum_values
));
}
}
}
None => {
if field_def.required {
errors.push(format!(
"Missing required field: {} (type: {:?})",
field_name, field_def.field_type
));
}
}
}
}
if errors.is_empty() {
Ok(())
} else {
Err(anyhow!(
"Contract validation failed:\n{}",
errors.join("\n")
))
}
}
fn validate_type(value: &serde_json::Value, expected: &FieldType) -> Result<()> {
match expected {
FieldType::String => {
if !value.is_string() {
return Err(anyhow!("Expected string, got {}", value));
}
}
FieldType::Integer => {
if !value.is_i64() && !value.is_u64() {
return Err(anyhow!("Expected integer, got {}", value));
}
}
FieldType::Float => {
if !value.is_f64() && !value.is_i64() && !value.is_u64() {
return Err(anyhow!("Expected number, got {}", value));
}
}
FieldType::Boolean => {
if !value.is_boolean() {
return Err(anyhow!("Expected boolean, got {}", value));
}
}
FieldType::Json => {
}
FieldType::StringArray => {
if let serde_json::Value::Array(arr) = value {
for (i, item) in arr.iter().enumerate() {
if !item.is_string() {
return Err(anyhow!(
"Expected string array, but item {} is {}",
i,
item
));
}
}
} else {
return Err(anyhow!("Expected array, got {}", value));
}
}
}
Ok(())
}
pub fn get_feedback(output: &str, field_name: &str) -> Option<String> {
let fields = Self::parse_fields(output).ok()?;
fields.get(field_name).map(|v| v.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_status() {
let output = "STATUS: done\nREPO: /path/to/repo";
let status = ContractParser::extract_status(output).unwrap();
assert_eq!(status, StepStatus::Done);
let output = "STATUS: retry\nISSUES: something went wrong";
let status = ContractParser::extract_status(output).unwrap();
assert_eq!(status, StepStatus::Retry);
let output = "STATUS: blocked\nREASON: need permission";
let status = ContractParser::extract_status(output).unwrap();
assert_eq!(status, StepStatus::Blocked);
}
#[test]
fn test_parse_fields() {
let output = r#"
STATUS: done
REPO: /path/to/repo
BRANCH: feature-branch
COUNT: 42
"#;
let fields = ContractParser::parse_fields(output).unwrap();
assert_eq!(
fields.get("REPO").unwrap().as_str().unwrap(),
"/path/to/repo"
);
assert_eq!(
fields.get("BRANCH").unwrap().as_str().unwrap(),
"feature-branch"
);
assert_eq!(fields.get("COUNT").unwrap().as_str().unwrap(), "42");
}
#[test]
fn test_parse_json_field() {
let output = r#"
STATUS: done
STORIES_JSON: [{"id": 1, "title": "Story 1"}, {"id": 2, "title": "Story 2"}]
"#;
let fields = ContractParser::parse_fields(output).unwrap();
let stories = fields.get("STORIES_JSON").unwrap();
assert!(stories.is_array());
assert_eq!(stories.as_array().unwrap().len(), 2);
}
#[test]
fn test_validate_contract() {
let contract = StepContract {
expects: ContractExpectation {
status: StepStatus::Done,
outputs: vec![
ExpectedField {
name: "REPO".to_string(),
field_type: FieldType::String,
required: true,
pattern: None,
enum_values: vec![],
},
ExpectedField {
name: "BRANCH".to_string(),
field_type: FieldType::String,
required: true,
pattern: None,
enum_values: vec![],
},
],
},
on_failure: None,
};
let output = r#"
STATUS: done
REPO: /path/to/repo
BRANCH: feature-branch
"#;
let result = ContractParser::parse(output, &contract);
assert!(result.is_ok());
let parsed = result.unwrap();
assert_eq!(parsed.status, StepStatus::Done);
assert_eq!(
parsed.fields.get("REPO").unwrap().as_str().unwrap(),
"/path/to/repo"
);
}
#[test]
fn test_validate_missing_field() {
let contract = StepContract {
expects: ContractExpectation {
status: StepStatus::Done,
outputs: vec![
ExpectedField {
name: "REPO".to_string(),
field_type: FieldType::String,
required: true,
pattern: None,
enum_values: vec![],
},
ExpectedField {
name: "BRANCH".to_string(),
field_type: FieldType::String,
required: true,
pattern: None,
enum_values: vec![],
},
],
},
on_failure: None,
};
let output = r#"
STATUS: done
REPO: /path/to/repo
"#;
let result = ContractParser::parse(output, &contract);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("BRANCH"));
}
#[test]
fn test_get_feedback() {
let output = r#"
STATUS: retry
ISSUES: The test is failing due to missing imports
"#;
let feedback = ContractParser::get_feedback(output, "ISSUES");
assert!(feedback.is_some());
assert!(feedback.unwrap().contains("missing imports"));
}
}