1use indexmap::IndexMap;
7use marked_yaml::{parse_yaml, LoadError, Marker, Node, Span as MarkedSpan};
8
9use super::action::{
10 RawAgentAction, RawExecAction, RawFetchAction, RawInferAction, RawInvokeAction, RawTaskAction,
11};
12use super::mcp::{RawMcpConfig, RawMcpServer};
13use super::task::{RawForEach, RawOutputConfig, RawRetryConfig, RawTask};
14use super::workflow::{RawContextConfig, RawImportSpec, RawPkgConfig, RawWorkflow};
15use crate::ast::decompose::{DecomposeSpec, DecomposeStrategy};
16use crate::ast::structured::StructuredOutputSpec;
17use crate::source::{ByteOffset, FileId, Span, Spanned};
18
19#[derive(Debug, Clone)]
21pub struct ParseError {
22 pub kind: ParseErrorKind,
24 pub span: Span,
26 pub message: String,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum ParseErrorKind {
33 Syntax,
35 MissingField,
37 InvalidType,
39 UnknownField,
41 InvalidSchema,
43}
44
45impl ParseErrorKind {
46 pub fn code(&self) -> &'static str {
51 match self {
52 Self::Syntax => "NIKA-160",
53 Self::MissingField => "NIKA-161",
54 Self::InvalidType => "NIKA-162",
55 Self::UnknownField => "NIKA-163",
56 Self::InvalidSchema => "NIKA-164",
57 }
58 }
59}
60
61impl std::fmt::Display for ParseError {
62 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63 write!(f, "{}", self.message)
64 }
65}
66
67impl std::error::Error for ParseError {}
68
69fn marker_to_offset(marker: &Marker) -> ByteOffset {
71 ByteOffset::new(marker.character() as u32)
73}
74
75fn marker_to_span(file: FileId, marker: &Marker) -> Span {
77 let offset = marker_to_offset(marker);
78 Span {
79 file,
80 start: offset,
81 end: offset,
82 }
83}
84
85fn extract_span_from_load_error(file: FileId, error: &LoadError) -> Span {
96 match error {
97 LoadError::TopLevelMustBeMapping(marker)
98 | LoadError::TopLevelMustBeSequence(marker)
99 | LoadError::UnexpectedAnchor(marker)
100 | LoadError::MappingKeyMustBeScalar(marker)
101 | LoadError::UnexpectedTag(marker) => marker_to_span(file, marker),
102 LoadError::ScanError(marker, _) => marker_to_span(file, marker),
103 LoadError::DuplicateKey(inner) => {
104 marked_span_to_span(file, inner.key.span())
106 }
107 }
108}
109
110fn marked_span_to_span(file: FileId, span: &MarkedSpan) -> Span {
112 match (span.start(), span.end()) {
113 (Some(start), Some(end)) => Span {
114 file,
115 start: marker_to_offset(start),
116 end: marker_to_offset(end),
117 },
118 (Some(start), None) => Span {
119 file,
120 start: marker_to_offset(start),
121 end: marker_to_offset(start), },
123 _ => Span::dummy(),
124 }
125}
126
127fn node_to_span(file: FileId, node: &Node) -> Span {
129 marked_span_to_span(file, node.span())
130}
131
132fn extract_string(file: FileId, node: &Node) -> Result<Spanned<String>, ParseError> {
134 let span = node_to_span(file, node);
135 match node {
136 Node::Scalar(s) => Ok(Spanned::new(s.to_string(), span)),
137 _ => Err(ParseError {
138 kind: ParseErrorKind::InvalidType,
139 span,
140 message: "expected string".to_string(),
141 }),
142 }
143}
144
145fn get_string_field(
147 file: FileId,
148 map: &marked_yaml::types::MarkedMappingNode,
149 key: &str,
150) -> Result<Option<Spanned<String>>, ParseError> {
151 match map.get_node(key) {
152 Some(node) => extract_string(file, node).map(Some),
153 None => Ok(None),
154 }
155}
156
157fn get_f64_field(
159 file: FileId,
160 map: &marked_yaml::types::MarkedMappingNode,
161 key: &str,
162) -> Result<Option<Spanned<f64>>, ParseError> {
163 match map.get_node(key) {
164 Some(Node::Scalar(s)) => {
165 let span = marked_span_to_span(file, s.span());
166 let value: f64 = s.as_str().parse().map_err(|_| ParseError {
167 kind: ParseErrorKind::InvalidType,
168 span,
169 message: format!("'{}' must be a number", key),
170 })?;
171 if !value.is_finite() {
172 return Err(ParseError {
173 kind: ParseErrorKind::InvalidType,
174 span,
175 message: format!("'{}' must be a finite number (got {})", key, s.as_str()),
176 });
177 }
178 Ok(Some(Spanned::new(value, span)))
179 }
180 Some(node) => Err(ParseError {
181 kind: ParseErrorKind::InvalidType,
182 span: node_to_span(file, node),
183 message: format!("'{}' must be a number", key),
184 }),
185 None => Ok(None),
186 }
187}
188
189fn get_u32_field(
191 file: FileId,
192 map: &marked_yaml::types::MarkedMappingNode,
193 key: &str,
194) -> Result<Option<Spanned<u32>>, ParseError> {
195 match map.get_node(key) {
196 Some(Node::Scalar(s)) => {
197 let span = marked_span_to_span(file, s.span());
198 let value: u32 = s.as_str().parse().map_err(|_| ParseError {
199 kind: ParseErrorKind::InvalidType,
200 span,
201 message: format!("'{}' must be a positive integer", key),
202 })?;
203 Ok(Some(Spanned::new(value, span)))
204 }
205 Some(node) => Err(ParseError {
206 kind: ParseErrorKind::InvalidType,
207 span: node_to_span(file, node),
208 message: format!("'{}' must be a positive integer", key),
209 }),
210 None => Ok(None),
211 }
212}
213
214fn get_u64_field(
216 file: FileId,
217 map: &marked_yaml::types::MarkedMappingNode,
218 key: &str,
219) -> Result<Option<Spanned<u64>>, ParseError> {
220 match map.get_node(key) {
221 Some(Node::Scalar(s)) => {
222 let span = marked_span_to_span(file, s.span());
223 let value: u64 = s.as_str().parse().map_err(|_| ParseError {
224 kind: ParseErrorKind::InvalidType,
225 span,
226 message: format!("'{}' must be a positive integer", key),
227 })?;
228 Ok(Some(Spanned::new(value, span)))
229 }
230 Some(node) => Err(ParseError {
231 kind: ParseErrorKind::InvalidType,
232 span: node_to_span(file, node),
233 message: format!("'{}' must be a positive integer", key),
234 }),
235 None => Ok(None),
236 }
237}
238
239fn get_bool_field(
241 file: FileId,
242 map: &marked_yaml::types::MarkedMappingNode,
243 key: &str,
244) -> Result<Option<Spanned<bool>>, ParseError> {
245 match map.get_node(key) {
246 Some(Node::Scalar(s)) => {
247 let span = marked_span_to_span(file, s.span());
248 let value = match s.as_str().to_lowercase().as_str() {
249 "true" | "yes" | "on" | "1" => true,
250 "false" | "no" | "off" | "0" => false,
251 _ => {
252 return Err(ParseError {
253 kind: ParseErrorKind::InvalidType,
254 span,
255 message: format!("'{}' must be a boolean", key),
256 });
257 }
258 };
259 Ok(Some(Spanned::new(value, span)))
260 }
261 Some(node) => Err(ParseError {
262 kind: ParseErrorKind::InvalidType,
263 span: node_to_span(file, node),
264 message: format!("'{}' must be a boolean", key),
265 }),
266 None => Ok(None),
267 }
268}
269
270#[allow(clippy::type_complexity)]
272fn parse_string_map(
273 file: FileId,
274 parent: &marked_yaml::types::MarkedMappingNode,
275 key: &str,
276) -> Result<Option<Spanned<IndexMap<Spanned<String>, Spanned<String>>>>, ParseError> {
277 match parent.get_node(key) {
278 Some(Node::Mapping(m)) => {
279 let span = marked_span_to_span(file, m.span());
280 let mut result = IndexMap::new();
281
282 for (k, v) in m.iter() {
283 let key_span = marked_span_to_span(file, k.span());
284 let key_str = Spanned::new(k.as_str().to_string(), key_span);
285 let val = extract_string(file, v)?;
286 result.insert(key_str, val);
287 }
288
289 Ok(Some(Spanned::new(result, span)))
290 }
291 Some(node) => Err(ParseError {
292 kind: ParseErrorKind::InvalidType,
293 span: node_to_span(file, node),
294 message: format!("'{}' must be a mapping", key),
295 }),
296 None => Ok(None),
297 }
298}
299
300fn parse_string_array(
302 file: FileId,
303 parent: &marked_yaml::types::MarkedMappingNode,
304 key: &str,
305) -> Result<Option<Spanned<Vec<Spanned<String>>>>, ParseError> {
306 match parent.get_node(key) {
307 Some(Node::Sequence(seq)) => {
308 let span = marked_span_to_span(file, seq.span());
309 let items: Result<Vec<_>, _> =
310 seq.iter().map(|node| extract_string(file, node)).collect();
311 Ok(Some(Spanned::new(items?, span)))
312 }
313 Some(node) => Err(ParseError {
314 kind: ParseErrorKind::InvalidType,
315 span: node_to_span(file, node),
316 message: format!("'{}' must be an array", key),
317 }),
318 None => Ok(None),
319 }
320}
321
322fn parse_json_value(
324 file: FileId,
325 parent: &marked_yaml::types::MarkedMappingNode,
326 key: &str,
327) -> Result<Option<Spanned<serde_json::Value>>, ParseError> {
328 match parent.get_node(key) {
329 Some(node) => {
330 let span = node_to_span(file, node);
331 let value = node_to_json(node);
332 Ok(Some(Spanned::new(value, span)))
333 }
334 None => Ok(None),
335 }
336}
337
338fn node_to_json(node: &Node) -> serde_json::Value {
340 match node {
341 Node::Scalar(s) => {
342 let str_val = s.as_str();
343 if let Ok(n) = str_val.parse::<i64>() {
345 serde_json::Value::Number(n.into())
346 } else if let Ok(n) = str_val.parse::<f64>() {
347 serde_json::Number::from_f64(n)
348 .map(serde_json::Value::Number)
349 .unwrap_or(serde_json::Value::String(str_val.to_string()))
350 } else if str_val == "true" {
351 serde_json::Value::Bool(true)
352 } else if str_val == "false" {
353 serde_json::Value::Bool(false)
354 } else if str_val == "null" || str_val == "~" {
355 serde_json::Value::Null
356 } else {
357 serde_json::Value::String(str_val.to_string())
358 }
359 }
360 Node::Mapping(m) => {
361 let obj: serde_json::Map<String, serde_json::Value> = m
362 .iter()
363 .map(|(k, v)| (k.as_str().to_string(), node_to_json(v)))
364 .collect();
365 serde_json::Value::Object(obj)
366 }
367 Node::Sequence(s) => {
368 let arr: Vec<serde_json::Value> = s.iter().map(node_to_json).collect();
369 serde_json::Value::Array(arr)
370 }
371 }
372}
373
374fn parse_action(
380 file: FileId,
381 map: &marked_yaml::types::MarkedMappingNode,
382) -> Result<Option<RawTaskAction>, ParseError> {
383 let verb_keys = ["infer", "exec", "fetch", "invoke", "agent"];
385 let found: Vec<&str> = verb_keys
386 .iter()
387 .filter(|k| map.get_node(k).is_some())
388 .copied()
389 .collect();
390 if found.len() > 1 {
391 let span = marked_span_to_span(file, map.span());
392 return Err(ParseError {
393 kind: ParseErrorKind::InvalidType,
394 span,
395 message: format!(
396 "task has multiple verbs ({}); each task must have exactly one",
397 found.join(", ")
398 ),
399 });
400 }
401
402 if let Some(node) = map.get_node("infer") {
404 let mut action = parse_infer_action(file, node)?;
405 let span = node_to_span(file, node);
406
407 if matches!(node, Node::Scalar(_)) {
411 if action.max_tokens.is_none() {
412 action.max_tokens = get_u32_field(file, map, "max_tokens")?;
413 }
414 if action.temperature.is_none() {
415 action.temperature = get_f64_field(file, map, "temperature")?;
416 }
417 if action.system.is_none() {
418 action.system = get_string_field(file, map, "system")?;
419 }
420 if action.extended_thinking.is_none() {
421 action.extended_thinking = get_bool_field(file, map, "extended_thinking")?;
422 }
423 if action.thinking_budget.is_none() {
424 action.thinking_budget = get_u32_field(file, map, "thinking_budget")?;
425 }
426 if action.response_format.is_none() {
427 action.response_format = get_string_field(file, map, "response_format")?;
428 }
429 }
430
431 return Ok(Some(RawTaskAction::Infer(Spanned::new(action, span))));
432 }
433 if let Some(node) = map.get_node("exec") {
435 let action = parse_exec_action(file, node)?;
436 let span = node_to_span(file, node);
437 return Ok(Some(RawTaskAction::Exec(Spanned::new(action, span))));
438 }
439 if let Some(node) = map.get_node("fetch") {
441 let action = parse_fetch_action(file, node)?;
442 let span = node_to_span(file, node);
443 return Ok(Some(RawTaskAction::Fetch(Spanned::new(action, span))));
444 }
445 if let Some(node) = map.get_node("invoke") {
447 let action = parse_invoke_action(file, node)?;
448 let span = node_to_span(file, node);
449 return Ok(Some(RawTaskAction::Invoke(Spanned::new(action, span))));
450 }
451 if let Some(node) = map.get_node("agent") {
453 let action = parse_agent_action(file, node)?;
454 let span = node_to_span(file, node);
455 return Ok(Some(RawTaskAction::Agent(Box::new(Spanned::new(
456 action, span,
457 )))));
458 }
459
460 let known_non_verb_keys: &[&str] = &[
463 "id",
464 "description",
465 "provider",
466 "model",
467 "with",
468 "depends_on",
469 "output",
470 "for_each",
471 "retry",
472 "decompose",
473 "structured",
474 "artifact",
475 "log",
476 "concurrency",
477 "fail_fast",
478 "timeout",
479 ];
480
481 let task_keys: Vec<String> = map.iter().map(|(k, _)| k.as_str().to_string()).collect();
482 let unrecognized: Vec<&str> = task_keys
483 .iter()
484 .map(|s| s.as_str())
485 .filter(|k| !verb_keys.contains(k) && !known_non_verb_keys.contains(k))
486 .collect();
487
488 if !unrecognized.is_empty() {
489 let misspellings: Vec<(&str, &str)> = unrecognized
491 .iter()
492 .filter_map(|key| {
493 verb_keys.iter().find_map(|verb| {
494 if is_likely_misspelling(key, verb) {
495 Some((*key, *verb))
496 } else {
497 None
498 }
499 })
500 })
501 .collect();
502
503 if !misspellings.is_empty() {
504 let suggestions: Vec<String> = misspellings
505 .iter()
506 .map(|(key, verb)| format!("'{}' (did you mean '{}'?)", key, verb))
507 .collect();
508 let span = marked_span_to_span(file, map.span());
509 return Err(ParseError {
510 kind: ParseErrorKind::MissingField,
511 span,
512 message: format!(
513 "no valid verb found. Expected one of: {}. Possible misspelling: {}",
514 verb_keys.join(", "),
515 suggestions.join(", ")
516 ),
517 });
518 }
519 }
520
521 Ok(None)
522}
523
524fn is_likely_misspelling(input: &str, target: &str) -> bool {
527 if input == target {
528 return false;
529 }
530 let len_diff = (input.len() as isize - target.len() as isize).unsigned_abs();
531 if len_diff > 2 {
532 return false;
533 }
534 levenshtein_bounded(input, target, 2) <= 2
536}
537
538fn levenshtein_bounded(a: &str, b: &str, bound: usize) -> usize {
540 let a_bytes = a.as_bytes();
541 let b_bytes = b.as_bytes();
542 let m = a_bytes.len();
543 let n = b_bytes.len();
544
545 if m.abs_diff(n) > bound {
546 return bound + 1;
547 }
548
549 let mut prev: Vec<usize> = (0..=n).collect();
550 let mut curr = vec![0; n + 1];
551
552 for i in 1..=m {
553 curr[0] = i;
554 let mut min_in_row = curr[0];
555 for j in 1..=n {
556 let cost = if a_bytes[i - 1] == b_bytes[j - 1] {
557 0
558 } else {
559 1
560 };
561 curr[j] = (prev[j] + 1).min(curr[j - 1] + 1).min(prev[j - 1] + cost);
562 min_in_row = min_in_row.min(curr[j]);
563 }
564 if min_in_row > bound {
565 return bound + 1;
566 }
567 std::mem::swap(&mut prev, &mut curr);
568 }
569 prev[n]
570}
571
572fn parse_infer_action(file: FileId, node: &Node) -> Result<RawInferAction, ParseError> {
582 let span = node_to_span(file, node);
583
584 match node {
585 Node::Scalar(s) => Ok(RawInferAction {
587 prompt: Spanned::new(s.as_str().to_string(), span),
588 system: None,
589 temperature: None,
590 max_tokens: None,
591 extended_thinking: None,
592 thinking_budget: None,
593 content: None,
594 response_format: None,
595 guardrails: Vec::new(),
596 }),
597 Node::Mapping(m) => {
599 let prompt = get_string_field(file, m, "prompt")?;
600 let content = parse_content_field(file, m)?;
601
602 if prompt.is_none() && content.is_none() {
604 return Err(ParseError {
605 kind: ParseErrorKind::MissingField,
606 span,
607 message: "infer action requires 'prompt' or 'content' field".to_string(),
608 });
609 }
610
611 let prompt = prompt.unwrap_or_else(|| Spanned::new(String::new(), span));
613
614 let guardrails = parse_guardrails_field(file, m)?;
615
616 Ok(RawInferAction {
617 prompt,
618 system: get_string_field(file, m, "system")?,
619 temperature: get_f64_field(file, m, "temperature")?,
620 max_tokens: get_u32_field(file, m, "max_tokens")?,
621 extended_thinking: get_bool_field(file, m, "extended_thinking")?,
622 thinking_budget: get_u32_field(file, m, "thinking_budget")?,
623 content,
624 response_format: get_string_field(file, m, "response_format")?,
625 guardrails,
626 })
627 }
628 _ => Err(ParseError {
629 kind: ParseErrorKind::InvalidType,
630 span,
631 message: "infer must be a string or mapping".to_string(),
632 }),
633 }
634}
635
636fn parse_content_field(
641 file: FileId,
642 map: &marked_yaml::types::MarkedMappingNode,
643) -> Result<Option<Spanned<Vec<crate::ast::content::RawContentPart>>>, ParseError> {
644 use crate::ast::content::RawContentPart;
645
646 let node = match map.get_node("content") {
647 Some(n) => n,
648 None => return Ok(None),
649 };
650
651 let span = node_to_span(file, node);
652
653 let seq = match node {
654 Node::Sequence(s) => s,
655 _ => {
656 return Err(ParseError {
657 kind: ParseErrorKind::InvalidType,
658 span,
659 message: "content must be a sequence".to_string(),
660 });
661 }
662 };
663
664 if seq.is_empty() {
665 return Err(ParseError {
666 kind: ParseErrorKind::InvalidType,
667 span,
668 message: "content must not be empty".to_string(),
669 });
670 }
671
672 let mut parts = Vec::with_capacity(seq.len());
673
674 for item in seq.iter() {
675 let item_span = node_to_span(file, item);
676 let m = match item {
677 Node::Mapping(m) => m,
678 _ => {
679 return Err(ParseError {
680 kind: ParseErrorKind::InvalidType,
681 span: item_span,
682 message: "each content part must be a mapping with 'type' field".to_string(),
683 });
684 }
685 };
686
687 let type_field = get_string_field(file, m, "type")?.ok_or_else(|| ParseError {
688 kind: ParseErrorKind::MissingField,
689 span: item_span,
690 message: "content part requires 'type' field".to_string(),
691 })?;
692
693 let part = match type_field.value.as_str() {
694 "text" => {
695 let text = get_string_field(file, m, "text")?.ok_or_else(|| ParseError {
696 kind: ParseErrorKind::MissingField,
697 span: item_span,
698 message: "text content part requires 'text' field".to_string(),
699 })?;
700 RawContentPart::Text { text }
701 }
702 "image" => {
703 let source = get_string_field(file, m, "source")?.ok_or_else(|| ParseError {
704 kind: ParseErrorKind::MissingField,
705 span: item_span,
706 message: "image content part requires 'source' field".to_string(),
707 })?;
708 let detail = get_string_field(file, m, "detail")?;
709 RawContentPart::Image { source, detail }
710 }
711 "image_url" => {
712 let url = get_string_field(file, m, "url")?.ok_or_else(|| ParseError {
713 kind: ParseErrorKind::MissingField,
714 span: item_span,
715 message: "image_url content part requires 'url' field".to_string(),
716 })?;
717 let detail = get_string_field(file, m, "detail")?;
718 RawContentPart::ImageUrl { url, detail }
719 }
720 other => {
721 return Err(ParseError {
722 kind: ParseErrorKind::InvalidType,
723 span: type_field.span,
724 message: format!(
725 "unknown content part type '{}', expected: text, image, image_url",
726 other
727 ),
728 });
729 }
730 };
731
732 parts.push(part);
733 }
734
735 Ok(Some(Spanned::new(parts, span)))
736}
737
738fn parse_exec_action(file: FileId, node: &Node) -> Result<RawExecAction, ParseError> {
740 let span = node_to_span(file, node);
741
742 match node {
743 Node::Scalar(s) => Ok(RawExecAction {
745 command: Spanned::new(s.as_str().to_string(), span),
746 shell: None,
747 cwd: None,
748 env: None,
749 timeout_ms: None,
750 }),
751 Node::Mapping(m) => {
753 let command = get_string_field(file, m, "command")?.ok_or_else(|| ParseError {
754 kind: ParseErrorKind::MissingField,
755 span,
756 message: "exec action requires 'command' field".to_string(),
757 })?;
758
759 Ok(RawExecAction {
760 command,
761 shell: get_bool_field(file, m, "shell")?,
762 cwd: get_string_field(file, m, "cwd")?,
763 env: parse_string_map(file, m, "env")?,
764 timeout_ms: match get_u64_field(file, m, "timeout_ms")? {
767 Some(v) => Some(v),
768 None => get_u64_field(file, m, "timeout")?
769 .map(|s| Spanned::new(s.value.saturating_mul(1000), s.span)),
770 },
771 })
772 }
773 _ => Err(ParseError {
774 kind: ParseErrorKind::InvalidType,
775 span,
776 message: "exec must be a string or mapping".to_string(),
777 }),
778 }
779}
780
781fn parse_fetch_action(file: FileId, node: &Node) -> Result<RawFetchAction, ParseError> {
783 let span = node_to_span(file, node);
784
785 let m = match node {
786 Node::Mapping(m) => m,
787 _ => {
788 return Err(ParseError {
789 kind: ParseErrorKind::InvalidType,
790 span,
791 message: "fetch must be a mapping".to_string(),
792 });
793 }
794 };
795
796 let url = get_string_field(file, m, "url")?.ok_or_else(|| ParseError {
797 kind: ParseErrorKind::MissingField,
798 span,
799 message: "fetch action requires 'url' field".to_string(),
800 })?;
801
802 let extract = get_string_field(file, m, "extract")?;
803 let selector = get_string_field(file, m, "selector")?;
804
805 Ok(RawFetchAction {
806 url,
807 method: get_string_field(file, m, "method")?,
808 headers: parse_string_map(file, m, "headers")?,
809 body: get_string_field(file, m, "body")?,
810 json: parse_json_value(file, m, "json")?,
811 timeout_ms: match get_u64_field(file, m, "timeout_ms")? {
812 Some(v) => Some(v),
813 None => get_u64_field(file, m, "timeout")?
814 .map(|s| Spanned::new(s.value.saturating_mul(1000), s.span)),
815 },
816 follow_redirects: get_bool_field(file, m, "follow_redirects")?,
817 response: get_string_field(file, m, "response")?,
818 extract,
819 selector,
820 })
821}
822
823fn parse_invoke_action(file: FileId, node: &Node) -> Result<RawInvokeAction, ParseError> {
825 let span = node_to_span(file, node);
826
827 let m = match node {
828 Node::Mapping(m) => m,
829 _ => {
830 return Err(ParseError {
831 kind: ParseErrorKind::InvalidType,
832 span,
833 message: "invoke must be a mapping".to_string(),
834 });
835 }
836 };
837
838 let tool = get_string_field(file, m, "tool")?;
839 let resource = get_string_field(file, m, "resource")?;
840
841 if tool.is_none() && resource.is_none() {
842 return Err(ParseError {
843 kind: ParseErrorKind::MissingField,
844 span,
845 message: "invoke action requires 'tool' or 'resource' field".to_string(),
846 });
847 }
848
849 Ok(RawInvokeAction {
850 tool,
851 resource,
852 params: parse_json_value(file, m, "params")?,
853 mcp: get_string_field(file, m, "mcp")?.or(get_string_field(file, m, "server")?),
854 timeout_ms: match get_u64_field(file, m, "timeout_ms")? {
855 Some(v) => Some(v),
856 None => get_u64_field(file, m, "timeout")?
857 .map(|s| Spanned::new(s.value.saturating_mul(1000), s.span)),
858 },
859 })
860}
861
862fn parse_agent_action(file: FileId, node: &Node) -> Result<RawAgentAction, ParseError> {
864 let span = node_to_span(file, node);
865
866 let m = match node {
867 Node::Mapping(m) => m,
868 _ => {
869 return Err(ParseError {
870 kind: ParseErrorKind::InvalidType,
871 span,
872 message: "agent must be a mapping".to_string(),
873 });
874 }
875 };
876
877 let prompt = get_string_field(file, m, "prompt")?.ok_or_else(|| ParseError {
878 kind: ParseErrorKind::MissingField,
879 span,
880 message: "agent action requires 'prompt' field".to_string(),
881 })?;
882
883 Ok(RawAgentAction {
884 prompt,
885 tools: parse_string_array(file, m, "tools")?,
886 max_turns: get_u32_field(file, m, "max_turns")?,
887 max_tokens: get_u32_field(file, m, "max_tokens")?,
888 from: get_string_field(file, m, "from")?,
889 skills: parse_string_array(file, m, "skills")?,
890 provider: get_string_field(file, m, "provider")?,
891 model: get_string_field(file, m, "model")?,
892 mcp: parse_string_array(file, m, "mcp")?,
893 system: get_string_field(file, m, "system")?,
894 temperature: get_f64_field(file, m, "temperature")?,
895 token_budget: get_u32_field(file, m, "token_budget")?,
896 extended_thinking: get_bool_field(file, m, "extended_thinking")?,
897 thinking_budget: get_u32_field(file, m, "thinking_budget")?,
898 depth_limit: get_u32_field(file, m, "depth_limit")?,
899 tool_choice: get_string_field(file, m, "tool_choice")?,
900 stop_sequences: parse_string_array(file, m, "stop_sequences")?,
901 scope: get_string_field(file, m, "scope")?,
902 guardrails: parse_guardrails_field(file, m)?,
903 completion: parse_optional_serde_field(file, m, "completion")?,
904 limits: parse_optional_serde_field(file, m, "limits")?,
905 })
906}
907
908#[allow(clippy::type_complexity)]
921fn parse_with_refs(
922 file: FileId,
923 map: &marked_yaml::types::MarkedMappingNode,
924) -> Result<Option<Spanned<IndexMap<Spanned<String>, Spanned<String>>>>, ParseError> {
925 parse_string_map(file, map, "with")
926}
927
928fn parse_depends_on(
933 file: FileId,
934 map: &marked_yaml::types::MarkedMappingNode,
935) -> Result<Option<Spanned<Vec<Spanned<String>>>>, ParseError> {
936 match map.get_node("depends_on") {
937 Some(Node::Scalar(s)) => {
938 let span = marked_span_to_span(file, s.span());
939 Ok(Some(Spanned::new(
940 vec![Spanned::new(s.as_str().to_string(), span)],
941 span,
942 )))
943 }
944 Some(Node::Sequence(seq)) => {
945 let span = marked_span_to_span(file, seq.span());
946 let ids: Result<Vec<_>, _> = seq.iter().map(|n| extract_string(file, n)).collect();
947 Ok(Some(Spanned::new(ids?, span)))
948 }
949 Some(node) => Err(ParseError {
950 kind: ParseErrorKind::InvalidType,
951 span: node_to_span(file, node),
952 message: "depends_on/flow must be a string or array of strings".to_string(),
953 }),
954 None => Ok(None),
955 }
956}
957
958fn parse_for_each(
960 file: FileId,
961 map: &marked_yaml::types::MarkedMappingNode,
962) -> Result<Option<Spanned<RawForEach>>, ParseError> {
963 match map.get_node("for_each") {
964 Some(Node::Sequence(seq)) => {
965 let span = marked_span_to_span(file, seq.span());
966 let arr: Vec<serde_json::Value> = seq.iter().map(node_to_json).collect();
968 let items_str = serde_json::to_string(&arr).map_err(|e| ParseError {
969 kind: ParseErrorKind::InvalidType,
970 span,
971 message: format!("failed to serialize for_each items: {}", e),
972 })?;
973
974 Ok(Some(Spanned::new(
975 RawForEach {
976 items: Spanned::new(items_str, span),
977 as_var: get_string_field(file, map, "as")?,
978 concurrency: get_u32_field(file, map, "concurrency")?,
979 fail_fast: get_bool_field(file, map, "fail_fast")?,
980 },
981 span,
982 )))
983 }
984 Some(Node::Scalar(s)) => {
985 let span = marked_span_to_span(file, s.span());
986 Ok(Some(Spanned::new(
987 RawForEach {
988 items: Spanned::new(s.as_str().to_string(), span),
989 as_var: get_string_field(file, map, "as")?,
990 concurrency: get_u32_field(file, map, "concurrency")?,
991 fail_fast: get_bool_field(file, map, "fail_fast")?,
992 },
993 span,
994 )))
995 }
996 Some(node) => Err(ParseError {
997 kind: ParseErrorKind::InvalidType,
998 span: node_to_span(file, node),
999 message: "for_each must be array or string".to_string(),
1000 }),
1001 None => Ok(None),
1002 }
1003}
1004
1005fn parse_retry(
1007 file: FileId,
1008 map: &marked_yaml::types::MarkedMappingNode,
1009) -> Result<Option<Spanned<RawRetryConfig>>, ParseError> {
1010 match map.get_node("retry") {
1011 Some(Node::Mapping(m)) => {
1012 let span = marked_span_to_span(file, m.span());
1013 Ok(Some(Spanned::new(
1014 RawRetryConfig {
1015 max_attempts: get_u32_field(file, m, "max_attempts")?.or(get_u32_field(
1016 file,
1017 m,
1018 "max_retries",
1019 )?),
1020 delay_ms: match get_u64_field(file, m, "delay_ms")? {
1021 Some(v) => Some(v),
1022 None => get_u64_field(file, m, "delay")?
1023 .map(|s| Spanned::new(s.value.saturating_mul(1000), s.span)),
1024 },
1025 backoff: get_f64_field(file, m, "backoff")?,
1026 },
1027 span,
1028 )))
1029 }
1030 Some(node) => Err(ParseError {
1031 kind: ParseErrorKind::InvalidType,
1032 span: node_to_span(file, node),
1033 message: "retry must be a mapping".to_string(),
1034 }),
1035 None => Ok(None),
1036 }
1037}
1038
1039fn parse_decompose(
1041 file: FileId,
1042 map: &marked_yaml::types::MarkedMappingNode,
1043) -> Result<Option<Spanned<DecomposeSpec>>, ParseError> {
1044 match map.get_node("decompose") {
1045 Some(Node::Mapping(m)) => {
1046 let span = marked_span_to_span(file, m.span());
1047
1048 let traverse = get_string_field(file, m, "traverse")?
1049 .ok_or_else(|| ParseError {
1050 kind: ParseErrorKind::MissingField,
1051 span,
1052 message: "decompose missing required field 'traverse'".to_string(),
1053 })?
1054 .value;
1055
1056 let source = get_string_field(file, m, "source")?
1057 .ok_or_else(|| ParseError {
1058 kind: ParseErrorKind::MissingField,
1059 span,
1060 message: "decompose missing required field 'source'".to_string(),
1061 })?
1062 .value;
1063
1064 let strategy = match get_string_field(file, m, "strategy")? {
1065 Some(s) => match s.value.as_str() {
1066 "semantic" => DecomposeStrategy::Semantic,
1067 "static" => DecomposeStrategy::Static,
1068 "nested" => DecomposeStrategy::Nested,
1069 other => {
1070 return Err(ParseError {
1071 kind: ParseErrorKind::InvalidType,
1072 span: s.span,
1073 message: format!(
1074 "invalid decompose strategy '{}': expected semantic, static, or nested",
1075 other
1076 ),
1077 });
1078 }
1079 },
1080 None => DecomposeStrategy::default(),
1081 };
1082
1083 let mcp_server = get_string_field(file, m, "mcp_server")?.map(|s| s.value);
1084
1085 let max_items = get_u32_field(file, m, "max_items")?.map(|s| s.value as usize);
1086
1087 let max_depth = get_u32_field(file, m, "max_depth")?.map(|s| s.value as usize);
1088
1089 Ok(Some(Spanned::new(
1090 DecomposeSpec {
1091 strategy,
1092 traverse,
1093 source,
1094 mcp_server,
1095 max_items,
1096 max_depth,
1097 },
1098 span,
1099 )))
1100 }
1101 Some(node) => Err(ParseError {
1102 kind: ParseErrorKind::InvalidType,
1103 span: node_to_span(file, node),
1104 message: "decompose must be a mapping".to_string(),
1105 }),
1106 None => Ok(None),
1107 }
1108}
1109
1110fn parse_output(
1112 file: FileId,
1113 map: &marked_yaml::types::MarkedMappingNode,
1114) -> Result<Option<Spanned<RawOutputConfig>>, ParseError> {
1115 match map.get_node("output") {
1116 Some(Node::Mapping(m)) => {
1117 let span = marked_span_to_span(file, m.span());
1118 Ok(Some(Spanned::new(
1119 RawOutputConfig {
1120 format: get_string_field(file, m, "format")?,
1121 schema: parse_json_value(file, m, "schema")?,
1122 schema_ref: get_string_field(file, m, "schema_ref")?,
1123 max_retries: get_u32_field(file, m, "max_retries")?,
1124 },
1125 span,
1126 )))
1127 }
1128 Some(node) => Err(ParseError {
1129 kind: ParseErrorKind::InvalidType,
1130 span: node_to_span(file, node),
1131 message: "output must be a mapping".to_string(),
1132 }),
1133 None => Ok(None),
1134 }
1135}
1136
1137fn parse_structured(
1147 file: FileId,
1148 map: &marked_yaml::types::MarkedMappingNode,
1149) -> Result<Option<StructuredOutputSpec>, ParseError> {
1150 match map.get_node("structured") {
1151 Some(node) => {
1152 let span = node_to_span(file, node);
1153 let json_value = node_to_json(node);
1156 let spec: StructuredOutputSpec =
1157 serde_json::from_value(json_value).map_err(|e| ParseError {
1158 kind: ParseErrorKind::InvalidType,
1159 span,
1160 message: format!("invalid structured output config: {e}"),
1161 })?;
1162 Ok(Some(spec))
1163 }
1164 None => Ok(None),
1165 }
1166}
1167
1168fn parse_guardrails_field(
1175 file: FileId,
1176 map: &marked_yaml::types::MarkedMappingNode,
1177) -> Result<Vec<crate::ast::guardrails::GuardrailConfig>, ParseError> {
1178 match map.get_node("guardrails") {
1179 Some(node) => {
1180 let span = node_to_span(file, node);
1181 let json_value = node_to_json(node);
1182 serde_json::from_value(json_value).map_err(|e| ParseError {
1183 kind: ParseErrorKind::InvalidType,
1184 span,
1185 message: format!("invalid guardrails config: {e}"),
1186 })
1187 }
1188 None => Ok(Vec::new()),
1189 }
1190}
1191
1192fn parse_optional_serde_field<T: serde::de::DeserializeOwned>(
1193 file: FileId,
1194 map: &marked_yaml::types::MarkedMappingNode,
1195 field_name: &str,
1196) -> Result<Option<T>, ParseError> {
1197 match map.get_node(field_name) {
1198 Some(node) => {
1199 let span = node_to_span(file, node);
1200 let json_value = node_to_json(node);
1201 let parsed = serde_json::from_value(json_value).map_err(|e| ParseError {
1202 kind: ParseErrorKind::InvalidType,
1203 span,
1204 message: format!("invalid {field_name} config: {e}"),
1205 })?;
1206 Ok(Some(parsed))
1207 }
1208 None => Ok(None),
1209 }
1210}
1211
1212pub fn parse(source: &str, file_id: FileId) -> Result<RawWorkflow, ParseError> {
1241 let node = parse_yaml(file_id.0 as usize, source).map_err(|e| {
1244 let span = extract_span_from_load_error(file_id, &e);
1246 ParseError {
1247 kind: ParseErrorKind::Syntax,
1248 span,
1249 message: format!("YAML syntax error: {}", e),
1250 }
1251 })?;
1252
1253 let map = match &node {
1255 Node::Mapping(m) => m,
1256 _ => {
1257 return Err(ParseError {
1258 kind: ParseErrorKind::InvalidType,
1259 span: node_to_span(file_id, &node),
1260 message: "workflow must be a YAML mapping".to_string(),
1261 });
1262 }
1263 };
1264
1265 let mut workflow = RawWorkflow::default();
1267 workflow.span = node_to_span(file_id, &node);
1268
1269 workflow.schema = get_string_field(file_id, map, "schema")?.ok_or_else(|| ParseError {
1271 kind: ParseErrorKind::MissingField,
1272 span: workflow.span,
1273 message: "missing required field 'schema'".to_string(),
1274 })?;
1275
1276 workflow.workflow = get_string_field(file_id, map, "workflow")?;
1278 workflow.description = get_string_field(file_id, map, "description")?;
1279 workflow.provider = get_string_field(file_id, map, "provider")?;
1280 workflow.model = get_string_field(file_id, map, "model")?;
1281
1282 workflow.mcp = parse_mcp_config(file_id, map)?;
1284
1285 workflow.pkg = parse_pkg_config(file_id, map)?;
1287
1288 workflow.context = parse_context_config(file_id, map)?;
1290
1291 workflow.imports = parse_imports(file_id, map)?;
1293
1294 workflow.inputs = parse_inputs(file_id, map)?;
1296
1297 workflow.artifacts = match map.get_node("artifacts") {
1299 Some(node) => {
1300 let span = node_to_span(file_id, node);
1301 Some(Spanned::new(node_to_json(node), span))
1302 }
1303 None => None,
1304 };
1305
1306 workflow.log = match map.get_node("log") {
1308 Some(node) => {
1309 let span = node_to_span(file_id, node);
1310 Some(Spanned::new(node_to_json(node), span))
1311 }
1312 None => None,
1313 };
1314
1315 workflow.agents = match map.get_node("agents") {
1317 Some(node) => {
1318 let span = node_to_span(file_id, node);
1319 Some(Spanned::new(node_to_json(node), span))
1320 }
1321 None => None,
1322 };
1323
1324 workflow.skills = parse_string_map(file_id, map, "skills")?;
1326
1327 workflow.tasks = parse_tasks(file_id, map)?;
1329
1330 Ok(workflow)
1331}
1332
1333fn parse_mcp_config(
1335 file_id: FileId,
1336 map: &marked_yaml::types::MarkedMappingNode,
1337) -> Result<Option<Spanned<RawMcpConfig>>, ParseError> {
1338 let mcp_node = match map.get_node("mcp") {
1340 Some(node) => node,
1341 None => return Ok(None),
1342 };
1343
1344 let mcp_map = match mcp_node {
1345 Node::Mapping(m) => m,
1346 _ => {
1347 return Err(ParseError {
1348 kind: ParseErrorKind::InvalidType,
1349 span: node_to_span(file_id, mcp_node),
1350 message: "mcp must be a mapping".to_string(),
1351 });
1352 }
1353 };
1354
1355 let mcp_span = marked_span_to_span(file_id, mcp_map.span());
1356 let mut config = RawMcpConfig::default();
1357
1358 let servers_map = if let Some(servers_node) = mcp_map.get_node("servers") {
1362 match servers_node {
1363 Node::Mapping(m) => m,
1364 _ => {
1365 return Err(ParseError {
1366 kind: ParseErrorKind::InvalidType,
1367 span: node_to_span(file_id, servers_node),
1368 message: "mcp.servers must be a mapping".to_string(),
1369 });
1370 }
1371 }
1372 } else {
1373 mcp_map
1375 };
1376
1377 for (key, value) in servers_map.iter() {
1378 let server_name = Spanned::new(
1379 key.as_str().to_string(),
1380 marked_span_to_span(file_id, key.span()),
1381 );
1382
1383 let server = parse_mcp_server(file_id, value)?;
1384 config.servers.insert(server_name, server);
1385 }
1386
1387 Ok(Some(Spanned::new(config, mcp_span)))
1388}
1389
1390fn parse_mcp_server(file_id: FileId, node: &Node) -> Result<Spanned<RawMcpServer>, ParseError> {
1392 let span = node_to_span(file_id, node);
1393
1394 let map = match node {
1395 Node::Mapping(m) => m,
1396 _ => {
1397 return Err(ParseError {
1398 kind: ParseErrorKind::InvalidType,
1399 span,
1400 message: "MCP server config must be a mapping".to_string(),
1401 });
1402 }
1403 };
1404
1405 let server = RawMcpServer {
1406 command: get_string_field(file_id, map, "command")?,
1407 args: parse_string_array(file_id, map, "args")?,
1408 env: parse_string_map(file_id, map, "env")?,
1409 cwd: get_string_field(file_id, map, "cwd")?,
1410 url: get_string_field(file_id, map, "url")?,
1411 transport: get_string_field(file_id, map, "transport")?,
1412 };
1413
1414 Ok(Spanned::new(server, span))
1415}
1416
1417fn parse_pkg_config(
1419 file_id: FileId,
1420 map: &marked_yaml::types::MarkedMappingNode,
1421) -> Result<Option<Spanned<RawPkgConfig>>, ParseError> {
1422 let pkg_node = match map.get_node("pkg") {
1423 Some(node) => node,
1424 None => return Ok(None),
1425 };
1426
1427 let pkg_map = match pkg_node {
1428 Node::Mapping(m) => m,
1429 _ => {
1430 return Err(ParseError {
1431 kind: ParseErrorKind::InvalidType,
1432 span: node_to_span(file_id, pkg_node),
1433 message: "pkg must be a mapping".to_string(),
1434 });
1435 }
1436 };
1437
1438 let span = marked_span_to_span(file_id, pkg_map.span());
1439 let include = match parse_string_array(file_id, pkg_map, "include")? {
1440 Some(arr) => arr.value,
1441 None => Vec::new(),
1442 };
1443
1444 Ok(Some(Spanned::new(RawPkgConfig { include }, span)))
1445}
1446
1447fn parse_context_config(
1449 file_id: FileId,
1450 map: &marked_yaml::types::MarkedMappingNode,
1451) -> Result<Option<Spanned<RawContextConfig>>, ParseError> {
1452 let ctx_node = match map.get_node("context") {
1453 Some(node) => node,
1454 None => return Ok(None),
1455 };
1456
1457 let ctx_map = match ctx_node {
1458 Node::Mapping(m) => m,
1459 _ => {
1460 return Err(ParseError {
1461 kind: ParseErrorKind::InvalidType,
1462 span: node_to_span(file_id, ctx_node),
1463 message: "context must be a mapping".to_string(),
1464 });
1465 }
1466 };
1467
1468 let span = marked_span_to_span(file_id, ctx_map.span());
1469 let files = parse_string_map(file_id, ctx_map, "files")?.map(|s| s.value);
1470
1471 Ok(Some(Spanned::new(RawContextConfig { files }, span)))
1472}
1473
1474fn parse_imports(
1483 file_id: FileId,
1484 map: &marked_yaml::types::MarkedMappingNode,
1485) -> Result<Option<Spanned<Vec<Spanned<RawImportSpec>>>>, ParseError> {
1486 let imports_node = match map.get_node("imports") {
1487 Some(node) => node,
1488 None => return Ok(None),
1489 };
1490
1491 let seq = match imports_node {
1492 Node::Sequence(s) => s,
1493 _ => {
1494 return Err(ParseError {
1495 kind: ParseErrorKind::InvalidType,
1496 span: node_to_span(file_id, imports_node),
1497 message: "imports must be a sequence".to_string(),
1498 });
1499 }
1500 };
1501
1502 let outer_span = marked_span_to_span(file_id, seq.span());
1503 let mut specs = Vec::new();
1504
1505 for item_node in seq.iter() {
1506 let item_span = node_to_span(file_id, item_node);
1507
1508 let item_map = match item_node {
1509 Node::Mapping(m) => m,
1510 _ => {
1511 return Err(ParseError {
1512 kind: ParseErrorKind::InvalidType,
1513 span: item_span,
1514 message: "import entry must be a mapping with 'path' field".to_string(),
1515 });
1516 }
1517 };
1518
1519 let path = get_string_field(file_id, item_map, "path")?.ok_or_else(|| ParseError {
1520 kind: ParseErrorKind::MissingField,
1521 span: item_span,
1522 message: "import entry requires 'path' field".to_string(),
1523 })?;
1524
1525 let prefix = get_string_field(file_id, item_map, "prefix")?;
1526
1527 specs.push(Spanned::new(
1528 RawImportSpec {
1529 path,
1530 prefix,
1531 span: item_span,
1532 },
1533 item_span,
1534 ));
1535 }
1536
1537 Ok(Some(Spanned::new(specs, outer_span)))
1538}
1539
1540#[allow(clippy::type_complexity)]
1548fn parse_inputs(
1549 file_id: FileId,
1550 map: &marked_yaml::types::MarkedMappingNode,
1551) -> Result<Option<Spanned<IndexMap<Spanned<String>, Spanned<serde_json::Value>>>>, ParseError> {
1552 let inputs_node = match map.get_node("inputs") {
1553 Some(node) => node,
1554 None => return Ok(None),
1555 };
1556
1557 let inputs_map = match inputs_node {
1558 Node::Mapping(m) => m,
1559 _ => {
1560 return Err(ParseError {
1561 kind: ParseErrorKind::InvalidType,
1562 span: node_to_span(file_id, inputs_node),
1563 message: "inputs must be a mapping".to_string(),
1564 });
1565 }
1566 };
1567
1568 let span = marked_span_to_span(file_id, inputs_map.span());
1569 let mut result = IndexMap::new();
1570
1571 for (k, v) in inputs_map.iter() {
1572 let key_span = marked_span_to_span(file_id, k.span());
1573 let key = Spanned::new(k.as_str().to_string(), key_span);
1574 let val_span = node_to_span(file_id, v);
1575 let val = Spanned::new(node_to_json(v), val_span);
1576 result.insert(key, val);
1577 }
1578
1579 Ok(Some(Spanned::new(result, span)))
1580}
1581
1582fn parse_tasks(
1584 file_id: FileId,
1585 map: &marked_yaml::types::MarkedMappingNode,
1586) -> Result<Spanned<Vec<Spanned<RawTask>>>, ParseError> {
1587 match map.get_node("tasks") {
1588 Some(Node::Sequence(seq)) => {
1589 let span = marked_span_to_span(file_id, seq.span());
1590 let mut seen_ids = std::collections::HashSet::new();
1591 let mut tasks = Vec::with_capacity(seq.len());
1592 for task_node in seq.iter() {
1593 let task = parse_task(file_id, task_node)?;
1594 let task_id = &task.value.id.value;
1595 if !seen_ids.insert(task_id.clone()) {
1596 return Err(ParseError {
1597 kind: ParseErrorKind::InvalidType,
1598 span: task.value.id.span,
1599 message: format!("duplicate task id '{}'", task_id),
1600 });
1601 }
1602 tasks.push(task);
1603 }
1604 Ok(Spanned::new(tasks, span))
1605 }
1606 Some(node) => Err(ParseError {
1607 kind: ParseErrorKind::InvalidType,
1608 span: node_to_span(file_id, node),
1609 message: "tasks must be a sequence".to_string(),
1610 }),
1611 None => {
1612 Ok(Spanned::dummy(Vec::new()))
1614 }
1615 }
1616}
1617
1618fn parse_task(file_id: FileId, node: &Node) -> Result<Spanned<RawTask>, ParseError> {
1620 let span = node_to_span(file_id, node);
1621
1622 let map = match node {
1623 Node::Mapping(m) => m,
1624 _ => {
1625 return Err(ParseError {
1626 kind: ParseErrorKind::InvalidType,
1627 span,
1628 message: "task must be a mapping".to_string(),
1629 });
1630 }
1631 };
1632
1633 let id = get_string_field(file_id, map, "id")?.ok_or_else(|| ParseError {
1635 kind: ParseErrorKind::MissingField,
1636 span,
1637 message: "task missing required field 'id'".to_string(),
1638 })?;
1639
1640 let description = get_string_field(file_id, map, "description")?;
1642 let provider = get_string_field(file_id, map, "provider")?;
1643 let model = get_string_field(file_id, map, "model")?;
1644
1645 let action = parse_action(file_id, map)?;
1647 let with_refs = parse_with_refs(file_id, map)?;
1648 let depends_on = parse_depends_on(file_id, map)?;
1649 let output = parse_output(file_id, map)?;
1650 let for_each = parse_for_each(file_id, map)?;
1651 let retry = parse_retry(file_id, map)?;
1652 let decompose = parse_decompose(file_id, map)?;
1653 let structured = parse_structured(file_id, map)?;
1654
1655 let artifact = match map.get_node("artifact") {
1657 Some(node) => {
1658 let span = node_to_span(file_id, node);
1659 let value = node_to_json(node);
1660 Some(Spanned::new(value, span))
1661 }
1662 None => None,
1663 };
1664
1665 let log = match map.get_node("log") {
1667 Some(node) => {
1668 let span = node_to_span(file_id, node);
1669 let value = node_to_json(node);
1670 Some(Spanned::new(value, span))
1671 }
1672 None => None,
1673 };
1674
1675 let standalone_concurrency = if for_each.is_none() {
1677 get_u32_field(file_id, map, "concurrency")?
1678 } else {
1679 None
1680 };
1681 let standalone_fail_fast = if for_each.is_none() {
1682 get_bool_field(file_id, map, "fail_fast")?
1683 } else {
1684 None
1685 };
1686
1687 let task = RawTask {
1688 span,
1689 id,
1690 description,
1691 provider,
1692 model,
1693 action,
1694 with_refs,
1695 depends_on,
1696 output,
1697 for_each,
1698 retry,
1699 decompose,
1700 concurrency: standalone_concurrency,
1701 fail_fast: standalone_fail_fast,
1702 structured,
1703 artifact,
1704 log,
1705 };
1706
1707 Ok(Spanned::new(task, span))
1708}
1709
1710#[cfg(test)]
1711mod tests {
1712 use super::*;
1713
1714 const SIMPLE_WORKFLOW: &str = r#"
1715schema: "nika/workflow@0.12"
1716workflow: test-workflow
1717description: "A test workflow"
1718provider: claude
1719model: claude-sonnet-4-6
1720
1721tasks:
1722 - id: task1
1723 description: "First task"
1724
1725 - id: task2
1726 description: "Second task"
1727"#;
1728
1729 #[test]
1730 fn test_parse_simple_workflow() {
1731 let file_id = FileId(0);
1732 let result = parse(SIMPLE_WORKFLOW, file_id);
1733
1734 assert!(result.is_ok(), "Parse failed: {:?}", result.err());
1735 let workflow = result.unwrap();
1736
1737 assert_eq!(workflow.schema.value, "nika/workflow@0.12");
1738 assert_eq!(workflow.name(), "test-workflow");
1739 assert_eq!(
1740 workflow.description.as_ref().unwrap().value,
1741 "A test workflow"
1742 );
1743 assert_eq!(workflow.provider.as_ref().unwrap().value, "claude");
1744 assert_eq!(workflow.model.as_ref().unwrap().value, "claude-sonnet-4-6");
1745 assert_eq!(workflow.task_count(), 2);
1746
1747 assert!(!workflow.schema.span.is_dummy());
1749 assert!(!workflow.tasks.span.is_dummy());
1750 }
1751
1752 #[test]
1753 fn test_parse_task_ids() {
1754 let file_id = FileId(0);
1755 let workflow = parse(SIMPLE_WORKFLOW, file_id).unwrap();
1756
1757 let task1 = workflow.get_task("task1");
1758 assert!(task1.is_some());
1759 assert_eq!(task1.unwrap().value.id.value, "task1");
1760
1761 let task2 = workflow.get_task("task2");
1762 assert!(task2.is_some());
1763 assert_eq!(task2.unwrap().value.id.value, "task2");
1764 }
1765
1766 #[test]
1767 fn test_parse_missing_schema() {
1768 let yaml = r#"
1769workflow: test
1770tasks: []
1771"#;
1772 let result = parse(yaml, FileId(0));
1773 assert!(result.is_err());
1774
1775 let err = result.unwrap_err();
1776 assert_eq!(err.kind, ParseErrorKind::MissingField);
1777 assert!(err.message.contains("schema"));
1778 }
1779
1780 #[test]
1781 fn test_parse_invalid_yaml() {
1782 let yaml = "invalid: yaml: syntax: [";
1783 let result = parse(yaml, FileId(0));
1784 assert!(result.is_err());
1785
1786 let err = result.unwrap_err();
1787 assert_eq!(err.kind, ParseErrorKind::Syntax);
1788 }
1789
1790 #[test]
1791 fn test_span_tracking() {
1792 let yaml = r#"schema: "nika/workflow@0.12"
1793workflow: my-workflow
1794tasks:
1795 - id: hello
1796"#;
1797 let file_id = FileId(0);
1798 let workflow = parse(yaml, file_id).unwrap();
1799
1800 let schema_span = workflow.schema.span;
1802 assert!(!schema_span.is_dummy());
1803
1804 assert!(schema_span.start.0 <= schema_span.end.0);
1806 }
1807
1808 #[test]
1813 fn test_parse_infer_shorthand() {
1814 let yaml = r#"
1815schema: "nika/workflow@0.12"
1816tasks:
1817 - id: generate
1818 infer: "Generate a headline"
1819"#;
1820 let workflow = parse(yaml, FileId(0)).unwrap();
1821 let task = workflow.get_task("generate").unwrap();
1822
1823 match &task.value.action {
1824 Some(RawTaskAction::Infer(action)) => {
1825 assert_eq!(action.value.prompt.value, "Generate a headline");
1826 assert!(action.value.temperature.is_none());
1827 assert!(action.value.system.is_none());
1828 }
1829 _ => panic!("Expected Infer action"),
1830 }
1831 }
1832
1833 #[test]
1834 fn test_parse_infer_shorthand_with_task_level_max_tokens_and_temperature() {
1835 let yaml = r#"
1836schema: "nika/workflow@0.12"
1837tasks:
1838 - id: test
1839 infer: "Say hello"
1840 max_tokens: 20
1841 temperature: 0.5
1842"#;
1843 let workflow = parse(yaml, FileId(0)).unwrap();
1844 let task = workflow.get_task("test").unwrap();
1845
1846 match &task.value.action {
1847 Some(RawTaskAction::Infer(action)) => {
1848 assert_eq!(action.value.prompt.value, "Say hello");
1849 assert_eq!(action.value.max_tokens.as_ref().unwrap().value, 20);
1850 assert!((action.value.temperature.as_ref().unwrap().value - 0.5).abs() < 0.001);
1851 }
1852 _ => panic!("Expected Infer action"),
1853 }
1854 }
1855
1856 #[test]
1857 fn test_parse_infer_shorthand_with_task_level_system() {
1858 let yaml = r#"
1859schema: "nika/workflow@0.12"
1860tasks:
1861 - id: test
1862 infer: "Translate this"
1863 system: "You are a translator"
1864 temperature: 0.3
1865"#;
1866 let workflow = parse(yaml, FileId(0)).unwrap();
1867 let task = workflow.get_task("test").unwrap();
1868
1869 match &task.value.action {
1870 Some(RawTaskAction::Infer(action)) => {
1871 assert_eq!(action.value.prompt.value, "Translate this");
1872 assert_eq!(
1873 action.value.system.as_ref().unwrap().value,
1874 "You are a translator"
1875 );
1876 assert!((action.value.temperature.as_ref().unwrap().value - 0.3).abs() < 0.001);
1877 }
1878 _ => panic!("Expected Infer action"),
1879 }
1880 }
1881
1882 #[test]
1883 fn test_parse_infer_shorthand_with_all_task_level_fields() {
1884 let yaml = r#"
1885schema: "nika/workflow@0.12"
1886tasks:
1887 - id: test
1888 infer: "Think deeply"
1889 system: "Be thorough"
1890 max_tokens: 4096
1891 temperature: 0.9
1892 extended_thinking: true
1893 thinking_budget: 8000
1894 response_format: json
1895"#;
1896 let workflow = parse(yaml, FileId(0)).unwrap();
1897 let task = workflow.get_task("test").unwrap();
1898
1899 match &task.value.action {
1900 Some(RawTaskAction::Infer(action)) => {
1901 assert_eq!(action.value.prompt.value, "Think deeply");
1902 assert_eq!(action.value.system.as_ref().unwrap().value, "Be thorough");
1903 assert_eq!(action.value.max_tokens.as_ref().unwrap().value, 4096);
1904 assert!((action.value.temperature.as_ref().unwrap().value - 0.9).abs() < 0.001);
1905 assert!(action.value.extended_thinking.as_ref().unwrap().value);
1906 assert_eq!(action.value.thinking_budget.as_ref().unwrap().value, 8000);
1907 assert_eq!(action.value.response_format.as_ref().unwrap().value, "json");
1908 }
1909 _ => panic!("Expected Infer action"),
1910 }
1911 }
1912
1913 #[test]
1914 fn test_parse_infer_full_form() {
1915 let yaml = r#"
1916schema: "nika/workflow@0.12"
1917tasks:
1918 - id: generate
1919 infer:
1920 prompt: "Generate content"
1921 system: "You are a helpful assistant"
1922 temperature: 0.7
1923 max_tokens: 1000
1924 extended_thinking: true
1925 thinking_budget: 8000
1926"#;
1927 let workflow = parse(yaml, FileId(0)).unwrap();
1928 let task = workflow.get_task("generate").unwrap();
1929
1930 match &task.value.action {
1931 Some(RawTaskAction::Infer(action)) => {
1932 assert_eq!(action.value.prompt.value, "Generate content");
1933 assert_eq!(
1934 action.value.system.as_ref().unwrap().value,
1935 "You are a helpful assistant"
1936 );
1937 assert!((action.value.temperature.as_ref().unwrap().value - 0.7).abs() < 0.001);
1938 assert_eq!(action.value.max_tokens.as_ref().unwrap().value, 1000);
1939 assert!(action.value.extended_thinking.as_ref().unwrap().value);
1940 assert_eq!(action.value.thinking_budget.as_ref().unwrap().value, 8000);
1941 }
1942 _ => panic!("Expected Infer action"),
1943 }
1944 }
1945
1946 #[test]
1947 fn test_parse_exec_shorthand() {
1948 let yaml = r#"
1949schema: "nika/workflow@0.12"
1950tasks:
1951 - id: build
1952 exec: "npm run build"
1953"#;
1954 let workflow = parse(yaml, FileId(0)).unwrap();
1955 let task = workflow.get_task("build").unwrap();
1956
1957 match &task.value.action {
1958 Some(RawTaskAction::Exec(action)) => {
1959 assert_eq!(action.value.command.value, "npm run build");
1960 assert!(action.value.shell.is_none());
1961 }
1962 _ => panic!("Expected Exec action"),
1963 }
1964 }
1965
1966 #[test]
1967 fn test_parse_exec_full_form() {
1968 let yaml = r#"
1969schema: "nika/workflow@0.12"
1970tasks:
1971 - id: build
1972 exec:
1973 command: "npm run build"
1974 shell: true
1975 cwd: "/app"
1976 timeout: 30
1977 env:
1978 NODE_ENV: production
1979"#;
1980 let workflow = parse(yaml, FileId(0)).unwrap();
1981 let task = workflow.get_task("build").unwrap();
1982
1983 match &task.value.action {
1984 Some(RawTaskAction::Exec(action)) => {
1985 assert_eq!(action.value.command.value, "npm run build");
1986 assert!(action.value.shell.as_ref().unwrap().value);
1987 assert_eq!(action.value.cwd.as_ref().unwrap().value, "/app");
1988 assert_eq!(action.value.timeout_ms.as_ref().unwrap().value, 30000);
1989 let env = action.value.env.as_ref().unwrap();
1990 assert!(env.value.values().any(|v| v.value == "production"));
1991 }
1992 _ => panic!("Expected Exec action"),
1993 }
1994 }
1995
1996 #[test]
1997 fn test_parse_fetch_action() {
1998 let yaml = r#"
1999schema: "nika/workflow@0.12"
2000tasks:
2001 - id: api_call
2002 fetch:
2003 url: "https://api.example.com/data"
2004 method: POST
2005 headers:
2006 Authorization: "Bearer token"
2007 timeout: 5
2008"#;
2009 let workflow = parse(yaml, FileId(0)).unwrap();
2010 let task = workflow.get_task("api_call").unwrap();
2011
2012 match &task.value.action {
2013 Some(RawTaskAction::Fetch(action)) => {
2014 assert_eq!(action.value.url.value, "https://api.example.com/data");
2015 assert_eq!(action.value.method.as_ref().unwrap().value, "POST");
2016 assert_eq!(action.value.timeout_ms.as_ref().unwrap().value, 5000); let headers = action.value.headers.as_ref().unwrap();
2018 assert!(headers.value.values().any(|v| v.value.contains("Bearer")));
2019 }
2020 _ => panic!("Expected Fetch action"),
2021 }
2022 }
2023
2024 #[test]
2025 fn test_parse_invoke_action() {
2026 let yaml = r#"
2027schema: "nika/workflow@0.12"
2028tasks:
2029 - id: mcp_call
2030 invoke:
2031 tool: novanet_context
2032 mcp: novanet
2033 params:
2034 entity: "qr-code"
2035 locale: "fr-FR"
2036"#;
2037 let workflow = parse(yaml, FileId(0)).unwrap();
2038 let task = workflow.get_task("mcp_call").unwrap();
2039
2040 match &task.value.action {
2041 Some(RawTaskAction::Invoke(action)) => {
2042 assert_eq!(action.value.tool.as_ref().unwrap().value, "novanet_context");
2043 assert_eq!(action.value.mcp.as_ref().unwrap().value, "novanet");
2044 assert!(action.value.params.is_some());
2045 }
2046 _ => panic!("Expected Invoke action"),
2047 }
2048 }
2049
2050 #[test]
2051 fn test_parse_agent_action() {
2052 let yaml = r#"
2053schema: "nika/workflow@0.12"
2054tasks:
2055 - id: research
2056 agent:
2057 prompt: "Research AI trends"
2058 tools:
2059 - nika:read
2060 - nika:write
2061 max_turns: 10
2062"#;
2063 let workflow = parse(yaml, FileId(0)).unwrap();
2064 let task = workflow.get_task("research").unwrap();
2065
2066 match &task.value.action {
2067 Some(RawTaskAction::Agent(action)) => {
2068 assert_eq!(action.value.prompt.value, "Research AI trends");
2069 let tools = action.value.tools.as_ref().unwrap();
2070 assert_eq!(tools.value.len(), 2);
2071 assert_eq!(tools.value[0].value, "nika:read");
2072 assert_eq!(action.value.max_turns.as_ref().unwrap().value, 10);
2073 }
2074 _ => panic!("Expected Agent action"),
2075 }
2076 }
2077
2078 #[test]
2079 fn test_parse_agent_prompt_is_primary_field() {
2080 let yaml = r#"
2081schema: "nika/workflow@0.12"
2082tasks:
2083 - id: research
2084 agent:
2085 prompt: "Research AI trends"
2086 max_turns: 10
2087"#;
2088 let workflow = parse(yaml, FileId(0)).unwrap();
2089 let task = workflow.get_task("research").unwrap();
2090
2091 match &task.value.action {
2092 Some(RawTaskAction::Agent(action)) => {
2093 assert_eq!(action.value.prompt.value, "Research AI trends");
2095 }
2096 _ => panic!("Expected Agent action"),
2097 }
2098 }
2099
2100 #[test]
2101 fn test_parse_agent_goal_removed() {
2102 let yaml = r#"
2103schema: "nika/workflow@0.12"
2104tasks:
2105 - id: research
2106 agent:
2107 goal: "Legacy goal syntax"
2108 max_turns: 5
2109"#;
2110 let result = parse(yaml, FileId(0));
2111 assert!(result.is_err(), "goal alias should be rejected");
2112 let err = result.unwrap_err();
2113 assert_eq!(err.kind, ParseErrorKind::MissingField);
2114 assert!(err.message.contains("prompt"));
2115 }
2116
2117 #[test]
2122 fn test_parse_with_refs_simple() {
2123 let yaml = r#"
2124schema: "nika/workflow@0.12"
2125tasks:
2126 - id: step1
2127 infer: "Generate"
2128 - id: step2
2129 with:
2130 data: step1
2131 infer: "Process {{with.data}}"
2132"#;
2133 let workflow = parse(yaml, FileId(0)).unwrap();
2134 let task = workflow.get_task("step2").unwrap();
2135
2136 let with_refs = task.value.with_refs.as_ref().unwrap();
2137 assert_eq!(with_refs.value.len(), 1);
2138
2139 let (alias, value) = with_refs.value.iter().next().unwrap();
2140 assert_eq!(alias.value, "data");
2141 assert_eq!(value.value, "step1");
2142 }
2143
2144 #[test]
2145 fn test_parse_with_refs_binding_expr() {
2146 let yaml = r#"
2147schema: "nika/workflow@0.12"
2148tasks:
2149 - id: step1
2150 infer: "Generate"
2151 - id: step2
2152 with:
2153 data: "step1"
2154 temp: "step1.data.temp ?? 20"
2155 cfg: "$env.API_KEY"
2156 val: "step1.output | upper | trim"
2157 infer: "Process"
2158"#;
2159 let workflow = parse(yaml, FileId(0)).unwrap();
2160 let task = workflow.get_task("step2").unwrap();
2161
2162 let with_refs = task.value.with_refs.as_ref().unwrap();
2163 assert_eq!(with_refs.value.len(), 4);
2164
2165 let vals: Vec<&str> = with_refs.value.values().map(|v| v.value.as_str()).collect();
2166 assert_eq!(vals[0], "step1");
2167 assert_eq!(vals[1], "step1.data.temp ?? 20");
2168 assert_eq!(vals[2], "$env.API_KEY");
2169 assert_eq!(vals[3], "step1.output | upper | trim");
2170 }
2171
2172 #[test]
2173 fn test_parse_depends_on_single() {
2174 let yaml = r#"
2175schema: "nika/workflow@0.12"
2176tasks:
2177 - id: step1
2178 infer: "Generate"
2179 - id: step2
2180 depends_on: step1
2181 infer: "Process"
2182"#;
2183 let workflow = parse(yaml, FileId(0)).unwrap();
2184 let task = workflow.get_task("step2").unwrap();
2185
2186 let deps = task.value.depends_on.as_ref().unwrap();
2187 assert_eq!(deps.value.len(), 1);
2188 assert_eq!(deps.value[0].value, "step1");
2189 }
2190
2191 #[test]
2192 fn test_parse_depends_on_multiple() {
2193 let yaml = r#"
2194schema: "nika/workflow@0.12"
2195tasks:
2196 - id: step1
2197 infer: "Step 1"
2198 - id: step2
2199 infer: "Step 2"
2200 - id: step3
2201 depends_on: [step1, step2]
2202 infer: "Process"
2203"#;
2204 let workflow = parse(yaml, FileId(0)).unwrap();
2205 let task = workflow.get_task("step3").unwrap();
2206
2207 let deps = task.value.depends_on.as_ref().unwrap();
2208 assert_eq!(deps.value.len(), 2);
2209 assert_eq!(deps.value[0].value, "step1");
2210 assert_eq!(deps.value[1].value, "step2");
2211 }
2212
2213 #[test]
2214 fn test_parse_imports() {
2215 let yaml = r#"
2216schema: "nika/workflow@0.12"
2217imports:
2218 - path: ./partials/setup.nika.yaml
2219 prefix: setup_
2220 - path: "pkg:@nika/core@1.0/seo.nika.yaml"
2221tasks:
2222 - id: main_task
2223 infer: "Main logic"
2224"#;
2225 let workflow = parse(yaml, FileId(0)).unwrap();
2226
2227 let imports = workflow.imports.as_ref().unwrap();
2228 assert_eq!(imports.value.len(), 2);
2229
2230 assert_eq!(
2231 imports.value[0].value.path.value,
2232 "./partials/setup.nika.yaml"
2233 );
2234 assert_eq!(
2235 imports.value[0].value.prefix.as_ref().unwrap().value,
2236 "setup_"
2237 );
2238
2239 assert_eq!(
2240 imports.value[1].value.path.value,
2241 "pkg:@nika/core@1.0/seo.nika.yaml"
2242 );
2243 assert!(imports.value[1].value.prefix.is_none());
2244 }
2245
2246 #[test]
2247 fn test_parse_inputs() {
2248 let yaml = r#"
2249schema: "nika/workflow@0.12"
2250inputs:
2251 locale: "fr-FR"
2252 max_items: 10
2253 debug: true
2254tasks:
2255 - id: main_task
2256 infer: "Main"
2257"#;
2258 let workflow = parse(yaml, FileId(0)).unwrap();
2259
2260 let inputs = workflow.inputs.as_ref().unwrap();
2261 assert_eq!(inputs.value.len(), 3);
2262
2263 let keys: Vec<&str> = inputs.value.keys().map(|k| k.value.as_str()).collect();
2264 assert_eq!(keys, vec!["locale", "max_items", "debug"]);
2265
2266 assert_eq!(
2267 inputs.value.values().next().unwrap().value,
2268 serde_json::Value::String("fr-FR".to_string())
2269 );
2270 }
2271
2272 #[test]
2273 fn test_parse_context_config() {
2274 let yaml = r#"
2275schema: "nika/workflow@0.12"
2276context:
2277 files:
2278 brand: ./context/brand.md
2279 data: ./context/data.json
2280tasks:
2281 - id: main
2282 infer: "Use brand: {{context.files.brand}}"
2283"#;
2284 let workflow = parse(yaml, FileId(0)).unwrap();
2285
2286 let ctx = workflow.context.as_ref().unwrap();
2287 let files = ctx.value.files.as_ref().unwrap();
2288 assert_eq!(files.len(), 2);
2289 assert!(files.values().any(|v| v.value == "./context/brand.md"));
2290 }
2291
2292 #[test]
2293 fn test_parse_pkg_config() {
2294 let yaml = r#"
2295schema: "nika/workflow@0.12"
2296pkg:
2297 include:
2298 - "github:user/repo"
2299 - "local:./path"
2300tasks:
2301 - id: main
2302 infer: "Main"
2303"#;
2304 let workflow = parse(yaml, FileId(0)).unwrap();
2305
2306 let pkg = workflow.pkg.as_ref().unwrap();
2307 assert_eq!(pkg.value.include.len(), 2);
2308 assert_eq!(pkg.value.include[0].value, "github:user/repo");
2309 }
2310
2311 #[test]
2312 fn test_parse_for_each_array() {
2313 let yaml = r#"
2314schema: "nika/workflow@0.12"
2315tasks:
2316 - id: parallel
2317 for_each: ["a", "b", "c"]
2318 as: item
2319 concurrency: 3
2320 infer: "Process {{with.item}}"
2321"#;
2322 let workflow = parse(yaml, FileId(0)).unwrap();
2323 let task = workflow.get_task("parallel").unwrap();
2324
2325 let for_each = task.value.for_each.as_ref().unwrap();
2326 assert!(for_each.value.items.value.contains("["));
2327 assert_eq!(for_each.value.as_var.as_ref().unwrap().value, "item");
2328 assert_eq!(for_each.value.concurrency.as_ref().unwrap().value, 3);
2329 }
2330
2331 #[test]
2332 fn test_parse_for_each_binding() {
2333 let yaml = r#"
2334schema: "nika/workflow@0.12"
2335tasks:
2336 - id: parallel
2337 for_each: "{{with.items}}"
2338 infer: "Process"
2339"#;
2340 let workflow = parse(yaml, FileId(0)).unwrap();
2341 let task = workflow.get_task("parallel").unwrap();
2342
2343 let for_each = task.value.for_each.as_ref().unwrap();
2344 assert_eq!(for_each.value.items.value, "{{with.items}}");
2345 }
2346
2347 #[test]
2348 fn test_parse_retry_config() {
2349 let yaml = r#"
2350schema: "nika/workflow@0.12"
2351tasks:
2352 - id: resilient
2353 retry:
2354 max_attempts: 3
2355 delay_ms: 1000
2356 backoff: 2.0
2357 infer: "Generate"
2358"#;
2359 let workflow = parse(yaml, FileId(0)).unwrap();
2360 let task = workflow.get_task("resilient").unwrap();
2361
2362 let retry = task.value.retry.as_ref().unwrap();
2363 assert_eq!(retry.value.max_attempts.as_ref().unwrap().value, 3);
2364 assert_eq!(retry.value.delay_ms.as_ref().unwrap().value, 1000);
2365 assert!((retry.value.backoff.as_ref().unwrap().value - 2.0).abs() < 0.001);
2366 }
2367
2368 #[test]
2369 fn test_parse_output_config() {
2370 let yaml = r#"
2371schema: "nika/workflow@0.12"
2372tasks:
2373 - id: structured
2374 output:
2375 format: json
2376 schema:
2377 type: object
2378 properties:
2379 name:
2380 type: string
2381 infer: "Generate JSON"
2382"#;
2383 let workflow = parse(yaml, FileId(0)).unwrap();
2384 let task = workflow.get_task("structured").unwrap();
2385
2386 let output = task.value.output.as_ref().unwrap();
2387 assert_eq!(output.value.format.as_ref().unwrap().value, "json");
2388 assert!(output.value.schema.is_some());
2389 }
2390
2391 #[test]
2396 fn test_parse_infer_missing_prompt() {
2397 let yaml = r#"
2398schema: "nika/workflow@0.12"
2399tasks:
2400 - id: generate
2401 infer:
2402 temperature: 0.7
2403"#;
2404 let result = parse(yaml, FileId(0));
2405 assert!(result.is_err());
2406
2407 let err = result.unwrap_err();
2408 assert_eq!(err.kind, ParseErrorKind::MissingField);
2409 assert!(err.message.contains("prompt"));
2410 }
2411
2412 #[test]
2413 fn test_parse_fetch_missing_url() {
2414 let yaml = r#"
2415schema: "nika/workflow@0.12"
2416tasks:
2417 - id: api_call
2418 fetch:
2419 method: GET
2420"#;
2421 let result = parse(yaml, FileId(0));
2422 assert!(result.is_err());
2423
2424 let err = result.unwrap_err();
2425 assert_eq!(err.kind, ParseErrorKind::MissingField);
2426 assert!(err.message.contains("url"));
2427 }
2428
2429 #[test]
2430 fn test_parse_invoke_missing_tool() {
2431 let yaml = r#"
2432schema: "nika/workflow@0.12"
2433tasks:
2434 - id: mcp_call
2435 invoke:
2436 mcp: novanet
2437"#;
2438 let result = parse(yaml, FileId(0));
2439 assert!(result.is_err());
2440
2441 let err = result.unwrap_err();
2442 assert_eq!(err.kind, ParseErrorKind::MissingField);
2443 assert!(err.message.contains("tool"));
2444 }
2445
2446 #[test]
2447 fn test_parse_agent_missing_prompt() {
2448 let yaml = r#"
2449schema: "nika/workflow@0.12"
2450tasks:
2451 - id: research
2452 agent:
2453 tools: [nika:read]
2454"#;
2455 let result = parse(yaml, FileId(0));
2456 assert!(result.is_err());
2457
2458 let err = result.unwrap_err();
2459 assert_eq!(err.kind, ParseErrorKind::MissingField);
2460 assert!(err.message.contains("prompt"));
2461 }
2462
2463 #[test]
2464 fn test_parse_rejects_multiple_verbs_in_task() {
2465 let yaml = r#"
2466schema: "nika/workflow@0.12"
2467tasks:
2468 - id: ambiguous
2469 infer: "Generate something"
2470 exec: "echo hello"
2471"#;
2472 let result = parse(yaml, FileId(0));
2473 assert!(
2474 result.is_err(),
2475 "task with multiple verbs should be rejected"
2476 );
2477 let err = result.unwrap_err();
2478 assert_eq!(err.kind, ParseErrorKind::InvalidType);
2479 assert!(
2480 err.message.contains("multiple verbs"),
2481 "error should mention multiple verbs, got: {}",
2482 err.message
2483 );
2484 }
2485
2486 #[test]
2487 fn test_parse_invalid_temperature() {
2488 let yaml = r#"
2489schema: "nika/workflow@0.12"
2490tasks:
2491 - id: generate
2492 infer:
2493 prompt: "Test"
2494 temperature: not_a_number
2495"#;
2496 let result = parse(yaml, FileId(0));
2497 assert!(result.is_err());
2498
2499 let err = result.unwrap_err();
2500 assert_eq!(err.kind, ParseErrorKind::InvalidType);
2501 }
2502
2503 #[test]
2504 fn parse_rejects_yaml_nan_temperature() {
2505 let yaml = r#"
2507schema: "nika/workflow@0.12"
2508tasks:
2509 - id: test
2510 infer:
2511 prompt: "hello"
2512 temperature: .nan
2513"#;
2514 let result = parse(yaml, FileId(0));
2515 assert!(result.is_err(), "YAML .nan temperature should be rejected");
2516 }
2517
2518 #[test]
2519 fn parse_rejects_nan_string_temperature() {
2520 let yaml = r#"
2522schema: "nika/workflow@0.12"
2523tasks:
2524 - id: test
2525 infer:
2526 prompt: "hello"
2527 temperature: NaN
2528"#;
2529 let result = parse(yaml, FileId(0));
2530 assert!(result.is_err(), "NaN temperature should be rejected");
2531 let err = result.unwrap_err();
2532 assert!(
2533 err.message.contains("finite") || err.message.contains("number"),
2534 "Error should mention finite or number: {}",
2535 err.message
2536 );
2537 }
2538
2539 #[test]
2540 fn parse_rejects_infinity_temperature() {
2541 let yaml = r#"
2542schema: "nika/workflow@0.12"
2543tasks:
2544 - id: test
2545 infer:
2546 prompt: "hello"
2547 temperature: .inf
2548"#;
2549 let result = parse(yaml, FileId(0));
2550 assert!(result.is_err(), "Infinity temperature should be rejected");
2551 }
2552
2553 #[test]
2554 fn parse_rejects_inf_string_temperature() {
2555 let yaml = "schema: \"nika/workflow@0.12\"\ntasks:\n - id: test\n infer:\n prompt: \"hello\"\n temperature: inf\n";
2557 let result = parse(yaml, FileId(0));
2558 assert!(result.is_err(), "inf temperature should be rejected");
2559 }
2560
2561 #[test]
2562 fn parse_rejects_negative_infinity_temperature() {
2563 let yaml = r#"
2564schema: "nika/workflow@0.12"
2565tasks:
2566 - id: test
2567 infer:
2568 prompt: "hello"
2569 temperature: -.inf
2570"#;
2571 let result = parse(yaml, FileId(0));
2572 assert!(result.is_err(), "Negative infinity should be rejected");
2573 }
2574
2575 #[test]
2578 fn parse_empty_string_errors() {
2579 let result = parse("", FileId(0));
2580 assert!(result.is_err(), "empty string should fail to parse");
2581 }
2582
2583 #[test]
2584 fn parse_yaml_array_instead_of_map() {
2585 let result = parse("- item1\n- item2", FileId(0));
2586 assert!(result.is_err(), "YAML array root should be rejected");
2587 }
2588
2589 #[test]
2590 fn parse_temperature_zero_is_valid() {
2591 let yaml = r#"
2592schema: "nika/workflow@0.12"
2593tasks:
2594 - id: t
2595 infer:
2596 prompt: "hi"
2597 temperature: 0.0
2598"#;
2599 let result = parse(yaml, FileId(0));
2600 assert!(result.is_ok(), "temperature 0.0 should be valid");
2601 }
2602
2603 #[test]
2604 fn parse_temperature_one_is_valid() {
2605 let yaml = r#"
2606schema: "nika/workflow@0.12"
2607tasks:
2608 - id: t
2609 infer:
2610 prompt: "hi"
2611 temperature: 1.0
2612"#;
2613 let result = parse(yaml, FileId(0));
2614 assert!(result.is_ok(), "temperature 1.0 should be valid");
2615 }
2616
2617 #[test]
2618 fn parse_whitespace_only_errors() {
2619 let result = parse(" \n\n \t ", FileId(0));
2620 assert!(result.is_err(), "whitespace-only input should fail");
2621 }
2622
2623 #[test]
2628 fn parse_infer_with_content_text_and_image() {
2629 let yaml = r#"
2630schema: "nika/workflow@0.12"
2631workflow: vision-test
2632provider: claude
2633model: claude-sonnet-4-6
2634tasks:
2635 - id: describe
2636 infer:
2637 content:
2638 - type: text
2639 text: "Describe this image"
2640 - type: image
2641 source: "blake3:abc123"
2642 detail: high
2643"#;
2644 let result = parse(yaml, FileId(0));
2645 assert!(
2646 result.is_ok(),
2647 "vision content should parse: {:?}",
2648 result.err()
2649 );
2650 let wf = result.unwrap();
2651 let task = &wf.tasks.value[0];
2652 match &task.value.action {
2653 Some(RawTaskAction::Infer(s)) => {
2654 let content = s.value.content.as_ref().expect("content should be Some");
2655 assert_eq!(content.value.len(), 2);
2656 }
2657 other => panic!("expected Some(Infer), got {:?}", other),
2658 }
2659 }
2660
2661 #[test]
2662 fn parse_infer_content_only_no_prompt() {
2663 let yaml = r#"
2664schema: "nika/workflow@0.12"
2665workflow: vision-no-prompt
2666provider: claude
2667model: claude-sonnet-4-6
2668tasks:
2669 - id: t
2670 infer:
2671 content:
2672 - type: text
2673 text: "What is this?"
2674"#;
2675 let result = parse(yaml, FileId(0));
2676 assert!(
2677 result.is_ok(),
2678 "content without prompt should parse: {:?}",
2679 result.err()
2680 );
2681 let wf = result.unwrap();
2682 let task = &wf.tasks.value[0];
2683 match &task.value.action {
2684 Some(RawTaskAction::Infer(s)) => {
2685 assert!(
2686 s.value.prompt.value.is_empty(),
2687 "prompt should be empty string"
2688 );
2689 assert!(s.value.content.is_some(), "content should be present");
2690 }
2691 other => panic!("expected Some(Infer), got {:?}", other),
2692 }
2693 }
2694
2695 #[test]
2696 fn parse_infer_prompt_and_content() {
2697 let yaml = r#"
2698schema: "nika/workflow@0.12"
2699workflow: both
2700provider: claude
2701model: claude-sonnet-4-6
2702tasks:
2703 - id: t
2704 infer:
2705 prompt: "Analyze carefully"
2706 content:
2707 - type: image
2708 source: "blake3:xyz"
2709"#;
2710 let result = parse(yaml, FileId(0));
2711 assert!(
2712 result.is_ok(),
2713 "prompt+content should parse: {:?}",
2714 result.err()
2715 );
2716 let wf = result.unwrap();
2717 let task = &wf.tasks.value[0];
2718 match &task.value.action {
2719 Some(RawTaskAction::Infer(s)) => {
2720 assert_eq!(s.value.prompt.value, "Analyze carefully");
2721 assert!(s.value.content.is_some());
2722 }
2723 other => panic!("expected Some(Infer), got {:?}", other),
2724 }
2725 }
2726
2727 #[test]
2728 fn parse_infer_shorthand_still_works() {
2729 let yaml = r#"
2730schema: "nika/workflow@0.12"
2731workflow: shorthand
2732provider: claude
2733model: claude-sonnet-4-6
2734tasks:
2735 - id: t
2736 infer: "Just a simple prompt"
2737"#;
2738 let result = parse(yaml, FileId(0));
2739 assert!(result.is_ok());
2740 let wf = result.unwrap();
2741 match &wf.tasks.value[0].value.action {
2742 Some(RawTaskAction::Infer(s)) => {
2743 assert_eq!(s.value.prompt.value, "Just a simple prompt");
2744 assert!(s.value.content.is_none());
2745 }
2746 other => panic!("expected Some(Infer), got {:?}", other),
2747 }
2748 }
2749
2750 #[test]
2751 fn parse_infer_neither_prompt_nor_content_errors() {
2752 let yaml = r#"
2753schema: "nika/workflow@0.12"
2754workflow: err
2755provider: claude
2756model: claude-sonnet-4-6
2757tasks:
2758 - id: t
2759 infer:
2760 temperature: 0.5
2761"#;
2762 let result = parse(yaml, FileId(0));
2763 assert!(result.is_err(), "neither prompt nor content should fail");
2764 let err = result.unwrap_err();
2765 assert!(err.message.contains("prompt") || err.message.contains("content"));
2766 }
2767
2768 #[test]
2769 fn parse_infer_content_invalid_type_errors() {
2770 let yaml = r#"
2771schema: "nika/workflow@0.12"
2772workflow: err
2773provider: claude
2774model: claude-sonnet-4-6
2775tasks:
2776 - id: t
2777 infer:
2778 content:
2779 - type: video
2780 url: "https://example.com"
2781"#;
2782 let result = parse(yaml, FileId(0));
2783 assert!(result.is_err(), "unknown content type should fail");
2784 let err = result.unwrap_err();
2785 assert!(err.message.contains("unknown content part type"));
2786 }
2787
2788 #[test]
2789 fn parse_infer_content_empty_sequence_errors() {
2790 let yaml = r#"
2791schema: "nika/workflow@0.12"
2792workflow: err
2793provider: claude
2794model: claude-sonnet-4-6
2795tasks:
2796 - id: t
2797 infer:
2798 content: []
2799"#;
2800 let result = parse(yaml, FileId(0));
2801 assert!(result.is_err(), "empty content should fail");
2802 }
2803
2804 #[test]
2805 fn parse_infer_content_image_url_part() {
2806 let yaml = r#"
2807schema: "nika/workflow@0.12"
2808workflow: url
2809provider: openai
2810model: gpt-4o
2811tasks:
2812 - id: t
2813 infer:
2814 content:
2815 - type: image_url
2816 url: "https://example.com/photo.jpg"
2817 detail: low
2818 - type: text
2819 text: "What is in this photo?"
2820"#;
2821 let result = parse(yaml, FileId(0));
2822 assert!(result.is_ok(), "image_url should parse: {:?}", result.err());
2823 }
2824
2825 #[test]
2826 fn test_parse_infer_with_guardrails() {
2827 let yaml = r#"
2828schema: "nika/workflow@0.12"
2829tasks:
2830 - id: summarize
2831 infer:
2832 prompt: "Summarize this article"
2833 guardrails:
2834 - type: length
2835 min_words: 50
2836 max_words: 200
2837 - type: regex
2838 pattern: "^Summary:"
2839 message: "Output must start with 'Summary:'"
2840"#;
2841 let workflow = parse(yaml, FileId(0)).unwrap();
2842 let task = workflow.get_task("summarize").unwrap();
2843
2844 match &task.value.action {
2845 Some(RawTaskAction::Infer(action)) => {
2846 assert_eq!(action.value.prompt.value, "Summarize this article");
2847 assert_eq!(action.value.guardrails.len(), 2);
2848 assert_eq!(action.value.guardrails[0].guardrail_type(), "length");
2849 assert_eq!(action.value.guardrails[1].guardrail_type(), "regex");
2850 }
2851 _ => panic!("Expected Infer action"),
2852 }
2853 }
2854
2855 #[test]
2856 fn test_parse_infer_shorthand_no_guardrails() {
2857 let yaml = r#"
2858schema: "nika/workflow@0.12"
2859tasks:
2860 - id: quick
2861 infer: "Generate a headline"
2862"#;
2863 let workflow = parse(yaml, FileId(0)).unwrap();
2864 let task = workflow.get_task("quick").unwrap();
2865
2866 match &task.value.action {
2867 Some(RawTaskAction::Infer(action)) => {
2868 assert!(
2869 action.value.guardrails.is_empty(),
2870 "Shorthand infer should have no guardrails"
2871 );
2872 }
2873 _ => panic!("Expected Infer action"),
2874 }
2875 }
2876
2877 #[test]
2878 fn test_parse_infer_guardrails_on_failure_fail() {
2879 let yaml = r#"
2880schema: "nika/workflow@0.12"
2881tasks:
2882 - id: strict
2883 infer:
2884 prompt: "Generate strict output"
2885 guardrails:
2886 - type: length
2887 min_words: 10
2888 on_failure: fail
2889"#;
2890 let workflow = parse(yaml, FileId(0)).unwrap();
2891 let task = workflow.get_task("strict").unwrap();
2892
2893 match &task.value.action {
2894 Some(RawTaskAction::Infer(action)) => {
2895 assert_eq!(action.value.guardrails.len(), 1);
2896 assert_eq!(
2897 action.value.guardrails[0].on_failure(),
2898 crate::ast::guardrails::OnFailure::Fail
2899 );
2900 }
2901 _ => panic!("Expected Infer action"),
2902 }
2903 }
2904}