use indexmap::IndexMap;
use marked_yaml::{parse_yaml, LoadError, Marker, Node, Span as MarkedSpan};
use super::action::{
RawAgentAction, RawExecAction, RawFetchAction, RawInferAction, RawInvokeAction, RawTaskAction,
};
use super::mcp::{RawMcpConfig, RawMcpServer};
use super::task::{RawForEach, RawOutputConfig, RawRetryConfig, RawTask};
use super::workflow::{RawContextConfig, RawImportSpec, RawPkgConfig, RawWorkflow};
use crate::ast::decompose::{DecomposeSpec, DecomposeStrategy};
use crate::ast::structured::StructuredOutputSpec;
use crate::source::{ByteOffset, FileId, Span, Spanned};
#[derive(Debug, Clone)]
pub struct ParseError {
pub kind: ParseErrorKind,
pub span: Span,
pub message: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ParseErrorKind {
Syntax,
MissingField,
InvalidType,
UnknownField,
InvalidSchema,
}
impl ParseErrorKind {
pub fn code(&self) -> &'static str {
match self {
Self::Syntax => "NIKA-160",
Self::MissingField => "NIKA-161",
Self::InvalidType => "NIKA-162",
Self::UnknownField => "NIKA-163",
Self::InvalidSchema => "NIKA-164",
}
}
}
impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.message)
}
}
impl std::error::Error for ParseError {}
fn marker_to_offset(marker: &Marker) -> ByteOffset {
ByteOffset::new(marker.character() as u32)
}
fn marker_to_span(file: FileId, marker: &Marker) -> Span {
let offset = marker_to_offset(marker);
Span {
file,
start: offset,
end: offset,
}
}
fn extract_span_from_load_error(file: FileId, error: &LoadError) -> Span {
match error {
LoadError::TopLevelMustBeMapping(marker)
| LoadError::TopLevelMustBeSequence(marker)
| LoadError::UnexpectedAnchor(marker)
| LoadError::MappingKeyMustBeScalar(marker)
| LoadError::UnexpectedTag(marker) => marker_to_span(file, marker),
LoadError::ScanError(marker, _) => marker_to_span(file, marker),
LoadError::DuplicateKey(inner) => {
marked_span_to_span(file, inner.key.span())
}
}
}
fn marked_span_to_span(file: FileId, span: &MarkedSpan) -> Span {
match (span.start(), span.end()) {
(Some(start), Some(end)) => Span {
file,
start: marker_to_offset(start),
end: marker_to_offset(end),
},
(Some(start), None) => Span {
file,
start: marker_to_offset(start),
end: marker_to_offset(start), },
_ => Span::dummy(),
}
}
fn node_to_span(file: FileId, node: &Node) -> Span {
marked_span_to_span(file, node.span())
}
fn extract_string(file: FileId, node: &Node) -> Result<Spanned<String>, ParseError> {
let span = node_to_span(file, node);
match node {
Node::Scalar(s) => Ok(Spanned::new(s.to_string(), span)),
_ => Err(ParseError {
kind: ParseErrorKind::InvalidType,
span,
message: "expected string".to_string(),
}),
}
}
fn get_string_field(
file: FileId,
map: &marked_yaml::types::MarkedMappingNode,
key: &str,
) -> Result<Option<Spanned<String>>, ParseError> {
match map.get_node(key) {
Some(node) => extract_string(file, node).map(Some),
None => Ok(None),
}
}
fn get_f64_field(
file: FileId,
map: &marked_yaml::types::MarkedMappingNode,
key: &str,
) -> Result<Option<Spanned<f64>>, ParseError> {
match map.get_node(key) {
Some(Node::Scalar(s)) => {
let span = marked_span_to_span(file, s.span());
let value: f64 = s.as_str().parse().map_err(|_| ParseError {
kind: ParseErrorKind::InvalidType,
span,
message: format!("'{}' must be a number", key),
})?;
if !value.is_finite() {
return Err(ParseError {
kind: ParseErrorKind::InvalidType,
span,
message: format!("'{}' must be a finite number (got {})", key, s.as_str()),
});
}
Ok(Some(Spanned::new(value, span)))
}
Some(node) => Err(ParseError {
kind: ParseErrorKind::InvalidType,
span: node_to_span(file, node),
message: format!("'{}' must be a number", key),
}),
None => Ok(None),
}
}
fn get_u32_field(
file: FileId,
map: &marked_yaml::types::MarkedMappingNode,
key: &str,
) -> Result<Option<Spanned<u32>>, ParseError> {
match map.get_node(key) {
Some(Node::Scalar(s)) => {
let span = marked_span_to_span(file, s.span());
let value: u32 = s.as_str().parse().map_err(|_| ParseError {
kind: ParseErrorKind::InvalidType,
span,
message: format!("'{}' must be a positive integer", key),
})?;
Ok(Some(Spanned::new(value, span)))
}
Some(node) => Err(ParseError {
kind: ParseErrorKind::InvalidType,
span: node_to_span(file, node),
message: format!("'{}' must be a positive integer", key),
}),
None => Ok(None),
}
}
fn get_u64_field(
file: FileId,
map: &marked_yaml::types::MarkedMappingNode,
key: &str,
) -> Result<Option<Spanned<u64>>, ParseError> {
match map.get_node(key) {
Some(Node::Scalar(s)) => {
let span = marked_span_to_span(file, s.span());
let value: u64 = s.as_str().parse().map_err(|_| ParseError {
kind: ParseErrorKind::InvalidType,
span,
message: format!("'{}' must be a positive integer", key),
})?;
Ok(Some(Spanned::new(value, span)))
}
Some(node) => Err(ParseError {
kind: ParseErrorKind::InvalidType,
span: node_to_span(file, node),
message: format!("'{}' must be a positive integer", key),
}),
None => Ok(None),
}
}
fn get_bool_field(
file: FileId,
map: &marked_yaml::types::MarkedMappingNode,
key: &str,
) -> Result<Option<Spanned<bool>>, ParseError> {
match map.get_node(key) {
Some(Node::Scalar(s)) => {
let span = marked_span_to_span(file, s.span());
let value = match s.as_str().to_lowercase().as_str() {
"true" | "yes" | "on" | "1" => true,
"false" | "no" | "off" | "0" => false,
_ => {
return Err(ParseError {
kind: ParseErrorKind::InvalidType,
span,
message: format!("'{}' must be a boolean", key),
});
}
};
Ok(Some(Spanned::new(value, span)))
}
Some(node) => Err(ParseError {
kind: ParseErrorKind::InvalidType,
span: node_to_span(file, node),
message: format!("'{}' must be a boolean", key),
}),
None => Ok(None),
}
}
#[allow(clippy::type_complexity)]
fn parse_string_map(
file: FileId,
parent: &marked_yaml::types::MarkedMappingNode,
key: &str,
) -> Result<Option<Spanned<IndexMap<Spanned<String>, Spanned<String>>>>, ParseError> {
match parent.get_node(key) {
Some(Node::Mapping(m)) => {
let span = marked_span_to_span(file, m.span());
let mut result = IndexMap::new();
for (k, v) in m.iter() {
let key_span = marked_span_to_span(file, k.span());
let key_str = Spanned::new(k.as_str().to_string(), key_span);
let val = extract_string(file, v)?;
result.insert(key_str, val);
}
Ok(Some(Spanned::new(result, span)))
}
Some(node) => Err(ParseError {
kind: ParseErrorKind::InvalidType,
span: node_to_span(file, node),
message: format!("'{}' must be a mapping", key),
}),
None => Ok(None),
}
}
fn parse_string_array(
file: FileId,
parent: &marked_yaml::types::MarkedMappingNode,
key: &str,
) -> Result<Option<Spanned<Vec<Spanned<String>>>>, ParseError> {
match parent.get_node(key) {
Some(Node::Sequence(seq)) => {
let span = marked_span_to_span(file, seq.span());
let items: Result<Vec<_>, _> =
seq.iter().map(|node| extract_string(file, node)).collect();
Ok(Some(Spanned::new(items?, span)))
}
Some(node) => Err(ParseError {
kind: ParseErrorKind::InvalidType,
span: node_to_span(file, node),
message: format!("'{}' must be an array", key),
}),
None => Ok(None),
}
}
fn parse_json_value(
file: FileId,
parent: &marked_yaml::types::MarkedMappingNode,
key: &str,
) -> Result<Option<Spanned<serde_json::Value>>, ParseError> {
match parent.get_node(key) {
Some(node) => {
let span = node_to_span(file, node);
let value = node_to_json(node);
Ok(Some(Spanned::new(value, span)))
}
None => Ok(None),
}
}
fn node_to_json(node: &Node) -> serde_json::Value {
match node {
Node::Scalar(s) => {
let str_val = s.as_str();
if let Ok(n) = str_val.parse::<i64>() {
serde_json::Value::Number(n.into())
} else if let Ok(n) = str_val.parse::<f64>() {
serde_json::Number::from_f64(n)
.map(serde_json::Value::Number)
.unwrap_or(serde_json::Value::String(str_val.to_string()))
} else if str_val == "true" {
serde_json::Value::Bool(true)
} else if str_val == "false" {
serde_json::Value::Bool(false)
} else if str_val == "null" || str_val == "~" {
serde_json::Value::Null
} else {
serde_json::Value::String(str_val.to_string())
}
}
Node::Mapping(m) => {
let obj: serde_json::Map<String, serde_json::Value> = m
.iter()
.map(|(k, v)| (k.as_str().to_string(), node_to_json(v)))
.collect();
serde_json::Value::Object(obj)
}
Node::Sequence(s) => {
let arr: Vec<serde_json::Value> = s.iter().map(node_to_json).collect();
serde_json::Value::Array(arr)
}
}
}
fn parse_action(
file: FileId,
map: &marked_yaml::types::MarkedMappingNode,
) -> Result<Option<RawTaskAction>, ParseError> {
let verb_keys = ["infer", "exec", "fetch", "invoke", "agent"];
let found: Vec<&str> = verb_keys
.iter()
.filter(|k| map.get_node(k).is_some())
.copied()
.collect();
if found.len() > 1 {
let span = marked_span_to_span(file, map.span());
return Err(ParseError {
kind: ParseErrorKind::InvalidType,
span,
message: format!(
"task has multiple verbs ({}); each task must have exactly one",
found.join(", ")
),
});
}
if let Some(node) = map.get_node("infer") {
let action = parse_infer_action(file, node)?;
let span = node_to_span(file, node);
return Ok(Some(RawTaskAction::Infer(Spanned::new(action, span))));
}
if let Some(node) = map.get_node("exec") {
let action = parse_exec_action(file, node)?;
let span = node_to_span(file, node);
return Ok(Some(RawTaskAction::Exec(Spanned::new(action, span))));
}
if let Some(node) = map.get_node("fetch") {
let action = parse_fetch_action(file, node)?;
let span = node_to_span(file, node);
return Ok(Some(RawTaskAction::Fetch(Spanned::new(action, span))));
}
if let Some(node) = map.get_node("invoke") {
let action = parse_invoke_action(file, node)?;
let span = node_to_span(file, node);
return Ok(Some(RawTaskAction::Invoke(Spanned::new(action, span))));
}
if let Some(node) = map.get_node("agent") {
let action = parse_agent_action(file, node)?;
let span = node_to_span(file, node);
return Ok(Some(RawTaskAction::Agent(Spanned::new(action, span))));
}
let known_non_verb_keys: &[&str] = &[
"id",
"description",
"provider",
"model",
"with",
"depends_on",
"output",
"for_each",
"retry",
"decompose",
"structured",
"artifact",
"log",
"concurrency",
"fail_fast",
"timeout",
];
let task_keys: Vec<String> = map.iter().map(|(k, _)| k.as_str().to_string()).collect();
let unrecognized: Vec<&str> = task_keys
.iter()
.map(|s| s.as_str())
.filter(|k| !verb_keys.contains(k) && !known_non_verb_keys.contains(k))
.collect();
if !unrecognized.is_empty() {
let misspellings: Vec<(&str, &str)> = unrecognized
.iter()
.filter_map(|key| {
verb_keys.iter().find_map(|verb| {
if is_likely_misspelling(key, verb) {
Some((*key, *verb))
} else {
None
}
})
})
.collect();
if !misspellings.is_empty() {
let suggestions: Vec<String> = misspellings
.iter()
.map(|(key, verb)| format!("'{}' (did you mean '{}'?)", key, verb))
.collect();
let span = marked_span_to_span(file, map.span());
return Err(ParseError {
kind: ParseErrorKind::MissingField,
span,
message: format!(
"no valid verb found. Expected one of: {}. Possible misspelling: {}",
verb_keys.join(", "),
suggestions.join(", ")
),
});
}
}
Ok(None)
}
fn is_likely_misspelling(input: &str, target: &str) -> bool {
if input == target {
return false;
}
let len_diff = (input.len() as isize - target.len() as isize).unsigned_abs();
if len_diff > 2 {
return false;
}
levenshtein_bounded(input, target, 2) <= 2
}
fn levenshtein_bounded(a: &str, b: &str, bound: usize) -> usize {
let a_bytes = a.as_bytes();
let b_bytes = b.as_bytes();
let m = a_bytes.len();
let n = b_bytes.len();
if m.abs_diff(n) > bound {
return bound + 1;
}
let mut prev: Vec<usize> = (0..=n).collect();
let mut curr = vec![0; n + 1];
for i in 1..=m {
curr[0] = i;
let mut min_in_row = curr[0];
for j in 1..=n {
let cost = if a_bytes[i - 1] == b_bytes[j - 1] {
0
} else {
1
};
curr[j] = (prev[j] + 1).min(curr[j - 1] + 1).min(prev[j - 1] + cost);
min_in_row = min_in_row.min(curr[j]);
}
if min_in_row > bound {
return bound + 1;
}
std::mem::swap(&mut prev, &mut curr);
}
prev[n]
}
fn parse_infer_action(file: FileId, node: &Node) -> Result<RawInferAction, ParseError> {
let span = node_to_span(file, node);
match node {
Node::Scalar(s) => Ok(RawInferAction {
prompt: Spanned::new(s.as_str().to_string(), span),
system: None,
temperature: None,
max_tokens: None,
thinking: None,
thinking_budget: None,
content: None,
response_format: None,
guardrails: Vec::new(),
}),
Node::Mapping(m) => {
let prompt = get_string_field(file, m, "prompt")?;
let content = parse_content_field(file, m)?;
if prompt.is_none() && content.is_none() {
return Err(ParseError {
kind: ParseErrorKind::MissingField,
span,
message: "infer action requires 'prompt' or 'content' field".to_string(),
});
}
let prompt = prompt.unwrap_or_else(|| Spanned::new(String::new(), span));
let guardrails = parse_guardrails_field(file, m)?;
Ok(RawInferAction {
prompt,
system: get_string_field(file, m, "system")?,
temperature: get_f64_field(file, m, "temperature")?,
max_tokens: get_u32_field(file, m, "max_tokens")?,
thinking: get_bool_field(file, m, "thinking")?.or(get_bool_field(
file,
m,
"extended_thinking",
)?),
thinking_budget: get_u32_field(file, m, "thinking_budget")?,
content,
response_format: get_string_field(file, m, "response_format")?,
guardrails,
})
}
_ => Err(ParseError {
kind: ParseErrorKind::InvalidType,
span,
message: "infer must be a string or mapping".to_string(),
}),
}
}
fn parse_content_field(
file: FileId,
map: &marked_yaml::types::MarkedMappingNode,
) -> Result<Option<Spanned<Vec<crate::ast::content::RawContentPart>>>, ParseError> {
use crate::ast::content::RawContentPart;
let node = match map.get_node("content") {
Some(n) => n,
None => return Ok(None),
};
let span = node_to_span(file, node);
let seq = match node {
Node::Sequence(s) => s,
_ => {
return Err(ParseError {
kind: ParseErrorKind::InvalidType,
span,
message: "content must be a sequence".to_string(),
});
}
};
if seq.is_empty() {
return Err(ParseError {
kind: ParseErrorKind::InvalidType,
span,
message: "content must not be empty".to_string(),
});
}
let mut parts = Vec::with_capacity(seq.len());
for item in seq.iter() {
let item_span = node_to_span(file, item);
let m = match item {
Node::Mapping(m) => m,
_ => {
return Err(ParseError {
kind: ParseErrorKind::InvalidType,
span: item_span,
message: "each content part must be a mapping with 'type' field".to_string(),
});
}
};
let type_field = get_string_field(file, m, "type")?.ok_or_else(|| ParseError {
kind: ParseErrorKind::MissingField,
span: item_span,
message: "content part requires 'type' field".to_string(),
})?;
let part = match type_field.value.as_str() {
"text" => {
let text = get_string_field(file, m, "text")?.ok_or_else(|| ParseError {
kind: ParseErrorKind::MissingField,
span: item_span,
message: "text content part requires 'text' field".to_string(),
})?;
RawContentPart::Text { text }
}
"image" => {
let source = get_string_field(file, m, "source")?.ok_or_else(|| ParseError {
kind: ParseErrorKind::MissingField,
span: item_span,
message: "image content part requires 'source' field".to_string(),
})?;
let detail = get_string_field(file, m, "detail")?;
RawContentPart::Image { source, detail }
}
"image_url" => {
let url = get_string_field(file, m, "url")?.ok_or_else(|| ParseError {
kind: ParseErrorKind::MissingField,
span: item_span,
message: "image_url content part requires 'url' field".to_string(),
})?;
let detail = get_string_field(file, m, "detail")?;
RawContentPart::ImageUrl { url, detail }
}
other => {
return Err(ParseError {
kind: ParseErrorKind::InvalidType,
span: type_field.span,
message: format!(
"unknown content part type '{}', expected: text, image, image_url",
other
),
});
}
};
parts.push(part);
}
Ok(Some(Spanned::new(parts, span)))
}
fn parse_exec_action(file: FileId, node: &Node) -> Result<RawExecAction, ParseError> {
let span = node_to_span(file, node);
match node {
Node::Scalar(s) => Ok(RawExecAction {
command: Spanned::new(s.as_str().to_string(), span),
shell: None,
working_dir: None,
env: None,
timeout_ms: None,
}),
Node::Mapping(m) => {
let command = get_string_field(file, m, "command")?.ok_or_else(|| ParseError {
kind: ParseErrorKind::MissingField,
span,
message: "exec action requires 'command' field".to_string(),
})?;
Ok(RawExecAction {
command,
shell: get_bool_field(file, m, "shell")?,
working_dir: get_string_field(file, m, "working_dir")?
.or(get_string_field(file, m, "cwd")?),
env: parse_string_map(file, m, "env")?,
timeout_ms: match get_u64_field(file, m, "timeout_ms")? {
Some(v) => Some(v),
None => get_u64_field(file, m, "timeout")?
.map(|s| Spanned::new(s.value * 1000, s.span)),
},
})
}
_ => Err(ParseError {
kind: ParseErrorKind::InvalidType,
span,
message: "exec must be a string or mapping".to_string(),
}),
}
}
fn parse_fetch_action(file: FileId, node: &Node) -> Result<RawFetchAction, ParseError> {
let span = node_to_span(file, node);
let m = match node {
Node::Mapping(m) => m,
_ => {
return Err(ParseError {
kind: ParseErrorKind::InvalidType,
span,
message: "fetch must be a mapping".to_string(),
});
}
};
let url = get_string_field(file, m, "url")?.ok_or_else(|| ParseError {
kind: ParseErrorKind::MissingField,
span,
message: "fetch action requires 'url' field".to_string(),
})?;
let extract = get_string_field(file, m, "extract")?;
let selector = get_string_field(file, m, "selector")?;
Ok(RawFetchAction {
url,
method: get_string_field(file, m, "method")?,
headers: parse_string_map(file, m, "headers")?,
body: get_string_field(file, m, "body")?,
json: parse_json_value(file, m, "json")?,
timeout_ms: match get_u64_field(file, m, "timeout_ms")? {
Some(v) => Some(v),
None => {
get_u64_field(file, m, "timeout")?.map(|s| Spanned::new(s.value * 1000, s.span))
}
},
follow_redirects: get_bool_field(file, m, "follow_redirects")?,
response: get_string_field(file, m, "response")?,
extract,
selector,
})
}
fn parse_invoke_action(file: FileId, node: &Node) -> Result<RawInvokeAction, ParseError> {
let span = node_to_span(file, node);
let m = match node {
Node::Mapping(m) => m,
_ => {
return Err(ParseError {
kind: ParseErrorKind::InvalidType,
span,
message: "invoke must be a mapping".to_string(),
});
}
};
let tool = get_string_field(file, m, "tool")?.ok_or_else(|| ParseError {
kind: ParseErrorKind::MissingField,
span,
message: "invoke action requires 'tool' field".to_string(),
})?;
Ok(RawInvokeAction {
tool,
params: parse_json_value(file, m, "params")?,
mcp: get_string_field(file, m, "mcp")?.or(get_string_field(file, m, "server")?),
timeout_ms: match get_u64_field(file, m, "timeout_ms")? {
Some(v) => Some(v),
None => {
get_u64_field(file, m, "timeout")?.map(|s| Spanned::new(s.value * 1000, s.span))
}
},
})
}
fn parse_agent_action(file: FileId, node: &Node) -> Result<RawAgentAction, ParseError> {
let span = node_to_span(file, node);
let m = match node {
Node::Mapping(m) => m,
_ => {
return Err(ParseError {
kind: ParseErrorKind::InvalidType,
span,
message: "agent must be a mapping".to_string(),
});
}
};
let prompt = get_string_field(file, m, "prompt")?
.or(get_string_field(file, m, "goal")?)
.ok_or_else(|| ParseError {
kind: ParseErrorKind::MissingField,
span,
message: "agent action requires 'prompt' field (or legacy 'goal')".to_string(),
})?;
Ok(RawAgentAction {
prompt,
tools: parse_string_array(file, m, "tools")?,
max_iterations: get_u32_field(file, m, "max_iterations")?.or(get_u32_field(
file,
m,
"max_turns",
)?),
max_tokens: get_u32_field(file, m, "max_tokens")?,
from: get_string_field(file, m, "from")?,
skills: parse_string_array(file, m, "skills")?,
provider: get_string_field(file, m, "provider")?,
model: get_string_field(file, m, "model")?,
mcp: parse_string_array(file, m, "mcp")?,
system: get_string_field(file, m, "system")?,
temperature: get_f64_field(file, m, "temperature")?,
token_budget: get_u32_field(file, m, "token_budget")?,
extended_thinking: get_bool_field(file, m, "extended_thinking")?,
thinking_budget: get_u32_field(file, m, "thinking_budget")?,
depth_limit: get_u32_field(file, m, "depth_limit")?,
tool_choice: get_string_field(file, m, "tool_choice")?,
stop_sequences: parse_string_array(file, m, "stop_sequences")?,
scope: get_string_field(file, m, "scope")?,
})
}
#[allow(clippy::type_complexity)]
fn parse_with_refs(
file: FileId,
map: &marked_yaml::types::MarkedMappingNode,
) -> Result<Option<Spanned<IndexMap<Spanned<String>, Spanned<String>>>>, ParseError> {
parse_string_map(file, map, "with")
}
fn parse_depends_on(
file: FileId,
map: &marked_yaml::types::MarkedMappingNode,
) -> Result<Option<Spanned<Vec<Spanned<String>>>>, ParseError> {
match map.get_node("depends_on") {
Some(Node::Scalar(s)) => {
let span = marked_span_to_span(file, s.span());
Ok(Some(Spanned::new(
vec![Spanned::new(s.as_str().to_string(), span)],
span,
)))
}
Some(Node::Sequence(seq)) => {
let span = marked_span_to_span(file, seq.span());
let ids: Result<Vec<_>, _> = seq.iter().map(|n| extract_string(file, n)).collect();
Ok(Some(Spanned::new(ids?, span)))
}
Some(node) => Err(ParseError {
kind: ParseErrorKind::InvalidType,
span: node_to_span(file, node),
message: "depends_on/flow must be a string or array of strings".to_string(),
}),
None => Ok(None),
}
}
fn parse_for_each(
file: FileId,
map: &marked_yaml::types::MarkedMappingNode,
) -> Result<Option<Spanned<RawForEach>>, ParseError> {
match map.get_node("for_each") {
Some(Node::Sequence(seq)) => {
let span = marked_span_to_span(file, seq.span());
let arr: Vec<serde_json::Value> = seq.iter().map(node_to_json).collect();
let items_str = serde_json::to_string(&arr).map_err(|e| ParseError {
kind: ParseErrorKind::InvalidType,
span,
message: format!("failed to serialize for_each items: {}", e),
})?;
Ok(Some(Spanned::new(
RawForEach {
items: Spanned::new(items_str, span),
as_var: get_string_field(file, map, "as")?,
parallel: get_u32_field(file, map, "concurrency")?
.or(get_u32_field(file, map, "parallel")?),
fail_fast: get_bool_field(file, map, "fail_fast")?,
},
span,
)))
}
Some(Node::Scalar(s)) => {
let span = marked_span_to_span(file, s.span());
Ok(Some(Spanned::new(
RawForEach {
items: Spanned::new(s.as_str().to_string(), span),
as_var: get_string_field(file, map, "as")?,
parallel: get_u32_field(file, map, "concurrency")?
.or(get_u32_field(file, map, "parallel")?),
fail_fast: get_bool_field(file, map, "fail_fast")?,
},
span,
)))
}
Some(node) => Err(ParseError {
kind: ParseErrorKind::InvalidType,
span: node_to_span(file, node),
message: "for_each must be array or string".to_string(),
}),
None => Ok(None),
}
}
fn parse_retry(
file: FileId,
map: &marked_yaml::types::MarkedMappingNode,
) -> Result<Option<Spanned<RawRetryConfig>>, ParseError> {
match map.get_node("retry") {
Some(Node::Mapping(m)) => {
let span = marked_span_to_span(file, m.span());
Ok(Some(Spanned::new(
RawRetryConfig {
max_attempts: get_u32_field(file, m, "max_attempts")?
.or(get_u32_field(file, m, "max")?),
delay_ms: get_u64_field(file, m, "delay_ms")?
.or(get_u64_field(file, m, "delay")?),
backoff: get_f64_field(file, m, "backoff")?.or(get_f64_field(
file,
m,
"backoff_multiplier",
)?),
},
span,
)))
}
Some(node) => Err(ParseError {
kind: ParseErrorKind::InvalidType,
span: node_to_span(file, node),
message: "retry must be a mapping".to_string(),
}),
None => Ok(None),
}
}
fn parse_decompose(
file: FileId,
map: &marked_yaml::types::MarkedMappingNode,
) -> Result<Option<Spanned<DecomposeSpec>>, ParseError> {
match map.get_node("decompose") {
Some(Node::Mapping(m)) => {
let span = marked_span_to_span(file, m.span());
let traverse = get_string_field(file, m, "traverse")?
.ok_or_else(|| ParseError {
kind: ParseErrorKind::MissingField,
span,
message: "decompose missing required field 'traverse'".to_string(),
})?
.value;
let source = get_string_field(file, m, "source")?
.ok_or_else(|| ParseError {
kind: ParseErrorKind::MissingField,
span,
message: "decompose missing required field 'source'".to_string(),
})?
.value;
let strategy = match get_string_field(file, m, "strategy")? {
Some(s) => match s.value.as_str() {
"semantic" => DecomposeStrategy::Semantic,
"static" => DecomposeStrategy::Static,
"nested" => DecomposeStrategy::Nested,
other => {
return Err(ParseError {
kind: ParseErrorKind::InvalidType,
span: s.span,
message: format!(
"invalid decompose strategy '{}': expected semantic, static, or nested",
other
),
});
}
},
None => DecomposeStrategy::default(),
};
let mcp_server = get_string_field(file, m, "mcp_server")?.map(|s| s.value);
let max_items = get_u32_field(file, m, "max_items")?.map(|s| s.value as usize);
let max_depth = get_u32_field(file, m, "max_depth")?.map(|s| s.value as usize);
Ok(Some(Spanned::new(
DecomposeSpec {
strategy,
traverse,
source,
mcp_server,
max_items,
max_depth,
},
span,
)))
}
Some(node) => Err(ParseError {
kind: ParseErrorKind::InvalidType,
span: node_to_span(file, node),
message: "decompose must be a mapping".to_string(),
}),
None => Ok(None),
}
}
fn parse_output(
file: FileId,
map: &marked_yaml::types::MarkedMappingNode,
) -> Result<Option<Spanned<RawOutputConfig>>, ParseError> {
match map.get_node("output") {
Some(Node::Mapping(m)) => {
let span = marked_span_to_span(file, m.span());
Ok(Some(Spanned::new(
RawOutputConfig {
format: get_string_field(file, m, "format")?,
schema: parse_json_value(file, m, "schema")?,
schema_ref: get_string_field(file, m, "schema_ref")?
.or(get_string_field(file, m, "$ref")?),
max_retries: get_u32_field(file, m, "max_retries")?,
},
span,
)))
}
Some(node) => Err(ParseError {
kind: ParseErrorKind::InvalidType,
span: node_to_span(file, node),
message: "output must be a mapping".to_string(),
}),
None => Ok(None),
}
}
fn parse_structured(
file: FileId,
map: &marked_yaml::types::MarkedMappingNode,
) -> Result<Option<StructuredOutputSpec>, ParseError> {
match map.get_node("structured") {
Some(node) => {
let span = node_to_span(file, node);
let json_value = node_to_json(node);
let spec: StructuredOutputSpec =
serde_json::from_value(json_value).map_err(|e| ParseError {
kind: ParseErrorKind::InvalidType,
span,
message: format!("invalid structured output config: {e}"),
})?;
Ok(Some(spec))
}
None => Ok(None),
}
}
fn parse_guardrails_field(
file: FileId,
map: &marked_yaml::types::MarkedMappingNode,
) -> Result<Vec<crate::ast::guardrails::GuardrailConfig>, ParseError> {
match map.get_node("guardrails") {
Some(node) => {
let span = node_to_span(file, node);
let json_value = node_to_json(node);
serde_json::from_value(json_value).map_err(|e| ParseError {
kind: ParseErrorKind::InvalidType,
span,
message: format!("invalid guardrails config: {e}"),
})
}
None => Ok(Vec::new()),
}
}
pub fn parse(source: &str, file_id: FileId) -> Result<RawWorkflow, ParseError> {
let node = parse_yaml(file_id.0 as usize, source).map_err(|e| {
let span = extract_span_from_load_error(file_id, &e);
ParseError {
kind: ParseErrorKind::Syntax,
span,
message: format!("YAML syntax error: {}", e),
}
})?;
let map = match &node {
Node::Mapping(m) => m,
_ => {
return Err(ParseError {
kind: ParseErrorKind::InvalidType,
span: node_to_span(file_id, &node),
message: "workflow must be a YAML mapping".to_string(),
});
}
};
let mut workflow = RawWorkflow::default();
workflow.span = node_to_span(file_id, &node);
workflow.schema = get_string_field(file_id, map, "schema")?.ok_or_else(|| ParseError {
kind: ParseErrorKind::MissingField,
span: workflow.span,
message: "missing required field 'schema'".to_string(),
})?;
workflow.workflow = get_string_field(file_id, map, "workflow")?;
workflow.description = get_string_field(file_id, map, "description")?;
workflow.provider = get_string_field(file_id, map, "provider")?;
workflow.model = get_string_field(file_id, map, "model")?;
workflow.mcp = parse_mcp_config(file_id, map)?;
workflow.pkg = parse_pkg_config(file_id, map)?;
workflow.context = parse_context_config(file_id, map)?;
workflow.imports = parse_imports(file_id, map)?;
workflow.inputs = parse_inputs(file_id, map)?;
workflow.artifacts = match map.get_node("artifacts") {
Some(node) => {
let span = node_to_span(file_id, node);
Some(Spanned::new(node_to_json(node), span))
}
None => None,
};
workflow.log = match map.get_node("log") {
Some(node) => {
let span = node_to_span(file_id, node);
Some(Spanned::new(node_to_json(node), span))
}
None => None,
};
workflow.agents = match map.get_node("agents") {
Some(node) => {
let span = node_to_span(file_id, node);
Some(Spanned::new(node_to_json(node), span))
}
None => None,
};
workflow.tasks = parse_tasks(file_id, map)?;
Ok(workflow)
}
fn parse_mcp_config(
file_id: FileId,
map: &marked_yaml::types::MarkedMappingNode,
) -> Result<Option<Spanned<RawMcpConfig>>, ParseError> {
let mcp_node = match map.get_node("mcp") {
Some(node) => node,
None => return Ok(None),
};
let mcp_map = match mcp_node {
Node::Mapping(m) => m,
_ => {
return Err(ParseError {
kind: ParseErrorKind::InvalidType,
span: node_to_span(file_id, mcp_node),
message: "mcp must be a mapping".to_string(),
});
}
};
let mcp_span = marked_span_to_span(file_id, mcp_map.span());
let mut config = RawMcpConfig::default();
let servers_map = if let Some(servers_node) = mcp_map.get_node("servers") {
match servers_node {
Node::Mapping(m) => m,
_ => {
return Err(ParseError {
kind: ParseErrorKind::InvalidType,
span: node_to_span(file_id, servers_node),
message: "mcp.servers must be a mapping".to_string(),
});
}
}
} else {
mcp_map
};
for (key, value) in servers_map.iter() {
let server_name = Spanned::new(
key.as_str().to_string(),
marked_span_to_span(file_id, key.span()),
);
let server = parse_mcp_server(file_id, value)?;
config.servers.insert(server_name, server);
}
Ok(Some(Spanned::new(config, mcp_span)))
}
fn parse_mcp_server(file_id: FileId, node: &Node) -> Result<Spanned<RawMcpServer>, ParseError> {
let span = node_to_span(file_id, node);
let map = match node {
Node::Mapping(m) => m,
_ => {
return Err(ParseError {
kind: ParseErrorKind::InvalidType,
span,
message: "MCP server config must be a mapping".to_string(),
});
}
};
let server = RawMcpServer {
command: get_string_field(file_id, map, "command")?,
args: parse_string_array(file_id, map, "args")?,
env: parse_string_map(file_id, map, "env")?,
cwd: get_string_field(file_id, map, "cwd")?,
url: get_string_field(file_id, map, "url")?,
transport: get_string_field(file_id, map, "transport")?,
};
Ok(Spanned::new(server, span))
}
fn parse_pkg_config(
file_id: FileId,
map: &marked_yaml::types::MarkedMappingNode,
) -> Result<Option<Spanned<RawPkgConfig>>, ParseError> {
let pkg_node = match map.get_node("pkg") {
Some(node) => node,
None => return Ok(None),
};
let pkg_map = match pkg_node {
Node::Mapping(m) => m,
_ => {
return Err(ParseError {
kind: ParseErrorKind::InvalidType,
span: node_to_span(file_id, pkg_node),
message: "pkg must be a mapping".to_string(),
});
}
};
let span = marked_span_to_span(file_id, pkg_map.span());
let include = match parse_string_array(file_id, pkg_map, "include")? {
Some(arr) => arr.value,
None => Vec::new(),
};
Ok(Some(Spanned::new(RawPkgConfig { include }, span)))
}
fn parse_context_config(
file_id: FileId,
map: &marked_yaml::types::MarkedMappingNode,
) -> Result<Option<Spanned<RawContextConfig>>, ParseError> {
let ctx_node = match map.get_node("context") {
Some(node) => node,
None => return Ok(None),
};
let ctx_map = match ctx_node {
Node::Mapping(m) => m,
_ => {
return Err(ParseError {
kind: ParseErrorKind::InvalidType,
span: node_to_span(file_id, ctx_node),
message: "context must be a mapping".to_string(),
});
}
};
let span = marked_span_to_span(file_id, ctx_map.span());
let files = parse_string_map(file_id, ctx_map, "files")?.map(|s| s.value);
Ok(Some(Spanned::new(RawContextConfig { files }, span)))
}
fn parse_imports(
file_id: FileId,
map: &marked_yaml::types::MarkedMappingNode,
) -> Result<Option<Spanned<Vec<Spanned<RawImportSpec>>>>, ParseError> {
let imports_node = match map.get_node("imports").or_else(|| map.get_node("include")) {
Some(node) => node,
None => return Ok(None),
};
let seq = match imports_node {
Node::Sequence(s) => s,
_ => {
return Err(ParseError {
kind: ParseErrorKind::InvalidType,
span: node_to_span(file_id, imports_node),
message: "imports must be a sequence".to_string(),
});
}
};
let outer_span = marked_span_to_span(file_id, seq.span());
let mut specs = Vec::new();
for item_node in seq.iter() {
let item_span = node_to_span(file_id, item_node);
let item_map = match item_node {
Node::Mapping(m) => m,
_ => {
return Err(ParseError {
kind: ParseErrorKind::InvalidType,
span: item_span,
message: "import entry must be a mapping with 'path' field".to_string(),
});
}
};
let path = get_string_field(file_id, item_map, "path")?.ok_or_else(|| ParseError {
kind: ParseErrorKind::MissingField,
span: item_span,
message: "import entry requires 'path' field".to_string(),
})?;
let prefix = get_string_field(file_id, item_map, "prefix")?;
specs.push(Spanned::new(
RawImportSpec {
path,
prefix,
span: item_span,
},
item_span,
));
}
Ok(Some(Spanned::new(specs, outer_span)))
}
#[allow(clippy::type_complexity)]
fn parse_inputs(
file_id: FileId,
map: &marked_yaml::types::MarkedMappingNode,
) -> Result<Option<Spanned<IndexMap<Spanned<String>, Spanned<serde_json::Value>>>>, ParseError> {
let inputs_node = match map.get_node("inputs") {
Some(node) => node,
None => return Ok(None),
};
let inputs_map = match inputs_node {
Node::Mapping(m) => m,
_ => {
return Err(ParseError {
kind: ParseErrorKind::InvalidType,
span: node_to_span(file_id, inputs_node),
message: "inputs must be a mapping".to_string(),
});
}
};
let span = marked_span_to_span(file_id, inputs_map.span());
let mut result = IndexMap::new();
for (k, v) in inputs_map.iter() {
let key_span = marked_span_to_span(file_id, k.span());
let key = Spanned::new(k.as_str().to_string(), key_span);
let val_span = node_to_span(file_id, v);
let val = Spanned::new(node_to_json(v), val_span);
result.insert(key, val);
}
Ok(Some(Spanned::new(result, span)))
}
fn parse_tasks(
file_id: FileId,
map: &marked_yaml::types::MarkedMappingNode,
) -> Result<Spanned<Vec<Spanned<RawTask>>>, ParseError> {
match map.get_node("tasks") {
Some(Node::Sequence(seq)) => {
let span = marked_span_to_span(file_id, seq.span());
let tasks = seq
.iter()
.map(|task_node| parse_task(file_id, task_node))
.collect::<Result<Vec<_>, _>>()?;
Ok(Spanned::new(tasks, span))
}
Some(node) => Err(ParseError {
kind: ParseErrorKind::InvalidType,
span: node_to_span(file_id, node),
message: "tasks must be a sequence".to_string(),
}),
None => {
Ok(Spanned::dummy(Vec::new()))
}
}
}
fn parse_task(file_id: FileId, node: &Node) -> Result<Spanned<RawTask>, ParseError> {
let span = node_to_span(file_id, node);
let map = match node {
Node::Mapping(m) => m,
_ => {
return Err(ParseError {
kind: ParseErrorKind::InvalidType,
span,
message: "task must be a mapping".to_string(),
});
}
};
let id = get_string_field(file_id, map, "id")?.ok_or_else(|| ParseError {
kind: ParseErrorKind::MissingField,
span,
message: "task missing required field 'id'".to_string(),
})?;
let description = get_string_field(file_id, map, "description")?;
let provider = get_string_field(file_id, map, "provider")?;
let model = get_string_field(file_id, map, "model")?;
let action = parse_action(file_id, map)?;
let with_refs = parse_with_refs(file_id, map)?;
let depends_on = parse_depends_on(file_id, map)?;
let output = parse_output(file_id, map)?;
let for_each = parse_for_each(file_id, map)?;
let retry = parse_retry(file_id, map)?;
let decompose = parse_decompose(file_id, map)?;
let structured = parse_structured(file_id, map)?;
let artifact = match map.get_node("artifact") {
Some(node) => {
let span = node_to_span(file_id, node);
let value = node_to_json(node);
Some(Spanned::new(value, span))
}
None => None,
};
let log = match map.get_node("log") {
Some(node) => {
let span = node_to_span(file_id, node);
let value = node_to_json(node);
Some(Spanned::new(value, span))
}
None => None,
};
let standalone_concurrency = if for_each.is_none() {
get_u32_field(file_id, map, "concurrency")?
} else {
None
};
let standalone_fail_fast = if for_each.is_none() {
get_bool_field(file_id, map, "fail_fast")?
} else {
None
};
let task = RawTask {
span,
id,
description,
provider,
model,
action,
with_refs,
depends_on,
output,
for_each,
retry,
decompose,
concurrency: standalone_concurrency,
fail_fast: standalone_fail_fast,
structured,
artifact,
log,
};
Ok(Spanned::new(task, span))
}
#[cfg(test)]
mod tests {
use super::*;
const SIMPLE_WORKFLOW: &str = r#"
schema: "nika/workflow@0.12"
workflow: test-workflow
description: "A test workflow"
provider: claude
model: claude-sonnet-4-6
tasks:
- id: task1
description: "First task"
- id: task2
description: "Second task"
"#;
#[test]
fn test_parse_simple_workflow() {
let file_id = FileId(0);
let result = parse(SIMPLE_WORKFLOW, file_id);
assert!(result.is_ok(), "Parse failed: {:?}", result.err());
let workflow = result.unwrap();
assert_eq!(workflow.schema.value, "nika/workflow@0.12");
assert_eq!(workflow.name(), "test-workflow");
assert_eq!(
workflow.description.as_ref().unwrap().value,
"A test workflow"
);
assert_eq!(workflow.provider.as_ref().unwrap().value, "claude");
assert_eq!(workflow.model.as_ref().unwrap().value, "claude-sonnet-4-6");
assert_eq!(workflow.task_count(), 2);
assert!(!workflow.schema.span.is_dummy());
assert!(!workflow.tasks.span.is_dummy());
}
#[test]
fn test_parse_task_ids() {
let file_id = FileId(0);
let workflow = parse(SIMPLE_WORKFLOW, file_id).unwrap();
let task1 = workflow.get_task("task1");
assert!(task1.is_some());
assert_eq!(task1.unwrap().value.id.value, "task1");
let task2 = workflow.get_task("task2");
assert!(task2.is_some());
assert_eq!(task2.unwrap().value.id.value, "task2");
}
#[test]
fn test_parse_missing_schema() {
let yaml = r#"
workflow: test
tasks: []
"#;
let result = parse(yaml, FileId(0));
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.kind, ParseErrorKind::MissingField);
assert!(err.message.contains("schema"));
}
#[test]
fn test_parse_invalid_yaml() {
let yaml = "invalid: yaml: syntax: [";
let result = parse(yaml, FileId(0));
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.kind, ParseErrorKind::Syntax);
}
#[test]
fn test_span_tracking() {
let yaml = r#"schema: "nika/workflow@0.12"
workflow: my-workflow
tasks:
- id: hello
"#;
let file_id = FileId(0);
let workflow = parse(yaml, file_id).unwrap();
let schema_span = workflow.schema.span;
assert!(!schema_span.is_dummy());
assert!(schema_span.start.0 <= schema_span.end.0);
}
#[test]
fn test_parse_infer_shorthand() {
let yaml = r#"
schema: "nika/workflow@0.12"
tasks:
- id: generate
infer: "Generate a headline"
"#;
let workflow = parse(yaml, FileId(0)).unwrap();
let task = workflow.get_task("generate").unwrap();
match &task.value.action {
Some(RawTaskAction::Infer(action)) => {
assert_eq!(action.value.prompt.value, "Generate a headline");
assert!(action.value.temperature.is_none());
assert!(action.value.system.is_none());
}
_ => panic!("Expected Infer action"),
}
}
#[test]
fn test_parse_infer_full_form() {
let yaml = r#"
schema: "nika/workflow@0.12"
tasks:
- id: generate
infer:
prompt: "Generate content"
system: "You are a helpful assistant"
temperature: 0.7
max_tokens: 1000
thinking: true
thinking_budget: 8000
"#;
let workflow = parse(yaml, FileId(0)).unwrap();
let task = workflow.get_task("generate").unwrap();
match &task.value.action {
Some(RawTaskAction::Infer(action)) => {
assert_eq!(action.value.prompt.value, "Generate content");
assert_eq!(
action.value.system.as_ref().unwrap().value,
"You are a helpful assistant"
);
assert!((action.value.temperature.as_ref().unwrap().value - 0.7).abs() < 0.001);
assert_eq!(action.value.max_tokens.as_ref().unwrap().value, 1000);
assert!(action.value.thinking.as_ref().unwrap().value);
assert_eq!(action.value.thinking_budget.as_ref().unwrap().value, 8000);
}
_ => panic!("Expected Infer action"),
}
}
#[test]
fn test_parse_exec_shorthand() {
let yaml = r#"
schema: "nika/workflow@0.12"
tasks:
- id: build
exec: "npm run build"
"#;
let workflow = parse(yaml, FileId(0)).unwrap();
let task = workflow.get_task("build").unwrap();
match &task.value.action {
Some(RawTaskAction::Exec(action)) => {
assert_eq!(action.value.command.value, "npm run build");
assert!(action.value.shell.is_none());
}
_ => panic!("Expected Exec action"),
}
}
#[test]
fn test_parse_exec_full_form() {
let yaml = r#"
schema: "nika/workflow@0.12"
tasks:
- id: build
exec:
command: "npm run build"
shell: true
cwd: "/app"
timeout: 30
env:
NODE_ENV: production
"#;
let workflow = parse(yaml, FileId(0)).unwrap();
let task = workflow.get_task("build").unwrap();
match &task.value.action {
Some(RawTaskAction::Exec(action)) => {
assert_eq!(action.value.command.value, "npm run build");
assert!(action.value.shell.as_ref().unwrap().value);
assert_eq!(action.value.working_dir.as_ref().unwrap().value, "/app");
assert_eq!(action.value.timeout_ms.as_ref().unwrap().value, 30000);
let env = action.value.env.as_ref().unwrap();
assert!(env.value.values().any(|v| v.value == "production"));
}
_ => panic!("Expected Exec action"),
}
}
#[test]
fn test_parse_fetch_action() {
let yaml = r#"
schema: "nika/workflow@0.12"
tasks:
- id: api_call
fetch:
url: "https://api.example.com/data"
method: POST
headers:
Authorization: "Bearer token"
timeout: 5
"#;
let workflow = parse(yaml, FileId(0)).unwrap();
let task = workflow.get_task("api_call").unwrap();
match &task.value.action {
Some(RawTaskAction::Fetch(action)) => {
assert_eq!(action.value.url.value, "https://api.example.com/data");
assert_eq!(action.value.method.as_ref().unwrap().value, "POST");
assert_eq!(action.value.timeout_ms.as_ref().unwrap().value, 5000); let headers = action.value.headers.as_ref().unwrap();
assert!(headers.value.values().any(|v| v.value.contains("Bearer")));
}
_ => panic!("Expected Fetch action"),
}
}
#[test]
fn test_parse_invoke_action() {
let yaml = r#"
schema: "nika/workflow@0.12"
tasks:
- id: mcp_call
invoke:
tool: novanet_context
mcp: novanet
params:
entity: "qr-code"
locale: "fr-FR"
"#;
let workflow = parse(yaml, FileId(0)).unwrap();
let task = workflow.get_task("mcp_call").unwrap();
match &task.value.action {
Some(RawTaskAction::Invoke(action)) => {
assert_eq!(action.value.tool.value, "novanet_context");
assert_eq!(action.value.mcp.as_ref().unwrap().value, "novanet");
assert!(action.value.params.is_some());
}
_ => panic!("Expected Invoke action"),
}
}
#[test]
fn test_parse_agent_action() {
let yaml = r#"
schema: "nika/workflow@0.12"
tasks:
- id: research
agent:
prompt: "Research AI trends"
tools:
- nika:read
- nika:write
max_turns: 10
"#;
let workflow = parse(yaml, FileId(0)).unwrap();
let task = workflow.get_task("research").unwrap();
match &task.value.action {
Some(RawTaskAction::Agent(action)) => {
assert_eq!(action.value.prompt.value, "Research AI trends");
let tools = action.value.tools.as_ref().unwrap();
assert_eq!(tools.value.len(), 2);
assert_eq!(tools.value[0].value, "nika:read");
assert_eq!(action.value.max_iterations.as_ref().unwrap().value, 10);
}
_ => panic!("Expected Agent action"),
}
}
#[test]
fn test_parse_agent_prompt_is_primary_field() {
let yaml = r#"
schema: "nika/workflow@0.12"
tasks:
- id: research
agent:
prompt: "Research AI trends"
max_turns: 10
"#;
let workflow = parse(yaml, FileId(0)).unwrap();
let task = workflow.get_task("research").unwrap();
match &task.value.action {
Some(RawTaskAction::Agent(action)) => {
assert_eq!(action.value.prompt.value, "Research AI trends");
}
_ => panic!("Expected Agent action"),
}
}
#[test]
fn test_parse_agent_goal_legacy_fallback() {
let yaml = r#"
schema: "nika/workflow@0.12"
tasks:
- id: research
agent:
goal: "Legacy goal syntax"
max_turns: 5
"#;
let workflow = parse(yaml, FileId(0)).unwrap();
let task = workflow.get_task("research").unwrap();
match &task.value.action {
Some(RawTaskAction::Agent(action)) => {
assert_eq!(action.value.prompt.value, "Legacy goal syntax");
}
_ => panic!("Expected Agent action"),
}
}
#[test]
fn test_parse_with_refs_simple() {
let yaml = r#"
schema: "nika/workflow@0.12"
tasks:
- id: step1
infer: "Generate"
- id: step2
with:
data: step1
infer: "Process {{with.data}}"
"#;
let workflow = parse(yaml, FileId(0)).unwrap();
let task = workflow.get_task("step2").unwrap();
let with_refs = task.value.with_refs.as_ref().unwrap();
assert_eq!(with_refs.value.len(), 1);
let (alias, value) = with_refs.value.iter().next().unwrap();
assert_eq!(alias.value, "data");
assert_eq!(value.value, "step1");
}
#[test]
fn test_parse_with_refs_binding_expr() {
let yaml = r#"
schema: "nika/workflow@0.12"
tasks:
- id: step1
infer: "Generate"
- id: step2
with:
data: "step1"
temp: "step1.data.temp ?? 20"
cfg: "$env.API_KEY"
val: "step1.output | upper | trim"
infer: "Process"
"#;
let workflow = parse(yaml, FileId(0)).unwrap();
let task = workflow.get_task("step2").unwrap();
let with_refs = task.value.with_refs.as_ref().unwrap();
assert_eq!(with_refs.value.len(), 4);
let vals: Vec<&str> = with_refs.value.values().map(|v| v.value.as_str()).collect();
assert_eq!(vals[0], "step1");
assert_eq!(vals[1], "step1.data.temp ?? 20");
assert_eq!(vals[2], "$env.API_KEY");
assert_eq!(vals[3], "step1.output | upper | trim");
}
#[test]
fn test_parse_depends_on_single() {
let yaml = r#"
schema: "nika/workflow@0.12"
tasks:
- id: step1
infer: "Generate"
- id: step2
depends_on: step1
infer: "Process"
"#;
let workflow = parse(yaml, FileId(0)).unwrap();
let task = workflow.get_task("step2").unwrap();
let deps = task.value.depends_on.as_ref().unwrap();
assert_eq!(deps.value.len(), 1);
assert_eq!(deps.value[0].value, "step1");
}
#[test]
fn test_parse_depends_on_multiple() {
let yaml = r#"
schema: "nika/workflow@0.12"
tasks:
- id: step1
infer: "Step 1"
- id: step2
infer: "Step 2"
- id: step3
depends_on: [step1, step2]
infer: "Process"
"#;
let workflow = parse(yaml, FileId(0)).unwrap();
let task = workflow.get_task("step3").unwrap();
let deps = task.value.depends_on.as_ref().unwrap();
assert_eq!(deps.value.len(), 2);
assert_eq!(deps.value[0].value, "step1");
assert_eq!(deps.value[1].value, "step2");
}
#[test]
fn test_parse_imports() {
let yaml = r#"
schema: "nika/workflow@0.12"
imports:
- path: ./partials/setup.nika.yaml
prefix: setup_
- path: "pkg:@spn/core@1.0/seo.nika.yaml"
tasks:
- id: main_task
infer: "Main logic"
"#;
let workflow = parse(yaml, FileId(0)).unwrap();
let imports = workflow.imports.as_ref().unwrap();
assert_eq!(imports.value.len(), 2);
assert_eq!(
imports.value[0].value.path.value,
"./partials/setup.nika.yaml"
);
assert_eq!(
imports.value[0].value.prefix.as_ref().unwrap().value,
"setup_"
);
assert_eq!(
imports.value[1].value.path.value,
"pkg:@spn/core@1.0/seo.nika.yaml"
);
assert!(imports.value[1].value.prefix.is_none());
}
#[test]
fn test_parse_inputs() {
let yaml = r#"
schema: "nika/workflow@0.12"
inputs:
locale: "fr-FR"
max_items: 10
debug: true
tasks:
- id: main_task
infer: "Main"
"#;
let workflow = parse(yaml, FileId(0)).unwrap();
let inputs = workflow.inputs.as_ref().unwrap();
assert_eq!(inputs.value.len(), 3);
let keys: Vec<&str> = inputs.value.keys().map(|k| k.value.as_str()).collect();
assert_eq!(keys, vec!["locale", "max_items", "debug"]);
assert_eq!(
inputs.value.values().next().unwrap().value,
serde_json::Value::String("fr-FR".to_string())
);
}
#[test]
fn test_parse_context_config() {
let yaml = r#"
schema: "nika/workflow@0.12"
context:
files:
brand: ./context/brand.md
data: ./context/data.json
tasks:
- id: main
infer: "Use brand: {{context.files.brand}}"
"#;
let workflow = parse(yaml, FileId(0)).unwrap();
let ctx = workflow.context.as_ref().unwrap();
let files = ctx.value.files.as_ref().unwrap();
assert_eq!(files.len(), 2);
assert!(files.values().any(|v| v.value == "./context/brand.md"));
}
#[test]
fn test_parse_pkg_config() {
let yaml = r#"
schema: "nika/workflow@0.12"
pkg:
include:
- "github:user/repo"
- "local:./path"
tasks:
- id: main
infer: "Main"
"#;
let workflow = parse(yaml, FileId(0)).unwrap();
let pkg = workflow.pkg.as_ref().unwrap();
assert_eq!(pkg.value.include.len(), 2);
assert_eq!(pkg.value.include[0].value, "github:user/repo");
}
#[test]
fn test_parse_for_each_array() {
let yaml = r#"
schema: "nika/workflow@0.12"
tasks:
- id: parallel
for_each: ["a", "b", "c"]
as: item
concurrency: 3
infer: "Process {{with.item}}"
"#;
let workflow = parse(yaml, FileId(0)).unwrap();
let task = workflow.get_task("parallel").unwrap();
let for_each = task.value.for_each.as_ref().unwrap();
assert!(for_each.value.items.value.contains("["));
assert_eq!(for_each.value.as_var.as_ref().unwrap().value, "item");
assert_eq!(for_each.value.parallel.as_ref().unwrap().value, 3);
}
#[test]
fn test_parse_for_each_binding() {
let yaml = r#"
schema: "nika/workflow@0.12"
tasks:
- id: parallel
for_each: "{{with.items}}"
infer: "Process"
"#;
let workflow = parse(yaml, FileId(0)).unwrap();
let task = workflow.get_task("parallel").unwrap();
let for_each = task.value.for_each.as_ref().unwrap();
assert_eq!(for_each.value.items.value, "{{with.items}}");
}
#[test]
fn test_parse_retry_config() {
let yaml = r#"
schema: "nika/workflow@0.12"
tasks:
- id: resilient
retry:
max_attempts: 3
delay_ms: 1000
backoff: 2.0
infer: "Generate"
"#;
let workflow = parse(yaml, FileId(0)).unwrap();
let task = workflow.get_task("resilient").unwrap();
let retry = task.value.retry.as_ref().unwrap();
assert_eq!(retry.value.max_attempts.as_ref().unwrap().value, 3);
assert_eq!(retry.value.delay_ms.as_ref().unwrap().value, 1000);
assert!((retry.value.backoff.as_ref().unwrap().value - 2.0).abs() < 0.001);
}
#[test]
fn test_parse_output_config() {
let yaml = r#"
schema: "nika/workflow@0.12"
tasks:
- id: structured
output:
format: json
schema:
type: object
properties:
name:
type: string
infer: "Generate JSON"
"#;
let workflow = parse(yaml, FileId(0)).unwrap();
let task = workflow.get_task("structured").unwrap();
let output = task.value.output.as_ref().unwrap();
assert_eq!(output.value.format.as_ref().unwrap().value, "json");
assert!(output.value.schema.is_some());
}
#[test]
fn test_parse_infer_missing_prompt() {
let yaml = r#"
schema: "nika/workflow@0.12"
tasks:
- id: generate
infer:
temperature: 0.7
"#;
let result = parse(yaml, FileId(0));
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.kind, ParseErrorKind::MissingField);
assert!(err.message.contains("prompt"));
}
#[test]
fn test_parse_fetch_missing_url() {
let yaml = r#"
schema: "nika/workflow@0.12"
tasks:
- id: api_call
fetch:
method: GET
"#;
let result = parse(yaml, FileId(0));
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.kind, ParseErrorKind::MissingField);
assert!(err.message.contains("url"));
}
#[test]
fn test_parse_invoke_missing_tool() {
let yaml = r#"
schema: "nika/workflow@0.12"
tasks:
- id: mcp_call
invoke:
mcp: novanet
"#;
let result = parse(yaml, FileId(0));
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.kind, ParseErrorKind::MissingField);
assert!(err.message.contains("tool"));
}
#[test]
fn test_parse_agent_missing_prompt() {
let yaml = r#"
schema: "nika/workflow@0.12"
tasks:
- id: research
agent:
tools: [nika:read]
"#;
let result = parse(yaml, FileId(0));
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.kind, ParseErrorKind::MissingField);
assert!(err.message.contains("prompt"));
}
#[test]
fn test_parse_rejects_multiple_verbs_in_task() {
let yaml = r#"
schema: "nika/workflow@0.12"
tasks:
- id: ambiguous
infer: "Generate something"
exec: "echo hello"
"#;
let result = parse(yaml, FileId(0));
assert!(
result.is_err(),
"task with multiple verbs should be rejected"
);
let err = result.unwrap_err();
assert_eq!(err.kind, ParseErrorKind::InvalidType);
assert!(
err.message.contains("multiple verbs"),
"error should mention multiple verbs, got: {}",
err.message
);
}
#[test]
fn test_parse_invalid_temperature() {
let yaml = r#"
schema: "nika/workflow@0.12"
tasks:
- id: generate
infer:
prompt: "Test"
temperature: not_a_number
"#;
let result = parse(yaml, FileId(0));
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.kind, ParseErrorKind::InvalidType);
}
#[test]
fn parse_rejects_yaml_nan_temperature() {
let yaml = r#"
schema: "nika/workflow@0.12"
tasks:
- id: test
infer:
prompt: "hello"
temperature: .nan
"#;
let result = parse(yaml, FileId(0));
assert!(result.is_err(), "YAML .nan temperature should be rejected");
}
#[test]
fn parse_rejects_nan_string_temperature() {
let yaml = r#"
schema: "nika/workflow@0.12"
tasks:
- id: test
infer:
prompt: "hello"
temperature: NaN
"#;
let result = parse(yaml, FileId(0));
assert!(result.is_err(), "NaN temperature should be rejected");
let err = result.unwrap_err();
assert!(
err.message.contains("finite") || err.message.contains("number"),
"Error should mention finite or number: {}",
err.message
);
}
#[test]
fn parse_rejects_infinity_temperature() {
let yaml = r#"
schema: "nika/workflow@0.12"
tasks:
- id: test
infer:
prompt: "hello"
temperature: .inf
"#;
let result = parse(yaml, FileId(0));
assert!(result.is_err(), "Infinity temperature should be rejected");
}
#[test]
fn parse_rejects_inf_string_temperature() {
let yaml = "schema: \"nika/workflow@0.12\"\ntasks:\n - id: test\n infer:\n prompt: \"hello\"\n temperature: inf\n";
let result = parse(yaml, FileId(0));
assert!(result.is_err(), "inf temperature should be rejected");
}
#[test]
fn parse_rejects_negative_infinity_temperature() {
let yaml = r#"
schema: "nika/workflow@0.12"
tasks:
- id: test
infer:
prompt: "hello"
temperature: -.inf
"#;
let result = parse(yaml, FileId(0));
assert!(result.is_err(), "Negative infinity should be rejected");
}
#[test]
fn parse_empty_string_errors() {
let result = parse("", FileId(0));
assert!(result.is_err(), "empty string should fail to parse");
}
#[test]
fn parse_yaml_array_instead_of_map() {
let result = parse("- item1\n- item2", FileId(0));
assert!(result.is_err(), "YAML array root should be rejected");
}
#[test]
fn parse_temperature_zero_is_valid() {
let yaml = r#"
schema: "nika/workflow@0.12"
tasks:
- id: t
infer:
prompt: "hi"
temperature: 0.0
"#;
let result = parse(yaml, FileId(0));
assert!(result.is_ok(), "temperature 0.0 should be valid");
}
#[test]
fn parse_temperature_one_is_valid() {
let yaml = r#"
schema: "nika/workflow@0.12"
tasks:
- id: t
infer:
prompt: "hi"
temperature: 1.0
"#;
let result = parse(yaml, FileId(0));
assert!(result.is_ok(), "temperature 1.0 should be valid");
}
#[test]
fn parse_whitespace_only_errors() {
let result = parse(" \n\n \t ", FileId(0));
assert!(result.is_err(), "whitespace-only input should fail");
}
#[test]
fn parse_infer_with_content_text_and_image() {
let yaml = r#"
schema: "nika/workflow@0.12"
workflow: vision-test
provider: claude
model: claude-sonnet-4-6
tasks:
- id: describe
infer:
content:
- type: text
text: "Describe this image"
- type: image
source: "blake3:abc123"
detail: high
"#;
let result = parse(yaml, FileId(0));
assert!(
result.is_ok(),
"vision content should parse: {:?}",
result.err()
);
let wf = result.unwrap();
let task = &wf.tasks.value[0];
match &task.value.action {
Some(RawTaskAction::Infer(s)) => {
let content = s.value.content.as_ref().expect("content should be Some");
assert_eq!(content.value.len(), 2);
}
other => panic!("expected Some(Infer), got {:?}", other),
}
}
#[test]
fn parse_infer_content_only_no_prompt() {
let yaml = r#"
schema: "nika/workflow@0.12"
workflow: vision-no-prompt
provider: claude
model: claude-sonnet-4-6
tasks:
- id: t
infer:
content:
- type: text
text: "What is this?"
"#;
let result = parse(yaml, FileId(0));
assert!(
result.is_ok(),
"content without prompt should parse: {:?}",
result.err()
);
let wf = result.unwrap();
let task = &wf.tasks.value[0];
match &task.value.action {
Some(RawTaskAction::Infer(s)) => {
assert!(
s.value.prompt.value.is_empty(),
"prompt should be empty string"
);
assert!(s.value.content.is_some(), "content should be present");
}
other => panic!("expected Some(Infer), got {:?}", other),
}
}
#[test]
fn parse_infer_prompt_and_content() {
let yaml = r#"
schema: "nika/workflow@0.12"
workflow: both
provider: claude
model: claude-sonnet-4-6
tasks:
- id: t
infer:
prompt: "Analyze carefully"
content:
- type: image
source: "blake3:xyz"
"#;
let result = parse(yaml, FileId(0));
assert!(
result.is_ok(),
"prompt+content should parse: {:?}",
result.err()
);
let wf = result.unwrap();
let task = &wf.tasks.value[0];
match &task.value.action {
Some(RawTaskAction::Infer(s)) => {
assert_eq!(s.value.prompt.value, "Analyze carefully");
assert!(s.value.content.is_some());
}
other => panic!("expected Some(Infer), got {:?}", other),
}
}
#[test]
fn parse_infer_shorthand_still_works() {
let yaml = r#"
schema: "nika/workflow@0.12"
workflow: shorthand
provider: claude
model: claude-sonnet-4-6
tasks:
- id: t
infer: "Just a simple prompt"
"#;
let result = parse(yaml, FileId(0));
assert!(result.is_ok());
let wf = result.unwrap();
match &wf.tasks.value[0].value.action {
Some(RawTaskAction::Infer(s)) => {
assert_eq!(s.value.prompt.value, "Just a simple prompt");
assert!(s.value.content.is_none());
}
other => panic!("expected Some(Infer), got {:?}", other),
}
}
#[test]
fn parse_infer_neither_prompt_nor_content_errors() {
let yaml = r#"
schema: "nika/workflow@0.12"
workflow: err
provider: claude
model: claude-sonnet-4-6
tasks:
- id: t
infer:
temperature: 0.5
"#;
let result = parse(yaml, FileId(0));
assert!(result.is_err(), "neither prompt nor content should fail");
let err = result.unwrap_err();
assert!(err.message.contains("prompt") || err.message.contains("content"));
}
#[test]
fn parse_infer_content_invalid_type_errors() {
let yaml = r#"
schema: "nika/workflow@0.12"
workflow: err
provider: claude
model: claude-sonnet-4-6
tasks:
- id: t
infer:
content:
- type: video
url: "https://example.com"
"#;
let result = parse(yaml, FileId(0));
assert!(result.is_err(), "unknown content type should fail");
let err = result.unwrap_err();
assert!(err.message.contains("unknown content part type"));
}
#[test]
fn parse_infer_content_empty_sequence_errors() {
let yaml = r#"
schema: "nika/workflow@0.12"
workflow: err
provider: claude
model: claude-sonnet-4-6
tasks:
- id: t
infer:
content: []
"#;
let result = parse(yaml, FileId(0));
assert!(result.is_err(), "empty content should fail");
}
#[test]
fn parse_infer_content_image_url_part() {
let yaml = r#"
schema: "nika/workflow@0.12"
workflow: url
provider: openai
model: gpt-4o
tasks:
- id: t
infer:
content:
- type: image_url
url: "https://example.com/photo.jpg"
detail: low
- type: text
text: "What is in this photo?"
"#;
let result = parse(yaml, FileId(0));
assert!(result.is_ok(), "image_url should parse: {:?}", result.err());
}
#[test]
fn test_parse_infer_with_guardrails() {
let yaml = r#"
schema: "nika/workflow@0.12"
tasks:
- id: summarize
infer:
prompt: "Summarize this article"
guardrails:
- type: length
min_words: 50
max_words: 200
- type: regex
pattern: "^Summary:"
message: "Output must start with 'Summary:'"
"#;
let workflow = parse(yaml, FileId(0)).unwrap();
let task = workflow.get_task("summarize").unwrap();
match &task.value.action {
Some(RawTaskAction::Infer(action)) => {
assert_eq!(action.value.prompt.value, "Summarize this article");
assert_eq!(action.value.guardrails.len(), 2);
assert_eq!(action.value.guardrails[0].guardrail_type(), "length");
assert_eq!(action.value.guardrails[1].guardrail_type(), "regex");
}
_ => panic!("Expected Infer action"),
}
}
#[test]
fn test_parse_infer_shorthand_no_guardrails() {
let yaml = r#"
schema: "nika/workflow@0.12"
tasks:
- id: quick
infer: "Generate a headline"
"#;
let workflow = parse(yaml, FileId(0)).unwrap();
let task = workflow.get_task("quick").unwrap();
match &task.value.action {
Some(RawTaskAction::Infer(action)) => {
assert!(
action.value.guardrails.is_empty(),
"Shorthand infer should have no guardrails"
);
}
_ => panic!("Expected Infer action"),
}
}
#[test]
fn test_parse_infer_guardrails_on_failure_fail() {
let yaml = r#"
schema: "nika/workflow@0.12"
tasks:
- id: strict
infer:
prompt: "Generate strict output"
guardrails:
- type: length
min_words: 10
on_failure: fail
"#;
let workflow = parse(yaml, FileId(0)).unwrap();
let task = workflow.get_task("strict").unwrap();
match &task.value.action {
Some(RawTaskAction::Infer(action)) => {
assert_eq!(action.value.guardrails.len(), 1);
assert_eq!(
action.value.guardrails[0].on_failure(),
crate::ast::guardrails::OnFailure::Fail
);
}
_ => panic!("Expected Infer action"),
}
}
}