use std::collections::HashSet;
use std::path::Path;
use std::sync::Arc;
use crate::ast::{Task, Workflow};
use crate::error::NikaError;
use crate::registry::resolver;
const MAX_INCLUDE_DEPTH: usize = 10;
fn validate_path_boundary(base_path: &Path, target_path: &Path) -> Result<(), NikaError> {
crate::io::security::validate_canonicalized_boundary(base_path, target_path).map_err(|e| {
if e.reason.contains("Cannot resolve target path") {
NikaError::WorkflowNotFound {
path: format!("{}: {}", e.target_path.display(), e.reason),
}
} else {
NikaError::ValidationError { reason: e.reason }
}
})
}
pub fn expand_includes(workflow: Workflow, base_path: &Path) -> Result<Workflow, NikaError> {
expand_includes_recursive(workflow, base_path, 0, &mut HashSet::new())
}
fn expand_includes_recursive(
mut workflow: Workflow,
base_path: &Path,
depth: usize,
visited: &mut HashSet<String>,
) -> Result<Workflow, NikaError> {
if depth > MAX_INCLUDE_DEPTH {
return Err(NikaError::ValidationError {
reason: format!(
"Maximum include depth ({}) exceeded. Check for circular includes.",
MAX_INCLUDE_DEPTH
),
});
}
let includes = match workflow.include.take() {
Some(includes) if !includes.is_empty() => includes,
_ => return Ok(workflow),
};
for include_spec in includes {
include_spec.validate()?;
let include_path = if let Some(ref pkg) = include_spec.pkg {
let resolved =
resolver::resolve_package_path(pkg).map_err(|e| NikaError::WorkflowNotFound {
path: format!(
"Package not found: {}. Error: {}. Try: nika add {}",
pkg, e, pkg
),
})?;
let candidates = if pkg.starts_with("@jobs/") {
vec!["job.nika.yaml", "workflow.nika.yaml"]
} else {
vec!["workflow.nika.yaml"]
};
let mut found_path = None;
for filename in candidates {
let candidate_path = resolved.path.join(filename);
if std::fs::File::open(&candidate_path).is_ok() {
found_path = Some(candidate_path);
break;
}
}
match found_path {
Some(path) => path,
None => {
let expected = if pkg.starts_with("@jobs/") {
"job.nika.yaml or workflow.nika.yaml"
} else {
"workflow.nika.yaml"
};
return Err(NikaError::WorkflowNotFound {
path: format!(
"Package {} exists but missing {} at {}",
pkg,
expected,
resolved.path.display()
),
});
}
}
} else if let Some(ref path) = include_spec.path {
let resolved_path = base_path.join(path);
validate_path_boundary(base_path, &resolved_path)?;
resolved_path
} else {
return Err(NikaError::ValidationError {
reason: "Include spec must have either 'path' or 'pkg'".to_string(),
});
};
let canonical_path =
include_path
.canonicalize()
.map_err(|e| NikaError::WorkflowNotFound {
path: format!(
"Failed to canonicalize include path '{}': {}",
include_path.display(),
e
),
})?;
let path_str = canonical_path.to_string_lossy().to_string();
if visited.contains(&path_str) {
let display_ref = include_spec
.pkg
.as_deref()
.or(include_spec.path.as_deref())
.unwrap_or("unknown");
return Err(NikaError::ValidationError {
reason: format!("Circular include detected: {}", display_ref),
});
}
visited.insert(path_str.clone());
let included_workflow = load_included_workflow(&include_path)?;
let include_base = include_path.parent().unwrap_or(Path::new("."));
let expanded_included =
expand_includes_recursive(included_workflow, include_base, depth + 1, visited)?;
merge_workflow(
&mut workflow,
expanded_included,
include_spec.prefix.as_deref(),
)?;
visited.remove(&path_str);
}
Ok(workflow)
}
fn load_included_workflow(path: &Path) -> Result<Workflow, NikaError> {
let content = std::fs::read_to_string(path).map_err(|e| NikaError::WorkflowNotFound {
path: format!("{}: {}", path.display(), e),
})?;
super::parse_workflow(&content)
}
fn merge_workflow(
main: &mut Workflow,
included: Workflow,
prefix: Option<&str>,
) -> Result<(), NikaError> {
for task in included.tasks {
let prefixed_task = prefix_task(task, prefix);
main.tasks.push(prefixed_task);
}
if let Some(included_skills) = included.skills {
match main.skills.as_mut() {
Some(main_skills) => {
for (alias, skill_path) in included_skills {
main_skills.entry(alias).or_insert(skill_path);
}
}
None => {
main.skills = Some(included_skills);
}
}
}
Ok(())
}
fn prefix_task(task: Arc<Task>, prefix: Option<&str>) -> Arc<Task> {
match prefix {
Some(prefix) if !prefix.is_empty() => {
let mut new_task = (*task).clone();
new_task.id = format!("{}{}", prefix, new_task.id);
if let Some(ref mut deps) = new_task.depends_on {
for dep in deps.iter_mut() {
*dep = format!("{}{}", prefix, dep);
}
}
if let Some(ref mut with_spec) = new_task.with_spec {
use crate::binding::types::BindingSource;
for entry in with_spec.values_mut() {
if let BindingSource::Task(ref id) = entry.source.source {
let prefixed = format!("{}{}", prefix, id);
entry.source.source = BindingSource::Task(prefixed.into());
}
}
}
Arc::new(new_task)
}
_ => task, }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ast::context::ContextConfig;
use crate::ast::IncludeSpec;
use rustc_hash::FxHashMap;
use tempfile::TempDir;
fn make_test_workflow() -> Workflow {
Workflow {
schema: "nika/workflow@0.12".to_string(),
name: None,
provider: "claude".to_string(),
model: None,
mcp: None,
context: None,
include: None,
agents: None,
skills: None,
artifacts: None,
log: None,
inputs: None,
tasks: vec![],
}
}
#[test]
fn test_expand_includes_no_includes() {
let workflow = make_test_workflow();
let result = expand_includes(workflow.clone(), Path::new(".")).unwrap();
assert_eq!(result.tasks.len(), 0);
}
#[test]
fn test_expand_includes_empty_includes() {
let mut workflow = make_test_workflow();
workflow.include = Some(vec![]);
let result = expand_includes(workflow, Path::new(".")).unwrap();
assert_eq!(result.tasks.len(), 0);
}
#[test]
fn test_prefix_task() {
use crate::ast::{InferParams, TaskAction};
let task = Arc::new(Task {
id: "generate".to_string(),
with_spec: None,
output: None,
decompose: None,
for_each: None,
for_each_as: None,
concurrency: None,
fail_fast: None,
action: TaskAction::Infer {
infer: InferParams {
prompt: "test".to_string(),
..Default::default()
},
},
artifact: None,
log: None,
depends_on: None,
structured: None,
});
let prefixed = prefix_task(Arc::clone(&task), Some("seo_"));
assert_eq!(prefixed.id, "seo_generate");
let no_prefix = prefix_task(Arc::clone(&task), None);
assert_eq!(no_prefix.id, "generate");
let empty_prefix = prefix_task(task, Some(""));
assert_eq!(empty_prefix.id, "generate");
}
#[test]
fn test_prefix_task_with_with_spec() {
use crate::ast::{InferParams, TaskAction};
use crate::binding::{BindingPath, BindingSource, PathSegment, WithEntry, WithSpec};
let mut with_spec: WithSpec = Default::default();
with_spec.insert(
"data".to_string(),
WithEntry {
source: BindingPath {
source: BindingSource::Task("other_task".into()),
segments: vec![PathSegment::Field("result".into())],
},
binding_type: Default::default(),
transform: None,
default: None,
lazy: false,
},
);
with_spec.insert(
"config".to_string(),
WithEntry {
source: BindingPath {
source: BindingSource::Task("config_task".into()),
segments: vec![],
},
binding_type: Default::default(),
transform: None,
default: None,
lazy: false,
},
);
let task = Arc::new(Task {
id: "processor".to_string(),
with_spec: Some(with_spec),
output: None,
decompose: None,
for_each: None,
for_each_as: None,
concurrency: None,
fail_fast: None,
action: TaskAction::Infer {
infer: InferParams {
prompt: "test".to_string(),
..Default::default()
},
},
artifact: None,
log: None,
depends_on: None,
structured: None,
});
let prefixed = prefix_task(task, Some("lib_"));
assert_eq!(prefixed.id, "lib_processor");
let with = prefixed.with_spec.as_ref().unwrap();
let data_entry = with.get("data").unwrap();
assert!(
matches!(&data_entry.source.source, BindingSource::Task(id) if id.as_ref() == "lib_other_task")
);
let config_entry = with.get("config").unwrap();
assert!(
matches!(&config_entry.source.source, BindingSource::Task(id) if id.as_ref() == "lib_config_task")
);
}
#[test]
fn test_expand_includes_with_file() {
let temp_dir = TempDir::new().unwrap();
let included_yaml = r#"
schema: nika/workflow@0.12
provider: claude
tasks:
- id: helper
infer: "Help with something"
"#;
std::fs::write(temp_dir.path().join("helper.nika.yaml"), included_yaml).unwrap();
let mut workflow = make_test_workflow();
workflow.include = Some(vec![IncludeSpec {
path: Some("helper.nika.yaml".to_string()),
pkg: None,
prefix: None,
}]);
let result = expand_includes(workflow, temp_dir.path()).unwrap();
assert_eq!(result.tasks.len(), 1);
assert_eq!(result.tasks[0].id, "helper");
}
#[test]
fn test_expand_includes_with_prefix() {
let temp_dir = TempDir::new().unwrap();
let included_yaml = r#"
schema: nika/workflow@0.12
provider: claude
tasks:
- id: task1
infer: "Task 1"
- id: task2
depends_on: [task1]
infer: "Task 2"
"#;
std::fs::write(temp_dir.path().join("lib.nika.yaml"), included_yaml).unwrap();
let mut workflow = make_test_workflow();
workflow.include = Some(vec![IncludeSpec {
path: Some("lib.nika.yaml".to_string()),
pkg: None,
prefix: Some("lib_".to_string()),
}]);
let result = expand_includes(workflow, temp_dir.path()).unwrap();
assert_eq!(result.tasks.len(), 2);
assert_eq!(result.tasks[0].id, "lib_task1");
assert_eq!(result.tasks[1].id, "lib_task2");
assert!(result.tasks[0].depends_on.is_none()); let task2_deps = result.tasks[1].depends_on.as_ref().unwrap();
assert_eq!(task2_deps, &["lib_task1".to_string()]);
}
#[test]
fn test_expand_includes_multiple() {
let temp_dir = TempDir::new().unwrap();
std::fs::write(
temp_dir.path().join("a.nika.yaml"),
r#"
schema: nika/workflow@0.12
provider: claude
tasks:
- id: a_task
infer: "A"
"#,
)
.unwrap();
std::fs::write(
temp_dir.path().join("b.nika.yaml"),
r#"
schema: nika/workflow@0.12
provider: claude
tasks:
- id: b_task
infer: "B"
"#,
)
.unwrap();
let mut workflow = make_test_workflow();
workflow.include = Some(vec![
IncludeSpec {
path: Some("a.nika.yaml".to_string()),
pkg: None,
prefix: None,
},
IncludeSpec {
path: Some("b.nika.yaml".to_string()),
pkg: None,
prefix: None,
},
]);
let result = expand_includes(workflow, temp_dir.path()).unwrap();
assert_eq!(result.tasks.len(), 2);
}
#[test]
fn test_expand_includes_file_not_found() {
let temp_dir = TempDir::new().unwrap();
let mut workflow = make_test_workflow();
workflow.include = Some(vec![IncludeSpec {
path: Some("nonexistent.nika.yaml".to_string()),
pkg: None,
prefix: None,
}]);
let result = expand_includes(workflow, temp_dir.path());
assert!(result.is_err());
}
#[test]
fn test_expand_includes_preserves_main_workflow_fields() {
let temp_dir = TempDir::new().unwrap();
std::fs::write(
temp_dir.path().join("lib.nika.yaml"),
r#"
schema: nika/workflow@0.12
provider: openai
model: gpt-4
context:
files:
ignored: ./ignored.md
tasks:
- id: lib_task
infer: "Test"
"#,
)
.unwrap();
let mut workflow = make_test_workflow();
workflow.model = Some("claude-sonnet".to_string());
workflow.context = Some(ContextConfig {
files: {
let mut m = FxHashMap::default();
m.insert("main".to_string(), "./main.md".to_string());
m
},
session: None,
});
workflow.include = Some(vec![IncludeSpec {
path: Some("lib.nika.yaml".to_string()),
pkg: None,
prefix: None,
}]);
let result = expand_includes(workflow, temp_dir.path()).unwrap();
assert_eq!(result.provider, "claude");
assert_eq!(result.model, Some("claude-sonnet".to_string()));
let ctx = result.context.unwrap();
assert!(ctx.files.contains_key("main"));
assert!(!ctx.files.contains_key("ignored"));
}
#[test]
fn test_expand_includes_path_traversal_detection() {
let temp_dir = TempDir::new().unwrap();
let sub_dir = temp_dir.path().join("project");
std::fs::create_dir(&sub_dir).unwrap();
let parent_file = temp_dir.path().join("secret.nika.yaml");
std::fs::write(
&parent_file,
r#"
schema: nika/workflow@0.12
provider: claude
tasks:
- id: secret
infer: "Secret task"
"#,
)
.unwrap();
let mut workflow = make_test_workflow();
workflow.include = Some(vec![IncludeSpec {
path: Some("../secret.nika.yaml".to_string()),
pkg: None,
prefix: None,
}]);
let result = expand_includes(workflow, &sub_dir);
assert!(result.is_err());
let err = result.unwrap_err();
let err_str = err.to_string();
assert!(
err_str.contains("Path traversal") || err_str.contains("outside project"),
"Expected path traversal error, got: {}",
err_str
);
}
#[test]
fn test_validate_path_boundary() {
let temp_dir = TempDir::new().unwrap();
let project = temp_dir.path().join("project");
std::fs::create_dir_all(&project).unwrap();
let valid_file = project.join("valid.yaml");
std::fs::write(&valid_file, "test").unwrap();
let outside_file = temp_dir.path().join("outside.yaml");
std::fs::write(&outside_file, "test").unwrap();
assert!(validate_path_boundary(&project, &valid_file).is_ok());
let result = validate_path_boundary(&project, &outside_file);
assert!(result.is_err());
}
}