use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ExecBlock {
pub harness: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt: Option<String>,
#[serde(rename = "promptFile", skip_serializing_if = "Option::is_none")]
pub prompt_file: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub args: Option<std::collections::HashMap<String, String>>,
pub line: usize,
pub column: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct RunStatement {
#[serde(rename = "workflowName")]
pub workflow_name: String,
pub line: usize,
pub column: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct IfStatement {
#[serde(rename = "checkName")]
pub check_name: String,
pub body: Vec<Statement>,
pub line: usize,
pub column: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct IfNotStatement {
#[serde(rename = "checkName")]
pub check_name: String,
pub body: Vec<Statement>,
pub line: usize,
pub column: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct WhileStatement {
#[serde(rename = "checkName")]
pub check_name: String,
pub body: Vec<Statement>,
pub line: usize,
pub column: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct WhileNotStatement {
#[serde(rename = "checkName")]
pub check_name: String,
pub body: Vec<Statement>,
pub line: usize,
pub column: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ParAndStatement {
#[serde(rename = "joinWorkflowName")]
pub join_workflow_name: String,
pub branches: Vec<RunStatement>,
#[serde(rename = "failPolicy", skip_serializing_if = "Option::is_none")]
pub fail_policy: Option<FailPolicy>,
pub line: usize,
pub column: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct MatchArm {
pub variant: String,
pub body: Vec<Statement>,
pub line: usize,
pub column: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct MatchStatement {
#[serde(rename = "checkName")]
pub check_name: String,
pub arms: Vec<MatchArm>,
#[serde(rename = "elseBody", skip_serializing_if = "Option::is_none")]
pub else_body: Option<Vec<Statement>>,
#[serde(rename = "elseLine", skip_serializing_if = "Option::is_none")]
pub else_line: Option<usize>,
#[serde(rename = "elseColumn", skip_serializing_if = "Option::is_none")]
pub else_column: Option<usize>,
pub line: usize,
pub column: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum FailPolicy {
#[serde(rename = "fail-fast")]
FailFast,
#[serde(rename = "wait-then-fail")]
WaitThenFail,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "kind")]
pub enum Statement {
#[serde(rename = "run")]
Run(RunStatement),
#[serde(rename = "if")]
If(IfStatement),
#[serde(rename = "if-not")]
IfNot(IfNotStatement),
#[serde(rename = "while")]
While(WhileStatement),
#[serde(rename = "while-not")]
WhileNot(WhileNotStatement),
#[serde(rename = "par-and")]
ParAnd(ParAndStatement),
#[serde(rename = "exec")]
Exec(ExecBlock),
#[serde(rename = "match")]
Match(MatchStatement),
}
impl Statement {
pub fn line(&self) -> usize {
match self {
Statement::Run(s) => s.line,
Statement::If(s) => s.line,
Statement::IfNot(s) => s.line,
Statement::While(s) => s.line,
Statement::WhileNot(s) => s.line,
Statement::ParAnd(s) => s.line,
Statement::Exec(s) => s.line,
Statement::Match(s) => s.line,
}
}
pub fn column(&self) -> usize {
match self {
Statement::Run(s) => s.column,
Statement::If(s) => s.column,
Statement::IfNot(s) => s.column,
Statement::While(s) => s.column,
Statement::WhileNot(s) => s.column,
Statement::ParAnd(s) => s.column,
Statement::Exec(s) => s.column,
Statement::Match(s) => s.column,
}
}
pub fn body(&self) -> Option<&[Statement]> {
match self {
Statement::If(s) => Some(&s.body),
Statement::IfNot(s) => Some(&s.body),
Statement::While(s) => Some(&s.body),
Statement::WhileNot(s) => Some(&s.body),
_ => None,
}
}
pub fn referenced_names(&self) -> Vec<&str> {
match self {
Statement::Run(s) => vec![&s.workflow_name],
Statement::If(s) => vec![&s.check_name],
Statement::IfNot(s) => vec![&s.check_name],
Statement::While(s) => vec![&s.check_name],
Statement::WhileNot(s) => vec![&s.check_name],
Statement::ParAnd(s) => {
let mut names = vec![s.join_workflow_name.as_str()];
for branch in &s.branches {
names.push(&branch.workflow_name);
}
names
}
Statement::Exec(_) => vec![],
Statement::Match(s) => {
let mut names = vec![s.check_name.as_str()];
for arm in &s.arms {
for stmt in &arm.body {
names.extend(stmt.referenced_names());
}
}
if let Some(else_body) = &s.else_body {
for stmt in else_body {
names.extend(stmt.referenced_names());
}
}
names
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct WorkflowDecl {
pub name: String,
pub body: Vec<Statement>,
pub file: String,
pub line: usize,
pub column: usize,
}
#[derive(Debug, Clone)]
pub enum ParseResult {
Ok {
workflows: Vec<WorkflowDecl>,
},
Err {
errors: Vec<crate::parser::errors::ParseError>,
},
}
impl ParseResult {
pub fn is_ok(&self) -> bool {
matches!(self, ParseResult::Ok { .. })
}
pub fn workflows(&self) -> Option<&Vec<WorkflowDecl>> {
match self {
ParseResult::Ok { workflows } => Some(workflows),
ParseResult::Err { .. } => None,
}
}
pub fn errors(&self) -> Option<&Vec<crate::parser::errors::ParseError>> {
match self {
ParseResult::Err { errors } => Some(errors),
ParseResult::Ok { .. } => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_run_statement_creation() {
let stmt = Statement::Run(RunStatement {
workflow_name: "deploy".to_string(),
line: 3,
column: 3,
});
assert_eq!(stmt.line(), 3);
assert_eq!(stmt.column(), 3);
}
#[test]
fn test_statement_json_has_kind_and_camel_case() {
let stmt = Statement::Run(RunStatement {
workflow_name: "deploy".to_string(),
line: 3,
column: 3,
});
let json = serde_json::to_string(&stmt).unwrap();
assert!(
json.contains("\"kind\":\"run\""),
"missing kind tag: {}",
json
);
assert!(
json.contains("\"workflowName\":\"deploy\""),
"missing camelCase: {}",
json
);
}
#[test]
fn test_run_statement_roundtrip() {
let stmt = Statement::Run(RunStatement {
workflow_name: "deploy".to_string(),
line: 3,
column: 3,
});
let json = serde_json::to_string(&stmt).unwrap();
let deserialized: Statement = serde_json::from_str(&json).unwrap();
assert_eq!(stmt, deserialized);
}
#[test]
fn test_if_statement_serialization() {
let stmt = Statement::If(IfStatement {
check_name: "ready".to_string(),
body: vec![Statement::Run(RunStatement {
workflow_name: "go".to_string(),
line: 5,
column: 5,
})],
line: 4,
column: 3,
});
let json = serde_json::to_string(&stmt).unwrap();
assert!(json.contains("\"kind\":\"if\""), "missing kind: {}", json);
assert!(
json.contains("\"checkName\":\"ready\""),
"missing camelCase: {}",
json
);
assert!(
json.contains("\"kind\":\"run\""),
"nested missing kind: {}",
json
);
}
#[test]
fn test_par_and_serialization() {
let stmt = Statement::ParAnd(ParAndStatement {
join_workflow_name: "merge".to_string(),
branches: vec![
RunStatement {
workflow_name: "a".to_string(),
line: 10,
column: 5,
},
RunStatement {
workflow_name: "b".to_string(),
line: 11,
column: 5,
},
],
fail_policy: Some(FailPolicy::FailFast),
line: 9,
column: 3,
});
let json = serde_json::to_string(&stmt).unwrap();
assert!(
json.contains("\"kind\":\"par-and\""),
"missing kind: {}",
json
);
assert!(
json.contains("\"joinWorkflowName\":\"merge\""),
"missing camelCase: {}",
json
);
assert!(
json.contains("\"failPolicy\":\"fail-fast\""),
"missing failPolicy: {}",
json
);
}
#[test]
fn test_exec_block_serialization() {
let stmt = Statement::Exec(ExecBlock {
harness: "claude".to_string(),
prompt: Some("do stuff".to_string()),
prompt_file: None,
args: None,
line: 1,
column: 3,
});
let json = serde_json::to_string(&stmt).unwrap();
assert!(json.contains("\"kind\":\"exec\""), "missing kind: {}", json);
assert!(
json.contains("\"harness\":\"claude\""),
"missing harness: {}",
json
);
assert!(
!json.contains("promptFile"),
"promptFile should be absent when None: {}",
json
);
}
#[test]
fn test_exec_block_with_prompt_file() {
let stmt = Statement::Exec(ExecBlock {
harness: "claude".to_string(),
prompt: None,
prompt_file: Some("prompt.md".to_string()),
args: None,
line: 1,
column: 3,
});
let json = serde_json::to_string(&stmt).unwrap();
assert!(
json.contains("\"promptFile\":\"prompt.md\""),
"missing promptFile: {}",
json
);
}
#[test]
fn test_fail_policy_serialization() {
let ff = FailPolicy::FailFast;
let wtf = FailPolicy::WaitThenFail;
assert_eq!(serde_json::to_string(&ff).unwrap(), "\"fail-fast\"");
assert_eq!(serde_json::to_string(&wtf).unwrap(), "\"wait-then-fail\"");
}
#[test]
fn test_workflow_decl_serialization() {
let wf = WorkflowDecl {
name: "main".to_string(),
body: vec![Statement::Run(RunStatement {
workflow_name: "deploy".to_string(),
line: 3,
column: 3,
})],
file: "test.o7".to_string(),
line: 1,
column: 1,
};
let json = serde_json::to_string(&wf).unwrap();
assert!(json.contains("\"name\":\"main\""), "missing name: {}", json);
assert!(
json.contains("\"kind\":\"run\""),
"nested stmt missing kind: {}",
json
);
}
#[test]
fn test_parse_result_ok() {
let result = ParseResult::Ok {
workflows: vec![WorkflowDecl {
name: "main".to_string(),
body: vec![],
file: "test.o7".to_string(),
line: 1,
column: 1,
}],
};
assert!(result.is_ok());
assert_eq!(result.workflows().unwrap().len(), 1);
assert!(result.errors().is_none());
}
#[test]
fn test_parse_result_err() {
let result = ParseResult::Err {
errors: vec![crate::parser::errors::ParseError {
file: "test.o7".to_string(),
line: 1,
column: 1,
message: "unexpected token".to_string(),
}],
};
assert!(!result.is_ok());
assert!(result.workflows().is_none());
assert_eq!(result.errors().unwrap().len(), 1);
}
#[test]
fn test_while_not_serialization() {
let stmt = Statement::WhileNot(WhileNotStatement {
check_name: "done".to_string(),
body: vec![],
line: 7,
column: 3,
});
let json = serde_json::to_string(&stmt).unwrap();
assert!(
json.contains("\"kind\":\"while-not\""),
"missing kind: {}",
json
);
assert!(
json.contains("\"checkName\":\"done\""),
"missing camelCase: {}",
json
);
}
#[test]
fn test_if_not_deserialization() {
let json_str =
"{\"kind\":\"if-not\",\"checkName\":\"locked\",\"body\":[],\"line\":2,\"column\":3}";
let stmt: Statement = serde_json::from_str(json_str).unwrap();
match stmt {
Statement::IfNot(s) => {
assert_eq!(s.check_name, "locked");
assert_eq!(s.line, 2);
}
other => panic!("expected IfNot, got: {:?}", other),
}
}
#[test]
fn test_match_statement_serialization_kind_and_camel_case() {
let stmt = Statement::Match(MatchStatement {
check_name: "size-check".to_string(),
arms: vec![MatchArm {
variant: "small".to_string(),
body: vec![Statement::Run(RunStatement {
workflow_name: "small-fix".to_string(),
line: 4,
column: 5,
})],
line: 4,
column: 3,
}],
else_body: None,
else_line: None,
else_column: None,
line: 3,
column: 3,
});
let json = serde_json::to_string(&stmt).unwrap();
assert!(
json.contains("\"kind\":\"match\""),
"missing kind: {}",
json
);
assert!(
json.contains("\"checkName\":\"size-check\""),
"missing camelCase checkName: {}",
json
);
assert!(
!json.contains("elseBody"),
"elseBody should be absent when None: {}",
json
);
assert!(
!json.contains("elseLine"),
"elseLine should be absent when None: {}",
json
);
assert!(
!json.contains("elseColumn"),
"elseColumn should be absent when None: {}",
json
);
}
#[test]
fn test_match_statement_else_body_included_when_some() {
let stmt = Statement::Match(MatchStatement {
check_name: "size-check".to_string(),
arms: vec![],
else_body: Some(vec![Statement::Run(RunStatement {
workflow_name: "fallback".to_string(),
line: 5,
column: 5,
})]),
else_line: Some(5),
else_column: Some(3),
line: 3,
column: 3,
});
let json = serde_json::to_string(&stmt).unwrap();
assert!(
json.contains("\"elseBody\""),
"elseBody should be present when Some: {}",
json
);
assert!(
json.contains("\"elseLine\":5"),
"elseLine should be present when Some: {}",
json
);
assert!(
json.contains("\"elseColumn\":3"),
"elseColumn should be present when Some: {}",
json
);
}
#[test]
fn test_match_statement_referenced_names() {
let stmt = Statement::Match(MatchStatement {
check_name: "size-check".to_string(),
arms: vec![MatchArm {
variant: "small".to_string(),
body: vec![Statement::Run(RunStatement {
workflow_name: "small-fix".to_string(),
line: 4,
column: 5,
})],
line: 4,
column: 3,
}],
else_body: Some(vec![Statement::Run(RunStatement {
workflow_name: "fallback".to_string(),
line: 6,
column: 5,
})]),
else_line: Some(6),
else_column: Some(3),
line: 3,
column: 3,
});
let names = stmt.referenced_names();
assert!(names.contains(&"size-check"), "missing check_name");
assert!(names.contains(&"small-fix"), "missing arm body name");
assert!(names.contains(&"fallback"), "missing else body name");
}
}