Skip to main content

cuenv_core/
contributors.rs

1//! Contributor engine for task DAG injection
2//!
3//! Contributors are CUE-defined task injectors that modify the task DAG before execution.
4//! The engine evaluates activation conditions and injects tasks with proper naming.
5//!
6//! ## Data Flow
7//!
8//! 1. CUE evaluation produces Projects with Tasks (initial DAG)
9//! 2. ContributorEngine applies contributors:
10//!    - Evaluates `when` conditions (workspaceMember, command patterns)
11//!    - Injects contributor tasks with `cuenv:contributor:*` prefix
12//!    - Auto-associates user tasks with contributor setup tasks
13//!    - Loops until no changes (stable DAG)
14//! 3. Final DAG passed to executor (CLI or CI)
15//!
16//! ## Task Naming Convention
17//!
18//! Contributor tasks use the format: `cuenv:contributor:{contributor}.{task}`
19//! Example: `cuenv:contributor:bun.workspace.install`
20
21use std::collections::{BTreeMap, HashMap, HashSet};
22use std::path::Path;
23
24use serde::{Deserialize, Serialize};
25
26use crate::Result;
27use crate::tasks::{Input, Task, TaskDependency, TaskNode};
28
29/// Prefix for all contributor-injected tasks
30pub const CONTRIBUTOR_TASK_PREFIX: &str = "cuenv:contributor:";
31
32/// Context provided to contributors for activation condition evaluation
33#[derive(Debug, Clone, Default)]
34pub struct ContributorContext {
35    /// Detected workspace membership (e.g., "bun", "npm", "cargo")
36    pub workspace_member: Option<String>,
37
38    /// Path to workspace root (if member of a workspace)
39    pub workspace_root: Option<std::path::PathBuf>,
40
41    /// All commands used by tasks in the project (for command-based activation)
42    pub task_commands: HashSet<String>,
43
44    /// Commands used by services in the project (for service command-based activation)
45    pub service_commands: HashSet<String>,
46
47    /// Whether the project has any services defined
48    pub has_services: bool,
49}
50
51impl ContributorContext {
52    /// Create context by detecting workspace from project root
53    #[must_use]
54    pub fn detect(project_root: &Path) -> Self {
55        let mut ctx = Self::default();
56
57        // Use cuenv-workspaces for detection
58        if let Ok(managers) = cuenv_workspaces::detect_package_managers(project_root)
59            && let Some(first) = managers.first()
60        {
61            ctx.workspace_member = Some(workspace_name_for_manager(*first).to_string());
62        }
63
64        ctx
65    }
66
67    /// Add task commands from a project's tasks
68    pub fn with_task_commands(mut self, tasks: &HashMap<String, TaskNode>) -> Self {
69        for node in tasks.values() {
70            collect_commands_from_node(node, &mut self.task_commands);
71        }
72        self
73    }
74
75    /// Add service commands from a project's services
76    pub fn with_services(mut self, services: &HashMap<String, crate::manifest::Service>) -> Self {
77        self.has_services = !services.is_empty();
78        for service in services.values() {
79            if let Some(cmd) = service.primary_command()
80                && let Some(cmd_name) = cuenv_workspaces::command_name(cmd)
81            {
82                self.service_commands.insert(cmd_name);
83            }
84        }
85        self
86    }
87}
88
89/// Returns the canonical workspace name for a package manager
90fn workspace_name_for_manager(manager: cuenv_workspaces::PackageManager) -> &'static str {
91    match manager {
92        cuenv_workspaces::PackageManager::Npm => "npm",
93        cuenv_workspaces::PackageManager::Bun => "bun",
94        cuenv_workspaces::PackageManager::Pnpm => "pnpm",
95        cuenv_workspaces::PackageManager::YarnClassic
96        | cuenv_workspaces::PackageManager::YarnModern => "yarn",
97        cuenv_workspaces::PackageManager::Cargo => "cargo",
98        cuenv_workspaces::PackageManager::Deno => "deno",
99    }
100}
101
102/// Collect all commands from a task node recursively
103fn collect_commands_from_node(node: &TaskNode, commands: &mut HashSet<String>) {
104    match node {
105        TaskNode::Task(task) => {
106            if !task.command.is_empty()
107                && let Some(cmd) = cuenv_workspaces::command_name(&task.command)
108            {
109                commands.insert(cmd);
110            }
111        }
112        TaskNode::Group(group) => {
113            for sub in group.children.values() {
114                collect_commands_from_node(sub, commands);
115            }
116        }
117        TaskNode::Sequence(steps) => {
118            for sub in steps {
119                collect_commands_from_node(sub, commands);
120            }
121        }
122    }
123}
124
125/// Activation condition for contributors
126///
127/// All specified conditions must be true (AND logic)
128#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
129#[serde(rename_all = "camelCase")]
130pub struct ContributorActivation {
131    /// Always active (no conditions)
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub always: Option<bool>,
134
135    /// Workspace membership detection (active if project is member of these workspace types)
136    /// Values: "npm", "bun", "pnpm", "yarn", "cargo", "deno"
137    #[serde(default, skip_serializing_if = "Vec::is_empty")]
138    pub workspace_member: Vec<String>,
139
140    /// Command detection for auto-association (active if any task uses these commands)
141    #[serde(default, skip_serializing_if = "Vec::is_empty")]
142    pub command: Vec<String>,
143
144    /// Service command detection (active if any service uses these commands)
145    #[serde(default, skip_serializing_if = "Vec::is_empty")]
146    pub service_command: Vec<String>,
147
148    /// Service presence (active if project has any services defined)
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub has_service: Option<bool>,
151}
152
153/// Auto-association rules for contributors
154///
155/// Defines how user tasks are automatically connected to contributor tasks
156#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
157#[serde(rename_all = "camelCase")]
158pub struct AutoAssociate {
159    /// Commands that trigger auto-association (e.g., ["bun", "bunx"])
160    #[serde(default, skip_serializing_if = "Vec::is_empty")]
161    pub command: Vec<String>,
162
163    /// Task to inject as dependency (e.g., "cuenv:contributor:bun.workspace.setup")
164    #[serde(skip_serializing_if = "Option::is_none")]
165    pub inject_dependency: Option<String>,
166}
167
168/// A task contributed by a contributor
169#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
170#[serde(rename_all = "camelCase")]
171pub struct ContributorTask {
172    /// Task identifier (will be prefixed with contributor namespace)
173    pub id: String,
174
175    /// Shell command to execute
176    #[serde(skip_serializing_if = "Option::is_none")]
177    pub command: Option<String>,
178
179    /// Command arguments
180    #[serde(default, skip_serializing_if = "Vec::is_empty")]
181    pub args: Vec<String>,
182
183    /// Multi-line script (alternative to command)
184    #[serde(skip_serializing_if = "Option::is_none")]
185    pub script: Option<String>,
186
187    /// Input files/patterns for caching
188    #[serde(default, skip_serializing_if = "Vec::is_empty")]
189    pub inputs: Vec<String>,
190
191    /// Output files/patterns for caching
192    #[serde(default, skip_serializing_if = "Vec::is_empty")]
193    pub outputs: Vec<String>,
194
195    /// Whether task requires hermetic execution
196    #[serde(default)]
197    pub hermetic: bool,
198
199    /// Dependencies on other tasks (within contributor namespace)
200    #[serde(default, skip_serializing_if = "Vec::is_empty")]
201    pub depends_on: Vec<String>,
202
203    /// Human-readable description
204    #[serde(skip_serializing_if = "Option::is_none")]
205    pub description: Option<String>,
206}
207
208/// Contributor definition
209///
210/// Contributors inject tasks into the DAG based on activation conditions
211#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
212#[serde(rename_all = "camelCase")]
213pub struct Contributor {
214    /// Contributor identifier (e.g., "bun.workspace")
215    pub id: String,
216
217    /// Activation condition (defaults to always active)
218    #[serde(skip_serializing_if = "Option::is_none")]
219    pub when: Option<ContributorActivation>,
220
221    /// Tasks to contribute when active
222    pub tasks: Vec<ContributorTask>,
223
224    /// Auto-association rules for user tasks
225    #[serde(skip_serializing_if = "Option::is_none")]
226    pub auto_associate: Option<AutoAssociate>,
227}
228
229/// Engine that applies contributors to modify the task DAG
230pub struct ContributorEngine<'a> {
231    contributors: &'a [Contributor],
232    context: ContributorContext,
233}
234
235impl<'a> ContributorEngine<'a> {
236    /// Create a new contributor engine
237    #[must_use]
238    pub fn new(contributors: &'a [Contributor], context: ContributorContext) -> Self {
239        Self {
240            contributors,
241            context,
242        }
243    }
244
245    /// Apply all active contributors to the task DAG
246    ///
247    /// Loops until no contributor makes changes (stable DAG).
248    /// Returns the number of tasks injected.
249    pub fn apply(&self, tasks: &mut HashMap<String, TaskNode>) -> Result<usize> {
250        let mut total_injected = 0;
251        let max_iterations = 10; // Safety limit to prevent infinite loops
252
253        for iteration in 0..max_iterations {
254            let mut changed = false;
255
256            for contributor in self.contributors {
257                if self.is_active(contributor) {
258                    let injected = self.inject_tasks(contributor, tasks);
259                    if injected > 0 {
260                        changed = true;
261                        total_injected += injected;
262                        tracing::debug!(
263                            contributor = %contributor.id,
264                            injected,
265                            "Contributor injected tasks"
266                        );
267                    }
268
269                    // Apply auto-association rules
270                    if let Some(auto_assoc) = &contributor.auto_associate {
271                        self.apply_auto_association(auto_assoc, tasks);
272                    }
273                }
274            }
275
276            if !changed {
277                tracing::debug!(
278                    iterations = iteration + 1,
279                    total_injected,
280                    "Contributor loop stabilized"
281                );
282                break;
283            }
284        }
285
286        Ok(total_injected)
287    }
288
289    /// Check if a contributor should be active based on its conditions
290    fn is_active(&self, contributor: &Contributor) -> bool {
291        let Some(when) = &contributor.when else {
292            // No conditions means always active
293            return true;
294        };
295
296        // Check always flag
297        if when.always == Some(true) {
298            return true;
299        }
300
301        // Check workspace membership (OR within, AND with other conditions)
302        if !when.workspace_member.is_empty() {
303            let has_match = self.context.workspace_member.as_ref().is_some_and(|ws| {
304                when.workspace_member
305                    .iter()
306                    .any(|w| w.eq_ignore_ascii_case(ws))
307            });
308            if !has_match {
309                return false;
310            }
311        }
312
313        // Check command usage (OR within, AND with other conditions)
314        if !when.command.is_empty() {
315            let has_match = when
316                .command
317                .iter()
318                .any(|cmd| self.context.task_commands.contains(cmd));
319            if !has_match {
320                return false;
321            }
322        }
323
324        // Check service command usage (OR within, AND with other conditions)
325        if !when.service_command.is_empty() {
326            let has_match = when
327                .service_command
328                .iter()
329                .any(|cmd| self.context.service_commands.contains(cmd));
330            if !has_match {
331                return false;
332            }
333        }
334
335        // Check service presence
336        if when.has_service == Some(true) && !self.context.has_services {
337            return false;
338        }
339        if when.has_service == Some(false) && self.context.has_services {
340            return false;
341        }
342
343        true
344    }
345
346    /// Inject tasks from a contributor into the DAG
347    ///
348    /// Returns the number of tasks injected
349    fn inject_tasks(
350        &self,
351        contributor: &Contributor,
352        tasks: &mut HashMap<String, TaskNode>,
353    ) -> usize {
354        let mut injected = 0;
355
356        for contrib_task in &contributor.tasks {
357            // Build the full task ID with prefix
358            let task_id = if contrib_task.id.starts_with(CONTRIBUTOR_TASK_PREFIX) {
359                contrib_task.id.clone()
360            } else {
361                format!("{}{}", CONTRIBUTOR_TASK_PREFIX, contrib_task.id)
362            };
363
364            // Skip if already exists
365            if tasks.contains_key(&task_id) {
366                continue;
367            }
368
369            // Convert ContributorTask to TaskNode
370            let task = Task {
371                command: contrib_task.command.clone().unwrap_or_default(),
372                args: contrib_task.args.clone(),
373                script: contrib_task.script.clone(),
374                inputs: contrib_task
375                    .inputs
376                    .iter()
377                    .map(|s| Input::Path(s.clone()))
378                    .collect(),
379                outputs: contrib_task.outputs.clone(),
380                hermetic: contrib_task.hermetic,
381                depends_on: contrib_task
382                    .depends_on
383                    .iter()
384                    .map(|dep| {
385                        // Prefix dependencies if they don't already have it
386                        let name =
387                            if dep.starts_with(CONTRIBUTOR_TASK_PREFIX) || dep.starts_with('#') {
388                                dep.clone()
389                            } else {
390                                format!("{}{}", CONTRIBUTOR_TASK_PREFIX, dep)
391                            };
392                        TaskDependency::from_name(name)
393                    })
394                    .collect(),
395                description: contrib_task.description.clone(),
396                ..Default::default()
397            };
398
399            tasks.insert(task_id.clone(), TaskNode::Task(Box::new(task)));
400            injected += 1;
401
402            tracing::trace!(task = %task_id, "Injected contributor task");
403        }
404
405        injected
406    }
407
408    /// Apply auto-association rules to existing tasks
409    fn apply_auto_association(
410        &self,
411        auto_assoc: &AutoAssociate,
412        tasks: &mut HashMap<String, TaskNode>,
413    ) {
414        let Some(inject_dep) = &auto_assoc.inject_dependency else {
415            return;
416        };
417
418        // Verify the dependency task exists
419        if !tasks.contains_key(inject_dep) {
420            return;
421        }
422
423        // Collect task names to modify (can't modify while iterating)
424        let task_names: Vec<String> = tasks.keys().cloned().collect();
425
426        for task_name in task_names {
427            // Skip contributor tasks
428            if task_name.starts_with(CONTRIBUTOR_TASK_PREFIX) {
429                continue;
430            }
431
432            let Some(node) = tasks.get_mut(&task_name) else {
433                continue;
434            };
435
436            Self::auto_associate_node(node, &auto_assoc.command, inject_dep);
437        }
438    }
439
440    /// Recursively apply auto-association to a task node
441    fn auto_associate_node(node: &mut TaskNode, commands: &[String], inject_dep: &str) {
442        match node {
443            TaskNode::Task(task) => {
444                // Check if task command matches any auto-associate command
445                let Some(base_cmd) = cuenv_workspaces::command_name(&task.command) else {
446                    return;
447                };
448
449                if commands.iter().any(|c| c == &base_cmd) {
450                    // Add dependency if not already present
451                    if !task.depends_on.iter().any(|d| d.task_name() == inject_dep) {
452                        task.depends_on.push(TaskDependency::from_name(inject_dep));
453                        tracing::trace!(
454                            command = %task.command,
455                            dependency = %inject_dep,
456                            "Auto-associated task with contributor"
457                        );
458                    }
459                }
460            }
461            TaskNode::Group(group) => {
462                for sub in group.children.values_mut() {
463                    Self::auto_associate_node(sub, commands, inject_dep);
464                }
465            }
466            TaskNode::Sequence(steps) => {
467                for sub in steps {
468                    Self::auto_associate_node(sub, commands, inject_dep);
469                }
470            }
471        }
472    }
473}
474
475/// Result of applying contributors
476#[derive(Debug, Clone, Default)]
477pub struct ContributorResult {
478    /// Number of tasks injected
479    pub tasks_injected: usize,
480
481    /// Contributors that were activated
482    pub active_contributors: Vec<String>,
483}
484
485// =============================================================================
486// Built-in Workspace Contributors
487// =============================================================================
488
489/// Create the built-in bun workspace contributor
490#[must_use]
491pub fn bun_workspace_contributor() -> Contributor {
492    Contributor {
493        id: "bun.workspace".to_string(),
494        when: Some(ContributorActivation {
495            workspace_member: vec!["bun".to_string()],
496            ..Default::default()
497        }),
498        tasks: vec![
499            ContributorTask {
500                id: "bun.workspace.install".to_string(),
501                command: Some("bun".to_string()),
502                args: vec!["install".to_string(), "--frozen-lockfile".to_string()],
503                inputs: vec!["package.json".to_string(), "bun.lock".to_string()],
504                outputs: vec!["node_modules".to_string()],
505                hermetic: false,
506                description: Some("Install Bun dependencies".to_string()),
507                ..Default::default()
508            },
509            ContributorTask {
510                id: "bun.workspace.setup".to_string(),
511                script: Some("true".to_string()),
512                hermetic: false,
513                depends_on: vec!["bun.workspace.install".to_string()],
514                description: Some("Bun workspace setup complete".to_string()),
515                ..Default::default()
516            },
517        ],
518        auto_associate: Some(AutoAssociate {
519            command: vec!["bun".to_string(), "bunx".to_string()],
520            inject_dependency: Some(format!("{}bun.workspace.setup", CONTRIBUTOR_TASK_PREFIX)),
521        }),
522    }
523}
524
525/// Create the built-in npm workspace contributor
526#[must_use]
527pub fn npm_workspace_contributor() -> Contributor {
528    Contributor {
529        id: "npm.workspace".to_string(),
530        when: Some(ContributorActivation {
531            workspace_member: vec!["npm".to_string()],
532            ..Default::default()
533        }),
534        tasks: vec![
535            ContributorTask {
536                id: "npm.workspace.install".to_string(),
537                command: Some("npm".to_string()),
538                args: vec!["ci".to_string()],
539                inputs: vec!["package.json".to_string(), "package-lock.json".to_string()],
540                outputs: vec!["node_modules".to_string()],
541                hermetic: false,
542                description: Some("Install npm dependencies".to_string()),
543                ..Default::default()
544            },
545            ContributorTask {
546                id: "npm.workspace.setup".to_string(),
547                script: Some("true".to_string()),
548                hermetic: false,
549                depends_on: vec!["npm.workspace.install".to_string()],
550                description: Some("npm workspace setup complete".to_string()),
551                ..Default::default()
552            },
553        ],
554        auto_associate: Some(AutoAssociate {
555            command: vec!["npm".to_string(), "npx".to_string()],
556            inject_dependency: Some(format!("{}npm.workspace.setup", CONTRIBUTOR_TASK_PREFIX)),
557        }),
558    }
559}
560
561/// Create the built-in pnpm workspace contributor
562#[must_use]
563pub fn pnpm_workspace_contributor() -> Contributor {
564    Contributor {
565        id: "pnpm.workspace".to_string(),
566        when: Some(ContributorActivation {
567            workspace_member: vec!["pnpm".to_string()],
568            ..Default::default()
569        }),
570        tasks: vec![
571            ContributorTask {
572                id: "pnpm.workspace.install".to_string(),
573                command: Some("pnpm".to_string()),
574                args: vec!["install".to_string(), "--frozen-lockfile".to_string()],
575                inputs: vec!["package.json".to_string(), "pnpm-lock.yaml".to_string()],
576                outputs: vec!["node_modules".to_string()],
577                hermetic: false,
578                description: Some("Install pnpm dependencies".to_string()),
579                ..Default::default()
580            },
581            ContributorTask {
582                id: "pnpm.workspace.setup".to_string(),
583                script: Some("true".to_string()),
584                hermetic: false,
585                depends_on: vec!["pnpm.workspace.install".to_string()],
586                description: Some("pnpm workspace setup complete".to_string()),
587                ..Default::default()
588            },
589        ],
590        auto_associate: Some(AutoAssociate {
591            command: vec!["pnpm".to_string(), "pnpx".to_string()],
592            inject_dependency: Some(format!("{}pnpm.workspace.setup", CONTRIBUTOR_TASK_PREFIX)),
593        }),
594    }
595}
596
597/// Create the built-in yarn workspace contributor
598#[must_use]
599pub fn yarn_workspace_contributor() -> Contributor {
600    Contributor {
601        id: "yarn.workspace".to_string(),
602        when: Some(ContributorActivation {
603            workspace_member: vec!["yarn".to_string()],
604            ..Default::default()
605        }),
606        tasks: vec![
607            ContributorTask {
608                id: "yarn.workspace.install".to_string(),
609                command: Some("yarn".to_string()),
610                args: vec!["install".to_string(), "--immutable".to_string()],
611                inputs: vec!["package.json".to_string(), "yarn.lock".to_string()],
612                outputs: vec!["node_modules".to_string()],
613                hermetic: false,
614                description: Some("Install Yarn dependencies".to_string()),
615                ..Default::default()
616            },
617            ContributorTask {
618                id: "yarn.workspace.setup".to_string(),
619                script: Some("true".to_string()),
620                hermetic: false,
621                depends_on: vec!["yarn.workspace.install".to_string()],
622                description: Some("Yarn workspace setup complete".to_string()),
623                ..Default::default()
624            },
625        ],
626        auto_associate: Some(AutoAssociate {
627            command: vec!["yarn".to_string()],
628            inject_dependency: Some(format!("{}yarn.workspace.setup", CONTRIBUTOR_TASK_PREFIX)),
629        }),
630    }
631}
632
633/// Returns all built-in workspace contributors
634#[must_use]
635pub fn builtin_workspace_contributors() -> Vec<Contributor> {
636    vec![
637        bun_workspace_contributor(),
638        npm_workspace_contributor(),
639        pnpm_workspace_contributor(),
640        yarn_workspace_contributor(),
641    ]
642}
643
644/// Build a map of expected task dependencies for DAG verification
645#[must_use]
646pub fn build_expected_dag(tasks: &HashMap<String, TaskNode>) -> BTreeMap<String, Vec<String>> {
647    let mut dag = BTreeMap::new();
648
649    for (name, node) in tasks {
650        let deps = collect_deps_from_node(node);
651        dag.insert(name.clone(), deps);
652    }
653
654    dag
655}
656
657/// Collect dependencies from a task node as string names
658fn collect_deps_from_node(node: &TaskNode) -> Vec<String> {
659    match node {
660        TaskNode::Task(task) => task
661            .depends_on
662            .iter()
663            .map(|d| d.task_name().to_string())
664            .collect(),
665        TaskNode::Group(group) => group
666            .depends_on
667            .iter()
668            .map(|d| d.task_name().to_string())
669            .collect(),
670        TaskNode::Sequence(_) => Vec::new(), // Sequences don't have top-level deps
671    }
672}
673
674#[cfg(test)]
675mod tests {
676    use super::*;
677
678    fn create_test_contributor(id: &str, workspace_member: Vec<&str>) -> Contributor {
679        Contributor {
680            id: id.to_string(),
681            when: Some(ContributorActivation {
682                workspace_member: workspace_member.into_iter().map(String::from).collect(),
683                ..Default::default()
684            }),
685            tasks: vec![
686                ContributorTask {
687                    id: format!("{id}.install"),
688                    command: Some("test-cmd".to_string()),
689                    args: vec!["install".to_string()],
690                    inputs: vec!["package.json".to_string()],
691                    outputs: vec!["node_modules".to_string()],
692                    hermetic: false,
693                    depends_on: vec![],
694                    script: None,
695                    description: Some(format!("Install {id} dependencies")),
696                },
697                ContributorTask {
698                    id: format!("{id}.setup"),
699                    command: None,
700                    args: vec![],
701                    script: Some("true".to_string()),
702                    inputs: vec![],
703                    outputs: vec![],
704                    hermetic: false,
705                    depends_on: vec![format!("{id}.install")],
706                    description: Some(format!("{id} setup complete")),
707                },
708            ],
709            auto_associate: Some(AutoAssociate {
710                command: vec!["test-cmd".to_string()],
711                inject_dependency: Some(format!("{CONTRIBUTOR_TASK_PREFIX}{id}.setup")),
712            }),
713        }
714    }
715
716    #[test]
717    fn test_contributor_activation_workspace_member() {
718        let contrib = create_test_contributor("bun.workspace", vec!["bun"]);
719
720        // Should activate when workspace matches
721        let ctx = ContributorContext {
722            workspace_member: Some("bun".to_string()),
723            ..Default::default()
724        };
725        let contributors = [contrib.clone()];
726        let engine = ContributorEngine::new(&contributors, ctx);
727        assert!(engine.is_active(&contrib));
728
729        // Should not activate when workspace doesn't match
730        let ctx = ContributorContext {
731            workspace_member: Some("npm".to_string()),
732            ..Default::default()
733        };
734        let contributors = [contrib.clone()];
735        let engine = ContributorEngine::new(&contributors, ctx);
736        assert!(!engine.is_active(&contrib));
737
738        // Should not activate when no workspace
739        let ctx = ContributorContext::default();
740        let contributors = [contrib.clone()];
741        let engine = ContributorEngine::new(&contributors, ctx);
742        assert!(!engine.is_active(&contrib));
743    }
744
745    #[test]
746    fn test_contributor_injects_tasks() {
747        let contrib = create_test_contributor("bun.workspace", vec!["bun"]);
748        let ctx = ContributorContext {
749            workspace_member: Some("bun".to_string()),
750            ..Default::default()
751        };
752
753        let contributors = [contrib];
754        let engine = ContributorEngine::new(&contributors, ctx);
755        let mut tasks: HashMap<String, TaskNode> = HashMap::new();
756
757        let injected = engine.apply(&mut tasks).unwrap();
758
759        assert_eq!(injected, 2);
760        assert!(tasks.contains_key("cuenv:contributor:bun.workspace.install"));
761        assert!(tasks.contains_key("cuenv:contributor:bun.workspace.setup"));
762    }
763
764    #[test]
765    fn test_contributor_auto_association() {
766        let contrib = create_test_contributor("bun.workspace", vec!["bun"]);
767        let ctx = ContributorContext {
768            workspace_member: Some("bun".to_string()),
769            workspace_root: None,
770            task_commands: ["test-cmd".to_string()].into_iter().collect(),
771            ..Default::default()
772        };
773
774        // Create a user task that uses the matching command
775        let user_task = Task {
776            command: "test-cmd".to_string(),
777            args: vec!["run".to_string(), "dev".to_string()],
778            ..Default::default()
779        };
780
781        let mut tasks: HashMap<String, TaskNode> = HashMap::new();
782        tasks.insert("dev".to_string(), TaskNode::Task(Box::new(user_task)));
783
784        let contributors = [contrib];
785        let engine = ContributorEngine::new(&contributors, ctx);
786        engine.apply(&mut tasks).unwrap();
787
788        // User task should now depend on the contributor setup task
789        let dev_task = tasks.get("dev").unwrap();
790        if let TaskNode::Task(task) = dev_task {
791            assert!(
792                task.depends_on
793                    .iter()
794                    .any(|d| d.task_name() == "cuenv:contributor:bun.workspace.setup")
795            );
796        } else {
797            panic!("Expected single task");
798        }
799    }
800
801    #[test]
802    fn test_contributor_auto_association_with_env_prefixed_command() {
803        let contrib = create_test_contributor("bun.workspace", vec!["bun"]);
804        let ctx = ContributorContext {
805            workspace_member: Some("bun".to_string()),
806            workspace_root: None,
807            task_commands: ["test-cmd".to_string()].into_iter().collect(),
808            ..Default::default()
809        };
810
811        let user_task = Task {
812            command: "env TEST_MODE=1 test-cmd run dev".to_string(),
813            ..Default::default()
814        };
815
816        let mut tasks: HashMap<String, TaskNode> = HashMap::new();
817        tasks.insert("dev".to_string(), TaskNode::Task(Box::new(user_task)));
818
819        let contributors = [contrib];
820        let engine = ContributorEngine::new(&contributors, ctx);
821        engine.apply(&mut tasks).unwrap();
822
823        let dev_task = tasks.get("dev").unwrap();
824        if let TaskNode::Task(task) = dev_task {
825            assert!(
826                task.depends_on
827                    .iter()
828                    .any(|d| d.task_name() == "cuenv:contributor:bun.workspace.setup")
829            );
830        } else {
831            panic!("Expected single task");
832        }
833    }
834
835    #[test]
836    fn test_idempotent_injection() {
837        let contrib = create_test_contributor("bun.workspace", vec!["bun"]);
838        let ctx = ContributorContext {
839            workspace_member: Some("bun".to_string()),
840            ..Default::default()
841        };
842
843        let contributors = [contrib];
844        let engine = ContributorEngine::new(&contributors, ctx);
845        let mut tasks: HashMap<String, TaskNode> = HashMap::new();
846
847        // First application
848        let first_injected = engine.apply(&mut tasks).unwrap();
849        assert_eq!(first_injected, 2);
850
851        // Second application should inject nothing (already exists)
852        let second_injected = engine.apply(&mut tasks).unwrap();
853        assert_eq!(second_injected, 0);
854
855        // Should still have exactly 2 tasks
856        assert_eq!(tasks.len(), 2);
857    }
858
859    #[test]
860    fn test_always_active_contributor() {
861        let contrib = Contributor {
862            id: "always-on".to_string(),
863            when: Some(ContributorActivation {
864                always: Some(true),
865                ..Default::default()
866            }),
867            tasks: vec![ContributorTask {
868                id: "always-on.task".to_string(),
869                command: Some("echo".to_string()),
870                args: vec!["always".to_string()],
871                ..Default::default()
872            }],
873            auto_associate: None,
874        };
875
876        // Should activate regardless of context
877        let ctx = ContributorContext::default();
878        let contributors = [contrib.clone()];
879        let engine = ContributorEngine::new(&contributors, ctx);
880        assert!(engine.is_active(&contrib));
881    }
882
883    #[test]
884    fn test_no_condition_means_always_active() {
885        let contrib = Contributor {
886            id: "no-condition".to_string(),
887            when: None, // No condition
888            tasks: vec![ContributorTask {
889                id: "no-condition.task".to_string(),
890                command: Some("echo".to_string()),
891                args: vec!["hello".to_string()],
892                ..Default::default()
893            }],
894            auto_associate: None,
895        };
896
897        let ctx = ContributorContext::default();
898        let contributors = [contrib.clone()];
899        let engine = ContributorEngine::new(&contributors, ctx);
900        assert!(engine.is_active(&contrib));
901    }
902
903    #[test]
904    fn test_build_expected_dag() {
905        let mut tasks: HashMap<String, TaskNode> = HashMap::new();
906
907        let task_a = Task {
908            command: "echo".to_string(),
909            args: vec!["a".to_string()],
910            ..Default::default()
911        };
912
913        let task_b = Task {
914            command: "echo".to_string(),
915            args: vec!["b".to_string()],
916            depends_on: vec![TaskDependency::from_name("a")],
917            ..Default::default()
918        };
919
920        tasks.insert("a".to_string(), TaskNode::Task(Box::new(task_a)));
921        tasks.insert("b".to_string(), TaskNode::Task(Box::new(task_b)));
922
923        let dag = build_expected_dag(&tasks);
924
925        assert_eq!(dag.get("a"), Some(&vec![]));
926        assert_eq!(dag.get("b"), Some(&vec!["a".to_string()]));
927    }
928
929    #[test]
930    fn test_multiple_contributors_active_simultaneously() {
931        // Two contributors that both match (different workspace types)
932        let bun_contrib = create_test_contributor("bun.workspace", vec!["bun"]);
933        let npm_contrib = Contributor {
934            id: "npm.workspace".to_string(),
935            when: Some(ContributorActivation {
936                workspace_member: vec!["npm".to_string()],
937                ..Default::default()
938            }),
939            tasks: vec![ContributorTask {
940                id: "npm.workspace.install".to_string(),
941                command: Some("npm".to_string()),
942                args: vec!["install".to_string()],
943                ..Default::default()
944            }],
945            auto_associate: None,
946        };
947
948        // Context where both could theoretically match (we'll test bun only)
949        let ctx = ContributorContext {
950            workspace_member: Some("bun".to_string()),
951            ..Default::default()
952        };
953
954        let contributors = [bun_contrib.clone(), npm_contrib.clone()];
955        let engine = ContributorEngine::new(&contributors, ctx);
956        let mut tasks: HashMap<String, TaskNode> = HashMap::new();
957
958        engine.apply(&mut tasks).unwrap();
959
960        // Only bun tasks should be injected (npm doesn't match)
961        assert!(tasks.contains_key("cuenv:contributor:bun.workspace.install"));
962        assert!(tasks.contains_key("cuenv:contributor:bun.workspace.setup"));
963        assert!(!tasks.contains_key("cuenv:contributor:npm.workspace.install"));
964    }
965
966    #[test]
967    fn test_auto_association_no_duplicate_deps() {
968        let contrib = create_test_contributor("bun.workspace", vec!["bun"]);
969        let ctx = ContributorContext {
970            workspace_member: Some("bun".to_string()),
971            workspace_root: None,
972            task_commands: ["test-cmd".to_string()].into_iter().collect(),
973            ..Default::default()
974        };
975
976        // Create a user task that already has the dependency
977        let user_task = Task {
978            command: "test-cmd".to_string(),
979            args: vec!["run".to_string(), "dev".to_string()],
980            depends_on: vec![TaskDependency::from_name(
981                "cuenv:contributor:bun.workspace.setup",
982            )],
983            ..Default::default()
984        };
985
986        let mut tasks: HashMap<String, TaskNode> = HashMap::new();
987        tasks.insert("dev".to_string(), TaskNode::Task(Box::new(user_task)));
988
989        let contributors = [contrib];
990        let engine = ContributorEngine::new(&contributors, ctx);
991        engine.apply(&mut tasks).unwrap();
992
993        // Should not have duplicated the dependency
994        let dev_task = tasks.get("dev").unwrap();
995        if let TaskNode::Task(task) = dev_task {
996            let dep_count = task
997                .depends_on
998                .iter()
999                .filter(|d| d.task_name() == "cuenv:contributor:bun.workspace.setup")
1000                .count();
1001            assert_eq!(dep_count, 1, "Dependency should not be duplicated");
1002        } else {
1003            panic!("Expected single task");
1004        }
1005    }
1006
1007    #[test]
1008    fn test_command_matching_is_exact() {
1009        let contrib = create_test_contributor("bun.workspace", vec!["bun"]);
1010        let ctx = ContributorContext {
1011            workspace_member: Some("bun".to_string()),
1012            workspace_root: None,
1013            task_commands: ["test-cmd".to_string()].into_iter().collect(),
1014            ..Default::default()
1015        };
1016
1017        // Task with a command that is NOT an exact match
1018        let user_task = Task {
1019            command: "test-cmd-extra".to_string(), // Different command
1020            args: vec!["run".to_string()],
1021            ..Default::default()
1022        };
1023
1024        let mut tasks: HashMap<String, TaskNode> = HashMap::new();
1025        tasks.insert("other".to_string(), TaskNode::Task(Box::new(user_task)));
1026
1027        let contributors = [contrib];
1028        let engine = ContributorEngine::new(&contributors, ctx);
1029        engine.apply(&mut tasks).unwrap();
1030
1031        // Should NOT have auto-associated (command doesn't match exactly)
1032        let other_task = tasks.get("other").unwrap();
1033        if let TaskNode::Task(task) = other_task {
1034            assert!(
1035                !task
1036                    .depends_on
1037                    .iter()
1038                    .any(|d| d.task_name() == "cuenv:contributor:bun.workspace.setup"),
1039                "Non-matching command should not get auto-association"
1040            );
1041        } else {
1042            panic!("Expected single task");
1043        }
1044    }
1045
1046    #[test]
1047    fn test_contributor_with_empty_tasks() {
1048        let contrib = Contributor {
1049            id: "empty".to_string(),
1050            when: Some(ContributorActivation {
1051                always: Some(true),
1052                ..Default::default()
1053            }),
1054            tasks: vec![], // No tasks
1055            auto_associate: None,
1056        };
1057
1058        let ctx = ContributorContext::default();
1059        let contributors = [contrib];
1060        let engine = ContributorEngine::new(&contributors, ctx);
1061        let mut tasks: HashMap<String, TaskNode> = HashMap::new();
1062
1063        let injected = engine.apply(&mut tasks).unwrap();
1064
1065        // Should inject nothing
1066        assert_eq!(injected, 0);
1067        assert!(tasks.is_empty());
1068    }
1069
1070    #[test]
1071    fn test_contributor_task_dependencies_prefixed() {
1072        // Test that internal dependencies get the prefix too
1073        let contrib = Contributor {
1074            id: "test".to_string(),
1075            when: Some(ContributorActivation {
1076                always: Some(true),
1077                ..Default::default()
1078            }),
1079            tasks: vec![
1080                ContributorTask {
1081                    id: "test.first".to_string(),
1082                    command: Some("echo".to_string()),
1083                    args: vec!["first".to_string()],
1084                    ..Default::default()
1085                },
1086                ContributorTask {
1087                    id: "test.second".to_string(),
1088                    command: Some("echo".to_string()),
1089                    args: vec!["second".to_string()],
1090                    depends_on: vec!["test.first".to_string()], // Reference without prefix
1091                    ..Default::default()
1092                },
1093            ],
1094            auto_associate: None,
1095        };
1096
1097        let ctx = ContributorContext::default();
1098        let contributors = [contrib];
1099        let engine = ContributorEngine::new(&contributors, ctx);
1100        let mut tasks: HashMap<String, TaskNode> = HashMap::new();
1101
1102        engine.apply(&mut tasks).unwrap();
1103
1104        // Check that the second task's dependency got prefixed
1105        let second_task = tasks.get("cuenv:contributor:test.second").unwrap();
1106        if let TaskNode::Task(task) = second_task {
1107            assert!(
1108                task.depends_on
1109                    .iter()
1110                    .any(|d| d.task_name() == "cuenv:contributor:test.first"),
1111                "Internal dependency should be prefixed, got: {:?}",
1112                task.depends_on
1113            );
1114        } else {
1115            panic!("Expected single task");
1116        }
1117    }
1118}