Skip to main content

nika_core/ast/raw/
task.rs

1//! Raw task AST with all fields as Spanned.
2
3use indexmap::IndexMap;
4
5use super::action::RawTaskAction;
6use crate::ast::decompose::DecomposeSpec;
7use crate::ast::structured::StructuredOutputSpec;
8use crate::source::{Span, Spanned};
9
10/// A raw task as parsed from YAML.
11///
12/// All string references (task IDs, aliases) are unresolved.
13/// Resolution happens in Phase 2 (analyzed AST).
14///
15#[derive(Debug, Clone, Default)]
16pub struct RawTask {
17    /// Task identifier (must be unique within workflow)
18    pub id: Spanned<String>,
19
20    /// Human-readable description
21    pub description: Option<Spanned<String>>,
22
23    /// The action this task performs (infer, exec, fetch, invoke, agent)
24    pub action: Option<RawTaskAction>,
25
26    /// Task-specific provider override
27    pub provider: Option<Spanned<String>>,
28
29    /// Task-specific model override
30    pub model: Option<Spanned<String>>,
31
32    /// Binding declarations: `with: { alias: "expression" }`
33    ///
34    /// Values are raw strings that get parsed by `parse_with_entry()` in Phase 2.
35    /// Examples:
36    ///   - `data: step1` — simple task reference
37    ///   - `temp: step1.data.temp ?? 20` — path + default
38    ///   - `cfg: $env.API_KEY` — environment binding
39    ///   - `val: step1.output | upper | trim` — with transforms
40    pub with_refs: Option<Spanned<IndexMap<Spanned<String>, Spanned<String>>>>,
41
42    /// Explicit ordering dependencies: `depends_on: [task_id1, task_id2]`
43    ///
44    /// These are pure ordering edges — no data flows through them.
45    /// Data dependencies are expressed via `with:` bindings.
46    pub depends_on: Option<Spanned<Vec<Spanned<String>>>>,
47
48    /// Output configuration
49    pub output: Option<Spanned<RawOutputConfig>>,
50
51    /// For-each iteration
52    pub for_each: Option<Spanned<RawForEach>>,
53
54    /// Retry configuration
55    pub retry: Option<Spanned<RawRetryConfig>>,
56
57    /// Decompose modifier for runtime DAG expansion
58    pub decompose: Option<Spanned<DecomposeSpec>>,
59
60    /// Standalone concurrency (used with decompose when no for_each)
61    pub concurrency: Option<Spanned<u32>>,
62
63    /// Standalone fail_fast (used with decompose when no for_each)
64    pub fail_fast: Option<Spanned<bool>>,
65
66    /// Structured output specification (JSON schema enforcement)
67    pub structured: Option<StructuredOutputSpec>,
68
69    /// Artifact output configuration (persists task output to files)
70    pub artifact: Option<Spanned<serde_json::Value>>,
71
72    /// Log configuration override for this task
73    pub log: Option<Spanned<serde_json::Value>>,
74
75    /// The span of the entire task block
76    pub span: Span,
77}
78
79/// Output configuration for a task.
80#[derive(Debug, Clone, Default)]
81pub struct RawOutputConfig {
82    /// Output format: text, json, yaml
83    pub format: Option<Spanned<String>>,
84    /// JSON Schema for validation
85    pub schema: Option<Spanned<serde_json::Value>>,
86    /// Schema reference: file path or inline
87    pub schema_ref: Option<Spanned<String>>,
88    /// Maximum retries on validation failure
89    pub max_retries: Option<Spanned<u32>>,
90}
91
92/// For-each iteration configuration.
93#[derive(Debug, Clone, Default)]
94pub struct RawForEach {
95    /// Items expression: "$task_id" or "{{...}}"
96    pub items: Spanned<String>,
97    /// Loop variable name (default: "item")
98    pub as_var: Option<Spanned<String>>,
99    /// Maximum concurrency
100    pub concurrency: Option<Spanned<u32>>,
101    /// Stop all iterations on first error (default: true)
102    pub fail_fast: Option<Spanned<bool>>,
103}
104
105/// Retry configuration.
106#[derive(Debug, Clone, Default)]
107pub struct RawRetryConfig {
108    /// Maximum retry attempts
109    pub max_attempts: Option<Spanned<u32>>,
110    /// Delay between retries in milliseconds
111    pub delay_ms: Option<Spanned<u64>>,
112    /// Exponential backoff multiplier
113    pub backoff: Option<Spanned<f64>>,
114}
115
116impl RawTask {
117    /// Create a new raw task with the given ID.
118    pub fn new(id: impl Into<String>) -> Self {
119        Self {
120            id: Spanned::dummy(id.into()),
121            ..Default::default()
122        }
123    }
124
125    /// Check if this task has any dependencies (with: or depends_on:).
126    pub fn has_dependencies(&self) -> bool {
127        self.with_refs
128            .as_ref()
129            .map(|w| !w.value.is_empty())
130            .unwrap_or(false)
131            || self
132                .depends_on
133                .as_ref()
134                .map(|d| !d.value.is_empty())
135                .unwrap_or(false)
136    }
137
138    /// Get all explicit depends_on task IDs.
139    pub fn depends_on_ids(&self) -> Vec<&str> {
140        match &self.depends_on {
141            Some(deps) => deps.value.iter().map(|s| s.value.as_str()).collect(),
142            None => vec![],
143        }
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use crate::source::FileId;
151
152    fn make_span(start: u32, end: u32) -> Span {
153        Span::new(FileId(0), start, end)
154    }
155
156    #[test]
157    fn test_raw_task_new() {
158        let task = RawTask::new("my-task");
159        assert_eq!(task.id.value, "my-task");
160        assert!(!task.has_dependencies());
161    }
162
163    #[test]
164    fn test_task_has_dependencies_with() {
165        let mut task = RawTask::new("consumer");
166
167        let mut with_refs = IndexMap::new();
168        with_refs.insert(
169            Spanned::new("data".to_string(), make_span(0, 4)),
170            Spanned::new("producer".to_string(), make_span(6, 14)),
171        );
172        task.with_refs = Some(Spanned::new(with_refs, make_span(0, 20)));
173
174        assert!(task.has_dependencies());
175    }
176
177    #[test]
178    fn test_task_has_dependencies_depends_on() {
179        let mut task = RawTask::new("consumer");
180
181        task.depends_on = Some(Spanned::new(
182            vec![
183                Spanned::new("setup".to_string(), make_span(0, 5)),
184                Spanned::new("init".to_string(), make_span(7, 11)),
185            ],
186            make_span(0, 15),
187        ));
188
189        assert!(task.has_dependencies());
190        assert_eq!(task.depends_on_ids(), vec!["setup", "init"]);
191    }
192
193    #[test]
194    fn test_task_depends_on_empty() {
195        let task = RawTask::new("standalone");
196        assert!(task.depends_on_ids().is_empty());
197        assert!(!task.has_dependencies());
198    }
199}