Skip to main content

nika_core/ast/
include.rs

1//! Include specification for DAG fusion
2//!
3//! The `include:` block merges tasks from external workflows into the main DAG.
4//! Included tasks share the same RunContext as the parent workflow.
5//!
6//! # Example
7//!
8//! ```yaml
9//! include:
10//!   # Filesystem path
11//!   - path: ./lib/seo-tasks.nika.yaml
12//!     prefix: seo_
13//!   # Package reference
14//!   - pkg: "@workflows/common"
15//!     prefix: common_
16//! ```
17
18use serde::Deserialize;
19
20use crate::error::CoreError;
21
22/// Include specification for DAG fusion
23///
24/// Merges tasks from an external workflow into the main DAG at parse time.
25/// Supports both filesystem paths and package references.
26#[derive(Debug, Clone, Deserialize)]
27pub struct IncludeSpec {
28    /// Path to the workflow file to include (relative to parent workflow)
29    /// Mutually exclusive with `pkg`
30    #[serde(default)]
31    pub path: Option<String>,
32
33    /// Package reference to include (e.g., "@workflows/seo-audit")
34    /// Mutually exclusive with `path`
35    #[serde(default)]
36    pub pkg: Option<String>,
37
38    /// Optional prefix for all task IDs from this include
39    ///
40    /// Prevents ID collisions when including multiple workflows.
41    /// Example: prefix "seo_" transforms task "analyze" to "seo_analyze"
42    #[serde(default)]
43    pub prefix: Option<String>,
44}
45
46impl IncludeSpec {
47    /// Validate that exactly one of `path` or `pkg` is specified
48    pub fn validate(&self) -> Result<(), CoreError> {
49        match (&self.path, &self.pkg) {
50            (None, None) => Err(CoreError::ValidationError {
51                reason: "Include spec must have either 'path' or 'pkg'".into(),
52            }),
53            (Some(_), Some(_)) => Err(CoreError::ValidationError {
54                reason: "Include spec cannot have both 'path' and 'pkg'".into(),
55            }),
56            _ => Ok(()),
57        }
58    }
59}
60
61#[cfg(test)]
62mod tests {
63    use super::*;
64    use crate::serde_yaml;
65
66    #[test]
67    fn test_include_spec_parse_minimal() {
68        let yaml = r#"
69path: ./lib/tasks.nika.yaml
70"#;
71        let spec: IncludeSpec = serde_yaml::from_str(yaml).unwrap();
72        assert_eq!(spec.path, Some("./lib/tasks.nika.yaml".to_string()));
73        assert!(spec.pkg.is_none());
74        assert!(spec.prefix.is_none());
75        assert!(spec.validate().is_ok());
76    }
77
78    #[test]
79    fn test_include_spec_parse_with_prefix() {
80        let yaml = r#"
81path: ./lib/seo-tasks.nika.yaml
82prefix: seo_
83"#;
84        let spec: IncludeSpec = serde_yaml::from_str(yaml).unwrap();
85        assert_eq!(spec.path, Some("./lib/seo-tasks.nika.yaml".to_string()));
86        assert_eq!(spec.prefix, Some("seo_".to_string()));
87        assert!(spec.validate().is_ok());
88    }
89
90    #[test]
91    fn test_include_spec_parse_array() {
92        let yaml = r#"
93- path: ./lib/seo.nika.yaml
94  prefix: seo_
95- path: ./lib/common.nika.yaml
96"#;
97        let specs: Vec<IncludeSpec> = serde_yaml::from_str(yaml).unwrap();
98        assert_eq!(specs.len(), 2);
99        assert_eq!(specs[0].prefix, Some("seo_".to_string()));
100        assert!(specs[1].prefix.is_none());
101    }
102
103    #[test]
104    fn test_include_spec_empty_prefix() {
105        let yaml = r#"
106path: ./lib/tasks.nika.yaml
107prefix: ""
108"#;
109        let spec: IncludeSpec = serde_yaml::from_str(yaml).unwrap();
110        assert_eq!(spec.prefix, Some(String::new()));
111    }
112
113    // Package reference support
114    #[test]
115    fn test_include_spec_parse_pkg() {
116        let yaml = r#"
117pkg: "@workflows/seo-audit"
118prefix: seo_
119"#;
120        let spec: IncludeSpec = serde_yaml::from_str(yaml).unwrap();
121        assert_eq!(spec.pkg, Some("@workflows/seo-audit".to_string()));
122        assert!(spec.path.is_none());
123        assert_eq!(spec.prefix, Some("seo_".to_string()));
124        assert!(spec.validate().is_ok());
125    }
126
127    #[test]
128    fn test_include_spec_validate_missing_both() {
129        let spec = IncludeSpec {
130            path: None,
131            pkg: None,
132            prefix: None,
133        };
134        assert!(spec.validate().is_err());
135    }
136
137    #[test]
138    fn test_include_spec_validate_both_present() {
139        let spec = IncludeSpec {
140            path: Some("./lib/tasks.yaml".to_string()),
141            pkg: Some("@workflows/seo".to_string()),
142            prefix: None,
143        };
144        assert!(spec.validate().is_err());
145    }
146}