Skip to main content

nika_engine/ast/
workflow.rs

1//! Workflow Types - main workflow structure
2//!
3//! Contains the core YAML-parsed types:
4//! - `Workflow`: Root workflow with tasks (edges derived from `task.depends_on`)
5//! - `Task`: Individual task definition
6//! - `McpConfigInline`: Inline MCP server configuration
7//! - `ContextConfig`: File loading at workflow start
8//! - `IncludeSpec`: DAG fusion from external workflows
9
10use rustc_hash::FxHashMap;
11use std::sync::Arc;
12
13use serde::Deserialize;
14
15use crate::binding::WithSpec;
16use crate::error::NikaError;
17
18use super::action::TaskAction;
19use super::decompose::DecomposeSpec;
20use super::output::OutputPolicy;
21
22/// Inline MCP server configuration
23///
24/// Allows workflows to define MCP servers directly in YAML.
25/// The server name is the map key in the `mcp` field.
26///
27/// # Example
28///
29/// ```yaml
30/// mcp:
31///   novanet:
32///     command: cargo
33///     args: [run, -p, novanet-mcp]
34///     env:
35///       NEO4J_URI: bolt://localhost:7687
36/// ```
37/// MCP server inline configuration — re-exported from nika_mcp.
38pub type McpConfigInline = nika_mcp::McpConfigInline;
39
40/// Workflow with Arc-wrapped tasks for efficient cloning
41#[derive(Debug, Clone)]
42pub struct Workflow {
43    pub schema: String,
44    pub name: Option<String>,
45    pub provider: String,
46    pub model: Option<String>,
47    /// MCP server configurations
48    ///
49    /// Allows workflows to define MCP servers inline rather than
50    /// referencing external configuration. The map key is the server
51    /// name used in `invoke.mcp` fields.
52    pub mcp: Option<FxHashMap<String, McpConfigInline>>,
53    /// Context configuration for file loading at workflow start
54    ///
55    /// Files are loaded into the RunContext at workflow start and accessible
56    /// via `{{context.files.alias}}` bindings.
57    pub context: Option<super::context::ContextConfig>,
58    /// Include external workflows for DAG fusion
59    ///
60    /// Included workflows have their tasks merged into the main DAG
61    /// at parse time. They share the same RunContext.
62    pub include: Option<Vec<super::include::IncludeSpec>>,
63    /// Reusable agent definitions
64    ///
65    /// Named agent configurations that can be referenced by tasks.
66    /// Agents can be inline definitions or file references.
67    pub agents: Option<FxHashMap<String, super::agent_def::AgentDef>>,
68    /// Skill file mappings for prompt augmentation
69    ///
70    /// Named skill files that can be injected into agent system prompts.
71    pub skills: Option<FxHashMap<String, super::skill_def::SkillDef>>,
72    /// Artifact configuration for file persistence
73    ///
74    /// Workflow-level defaults for artifact output.
75    pub artifacts: Option<super::artifact::ArtifactsConfig>,
76    /// Log configuration
77    ///
78    /// Workflow-level logging configuration.
79    pub log: Option<super::logging::LogConfig>,
80    /// Input parameters with defaults
81    ///
82    /// Maps parameter names to their definitions. Each definition is a JSON object
83    /// with type, default, description, and optional enum values.
84    /// Accessible via `{{inputs.param_name}}` in templates.
85    pub inputs: Option<FxHashMap<String, serde_json::Value>>,
86    pub tasks: Vec<Arc<Task>>,
87}
88
89impl Workflow {
90    /// Compute a hash of the workflow for cache invalidation
91    ///
92    /// Uses xxhash3 for fast hashing. The hash is computed from:
93    /// - Schema version
94    /// - Provider + model
95    /// - Task count and IDs
96    ///
97    /// Returns a 16-character hex string (64-bit hash).
98    pub fn compute_hash(&self) -> String {
99        use xxhash_rust::xxh3::xxh3_64;
100
101        let mut hasher_input = String::new();
102        hasher_input.push_str(&self.schema);
103        hasher_input.push_str(&self.provider);
104        if let Some(ref model) = self.model {
105            hasher_input.push_str(model);
106        }
107        hasher_input.push_str(&self.tasks.len().to_string());
108        for task in &self.tasks {
109            hasher_input.push_str(&task.id);
110        }
111
112        let hash = xxh3_64(hasher_input.as_bytes());
113        format!("{:016x}", hash)
114    }
115
116    /// Compute DAG edge count from per-task dependencies.
117    pub fn flow_count(&self) -> usize {
118        self.tasks
119            .iter()
120            .map(|t| t.depends_on.as_ref().map_or(0, |deps| deps.len()))
121            .sum()
122    }
123
124    /// Iterate over DAG edges as (source, target) pairs.
125    ///
126    /// Built from each task's `depends_on` field.
127    pub fn edges(&self) -> Vec<(&str, &str)> {
128        let mut edges = Vec::new();
129        for task in &self.tasks {
130            if let Some(ref deps) = task.depends_on {
131                for dep in deps {
132                    edges.push((dep.as_str(), task.id.as_str()));
133                }
134            }
135        }
136        edges
137    }
138}
139
140#[derive(Debug, Clone, Deserialize)]
141pub struct Task {
142    pub id: String,
143    /// Typed binding system
144    ///
145    /// `with:` block for binding task outputs to local aliases.
146    ///
147    /// # Example
148    ///
149    /// ```yaml
150    /// tasks:
151    ///   - id: process
152    ///     with:
153    ///       summary: $step1.abstract | lower | trim
154    ///       count: $step1.items | length ?? 0
155    ///     infer: "Process: {{with.summary}} ({{with.count}} items)"
156    /// ```
157    #[serde(default, rename = "with")]
158    pub with_spec: Option<WithSpec>,
159    /// Output format and validation
160    #[serde(default)]
161    pub output: Option<OutputPolicy>,
162    /// Runtime DAG expansion via semantic traversal
163    ///
164    /// When specified, the task will be decomposed at runtime based on
165    /// graph traversal results. This takes precedence over static `for_each`.
166    ///
167    /// # Example
168    ///
169    /// ```yaml
170    /// tasks:
171    ///   - id: generate_children
172    ///     decompose:
173    ///       strategy: semantic
174    ///       traverse: HAS_CHILD
175    ///       source: $entity
176    ///     infer: "Generate for {{with.item}}"
177    /// ```
178    #[serde(default)]
179    pub decompose: Option<DecomposeSpec>,
180    /// Parallel iteration over array values
181    ///
182    /// When specified, the task will be executed once for each value in the array.
183    /// Each iteration runs in parallel with its own bindings.
184    ///
185    /// # Example
186    ///
187    /// ```yaml
188    /// tasks:
189    ///   - id: process_locales
190    ///     for_each: ["en-US", "fr-FR", "de-DE"]
191    ///     as: locale
192    ///     exec:
193    ///       command: "echo {{with.locale}}"
194    /// ```
195    #[serde(default)]
196    pub for_each: Option<serde_json::Value>,
197    /// Variable name for current iteration value
198    ///
199    /// Defaults to "item" if not specified.
200    /// The value is accessible as `{{with.<as>}}` in templates.
201    #[serde(default, rename = "as")]
202    pub for_each_as: Option<String>,
203    /// Maximum parallel executions for for_each
204    ///
205    /// Controls how many iterations run concurrently.
206    /// Defaults to 1 (sequential). Set higher for parallel execution.
207    ///
208    /// # Example
209    ///
210    /// ```yaml
211    /// for_each: ["a", "b", "c", "d", "e"]
212    /// concurrency: 3  # Run at most 3 at a time
213    /// ```
214    #[serde(default)]
215    pub concurrency: Option<usize>,
216    /// Stop all iterations on first error
217    ///
218    /// When true (default), aborts remaining iterations if any fails.
219    /// When false, continues executing remaining iterations.
220    ///
221    /// # Example
222    ///
223    /// ```yaml
224    /// for_each: $items
225    /// fail_fast: false  # Continue even if some fail
226    /// ```
227    #[serde(default)]
228    pub fail_fast: Option<bool>,
229    #[serde(flatten)]
230    pub action: TaskAction,
231    /// Artifact output configuration for this task
232    ///
233    /// Can be a simple boolean to enable/disable, a single output spec,
234    /// or an array of output specs for multiple artifacts.
235    #[serde(default)]
236    pub artifact: Option<super::artifact::ArtifactSpec>,
237    /// Task-level logging configuration
238    ///
239    /// Overrides workflow-level log settings for this task.
240    #[serde(default)]
241    pub log: Option<super::logging::LogConfig>,
242    /// Explicit task dependencies
243    ///
244    /// Task IDs that must complete before this task can execute.
245    ///
246    /// Accepts both `flow` and `depends_on` as field names.
247    ///
248    /// # Example
249    ///
250    /// ```yaml
251    /// - id: process
252    ///   depends_on: [fetch_data, validate]
253    ///   infer: "Process {{with.data}}"
254    /// ```
255    #[serde(default)]
256    pub depends_on: Option<Vec<String>>,
257    /// Structured output configuration
258    ///
259    /// When specified, enforces JSON Schema validation on task output.
260    /// Uses the 4-layer StructuredOutputEngine for ~99.99% compliance.
261    ///
262    /// # Example
263    ///
264    /// ```yaml
265    /// - id: extract_data
266    ///   infer: "Extract user data"
267    ///   structured: ./schemas/user.json
268    /// ```
269    ///
270    /// Or with full configuration:
271    ///
272    /// ```yaml
273    /// - id: extract_data
274    ///   infer: "Extract user data"
275    ///   structured:
276    ///     schema: ./schemas/user.json
277    ///     max_retries: 3
278    ///     enable_repair: true
279    /// ```
280    #[serde(default)]
281    pub structured: Option<super::structured::StructuredOutputSpec>,
282}
283
284impl Task {
285    /// Validate for_each configuration
286    ///
287    /// Returns error if:
288    /// - for_each is not an array and not a binding expression
289    /// - for_each array is empty
290    ///
291    /// Binding expressions (strings containing `{{`) are accepted because
292    /// they resolve to arrays at runtime.
293    pub fn validate_for_each(&self) -> Result<(), NikaError> {
294        if let Some(for_each) = &self.for_each {
295            // Accept arrays
296            if for_each.is_array() {
297                if let Some(arr) = for_each.as_array() {
298                    if arr.is_empty() {
299                        return Err(NikaError::ValidationError {
300                            reason: "for_each array cannot be empty".to_string(),
301                        });
302                    }
303                }
304                return Ok(());
305            }
306            // Accept binding expressions (e.g., "{{with.items}}", "$items")
307            if let Some(s) = for_each.as_str() {
308                if s.contains("{{") || s.starts_with('$') {
309                    return Ok(());
310                }
311            }
312            // Reject everything else
313            return Err(NikaError::ValidationError {
314                reason: format!(
315                    "for_each must be an array or binding expression, got {}",
316                    for_each
317                ),
318            });
319        }
320        Ok(())
321    }
322
323    /// Check if this task has for_each iteration
324    pub fn has_for_each(&self) -> bool {
325        self.for_each.is_some()
326    }
327
328    /// Get the iteration variable name (defaults to "item")
329    pub fn for_each_var(&self) -> &str {
330        self.for_each_as.as_deref().unwrap_or("item")
331    }
332
333    /// Get the concurrency limit for for_each (defaults to 1 = sequential)
334    pub fn for_each_concurrency(&self) -> usize {
335        self.concurrency.unwrap_or(1).max(1) // At least 1
336    }
337
338    /// Get the fail_fast setting for for_each (defaults to true)
339    pub fn for_each_fail_fast(&self) -> bool {
340        self.fail_fast.unwrap_or(true)
341    }
342
343    /// Check if this task has decompose modifier
344    pub fn has_decompose(&self) -> bool {
345        self.decompose.is_some()
346    }
347
348    /// Get the decompose spec if present
349    pub fn decompose_spec(&self) -> Option<&DecomposeSpec> {
350        self.decompose.as_ref()
351    }
352
353    /// Get the action icon for TUI display
354    ///
355    /// Returns an emoji icon based on the task's verb type.
356    /// Canonical icons from CLAUDE.md:
357    /// - ⚡ infer (LLM generation)
358    /// - 📟 exec (Shell command)
359    /// - 🛰️ fetch (HTTP request)
360    /// - 🔌 invoke (MCP tool)
361    /// - 🐔 agent (Agentic loop - parent)
362    /// - 🐤 subagent (spawned via spawn_agent)
363    pub fn action_icon(&self) -> &'static str {
364        match &self.action {
365            TaskAction::Infer { .. } => "⚡",  // LLM generation
366            TaskAction::Exec { .. } => "📟",   // Shell command
367            TaskAction::Fetch { .. } => "🛰️",  // HTTP request
368            TaskAction::Invoke { .. } => "🔌", // MCP tool
369            TaskAction::Agent { .. } => "🐔",  // Agentic loop (parent)
370        }
371    }
372
373    /// Get the icon for a subagent (spawned via spawn_agent)
374    pub fn subagent_icon() -> &'static str {
375        "🐤" // Spawned subagent
376    }
377
378    /// Get list of task IDs this task depends on
379    ///
380    /// Returns task IDs from the `depends_on` field.
381    pub fn depends_on_ids(&self) -> Vec<&str> {
382        self.depends_on
383            .as_ref()
384            .map(|deps| deps.iter().map(|s| s.as_str()).collect())
385            .unwrap_or_default()
386    }
387}
388
389// Edges are derived from task.depends_on
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394    use crate::ast::parse_workflow;
395    use crate::serde_yaml;
396
397    // ═══════════════════════════════════════════════════════════════════════════
398    // WORKFLOW PARSING TESTS
399    // ═══════════════════════════════════════════════════════════════════════════
400
401    #[test]
402    fn test_workflow_parse_minimal() {
403        let yaml = r#"
404schema: "nika/workflow@0.12"
405model: test-model
406tasks:
407  - id: hello
408    infer: "Say hello"
409"#;
410        let workflow = parse_workflow(yaml).expect("Failed to parse workflow");
411
412        assert_eq!(workflow.schema, "nika/workflow@0.12");
413        assert_eq!(workflow.provider, "claude"); // default
414        assert_eq!(workflow.tasks.len(), 1);
415        assert_eq!(workflow.tasks[0].id, "hello");
416        assert_eq!(workflow.model.as_deref(), Some("test-model"));
417        assert!(workflow.mcp.is_none());
418        assert_eq!(workflow.flow_count(), 0);
419    }
420
421    #[test]
422    fn test_workflow_parse_with_provider_and_model() {
423        let yaml = r#"
424schema: "nika/workflow@0.12"
425provider: openai
426model: gpt-4-turbo
427tasks:
428  - id: task1
429    exec: "echo test"
430"#;
431        let workflow = parse_workflow(yaml).expect("Failed to parse workflow");
432
433        assert_eq!(workflow.provider, "openai");
434        assert_eq!(workflow.model, Some("gpt-4-turbo".to_string()));
435    }
436
437    #[test]
438    fn test_workflow_parse_multiple_tasks() {
439        let yaml = r#"
440schema: "nika/workflow@0.12"
441model: test-model
442tasks:
443  - id: task1
444    infer: "First task"
445  - id: task2
446    exec: "echo done"
447  - id: task3
448    fetch:
449      url: "https://example.com"
450"#;
451        let workflow = parse_workflow(yaml).expect("Failed to parse workflow");
452
453        assert_eq!(workflow.tasks.len(), 3);
454        assert_eq!(workflow.tasks[0].id, "task1");
455        assert_eq!(workflow.tasks[1].id, "task2");
456        assert_eq!(workflow.tasks[2].id, "task3");
457    }
458
459    #[test]
460    fn test_workflow_parse_with_mcp_config() {
461        let yaml = r#"
462schema: "nika/workflow@0.12"
463mcp:
464  servers:
465    novanet:
466      command: cargo
467      args: [run, -p, novanet-mcp]
468      env:
469        NEO4J_URI: bolt://localhost:7687
470tasks:
471  - id: invoke_task
472    invoke:
473      mcp: novanet
474      tool: novanet_context
475      params:
476        entity: qr-code
477"#;
478        let workflow = parse_workflow(yaml).expect("Failed to parse workflow");
479
480        assert!(workflow.mcp.is_some());
481        let mcp = workflow.mcp.unwrap();
482        assert!(mcp.contains_key("novanet"));
483
484        let novanet_config = &mcp["novanet"];
485        assert_eq!(novanet_config.command, "cargo");
486        assert_eq!(novanet_config.args.len(), 3);
487    }
488
489    // ═══════════════════════════════════════════════════════════════════════════
490    // TASK OPERATIONS TESTS
491    // ═══════════════════════════════════════════════════════════════════════════
492
493    #[test]
494    fn test_task_for_each_helpers_with_for_each() {
495        let yaml = r#"
496id: test_task
497for_each: ["en-US", "fr-FR", "de-DE"]
498as: locale
499concurrency: 3
500fail_fast: false
501infer: "Generate for {{with.locale}}"
502"#;
503        let task: Task = serde_yaml::from_str(yaml).expect("Failed to parse task");
504
505        assert!(task.has_for_each());
506        assert_eq!(task.for_each_var(), "locale");
507        assert_eq!(task.for_each_concurrency(), 3);
508        assert!(!task.for_each_fail_fast());
509    }
510
511    #[test]
512    fn test_task_for_each_helpers_defaults() {
513        let yaml = r#"
514id: test_task
515for_each: ["a", "b"]
516infer: "Test {{with.item}}"
517"#;
518        let task: Task = serde_yaml::from_str(yaml).expect("Failed to parse task");
519
520        assert!(task.has_for_each());
521        assert_eq!(task.for_each_var(), "item"); // default
522        assert_eq!(task.for_each_concurrency(), 1); // default = sequential
523        assert!(task.for_each_fail_fast()); // default = true
524    }
525
526    #[test]
527    fn test_task_without_for_each() {
528        let yaml = r#"
529id: simple_task
530infer: "Simple test"
531"#;
532        let task: Task = serde_yaml::from_str(yaml).expect("Failed to parse task");
533
534        assert!(!task.has_for_each());
535        assert_eq!(task.for_each_var(), "item");
536        assert_eq!(task.for_each_concurrency(), 1);
537    }
538
539    #[test]
540    fn test_task_decompose_helpers() {
541        let yaml = r#"
542id: decompose_task
543decompose:
544  strategy: semantic
545  traverse: HAS_CHILD
546  source: "$entity"
547infer: "Generate for {{with.item}}"
548"#;
549        let task: Task = serde_yaml::from_str(yaml).expect("Failed to parse task");
550
551        assert!(task.has_decompose());
552        assert!(task.decompose_spec().is_some());
553    }
554
555    #[test]
556    fn test_task_without_decompose() {
557        let yaml = r#"
558id: normal_task
559infer: "No decompose"
560"#;
561        let task: Task = serde_yaml::from_str(yaml).expect("Failed to parse task");
562
563        assert!(!task.has_decompose());
564        assert!(task.decompose_spec().is_none());
565    }
566
567    // ═══════════════════════════════════════════════════════════════════════════
568    // FOR_EACH VALIDATION TESTS
569    // ═══════════════════════════════════════════════════════════════════════════
570
571    #[test]
572    fn test_validate_for_each_with_array() {
573        let yaml = r#"
574id: test
575for_each: ["a", "b", "c"]
576infer: "Test"
577"#;
578        let task: Task = serde_yaml::from_str(yaml).expect("Failed to parse");
579        assert!(task.validate_for_each().is_ok());
580    }
581
582    #[test]
583    fn test_validate_for_each_with_binding_expression_template() {
584        let yaml = r#"
585id: test
586for_each: "{{with.items}}"
587infer: "Test"
588"#;
589        let task: Task = serde_yaml::from_str(yaml).expect("Failed to parse");
590        assert!(task.validate_for_each().is_ok());
591    }
592
593    #[test]
594    fn test_validate_for_each_with_binding_expression_dollar() {
595        let yaml = r#"
596id: test
597for_each: "$items"
598infer: "Test"
599"#;
600        let task: Task = serde_yaml::from_str(yaml).expect("Failed to parse");
601        assert!(task.validate_for_each().is_ok());
602    }
603
604    #[test]
605    fn test_validate_for_each_empty_array_fails() {
606        let yaml = r#"
607id: test
608for_each: []
609infer: "Test"
610"#;
611        let task: Task = serde_yaml::from_str(yaml).expect("Failed to parse");
612        let result = task.validate_for_each();
613
614        assert!(result.is_err());
615        if let Err(e) = result {
616            let error_str = format!("{:?}", e);
617            assert!(error_str.contains("for_each array cannot be empty"));
618        }
619    }
620
621    #[test]
622    fn test_validate_for_each_invalid_type_fails() {
623        let yaml = r#"
624id: test
625for_each: 42
626infer: "Test"
627"#;
628        let task: Task = serde_yaml::from_str(yaml).expect("Failed to parse");
629        let result = task.validate_for_each();
630
631        assert!(result.is_err());
632        if let Err(e) = result {
633            let error_str = format!("{:?}", e);
634            assert!(error_str.contains("for_each must be an array or binding expression"));
635        }
636    }
637
638    #[test]
639    fn test_validate_for_each_invalid_string_fails() {
640        let yaml = r#"
641id: test
642for_each: "plain_string"
643infer: "Test"
644"#;
645        let task: Task = serde_yaml::from_str(yaml).expect("Failed to parse");
646        let result = task.validate_for_each();
647
648        assert!(result.is_err());
649    }
650
651    #[test]
652    fn test_validate_for_each_none() {
653        let yaml = r#"
654id: test
655infer: "Test"
656"#;
657        let task: Task = serde_yaml::from_str(yaml).expect("Failed to parse");
658        assert!(task.validate_for_each().is_ok());
659    }
660
661    // ═══════════════════════════════════════════════════════════════════════════
662    // TASK ACTION ICONS TESTS
663    // ═══════════════════════════════════════════════════════════════════════════
664
665    #[test]
666    fn test_task_action_icon_infer() {
667        let yaml = r#"
668id: test
669infer: "Generate something"
670"#;
671        let task: Task = serde_yaml::from_str(yaml).expect("Failed to parse");
672        assert_eq!(task.action_icon(), "⚡");
673    }
674
675    #[test]
676    fn test_task_action_icon_exec() {
677        let yaml = r#"
678id: test
679exec: "echo hello"
680"#;
681        let task: Task = serde_yaml::from_str(yaml).expect("Failed to parse");
682        assert_eq!(task.action_icon(), "📟");
683    }
684
685    #[test]
686    fn test_task_action_icon_fetch() {
687        let yaml = r#"
688id: test
689fetch:
690  url: "https://example.com"
691"#;
692        let task: Task = serde_yaml::from_str(yaml).expect("Failed to parse");
693        assert_eq!(task.action_icon(), "🛰️");
694    }
695
696    #[test]
697    fn test_task_action_icon_invoke() {
698        let yaml = r#"
699id: test
700invoke:
701  mcp: novanet
702  tool: novanet_context
703"#;
704        let task: Task = serde_yaml::from_str(yaml).expect("Failed to parse");
705        assert_eq!(task.action_icon(), "🔌");
706    }
707
708    #[test]
709    fn test_task_action_icon_agent() {
710        let yaml = r#"
711id: test
712agent:
713  prompt: "Generate something"
714"#;
715        let task: Task = serde_yaml::from_str(yaml).expect("Failed to parse");
716        assert_eq!(task.action_icon(), "🐔");
717    }
718
719    #[test]
720    fn test_task_subagent_icon() {
721        assert_eq!(Task::subagent_icon(), "🐤");
722    }
723
724    // ═══════════════════════════════════════════════════════════════════════════
725    // HASH COMPUTATION TESTS
726    // ═══════════════════════════════════════════════════════════════════════════
727
728    #[test]
729    fn test_workflow_compute_hash() {
730        let yaml = r#"
731schema: "nika/workflow@0.12"
732provider: claude
733model: claude-sonnet-4-6
734tasks:
735  - id: task1
736    infer: "Test"
737  - id: task2
738    exec: "echo done"
739"#;
740        let workflow = parse_workflow(yaml).expect("Failed to parse");
741        let hash = workflow.compute_hash();
742
743        // Should be 16-character hex string (64-bit hash)
744        assert_eq!(hash.len(), 16);
745        assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
746    }
747
748    #[test]
749    fn test_workflow_compute_hash_consistency() {
750        let yaml = r#"
751schema: "nika/workflow@0.12"
752model: test-model
753tasks:
754  - id: task1
755    infer: "Test"
756"#;
757        let workflow = parse_workflow(yaml).expect("Failed to parse");
758        let hash1 = workflow.compute_hash();
759        let hash2 = workflow.compute_hash();
760
761        // Same workflow should produce same hash
762        assert_eq!(hash1, hash2);
763    }
764
765    #[test]
766    fn test_workflow_compute_hash_differs_with_schema() {
767        let yaml_v10 = r#"
768schema: "nika/workflow@0.10"
769model: test-model
770tasks:
771  - id: task1
772    infer: "Test"
773"#;
774        let yaml_v12 = r#"
775schema: "nika/workflow@0.12"
776model: test-model
777tasks:
778  - id: task1
779    infer: "Test"
780"#;
781        let workflow_v10 = parse_workflow(yaml_v10).expect("Failed to parse");
782        let workflow_v12 = parse_workflow(yaml_v12).expect("Failed to parse");
783
784        let hash_v10 = workflow_v10.compute_hash();
785        let hash_v12 = workflow_v12.compute_hash();
786
787        // Different schema should produce different hash
788        assert_ne!(hash_v10, hash_v12);
789    }
790
791    #[test]
792    fn test_workflow_compute_hash_differs_with_tasks() {
793        let yaml_1task = r#"
794schema: "nika/workflow@0.12"
795model: test-model
796tasks:
797  - id: task1
798    infer: "Test"
799"#;
800        let yaml_2tasks = r#"
801schema: "nika/workflow@0.12"
802model: test-model
803tasks:
804  - id: task1
805    infer: "Test"
806  - id: task2
807    exec: "echo done"
808"#;
809        let workflow_1 = parse_workflow(yaml_1task).expect("Failed to parse");
810        let workflow_2 = parse_workflow(yaml_2tasks).expect("Failed to parse");
811
812        // Different task count should produce different hash
813        assert_ne!(workflow_1.compute_hash(), workflow_2.compute_hash());
814    }
815
816    #[test]
817    fn test_workflow_compute_hash_differs_with_model() {
818        let yaml_claude = r#"
819schema: "nika/workflow@0.12"
820model: claude-sonnet-4-6
821tasks:
822  - id: task1
823    infer: "Test"
824"#;
825        let yaml_openai = r#"
826schema: "nika/workflow@0.12"
827model: gpt-4-turbo
828tasks:
829  - id: task1
830    infer: "Test"
831"#;
832        let workflow_claude = parse_workflow(yaml_claude).expect("Failed to parse");
833        let workflow_openai = parse_workflow(yaml_openai).expect("Failed to parse");
834
835        // Different models should produce different hash
836        assert_ne!(
837            workflow_claude.compute_hash(),
838            workflow_openai.compute_hash()
839        );
840    }
841
842    // ═══════════════════════════════════════════════════════════════════════════
843    // EDGE CASES TESTS
844    // ═══════════════════════════════════════════════════════════════════════════
845
846    #[test]
847    fn test_task_depends_on_ids_returns_empty_when_no_deps() {
848        let yaml = r#"
849id: task1
850infer: "Test"
851"#;
852        let task: Task = serde_yaml::from_str(yaml).expect("Failed to parse");
853        let deps = task.depends_on_ids();
854        assert!(deps.is_empty());
855    }
856
857    #[test]
858    fn test_task_depends_on_alias_works() {
859        let yaml = r#"
860id: task1
861depends_on: [step_a, step_b]
862infer: "Test"
863"#;
864        let task: Task = serde_yaml::from_str(yaml).expect("Failed to parse");
865        let deps = task.depends_on_ids();
866        assert_eq!(deps, vec!["step_a", "step_b"]);
867    }
868
869    #[test]
870    fn test_task_depends_on_field_works() {
871        let yaml = r#"
872id: task1
873depends_on: [step_a, step_b]
874infer: "Test"
875"#;
876        let task: Task = serde_yaml::from_str(yaml).expect("Failed to parse");
877        let deps = task.depends_on_ids();
878        assert_eq!(deps, vec!["step_a", "step_b"]);
879    }
880
881    #[test]
882    fn test_task_with_with_spec() {
883        let yaml = r#"
884id: task1
885with:
886  input: $previous_task.result
887infer: "Process {{with.input}}"
888"#;
889        let task: Task = serde_yaml::from_str(yaml).expect("Failed to parse");
890        assert!(task.with_spec.is_some());
891    }
892
893    #[test]
894    fn test_task_with_output_policy() {
895        let yaml = r#"
896id: task1
897output:
898  format: json
899infer: "Generate JSON"
900"#;
901        let task: Task = serde_yaml::from_str(yaml).expect("Failed to parse");
902        assert!(task.output.is_some());
903    }
904
905    #[test]
906    fn test_mcp_config_inline_minimal() {
907        let yaml = r#"
908schema: "nika/workflow@0.12"
909model: test-model
910mcp:
911  servers:
912    test_server:
913      command: echo
914tasks:
915  - id: task1
916    infer: "Test"
917"#;
918        let workflow = parse_workflow(yaml).expect("Failed to parse");
919        let mcp = workflow.mcp.unwrap();
920        let server = &mcp["test_server"];
921
922        assert_eq!(server.command, "echo");
923        assert!(server.args.is_empty());
924        assert!(server.env.is_empty());
925        assert!(server.cwd.is_none());
926    }
927
928    #[test]
929    fn test_mcp_config_inline_full() {
930        let yaml = r#"
931schema: "nika/workflow@0.12"
932model: test-model
933mcp:
934  servers:
935    novanet:
936      command: cargo
937      args: [run, -p, novanet-mcp]
938      env:
939        NEO4J_URI: bolt://localhost:7687
940        NEO4J_USER: neo4j
941      cwd: /path/to/workspace
942tasks:
943  - id: task1
944    infer: "Test"
945"#;
946        let workflow = parse_workflow(yaml).expect("Failed to parse");
947        let mcp = workflow.mcp.unwrap();
948        let server = &mcp["novanet"];
949
950        assert_eq!(server.command, "cargo");
951        assert_eq!(server.args.len(), 3);
952        assert_eq!(server.env.len(), 2);
953        assert_eq!(server.cwd, Some("/path/to/workspace".to_string()));
954    }
955
956    #[test]
957    fn test_task_concurrency_zero_becomes_one() {
958        let yaml = r#"
959id: test
960for_each: ["a", "b"]
961concurrency: 0
962infer: "Test"
963"#;
964        let task: Task = serde_yaml::from_str(yaml).expect("Failed to parse");
965        // max(0, 1) = 1
966        assert_eq!(task.for_each_concurrency(), 1);
967    }
968
969    #[test]
970    fn test_task_concurrency_large_value() {
971        let yaml = r#"
972id: test
973for_each: ["a", "b"]
974concurrency: 1000
975infer: "Test"
976"#;
977        let task: Task = serde_yaml::from_str(yaml).expect("Failed to parse");
978        assert_eq!(task.for_each_concurrency(), 1000);
979    }
980
981    #[test]
982    fn test_workflow_default_provider_is_claude() {
983        let yaml = r#"
984schema: "nika/workflow@0.12"
985model: test-model
986tasks:
987  - id: task1
988    infer: "Test"
989"#;
990        let workflow = parse_workflow(yaml).expect("Failed to parse");
991        assert_eq!(workflow.provider, "claude");
992    }
993
994    #[test]
995    fn test_task_as_field_empty_string() {
996        let yaml = r#"
997id: test
998for_each: ["a", "b"]
999as: ""
1000infer: "Test"
1001"#;
1002        let task: Task = serde_yaml::from_str(yaml).expect("Failed to parse");
1003        // Empty string should use default "item"
1004        assert_eq!(task.for_each_var(), "");
1005    }
1006
1007    #[test]
1008    fn test_task_as_field_custom_name() {
1009        let yaml = r#"
1010id: test
1011for_each: ["en-US", "fr-FR"]
1012as: locale
1013infer: "Generate {{with.locale}}"
1014"#;
1015        let task: Task = serde_yaml::from_str(yaml).expect("Failed to parse");
1016        assert_eq!(task.for_each_var(), "locale");
1017    }
1018}