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