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 pub service_commands: HashSet<String>,
46
47 pub has_services: bool,
49}
50
51impl ContributorContext {
52 #[must_use]
54 pub fn detect(project_root: &Path) -> Self {
55 let mut ctx = Self::default();
56
57 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 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 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
89fn 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
102fn 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
129#[serde(rename_all = "camelCase")]
130pub struct ContributorActivation {
131 #[serde(skip_serializing_if = "Option::is_none")]
133 pub always: Option<bool>,
134
135 #[serde(default, skip_serializing_if = "Vec::is_empty")]
138 pub workspace_member: Vec<String>,
139
140 #[serde(default, skip_serializing_if = "Vec::is_empty")]
142 pub command: Vec<String>,
143
144 #[serde(default, skip_serializing_if = "Vec::is_empty")]
146 pub service_command: Vec<String>,
147
148 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub has_service: Option<bool>,
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
157#[serde(rename_all = "camelCase")]
158pub struct AutoAssociate {
159 #[serde(default, skip_serializing_if = "Vec::is_empty")]
161 pub command: Vec<String>,
162
163 #[serde(skip_serializing_if = "Option::is_none")]
165 pub inject_dependency: Option<String>,
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
170#[serde(rename_all = "camelCase")]
171pub struct ContributorTask {
172 pub id: String,
174
175 #[serde(skip_serializing_if = "Option::is_none")]
177 pub command: Option<String>,
178
179 #[serde(default, skip_serializing_if = "Vec::is_empty")]
181 pub args: Vec<String>,
182
183 #[serde(skip_serializing_if = "Option::is_none")]
185 pub script: Option<String>,
186
187 #[serde(default, skip_serializing_if = "Vec::is_empty")]
189 pub inputs: Vec<String>,
190
191 #[serde(default, skip_serializing_if = "Vec::is_empty")]
193 pub outputs: Vec<String>,
194
195 #[serde(default)]
197 pub hermetic: bool,
198
199 #[serde(default, skip_serializing_if = "Vec::is_empty")]
201 pub depends_on: Vec<String>,
202
203 #[serde(skip_serializing_if = "Option::is_none")]
205 pub description: Option<String>,
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
212#[serde(rename_all = "camelCase")]
213pub struct Contributor {
214 pub id: String,
216
217 #[serde(skip_serializing_if = "Option::is_none")]
219 pub when: Option<ContributorActivation>,
220
221 pub tasks: Vec<ContributorTask>,
223
224 #[serde(skip_serializing_if = "Option::is_none")]
226 pub auto_associate: Option<AutoAssociate>,
227}
228
229pub struct ContributorEngine<'a> {
231 contributors: &'a [Contributor],
232 context: ContributorContext,
233}
234
235impl<'a> ContributorEngine<'a> {
236 #[must_use]
238 pub fn new(contributors: &'a [Contributor], context: ContributorContext) -> Self {
239 Self {
240 contributors,
241 context,
242 }
243 }
244
245 pub fn apply(&self, tasks: &mut HashMap<String, TaskNode>) -> Result<usize> {
250 let mut total_injected = 0;
251 let max_iterations = 10; 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 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 fn is_active(&self, contributor: &Contributor) -> bool {
291 let Some(when) = &contributor.when else {
292 return true;
294 };
295
296 if when.always == Some(true) {
298 return true;
299 }
300
301 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 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 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 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 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 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 if tasks.contains_key(&task_id) {
366 continue;
367 }
368
369 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 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 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 if !tasks.contains_key(inject_dep) {
420 return;
421 }
422
423 let task_names: Vec<String> = tasks.keys().cloned().collect();
425
426 for task_name in task_names {
427 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 fn auto_associate_node(node: &mut TaskNode, commands: &[String], inject_dep: &str) {
442 match node {
443 TaskNode::Task(task) => {
444 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 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#[derive(Debug, Clone, Default)]
477pub struct ContributorResult {
478 pub tasks_injected: usize,
480
481 pub active_contributors: Vec<String>,
483}
484
485#[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#[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#[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#[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#[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#[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
657fn 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(), }
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 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 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 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 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 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 let first_injected = engine.apply(&mut tasks).unwrap();
849 assert_eq!(first_injected, 2);
850
851 let second_injected = engine.apply(&mut tasks).unwrap();
853 assert_eq!(second_injected, 0);
854
855 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 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, 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 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 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 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 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 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 let user_task = Task {
1019 command: "test-cmd-extra".to_string(), 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 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![], 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 assert_eq!(injected, 0);
1067 assert!(tasks.is_empty());
1068 }
1069
1070 #[test]
1071 fn test_contributor_task_dependencies_prefixed() {
1072 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()], ..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 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}