1use 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
29pub const CONTRIBUTOR_TASK_PREFIX: &str = "cuenv:contributor:";
31
32#[derive(Debug, Clone, Default)]
34pub struct ContributorContext {
35 pub workspace_member: Option<String>,
37
38 pub workspace_root: Option<std::path::PathBuf>,
40
41 pub task_commands: HashSet<String>,
43}
44
45impl ContributorContext {
46 #[must_use]
48 pub fn detect(project_root: &Path) -> Self {
49 let mut ctx = Self::default();
50
51 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 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
70fn 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
83fn collect_commands_from_node(node: &TaskNode, commands: &mut HashSet<String>) {
85 match node {
86 TaskNode::Task(task) => {
87 if !task.command.is_empty() {
88 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
111#[serde(rename_all = "camelCase")]
112pub struct ContributorActivation {
113 #[serde(skip_serializing_if = "Option::is_none")]
115 pub always: Option<bool>,
116
117 #[serde(default, skip_serializing_if = "Vec::is_empty")]
120 pub workspace_member: Vec<String>,
121
122 #[serde(default, skip_serializing_if = "Vec::is_empty")]
124 pub command: Vec<String>,
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
131#[serde(rename_all = "camelCase")]
132pub struct AutoAssociate {
133 #[serde(default, skip_serializing_if = "Vec::is_empty")]
135 pub command: Vec<String>,
136
137 #[serde(skip_serializing_if = "Option::is_none")]
139 pub inject_dependency: Option<String>,
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
144#[serde(rename_all = "camelCase")]
145pub struct ContributorTask {
146 pub id: String,
148
149 #[serde(skip_serializing_if = "Option::is_none")]
151 pub command: Option<String>,
152
153 #[serde(default, skip_serializing_if = "Vec::is_empty")]
155 pub args: Vec<String>,
156
157 #[serde(skip_serializing_if = "Option::is_none")]
159 pub script: Option<String>,
160
161 #[serde(default, skip_serializing_if = "Vec::is_empty")]
163 pub inputs: Vec<String>,
164
165 #[serde(default, skip_serializing_if = "Vec::is_empty")]
167 pub outputs: Vec<String>,
168
169 #[serde(default)]
171 pub hermetic: bool,
172
173 #[serde(default, skip_serializing_if = "Vec::is_empty")]
175 pub depends_on: Vec<String>,
176
177 #[serde(skip_serializing_if = "Option::is_none")]
179 pub description: Option<String>,
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
186#[serde(rename_all = "camelCase")]
187pub struct Contributor {
188 pub id: String,
190
191 #[serde(skip_serializing_if = "Option::is_none")]
193 pub when: Option<ContributorActivation>,
194
195 pub tasks: Vec<ContributorTask>,
197
198 #[serde(skip_serializing_if = "Option::is_none")]
200 pub auto_associate: Option<AutoAssociate>,
201}
202
203pub struct ContributorEngine<'a> {
205 contributors: &'a [Contributor],
206 context: ContributorContext,
207}
208
209impl<'a> ContributorEngine<'a> {
210 #[must_use]
212 pub fn new(contributors: &'a [Contributor], context: ContributorContext) -> Self {
213 Self {
214 contributors,
215 context,
216 }
217 }
218
219 pub fn apply(&self, tasks: &mut HashMap<String, TaskNode>) -> Result<usize> {
224 let mut total_injected = 0;
225 let max_iterations = 10; 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 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 fn is_active(&self, contributor: &Contributor) -> bool {
265 let Some(when) = &contributor.when else {
266 return true;
268 };
269
270 if when.always == Some(true) {
272 return true;
273 }
274
275 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 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 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 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 if tasks.contains_key(&task_id) {
321 continue;
322 }
323
324 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 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 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 if !tasks.contains_key(inject_dep) {
375 return;
376 }
377
378 let task_names: Vec<String> = tasks.keys().cloned().collect();
380
381 for task_name in task_names {
382 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 fn auto_associate_node(node: &mut TaskNode, commands: &[String], inject_dep: &str) {
397 match node {
398 TaskNode::Task(task) => {
399 let base_cmd = task.command.split_whitespace().next().unwrap_or("");
401
402 if commands.iter().any(|c| c == base_cmd) {
403 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#[derive(Debug, Clone, Default)]
430pub struct ContributorResult {
431 pub tasks_injected: usize,
433
434 pub active_contributors: Vec<String>,
436}
437
438#[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#[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#[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#[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#[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#[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
610fn 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(), }
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 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 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 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 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 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 let first_injected = engine.apply(&mut tasks).unwrap();
767 assert_eq!(first_injected, 2);
768
769 let second_injected = engine.apply(&mut tasks).unwrap();
771 assert_eq!(second_injected, 0);
772
773 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 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, 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 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 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 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 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 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 let user_task = Task {
935 command: "test-cmd-extra".to_string(), 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 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![], 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 assert_eq!(injected, 0);
983 assert!(tasks.is_empty());
984 }
985
986 #[test]
987 fn test_contributor_task_dependencies_prefixed() {
988 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()], ..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 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}