Skip to main content

nika_core/ast/raw/
parser.rs

1//! YAML parser with span tracking using marked_yaml.
2//!
3//! This module provides the entry point for parsing YAML workflows
4//! into the raw AST with full source position tracking.
5
6use 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/// Errors that can occur during parsing.
20#[derive(Debug, Clone)]
21pub struct ParseError {
22    /// The error kind
23    pub kind: ParseErrorKind,
24    /// Location of the error (if available)
25    pub span: Span,
26    /// Error message
27    pub message: String,
28}
29
30/// Kinds of parse errors.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum ParseErrorKind {
33    /// YAML syntax error
34    Syntax,
35    /// Missing required field
36    MissingField,
37    /// Invalid field type
38    InvalidType,
39    /// Unknown field
40    UnknownField,
41    /// Invalid schema version
42    InvalidSchema,
43}
44
45impl ParseErrorKind {
46    /// Get the error code for this kind.
47    ///
48    /// Parse-phase errors use NIKA-160..164 to avoid collision with
49    /// the top-level NikaError workflow codes (NIKA-001..005).
50    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
69/// Convert a marked_yaml Marker to our ByteOffset.
70fn marker_to_offset(marker: &Marker) -> ByteOffset {
71    // marked_yaml uses character() for byte offset
72    ByteOffset::new(marker.character() as u32)
73}
74
75/// Convert a marked_yaml Marker to a point Span.
76fn 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
85/// Extract span from a LoadError if it carries a Marker.
86///
87/// Most LoadError variants include position information:
88/// - TopLevelMustBeMapping(Marker)
89/// - TopLevelMustBeSequence(Marker)
90/// - UnexpectedAnchor(Marker)
91/// - MappingKeyMustBeScalar(Marker)
92/// - UnexpectedTag(Marker)
93/// - ScanError(Marker, _)
94/// - DuplicateKey(Box<DuplicateKeyInner>)
95fn 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            // DuplicateKeyInner has key: MarkedScalarNode, use its span
105            marked_span_to_span(file, inner.key.span())
106        }
107    }
108}
109
110/// Convert a marked_yaml Span to our Span.
111fn 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), // Point span
122        },
123        _ => Span::dummy(),
124    }
125}
126
127/// Convert a marked_yaml Node span to our Span.
128fn node_to_span(file: FileId, node: &Node) -> Span {
129    marked_span_to_span(file, node.span())
130}
131
132/// Extract a spanned string from a YAML scalar node.
133fn 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
145/// Get an optional string field from a mapping by key.
146fn 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
157/// Get an optional f64 field from a mapping.
158fn 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
189/// Get an optional u32 field from a mapping.
190fn 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
214/// Get an optional u64 field from a mapping.
215fn 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
239/// Get an optional bool field from a mapping.
240fn 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/// Parse a string-to-string mapping (for headers, env vars).
271#[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
300/// Parse an array of strings.
301fn 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
322/// Parse a JSON value from a node.
323fn 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
338/// Convert a YAML node to a JSON value.
339fn node_to_json(node: &Node) -> serde_json::Value {
340    match node {
341        Node::Scalar(s) => {
342            let str_val = s.as_str();
343            // Try parsing as different types
344            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
374// ============================================================================
375// Action Parsing (5 Verbs)
376// ============================================================================
377
378/// Parse the task action from the mapping (dispatches to verb-specific parsers).
379fn parse_action(
380    file: FileId,
381    map: &marked_yaml::types::MarkedMappingNode,
382) -> Result<Option<RawTaskAction>, ParseError> {
383    // Reject tasks with multiple verbs (must have exactly 0 or 1)
384    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    // Check for infer verb
403    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        // For shorthand `infer: "prompt"`, merge task-level LLM fields that
408        // would otherwise be silently ignored (they are siblings in the task
409        // mapping, not children of the infer node).
410        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    // Check for exec verb
434    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    // Check for fetch verb
440    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    // Check for invoke verb
446    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    // Check for agent verb
452    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    // No verb found — check for common misspellings before returning None.
461    // Known non-verb task keys that are legitimate without a verb (e.g. decompose tasks).
462    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        // Check if any unrecognized key looks like a misspelled verb
490        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
524/// Check if `input` is a likely misspelling of `target` using edit distance.
525/// Returns true if the strings are within edit distance 2 and share a common prefix.
526fn 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    // Simple Levenshtein distance check (bounded to 2)
535    levenshtein_bounded(input, target, 2) <= 2
536}
537
538/// Bounded Levenshtein distance. Returns distance or `bound + 1` if exceeded.
539fn 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
572/// Parse infer action - supports both shorthand (string) and full form (mapping).
573///
574/// # Content / Prompt Rules
575///
576/// - `infer: "prompt"` — shorthand, text-only
577/// - `infer: { prompt: "..." }` — full form, text-only
578/// - `infer: { content: [...] }` — vision mode, prompt optional
579/// - `infer: { prompt: "...", content: [...] }` — prompt prepended as first Text part
580/// - Error if neither `prompt` nor `content` is present
581fn parse_infer_action(file: FileId, node: &Node) -> Result<RawInferAction, ParseError> {
582    let span = node_to_span(file, node);
583
584    match node {
585        // Shorthand: infer: "prompt string"
586        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        // Full form: infer: { prompt: "...", temperature: ..., content: [...] }
598        Node::Mapping(m) => {
599            let prompt = get_string_field(file, m, "prompt")?;
600            let content = parse_content_field(file, m)?;
601
602            // Require at least one of prompt or content
603            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            // If content is present but no prompt, use empty prompt (validated later)
612            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
636/// Parse the `content:` field from an infer mapping.
637///
638/// Returns `None` if the field is absent. Parses a YAML sequence where each
639/// element is a mapping with `type`, and type-specific fields.
640fn 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
738/// Parse exec action - supports both shorthand (string) and full form (mapping).
739fn parse_exec_action(file: FileId, node: &Node) -> Result<RawExecAction, ParseError> {
740    let span = node_to_span(file, node);
741
742    match node {
743        // Shorthand: exec: "command string"
744        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        // Full form
752        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 is the primary field (milliseconds).
765                // timeout is the schema alias (seconds) — convert to ms.
766                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
781/// Parse fetch action - always requires a mapping.
782fn 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
823/// Parse invoke action - always requires a mapping.
824fn 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
862/// Parse agent action - always requires a mapping.
863fn 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// ============================================================================
909// with:/depends_on:/for_each:/retry:/output: Parsing
910// ============================================================================
911
912/// Parse with: bindings.
913///
914/// Values are raw strings parsed by `parse_with_entry()` in Phase 2 (analyzer).
915/// Examples:
916///   - `data: step1` — simple task reference
917///   - `temp: step1.data.temp ?? 20` — path + default
918///   - `cfg: $env.API_KEY` — environment binding
919///   - `val: step1.output | upper | trim` — with transforms
920#[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
928/// Parse depends_on: ordering dependencies.
929///
930/// Pure ordering edges — no data flows through them.
931/// Data dependencies are expressed via `with:` bindings.
932fn 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
958/// Parse for_each: iteration.
959fn 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            // Array literal - serialize to JSON string for storage
967            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
1005/// Parse retry: configuration.
1006fn 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
1039/// Parse decompose: configuration.
1040fn 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
1110/// Parse output: configuration.
1111fn 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
1137/// Parse structured: configuration (StructuredOutputSpec).
1138///
1139/// Supports shorthand (string path) or full mapping form:
1140/// ```yaml
1141/// structured: ./schemas/user.json          # shorthand
1142/// structured:                               # full form
1143///   schema: ./schemas/user.json
1144///   max_retries: 3
1145/// ```
1146fn 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            // Convert YAML node to JSON value, then deserialize via serde_json.
1154            // StructuredOutputSpec's custom Deserialize handles both string and map forms.
1155            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
1168/// Parse the `guardrails:` field from an infer or agent mapping.
1169///
1170/// Guardrails are a YAML sequence of objects, each with a `type` field.
1171/// Uses serde deserialization via `GuardrailConfig` which is `#[serde(tag = "type")]`.
1172///
1173/// Returns an empty Vec if the field is absent.
1174fn 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
1212// ============================================================================
1213// Main Parser
1214// ============================================================================
1215
1216/// Parse a YAML source string into a RawWorkflow.
1217///
1218/// This is the main entry point for Phase 1 parsing. The returned
1219/// RawWorkflow contains full span information for all nodes.
1220///
1221/// # Arguments
1222///
1223/// * `source` - The YAML source content
1224/// * `file_id` - The FileId from the SourceRegistry
1225///
1226/// # Returns
1227///
1228/// A RawWorkflow with span tracking, or a ParseError with location.
1229///
1230/// # Example
1231///
1232/// ```ignore
1233/// use nika::ast::raw;
1234/// use nika::source::SourceRegistry;
1235///
1236/// let mut sources = SourceRegistry::new();
1237/// let file_id = sources.add_file("workflow.yaml", content.clone());
1238/// let workflow = raw::parse(&content, file_id)?;
1239/// ```
1240pub fn parse(source: &str, file_id: FileId) -> Result<RawWorkflow, ParseError> {
1241    // Parse YAML with marked_yaml
1242    // The first argument is a source ID (we use file_id.0)
1243    let node = parse_yaml(file_id.0 as usize, source).map_err(|e| {
1244        // Extract span from LoadError variants that carry a Marker
1245        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    // The root must be a mapping
1254    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    // Parse workflow fields
1266    let mut workflow = RawWorkflow::default();
1267    workflow.span = node_to_span(file_id, &node);
1268
1269    // Extract schema (required)
1270    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    // Extract optional fields
1277    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    // Parse MCP server configurations
1283    workflow.mcp = parse_mcp_config(file_id, map)?;
1284
1285    // Parse pkg configuration
1286    workflow.pkg = parse_pkg_config(file_id, map)?;
1287
1288    // Parse context configuration
1289    workflow.context = parse_context_config(file_id, map)?;
1290
1291    // Parse imports
1292    workflow.imports = parse_imports(file_id, map)?;
1293
1294    // Parse inputs
1295    workflow.inputs = parse_inputs(file_id, map)?;
1296
1297    // Parse artifacts config
1298    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    // Parse log config
1307    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    // Parse agents config
1316    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    // Parse workflow-level skills mapping (alias -> path)
1325    workflow.skills = parse_string_map(file_id, map, "skills")?;
1326
1327    // Parse tasks
1328    workflow.tasks = parse_tasks(file_id, map)?;
1329
1330    Ok(workflow)
1331}
1332
1333/// Parse the MCP configuration block from a workflow mapping.
1334fn parse_mcp_config(
1335    file_id: FileId,
1336    map: &marked_yaml::types::MarkedMappingNode,
1337) -> Result<Option<Spanned<RawMcpConfig>>, ParseError> {
1338    // Look for "mcp:" mapping
1339    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    // Support both formats:
1359    //   Nested: mcp: { servers: { novanet: { command: ... } } }
1360    //   Flat:   mcp: { novanet: { command: ... } }
1361    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        // Flat format: entries directly under mcp:
1374        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
1390/// Parse a single MCP server configuration.
1391fn 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
1417/// Parse pkg: configuration.
1418fn 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
1447/// Parse context: configuration.
1448fn 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
1474/// Parse imports: specification.
1475///
1476/// ```yaml
1477/// imports:
1478///   - path: ./partials/setup.nika.yaml
1479///     prefix: setup_
1480///   - path: pkg:@nika/core@1.0/seo.nika.yaml
1481/// ```
1482fn 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/// Parse inputs: parameters with defaults.
1541///
1542/// ```yaml
1543/// inputs:
1544///   locale: "fr-FR"
1545///   max_items: 10
1546/// ```
1547#[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
1582/// Parse the tasks array from a workflow mapping.
1583fn 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            // No tasks field - return empty array with dummy span
1613            Ok(Spanned::dummy(Vec::new()))
1614        }
1615    }
1616}
1617
1618/// Parse a single task from a YAML node.
1619fn 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    // Extract task id (required)
1634    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    // Extract optional fields
1641    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    // Parse all task fields
1646    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    // Parse artifact: config (task-level artifact output)
1656    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    // Parse log: config (task-level log override)
1666    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    // Parse standalone concurrency/fail_fast (used with decompose when no for_each)
1676    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        // Check spans are not dummy
1748        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        // Check that schema has correct span
1801        let schema_span = workflow.schema.span;
1802        assert!(!schema_span.is_dummy());
1803
1804        // Check span bounds are reasonable
1805        assert!(schema_span.start.0 <= schema_span.end.0);
1806    }
1807
1808    // =========================================================================
1809    // Verb Parsing Tests (5 Verbs)
1810    // =========================================================================
1811
1812    #[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); // 5 seconds * 1000
2017                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                // Field must be named `prompt`, not `goal`
2094                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    // =========================================================================
2118    // Task Configuration Parsing Tests
2119    // =========================================================================
2120
2121    #[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    // =========================================================================
2392    // Error Cases
2393    // =========================================================================
2394
2395    #[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        // YAML .nan is rendered as ".nan" by marked_yaml, rejected by Rust's parse::<f64>()
2506        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        // "NaN" parses as f64::NaN successfully — caught by is_finite() check
2521        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        // "inf" parses as f64::INFINITY — caught by is_finite() check
2556        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    // --- Edge cases: empty/malformed input ---
2576
2577    #[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    // =========================================================================
2624    // Vision / Content Parsing Tests
2625    // =========================================================================
2626
2627    #[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}