use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::Result;
use crate::tasks::{Input, Task, TaskDependency, TaskNode};
pub const CONTRIBUTOR_TASK_PREFIX: &str = "cuenv:contributor:";
#[derive(Debug, Clone, Default)]
pub struct ContributorContext {
pub workspace_member: Option<String>,
pub workspace_root: Option<std::path::PathBuf>,
pub task_commands: HashSet<String>,
pub service_commands: HashSet<String>,
pub has_services: bool,
}
impl ContributorContext {
#[must_use]
pub fn detect(project_root: &Path) -> Self {
let mut ctx = Self::default();
if let Ok(managers) = cuenv_workspaces::detect_package_managers(project_root)
&& let Some(first) = managers.first()
{
ctx.workspace_member = Some(workspace_name_for_manager(*first).to_string());
}
ctx
}
pub fn with_task_commands(mut self, tasks: &HashMap<String, TaskNode>) -> Self {
for node in tasks.values() {
collect_commands_from_node(node, &mut self.task_commands);
}
self
}
pub fn with_services(mut self, services: &HashMap<String, crate::manifest::Service>) -> Self {
self.has_services = !services.is_empty();
for service in services.values() {
if let Some(cmd) = service.primary_command()
&& let Some(cmd_name) = cuenv_workspaces::command_name(cmd)
{
self.service_commands.insert(cmd_name);
}
}
self
}
}
fn workspace_name_for_manager(manager: cuenv_workspaces::PackageManager) -> &'static str {
match manager {
cuenv_workspaces::PackageManager::Npm => "npm",
cuenv_workspaces::PackageManager::Bun => "bun",
cuenv_workspaces::PackageManager::Pnpm => "pnpm",
cuenv_workspaces::PackageManager::YarnClassic
| cuenv_workspaces::PackageManager::YarnModern => "yarn",
cuenv_workspaces::PackageManager::Cargo => "cargo",
cuenv_workspaces::PackageManager::Deno => "deno",
}
}
fn collect_commands_from_node(node: &TaskNode, commands: &mut HashSet<String>) {
match node {
TaskNode::Task(task) => {
if !task.command.is_empty()
&& let Some(cmd) = cuenv_workspaces::command_name(&task.command)
{
commands.insert(cmd);
}
}
TaskNode::Group(group) => {
for sub in group.children.values() {
collect_commands_from_node(sub, commands);
}
}
TaskNode::Sequence(steps) => {
for sub in steps {
collect_commands_from_node(sub, commands);
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
pub struct ContributorActivation {
#[serde(skip_serializing_if = "Option::is_none")]
pub always: Option<bool>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub workspace_member: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub command: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub service_command: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub has_service: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
pub struct AutoAssociate {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub command: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub inject_dependency: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
pub struct ContributorTask {
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub args: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub script: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub inputs: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub outputs: Vec<String>,
#[serde(default)]
pub hermetic: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub depends_on: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct Contributor {
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub when: Option<ContributorActivation>,
pub tasks: Vec<ContributorTask>,
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_associate: Option<AutoAssociate>,
}
pub struct ContributorEngine<'a> {
contributors: &'a [Contributor],
context: ContributorContext,
}
impl<'a> ContributorEngine<'a> {
#[must_use]
pub fn new(contributors: &'a [Contributor], context: ContributorContext) -> Self {
Self {
contributors,
context,
}
}
pub fn apply(&self, tasks: &mut HashMap<String, TaskNode>) -> Result<usize> {
let mut total_injected = 0;
let max_iterations = 10;
for iteration in 0..max_iterations {
let mut changed = false;
for contributor in self.contributors {
if self.is_active(contributor) {
let injected = self.inject_tasks(contributor, tasks);
if injected > 0 {
changed = true;
total_injected += injected;
tracing::debug!(
contributor = %contributor.id,
injected,
"Contributor injected tasks"
);
}
if let Some(auto_assoc) = &contributor.auto_associate {
self.apply_auto_association(auto_assoc, tasks);
}
}
}
if !changed {
tracing::debug!(
iterations = iteration + 1,
total_injected,
"Contributor loop stabilized"
);
break;
}
}
Ok(total_injected)
}
fn is_active(&self, contributor: &Contributor) -> bool {
let Some(when) = &contributor.when else {
return true;
};
if when.always == Some(true) {
return true;
}
if !when.workspace_member.is_empty() {
let has_match = self.context.workspace_member.as_ref().is_some_and(|ws| {
when.workspace_member
.iter()
.any(|w| w.eq_ignore_ascii_case(ws))
});
if !has_match {
return false;
}
}
if !when.command.is_empty() {
let has_match = when
.command
.iter()
.any(|cmd| self.context.task_commands.contains(cmd));
if !has_match {
return false;
}
}
if !when.service_command.is_empty() {
let has_match = when
.service_command
.iter()
.any(|cmd| self.context.service_commands.contains(cmd));
if !has_match {
return false;
}
}
if when.has_service == Some(true) && !self.context.has_services {
return false;
}
if when.has_service == Some(false) && self.context.has_services {
return false;
}
true
}
fn inject_tasks(
&self,
contributor: &Contributor,
tasks: &mut HashMap<String, TaskNode>,
) -> usize {
let mut injected = 0;
for contrib_task in &contributor.tasks {
let task_id = if contrib_task.id.starts_with(CONTRIBUTOR_TASK_PREFIX) {
contrib_task.id.clone()
} else {
format!("{}{}", CONTRIBUTOR_TASK_PREFIX, contrib_task.id)
};
if tasks.contains_key(&task_id) {
continue;
}
let task = Task {
command: contrib_task.command.clone().unwrap_or_default(),
args: contrib_task.args.clone(),
script: contrib_task.script.clone(),
inputs: contrib_task
.inputs
.iter()
.map(|s| Input::Path(s.clone()))
.collect(),
outputs: contrib_task.outputs.clone(),
hermetic: contrib_task.hermetic,
depends_on: contrib_task
.depends_on
.iter()
.map(|dep| {
let name =
if dep.starts_with(CONTRIBUTOR_TASK_PREFIX) || dep.starts_with('#') {
dep.clone()
} else {
format!("{}{}", CONTRIBUTOR_TASK_PREFIX, dep)
};
TaskDependency::from_name(name)
})
.collect(),
description: contrib_task.description.clone(),
..Default::default()
};
tasks.insert(task_id.clone(), TaskNode::Task(Box::new(task)));
injected += 1;
tracing::trace!(task = %task_id, "Injected contributor task");
}
injected
}
fn apply_auto_association(
&self,
auto_assoc: &AutoAssociate,
tasks: &mut HashMap<String, TaskNode>,
) {
let Some(inject_dep) = &auto_assoc.inject_dependency else {
return;
};
if !tasks.contains_key(inject_dep) {
return;
}
let task_names: Vec<String> = tasks.keys().cloned().collect();
for task_name in task_names {
if task_name.starts_with(CONTRIBUTOR_TASK_PREFIX) {
continue;
}
let Some(node) = tasks.get_mut(&task_name) else {
continue;
};
Self::auto_associate_node(node, &auto_assoc.command, inject_dep);
}
}
fn auto_associate_node(node: &mut TaskNode, commands: &[String], inject_dep: &str) {
match node {
TaskNode::Task(task) => {
let Some(base_cmd) = cuenv_workspaces::command_name(&task.command) else {
return;
};
if commands.iter().any(|c| c == &base_cmd) {
if !task.depends_on.iter().any(|d| d.task_name() == inject_dep) {
task.depends_on.push(TaskDependency::from_name(inject_dep));
tracing::trace!(
command = %task.command,
dependency = %inject_dep,
"Auto-associated task with contributor"
);
}
}
}
TaskNode::Group(group) => {
for sub in group.children.values_mut() {
Self::auto_associate_node(sub, commands, inject_dep);
}
}
TaskNode::Sequence(steps) => {
for sub in steps {
Self::auto_associate_node(sub, commands, inject_dep);
}
}
}
}
}
#[derive(Debug, Clone, Default)]
pub struct ContributorResult {
pub tasks_injected: usize,
pub active_contributors: Vec<String>,
}
#[must_use]
pub fn bun_workspace_contributor() -> Contributor {
Contributor {
id: "bun.workspace".to_string(),
when: Some(ContributorActivation {
workspace_member: vec!["bun".to_string()],
..Default::default()
}),
tasks: vec![
ContributorTask {
id: "bun.workspace.install".to_string(),
command: Some("bun".to_string()),
args: vec!["install".to_string(), "--frozen-lockfile".to_string()],
inputs: vec!["package.json".to_string(), "bun.lock".to_string()],
outputs: vec!["node_modules".to_string()],
hermetic: false,
description: Some("Install Bun dependencies".to_string()),
..Default::default()
},
ContributorTask {
id: "bun.workspace.setup".to_string(),
script: Some("true".to_string()),
hermetic: false,
depends_on: vec!["bun.workspace.install".to_string()],
description: Some("Bun workspace setup complete".to_string()),
..Default::default()
},
],
auto_associate: Some(AutoAssociate {
command: vec!["bun".to_string(), "bunx".to_string()],
inject_dependency: Some(format!("{}bun.workspace.setup", CONTRIBUTOR_TASK_PREFIX)),
}),
}
}
#[must_use]
pub fn npm_workspace_contributor() -> Contributor {
Contributor {
id: "npm.workspace".to_string(),
when: Some(ContributorActivation {
workspace_member: vec!["npm".to_string()],
..Default::default()
}),
tasks: vec![
ContributorTask {
id: "npm.workspace.install".to_string(),
command: Some("npm".to_string()),
args: vec!["ci".to_string()],
inputs: vec!["package.json".to_string(), "package-lock.json".to_string()],
outputs: vec!["node_modules".to_string()],
hermetic: false,
description: Some("Install npm dependencies".to_string()),
..Default::default()
},
ContributorTask {
id: "npm.workspace.setup".to_string(),
script: Some("true".to_string()),
hermetic: false,
depends_on: vec!["npm.workspace.install".to_string()],
description: Some("npm workspace setup complete".to_string()),
..Default::default()
},
],
auto_associate: Some(AutoAssociate {
command: vec!["npm".to_string(), "npx".to_string()],
inject_dependency: Some(format!("{}npm.workspace.setup", CONTRIBUTOR_TASK_PREFIX)),
}),
}
}
#[must_use]
pub fn pnpm_workspace_contributor() -> Contributor {
Contributor {
id: "pnpm.workspace".to_string(),
when: Some(ContributorActivation {
workspace_member: vec!["pnpm".to_string()],
..Default::default()
}),
tasks: vec![
ContributorTask {
id: "pnpm.workspace.install".to_string(),
command: Some("pnpm".to_string()),
args: vec!["install".to_string(), "--frozen-lockfile".to_string()],
inputs: vec!["package.json".to_string(), "pnpm-lock.yaml".to_string()],
outputs: vec!["node_modules".to_string()],
hermetic: false,
description: Some("Install pnpm dependencies".to_string()),
..Default::default()
},
ContributorTask {
id: "pnpm.workspace.setup".to_string(),
script: Some("true".to_string()),
hermetic: false,
depends_on: vec!["pnpm.workspace.install".to_string()],
description: Some("pnpm workspace setup complete".to_string()),
..Default::default()
},
],
auto_associate: Some(AutoAssociate {
command: vec!["pnpm".to_string(), "pnpx".to_string()],
inject_dependency: Some(format!("{}pnpm.workspace.setup", CONTRIBUTOR_TASK_PREFIX)),
}),
}
}
#[must_use]
pub fn yarn_workspace_contributor() -> Contributor {
Contributor {
id: "yarn.workspace".to_string(),
when: Some(ContributorActivation {
workspace_member: vec!["yarn".to_string()],
..Default::default()
}),
tasks: vec![
ContributorTask {
id: "yarn.workspace.install".to_string(),
command: Some("yarn".to_string()),
args: vec!["install".to_string(), "--immutable".to_string()],
inputs: vec!["package.json".to_string(), "yarn.lock".to_string()],
outputs: vec!["node_modules".to_string()],
hermetic: false,
description: Some("Install Yarn dependencies".to_string()),
..Default::default()
},
ContributorTask {
id: "yarn.workspace.setup".to_string(),
script: Some("true".to_string()),
hermetic: false,
depends_on: vec!["yarn.workspace.install".to_string()],
description: Some("Yarn workspace setup complete".to_string()),
..Default::default()
},
],
auto_associate: Some(AutoAssociate {
command: vec!["yarn".to_string()],
inject_dependency: Some(format!("{}yarn.workspace.setup", CONTRIBUTOR_TASK_PREFIX)),
}),
}
}
#[must_use]
pub fn builtin_workspace_contributors() -> Vec<Contributor> {
vec![
bun_workspace_contributor(),
npm_workspace_contributor(),
pnpm_workspace_contributor(),
yarn_workspace_contributor(),
]
}
#[must_use]
pub fn build_expected_dag(tasks: &HashMap<String, TaskNode>) -> BTreeMap<String, Vec<String>> {
let mut dag = BTreeMap::new();
for (name, node) in tasks {
let deps = collect_deps_from_node(node);
dag.insert(name.clone(), deps);
}
dag
}
fn collect_deps_from_node(node: &TaskNode) -> Vec<String> {
match node {
TaskNode::Task(task) => task
.depends_on
.iter()
.map(|d| d.task_name().to_string())
.collect(),
TaskNode::Group(group) => group
.depends_on
.iter()
.map(|d| d.task_name().to_string())
.collect(),
TaskNode::Sequence(_) => Vec::new(), }
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_contributor(id: &str, workspace_member: Vec<&str>) -> Contributor {
Contributor {
id: id.to_string(),
when: Some(ContributorActivation {
workspace_member: workspace_member.into_iter().map(String::from).collect(),
..Default::default()
}),
tasks: vec![
ContributorTask {
id: format!("{id}.install"),
command: Some("test-cmd".to_string()),
args: vec!["install".to_string()],
inputs: vec!["package.json".to_string()],
outputs: vec!["node_modules".to_string()],
hermetic: false,
depends_on: vec![],
script: None,
description: Some(format!("Install {id} dependencies")),
},
ContributorTask {
id: format!("{id}.setup"),
command: None,
args: vec![],
script: Some("true".to_string()),
inputs: vec![],
outputs: vec![],
hermetic: false,
depends_on: vec![format!("{id}.install")],
description: Some(format!("{id} setup complete")),
},
],
auto_associate: Some(AutoAssociate {
command: vec!["test-cmd".to_string()],
inject_dependency: Some(format!("{CONTRIBUTOR_TASK_PREFIX}{id}.setup")),
}),
}
}
#[test]
fn test_contributor_activation_workspace_member() {
let contrib = create_test_contributor("bun.workspace", vec!["bun"]);
let ctx = ContributorContext {
workspace_member: Some("bun".to_string()),
..Default::default()
};
let contributors = [contrib.clone()];
let engine = ContributorEngine::new(&contributors, ctx);
assert!(engine.is_active(&contrib));
let ctx = ContributorContext {
workspace_member: Some("npm".to_string()),
..Default::default()
};
let contributors = [contrib.clone()];
let engine = ContributorEngine::new(&contributors, ctx);
assert!(!engine.is_active(&contrib));
let ctx = ContributorContext::default();
let contributors = [contrib.clone()];
let engine = ContributorEngine::new(&contributors, ctx);
assert!(!engine.is_active(&contrib));
}
#[test]
fn test_contributor_injects_tasks() {
let contrib = create_test_contributor("bun.workspace", vec!["bun"]);
let ctx = ContributorContext {
workspace_member: Some("bun".to_string()),
..Default::default()
};
let contributors = [contrib];
let engine = ContributorEngine::new(&contributors, ctx);
let mut tasks: HashMap<String, TaskNode> = HashMap::new();
let injected = engine.apply(&mut tasks).unwrap();
assert_eq!(injected, 2);
assert!(tasks.contains_key("cuenv:contributor:bun.workspace.install"));
assert!(tasks.contains_key("cuenv:contributor:bun.workspace.setup"));
}
#[test]
fn test_contributor_auto_association() {
let contrib = create_test_contributor("bun.workspace", vec!["bun"]);
let ctx = ContributorContext {
workspace_member: Some("bun".to_string()),
workspace_root: None,
task_commands: ["test-cmd".to_string()].into_iter().collect(),
..Default::default()
};
let user_task = Task {
command: "test-cmd".to_string(),
args: vec!["run".to_string(), "dev".to_string()],
..Default::default()
};
let mut tasks: HashMap<String, TaskNode> = HashMap::new();
tasks.insert("dev".to_string(), TaskNode::Task(Box::new(user_task)));
let contributors = [contrib];
let engine = ContributorEngine::new(&contributors, ctx);
engine.apply(&mut tasks).unwrap();
let dev_task = tasks.get("dev").unwrap();
if let TaskNode::Task(task) = dev_task {
assert!(
task.depends_on
.iter()
.any(|d| d.task_name() == "cuenv:contributor:bun.workspace.setup")
);
} else {
panic!("Expected single task");
}
}
#[test]
fn test_contributor_auto_association_with_env_prefixed_command() {
let contrib = create_test_contributor("bun.workspace", vec!["bun"]);
let ctx = ContributorContext {
workspace_member: Some("bun".to_string()),
workspace_root: None,
task_commands: ["test-cmd".to_string()].into_iter().collect(),
..Default::default()
};
let user_task = Task {
command: "env TEST_MODE=1 test-cmd run dev".to_string(),
..Default::default()
};
let mut tasks: HashMap<String, TaskNode> = HashMap::new();
tasks.insert("dev".to_string(), TaskNode::Task(Box::new(user_task)));
let contributors = [contrib];
let engine = ContributorEngine::new(&contributors, ctx);
engine.apply(&mut tasks).unwrap();
let dev_task = tasks.get("dev").unwrap();
if let TaskNode::Task(task) = dev_task {
assert!(
task.depends_on
.iter()
.any(|d| d.task_name() == "cuenv:contributor:bun.workspace.setup")
);
} else {
panic!("Expected single task");
}
}
#[test]
fn test_idempotent_injection() {
let contrib = create_test_contributor("bun.workspace", vec!["bun"]);
let ctx = ContributorContext {
workspace_member: Some("bun".to_string()),
..Default::default()
};
let contributors = [contrib];
let engine = ContributorEngine::new(&contributors, ctx);
let mut tasks: HashMap<String, TaskNode> = HashMap::new();
let first_injected = engine.apply(&mut tasks).unwrap();
assert_eq!(first_injected, 2);
let second_injected = engine.apply(&mut tasks).unwrap();
assert_eq!(second_injected, 0);
assert_eq!(tasks.len(), 2);
}
#[test]
fn test_always_active_contributor() {
let contrib = Contributor {
id: "always-on".to_string(),
when: Some(ContributorActivation {
always: Some(true),
..Default::default()
}),
tasks: vec![ContributorTask {
id: "always-on.task".to_string(),
command: Some("echo".to_string()),
args: vec!["always".to_string()],
..Default::default()
}],
auto_associate: None,
};
let ctx = ContributorContext::default();
let contributors = [contrib.clone()];
let engine = ContributorEngine::new(&contributors, ctx);
assert!(engine.is_active(&contrib));
}
#[test]
fn test_no_condition_means_always_active() {
let contrib = Contributor {
id: "no-condition".to_string(),
when: None, tasks: vec![ContributorTask {
id: "no-condition.task".to_string(),
command: Some("echo".to_string()),
args: vec!["hello".to_string()],
..Default::default()
}],
auto_associate: None,
};
let ctx = ContributorContext::default();
let contributors = [contrib.clone()];
let engine = ContributorEngine::new(&contributors, ctx);
assert!(engine.is_active(&contrib));
}
#[test]
fn test_build_expected_dag() {
let mut tasks: HashMap<String, TaskNode> = HashMap::new();
let task_a = Task {
command: "echo".to_string(),
args: vec!["a".to_string()],
..Default::default()
};
let task_b = Task {
command: "echo".to_string(),
args: vec!["b".to_string()],
depends_on: vec![TaskDependency::from_name("a")],
..Default::default()
};
tasks.insert("a".to_string(), TaskNode::Task(Box::new(task_a)));
tasks.insert("b".to_string(), TaskNode::Task(Box::new(task_b)));
let dag = build_expected_dag(&tasks);
assert_eq!(dag.get("a"), Some(&vec![]));
assert_eq!(dag.get("b"), Some(&vec!["a".to_string()]));
}
#[test]
fn test_multiple_contributors_active_simultaneously() {
let bun_contrib = create_test_contributor("bun.workspace", vec!["bun"]);
let npm_contrib = Contributor {
id: "npm.workspace".to_string(),
when: Some(ContributorActivation {
workspace_member: vec!["npm".to_string()],
..Default::default()
}),
tasks: vec![ContributorTask {
id: "npm.workspace.install".to_string(),
command: Some("npm".to_string()),
args: vec!["install".to_string()],
..Default::default()
}],
auto_associate: None,
};
let ctx = ContributorContext {
workspace_member: Some("bun".to_string()),
..Default::default()
};
let contributors = [bun_contrib.clone(), npm_contrib.clone()];
let engine = ContributorEngine::new(&contributors, ctx);
let mut tasks: HashMap<String, TaskNode> = HashMap::new();
engine.apply(&mut tasks).unwrap();
assert!(tasks.contains_key("cuenv:contributor:bun.workspace.install"));
assert!(tasks.contains_key("cuenv:contributor:bun.workspace.setup"));
assert!(!tasks.contains_key("cuenv:contributor:npm.workspace.install"));
}
#[test]
fn test_auto_association_no_duplicate_deps() {
let contrib = create_test_contributor("bun.workspace", vec!["bun"]);
let ctx = ContributorContext {
workspace_member: Some("bun".to_string()),
workspace_root: None,
task_commands: ["test-cmd".to_string()].into_iter().collect(),
..Default::default()
};
let user_task = Task {
command: "test-cmd".to_string(),
args: vec!["run".to_string(), "dev".to_string()],
depends_on: vec![TaskDependency::from_name(
"cuenv:contributor:bun.workspace.setup",
)],
..Default::default()
};
let mut tasks: HashMap<String, TaskNode> = HashMap::new();
tasks.insert("dev".to_string(), TaskNode::Task(Box::new(user_task)));
let contributors = [contrib];
let engine = ContributorEngine::new(&contributors, ctx);
engine.apply(&mut tasks).unwrap();
let dev_task = tasks.get("dev").unwrap();
if let TaskNode::Task(task) = dev_task {
let dep_count = task
.depends_on
.iter()
.filter(|d| d.task_name() == "cuenv:contributor:bun.workspace.setup")
.count();
assert_eq!(dep_count, 1, "Dependency should not be duplicated");
} else {
panic!("Expected single task");
}
}
#[test]
fn test_command_matching_is_exact() {
let contrib = create_test_contributor("bun.workspace", vec!["bun"]);
let ctx = ContributorContext {
workspace_member: Some("bun".to_string()),
workspace_root: None,
task_commands: ["test-cmd".to_string()].into_iter().collect(),
..Default::default()
};
let user_task = Task {
command: "test-cmd-extra".to_string(), args: vec!["run".to_string()],
..Default::default()
};
let mut tasks: HashMap<String, TaskNode> = HashMap::new();
tasks.insert("other".to_string(), TaskNode::Task(Box::new(user_task)));
let contributors = [contrib];
let engine = ContributorEngine::new(&contributors, ctx);
engine.apply(&mut tasks).unwrap();
let other_task = tasks.get("other").unwrap();
if let TaskNode::Task(task) = other_task {
assert!(
!task
.depends_on
.iter()
.any(|d| d.task_name() == "cuenv:contributor:bun.workspace.setup"),
"Non-matching command should not get auto-association"
);
} else {
panic!("Expected single task");
}
}
#[test]
fn test_contributor_with_empty_tasks() {
let contrib = Contributor {
id: "empty".to_string(),
when: Some(ContributorActivation {
always: Some(true),
..Default::default()
}),
tasks: vec![], auto_associate: None,
};
let ctx = ContributorContext::default();
let contributors = [contrib];
let engine = ContributorEngine::new(&contributors, ctx);
let mut tasks: HashMap<String, TaskNode> = HashMap::new();
let injected = engine.apply(&mut tasks).unwrap();
assert_eq!(injected, 0);
assert!(tasks.is_empty());
}
#[test]
fn test_contributor_task_dependencies_prefixed() {
let contrib = Contributor {
id: "test".to_string(),
when: Some(ContributorActivation {
always: Some(true),
..Default::default()
}),
tasks: vec![
ContributorTask {
id: "test.first".to_string(),
command: Some("echo".to_string()),
args: vec!["first".to_string()],
..Default::default()
},
ContributorTask {
id: "test.second".to_string(),
command: Some("echo".to_string()),
args: vec!["second".to_string()],
depends_on: vec!["test.first".to_string()], ..Default::default()
},
],
auto_associate: None,
};
let ctx = ContributorContext::default();
let contributors = [contrib];
let engine = ContributorEngine::new(&contributors, ctx);
let mut tasks: HashMap<String, TaskNode> = HashMap::new();
engine.apply(&mut tasks).unwrap();
let second_task = tasks.get("cuenv:contributor:test.second").unwrap();
if let TaskNode::Task(task) = second_task {
assert!(
task.depends_on
.iter()
.any(|d| d.task_name() == "cuenv:contributor:test.first"),
"Internal dependency should be prefixed, got: {:?}",
task.depends_on
);
} else {
panic!("Expected single task");
}
}
}