cuenv_core/manifest/
mod.rs

1//! Root Project configuration type
2//!
3//! Based on schema/core.cue
4
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9use crate::ci::CI;
10use crate::config::Config;
11use crate::environment::Env;
12use crate::hooks::Hook;
13use crate::tasks::{Input, Mapping, ProjectReference, TaskGroup};
14use crate::tasks::{Task, TaskDefinition};
15
16/// Workspace configuration
17#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
18#[serde(rename_all = "camelCase")]
19pub struct WorkspaceConfig {
20    /// Enable or disable the workspace
21    #[serde(default = "default_true")]
22    pub enabled: bool,
23
24    /// Optional: manually specify the root of the workspace relative to env.cue
25    pub root: Option<String>,
26
27    /// Optional: manually specify the package manager
28    pub package_manager: Option<String>,
29
30    /// Workspace lifecycle hooks
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub hooks: Option<WorkspaceHooks>,
33}
34
35/// Workspace lifecycle hooks for pre/post install
36#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
37#[serde(rename_all = "camelCase")]
38pub struct WorkspaceHooks {
39    /// Tasks or references to run before workspace install
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub before_install: Option<Vec<HookItem>>,
42
43    /// Tasks or references to run after workspace install
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub after_install: Option<Vec<HookItem>>,
46}
47
48/// A hook step to run as part of workspace lifecycle hooks.
49#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
50#[serde(untagged)]
51pub enum HookItem {
52    /// Reference to a task in another project
53    TaskRef(TaskRef),
54    /// Discovery-based hook step that expands a TaskMatcher into concrete tasks
55    Match(MatchHook),
56    /// Inline task definition
57    Task(Box<Task>),
58}
59
60/// Hook step that expands to tasks discovered via TaskMatcher.
61#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
62#[serde(rename_all = "camelCase")]
63pub struct MatchHook {
64    /// Optional stable name used for task naming/logging
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub name: Option<String>,
67
68    /// Task matcher to select tasks across the workspace
69    #[serde(rename = "match")]
70    pub matcher: TaskMatcher,
71}
72
73/// Reference to a task in another env.cue project by its name property
74#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
75pub struct TaskRef {
76    /// Format: "#project-name:task-name" where project-name is the `name` field in env.cue
77    /// Example: "#projen-generator:bun.install"
78    #[serde(rename = "ref")]
79    pub ref_: String,
80}
81
82impl TaskRef {
83    /// Parse the TaskRef into project name and task name
84    /// Returns None if the format is invalid
85    pub fn parse(&self) -> Option<(String, String)> {
86        let ref_str = self.ref_.strip_prefix('#')?;
87        let parts: Vec<&str> = ref_str.splitn(2, ':').collect();
88        if parts.len() == 2 {
89            Some((parts[0].to_string(), parts[1].to_string()))
90        } else {
91            None
92        }
93    }
94}
95
96/// Match tasks across workspace by metadata for discovery-based execution
97#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
98pub struct TaskMatcher {
99    /// Limit to specific workspaces (by name)
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub workspaces: Option<Vec<String>>,
102
103    /// Match tasks with these labels (all must match)
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub labels: Option<Vec<String>>,
106
107    /// Match tasks whose command matches this value
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub command: Option<String>,
110
111    /// Match tasks whose args contain specific patterns
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub args: Option<Vec<ArgMatcher>>,
114
115    /// Run matched tasks in parallel (default: true)
116    #[serde(default = "default_true")]
117    pub parallel: bool,
118}
119
120/// Pattern matcher for task arguments
121#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
122pub struct ArgMatcher {
123    /// Match if any arg contains this substring
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub contains: Option<String>,
126
127    /// Match if any arg matches this regex pattern
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub matches: Option<String>,
130}
131
132fn default_true() -> bool {
133    true
134}
135
136/// Collection of hooks that can be executed
137#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
138pub struct Hooks {
139    /// Named hooks to execute when entering an environment (map of name -> hook)
140    #[serde(skip_serializing_if = "Option::is_none")]
141    #[serde(rename = "onEnter")]
142    pub on_enter: Option<HashMap<String, Hook>>,
143
144    /// Named hooks to execute when exiting an environment (map of name -> hook)
145    #[serde(skip_serializing_if = "Option::is_none")]
146    #[serde(rename = "onExit")]
147    pub on_exit: Option<HashMap<String, Hook>>,
148}
149
150/// Base configuration structure (composable across directories)
151#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
152pub struct Base {
153    /// Configuration settings
154    #[serde(skip_serializing_if = "Option::is_none")]
155    pub config: Option<Config>,
156
157    /// Environment variables configuration
158    #[serde(skip_serializing_if = "Option::is_none")]
159    pub env: Option<Env>,
160
161    /// Workspaces configuration
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub workspaces: Option<HashMap<String, WorkspaceConfig>>,
164}
165
166/// Root Project configuration structure (leaf node - cannot unify with other projects)
167#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
168pub struct Project {
169    /// Configuration settings
170    #[serde(skip_serializing_if = "Option::is_none")]
171    pub config: Option<Config>,
172
173    /// Project name (unique identifier, required by the CUE schema)
174    pub name: String,
175
176    /// Environment variables configuration
177    #[serde(skip_serializing_if = "Option::is_none")]
178    pub env: Option<Env>,
179
180    /// Hooks configuration
181    #[serde(skip_serializing_if = "Option::is_none")]
182    pub hooks: Option<Hooks>,
183
184    /// Workspaces configuration
185    #[serde(skip_serializing_if = "Option::is_none")]
186    pub workspaces: Option<HashMap<String, WorkspaceConfig>>,
187
188    /// CI configuration
189    #[serde(skip_serializing_if = "Option::is_none")]
190    pub ci: Option<CI>,
191
192    /// Tasks configuration
193    #[serde(default)]
194    pub tasks: HashMap<String, TaskDefinition>,
195}
196
197/// Type alias for backward compatibility
198pub type Cuenv = Project;
199
200impl Project {
201    /// Create a new Project configuration with a required name.
202    pub fn new(name: impl Into<String>) -> Self {
203        Self {
204            name: name.into(),
205            ..Self::default()
206        }
207    }
208
209    /// Get hooks to execute when entering environment as a map (name -> hook)
210    pub fn on_enter_hooks_map(&self) -> HashMap<String, Hook> {
211        self.hooks
212            .as_ref()
213            .and_then(|h| h.on_enter.as_ref())
214            .cloned()
215            .unwrap_or_default()
216    }
217
218    /// Get hooks to execute when entering environment, sorted by (order, name)
219    pub fn on_enter_hooks(&self) -> Vec<Hook> {
220        let map = self.on_enter_hooks_map();
221        let mut hooks: Vec<(String, Hook)> = map.into_iter().collect();
222        hooks.sort_by(|a, b| a.1.order.cmp(&b.1.order).then(a.0.cmp(&b.0)));
223        hooks.into_iter().map(|(_, h)| h).collect()
224    }
225
226    /// Get hooks to execute when exiting environment as a map (name -> hook)
227    pub fn on_exit_hooks_map(&self) -> HashMap<String, Hook> {
228        self.hooks
229            .as_ref()
230            .and_then(|h| h.on_exit.as_ref())
231            .cloned()
232            .unwrap_or_default()
233    }
234
235    /// Get hooks to execute when exiting environment, sorted by (order, name)
236    pub fn on_exit_hooks(&self) -> Vec<Hook> {
237        let map = self.on_exit_hooks_map();
238        let mut hooks: Vec<(String, Hook)> = map.into_iter().collect();
239        hooks.sort_by(|a, b| a.1.order.cmp(&b.1.order).then(a.0.cmp(&b.0)));
240        hooks.into_iter().map(|(_, h)| h).collect()
241    }
242
243    /// Inject implicit tasks and dependencies based on workspace declarations.
244    ///
245    /// When a workspace is declared (e.g., `workspaces: bun: {}`), this method:
246    /// 1. Creates an install task for that workspace if one doesn't already exist
247    ///
248    /// This ensures users don't need to manually define common tasks like
249    /// `bun.install` or manually wire up dependencies.
250    pub fn with_implicit_tasks(mut self) -> Self {
251        fn get_task_mut_by_path<'a>(
252            tasks: &'a mut HashMap<String, TaskDefinition>,
253            raw_path: &str,
254        ) -> Option<&'a mut Task> {
255            let normalized = raw_path.replace(':', ".");
256            let mut segments = normalized
257                .split('.')
258                .filter(|s| !s.is_empty())
259                .map(str::trim)
260                .collect::<Vec<_>>();
261            if segments.is_empty() {
262                return None;
263            }
264
265            let first = segments.remove(0);
266            let mut current = tasks.get_mut(first)?;
267            for seg in segments {
268                match current {
269                    TaskDefinition::Group(TaskGroup::Parallel(group)) => {
270                        current = group.tasks.get_mut(seg)?;
271                    }
272                    _ => return None,
273                }
274            }
275
276            match current {
277                TaskDefinition::Single(task) => Some(task.as_mut()),
278                _ => None,
279            }
280        }
281
282        let Some(workspaces) = &self.workspaces else {
283            return self;
284        };
285
286        // Clone workspaces to avoid borrow issues
287        let workspaces = workspaces.clone();
288
289        for (name, config) in &workspaces {
290            if !config.enabled {
291                continue;
292            }
293
294            // Only known workspace types get implicit install tasks
295            if !matches!(name.as_str(), "bun" | "npm" | "pnpm" | "yarn" | "cargo") {
296                continue;
297            }
298
299            // Only process workspace if at least one task explicitly uses it
300            let workspace_used = self
301                .tasks
302                .values()
303                .any(|task_def| task_def.uses_workspace(name));
304            if !workspace_used {
305                tracing::debug!("Skipping workspace '{}' - no tasks declare usage", name);
306                continue;
307            }
308
309            let install_task_name = format!("{}.install", name);
310
311            // Don't override user-defined install tasks (including nested `tasks: bun: install: {}`)
312            if get_task_mut_by_path(&mut self.tasks, &install_task_name).is_some() {
313                continue;
314            }
315
316            // Create implicit install task
317            if let Some(task) = Self::create_implicit_install_task(name) {
318                self.tasks
319                    .insert(install_task_name, TaskDefinition::Single(Box::new(task)));
320            }
321        }
322
323        self
324    }
325
326    /// Create an implicit install task for a known workspace type.
327    fn create_implicit_install_task(workspace_name: &str) -> Option<Task> {
328        let (command, args, description, inputs, outputs) = match workspace_name {
329            "bun" => (
330                "bun",
331                vec!["install"],
332                "Install bun dependencies",
333                vec![
334                    Input::Path("package.json".to_string()),
335                    Input::Path("bun.lock".to_string()),
336                ],
337                vec!["node_modules".to_string()],
338            ),
339            "npm" => (
340                "npm",
341                vec!["install"],
342                "Install npm dependencies",
343                vec![
344                    Input::Path("package.json".to_string()),
345                    Input::Path("package-lock.json".to_string()),
346                ],
347                vec!["node_modules".to_string()],
348            ),
349            "pnpm" => (
350                "pnpm",
351                vec!["install"],
352                "Install pnpm dependencies",
353                vec![
354                    Input::Path("package.json".to_string()),
355                    Input::Path("pnpm-lock.yaml".to_string()),
356                ],
357                vec!["node_modules".to_string()],
358            ),
359            "yarn" => (
360                "yarn",
361                vec!["install"],
362                "Install yarn dependencies",
363                vec![
364                    Input::Path("package.json".to_string()),
365                    Input::Path("yarn.lock".to_string()),
366                ],
367                vec!["node_modules".to_string()],
368            ),
369            "cargo" => (
370                "cargo",
371                vec!["fetch"],
372                "Fetch cargo dependencies",
373                vec![
374                    Input::Path("Cargo.toml".to_string()),
375                    Input::Path("Cargo.lock".to_string()),
376                ],
377                vec![], // cargo fetch doesn't produce local outputs (uses shared cache)
378            ),
379            _ => return None, // Unknown workspace type, don't create implicit task
380        };
381
382        Some(Task {
383            command: command.to_string(),
384            args: args.into_iter().map(String::from).collect(),
385            workspaces: vec![workspace_name.to_string()],
386            hermetic: false, // Install tasks must run in real workspace root
387            description: Some(description.to_string()),
388            inputs,
389            outputs,
390            ..Default::default()
391        })
392    }
393
394    /// Expand shorthand cross-project references in inputs and implicit dependencies.
395    ///
396    /// Handles inputs in the format: "#project:task:path/to/file"
397    /// Converts them to explicit ProjectReference inputs.
398    /// Also adds implicit dependsOn entries for all project references.
399    pub fn expand_cross_project_references(&mut self) {
400        for (_, task_def) in self.tasks.iter_mut() {
401            Self::expand_task_definition(task_def);
402        }
403    }
404
405    fn expand_task_definition(task_def: &mut TaskDefinition) {
406        match task_def {
407            TaskDefinition::Single(task) => Self::expand_task(task),
408            TaskDefinition::Group(group) => match group {
409                TaskGroup::Sequential(tasks) => {
410                    for sub_task in tasks {
411                        Self::expand_task_definition(sub_task);
412                    }
413                }
414                TaskGroup::Parallel(group) => {
415                    for sub_task in group.tasks.values_mut() {
416                        Self::expand_task_definition(sub_task);
417                    }
418                }
419            },
420        }
421    }
422
423    fn expand_task(task: &mut Task) {
424        let mut new_inputs = Vec::new();
425        let mut implicit_deps = Vec::new();
426
427        // Process existing inputs
428        for input in &task.inputs {
429            match input {
430                Input::Path(path) if path.starts_with('#') => {
431                    // Parse "#project:task:path"
432                    // Remove leading #
433                    let parts: Vec<&str> = path[1..].split(':').collect();
434                    if parts.len() >= 3 {
435                        let project = parts[0].to_string();
436                        let task_name = parts[1].to_string();
437                        // Rejoin the rest as the path (it might contain colons)
438                        let file_path = parts[2..].join(":");
439
440                        new_inputs.push(Input::Project(ProjectReference {
441                            project: project.clone(),
442                            task: task_name.clone(),
443                            map: vec![Mapping {
444                                from: file_path.clone(),
445                                to: file_path,
446                            }],
447                        }));
448
449                        // Add implicit dependency
450                        implicit_deps.push(format!("#{}:{}", project, task_name));
451                    } else if parts.len() == 2 {
452                        // Handle "#project:task" as pure dependency?
453                        // The prompt says: `["#projectName:taskName"]` for dependsOn
454                        // For inputs, it likely expects a file mapping.
455                        // If user puts `["#p:t"]` in inputs, it's invalid as an input unless it maps something.
456                        // Assuming `#p:t:f` is the requirement for inputs.
457                        // Keeping original if not matching pattern (or maybe warning?)
458                        new_inputs.push(input.clone());
459                    } else {
460                        new_inputs.push(input.clone());
461                    }
462                }
463                Input::Project(proj_ref) => {
464                    // Add implicit dependency for explicit project references too
465                    implicit_deps.push(format!("#{}:{}", proj_ref.project, proj_ref.task));
466                    new_inputs.push(input.clone());
467                }
468                _ => new_inputs.push(input.clone()),
469            }
470        }
471
472        task.inputs = new_inputs;
473
474        // Add unique implicit dependencies
475        for dep in implicit_deps {
476            if !task.depends_on.contains(&dep) {
477                task.depends_on.push(dep);
478            }
479        }
480    }
481}
482
483#[cfg(test)]
484mod tests {
485    use super::*;
486    use crate::tasks::{ParallelGroup, TaskIndex};
487
488    #[test]
489    fn test_expand_cross_project_references() {
490        let task = Task {
491            inputs: vec![Input::Path("#myproj:build:dist/app.js".to_string())],
492            ..Default::default()
493        };
494
495        let mut cuenv = Cuenv::new("test");
496        cuenv
497            .tasks
498            .insert("deploy".into(), TaskDefinition::Single(Box::new(task)));
499
500        cuenv.expand_cross_project_references();
501
502        let task_def = cuenv.tasks.get("deploy").unwrap();
503        let task = task_def.as_single().unwrap();
504
505        // Check inputs expansion
506        assert_eq!(task.inputs.len(), 1);
507        match &task.inputs[0] {
508            Input::Project(proj_ref) => {
509                assert_eq!(proj_ref.project, "myproj");
510                assert_eq!(proj_ref.task, "build");
511                assert_eq!(proj_ref.map.len(), 1);
512                assert_eq!(proj_ref.map[0].from, "dist/app.js");
513                assert_eq!(proj_ref.map[0].to, "dist/app.js");
514            }
515            _ => panic!("Expected ProjectReference"),
516        }
517
518        // Check implicit dependency
519        assert_eq!(task.depends_on.len(), 1);
520        assert_eq!(task.depends_on[0], "#myproj:build");
521    }
522
523    #[test]
524    fn test_implicit_bun_install_task() {
525        let mut cuenv = Cuenv::new("test");
526        cuenv.workspaces = Some(HashMap::from([(
527            "bun".into(),
528            WorkspaceConfig {
529                enabled: true,
530                root: None,
531                package_manager: None,
532                hooks: None,
533            },
534        )]));
535
536        // Add a task that uses the bun workspace
537        cuenv.tasks.insert(
538            "dev".into(),
539            TaskDefinition::Single(Box::new(Task {
540                command: "bun".to_string(),
541                args: vec!["run".to_string(), "dev".to_string()],
542                workspaces: vec!["bun".to_string()],
543                ..Default::default()
544            })),
545        );
546
547        let cuenv = cuenv.with_implicit_tasks();
548        assert!(cuenv.tasks.contains_key("bun.install"));
549
550        let task_def = cuenv.tasks.get("bun.install").unwrap();
551        let task = task_def.as_single().unwrap();
552        assert_eq!(task.command, "bun");
553        assert_eq!(task.args, vec!["install"]);
554        assert_eq!(task.workspaces, vec!["bun"]);
555    }
556
557    #[test]
558    fn test_implicit_npm_install_task() {
559        let mut cuenv = Cuenv::new("test");
560        cuenv.workspaces = Some(HashMap::from([(
561            "npm".into(),
562            WorkspaceConfig {
563                enabled: true,
564                root: None,
565                package_manager: None,
566                hooks: None,
567            },
568        )]));
569
570        // Add a task that uses the npm workspace
571        cuenv.tasks.insert(
572            "build".into(),
573            TaskDefinition::Single(Box::new(Task {
574                command: "npm".to_string(),
575                args: vec!["run".to_string(), "build".to_string()],
576                workspaces: vec!["npm".to_string()],
577                ..Default::default()
578            })),
579        );
580
581        let cuenv = cuenv.with_implicit_tasks();
582        assert!(cuenv.tasks.contains_key("npm.install"));
583    }
584
585    #[test]
586    fn test_implicit_cargo_fetch_task() {
587        let mut cuenv = Cuenv::new("test");
588        cuenv.workspaces = Some(HashMap::from([(
589            "cargo".into(),
590            WorkspaceConfig {
591                enabled: true,
592                root: None,
593                package_manager: None,
594                hooks: None,
595            },
596        )]));
597
598        // Add a task that uses the cargo workspace
599        cuenv.tasks.insert(
600            "build".into(),
601            TaskDefinition::Single(Box::new(Task {
602                command: "cargo".to_string(),
603                args: vec!["build".to_string()],
604                workspaces: vec!["cargo".to_string()],
605                ..Default::default()
606            })),
607        );
608
609        let cuenv = cuenv.with_implicit_tasks();
610        assert!(cuenv.tasks.contains_key("cargo.install"));
611
612        let task_def = cuenv.tasks.get("cargo.install").unwrap();
613        let task = task_def.as_single().unwrap();
614        assert_eq!(task.command, "cargo");
615        assert_eq!(task.args, vec!["fetch"]);
616    }
617
618    #[test]
619    fn test_no_override_user_defined_task() {
620        let mut cuenv = Cuenv::new("test");
621        cuenv.workspaces = Some(HashMap::from([(
622            "bun".into(),
623            WorkspaceConfig {
624                enabled: true,
625                root: None,
626                package_manager: None,
627                hooks: None,
628            },
629        )]));
630
631        // User defines their own bun.install task
632        let user_task = Task {
633            command: "custom-bun".to_string(),
634            args: vec!["custom-install".to_string()],
635            ..Default::default()
636        };
637        cuenv.tasks.insert(
638            "bun.install".into(),
639            TaskDefinition::Single(Box::new(user_task)),
640        );
641
642        let cuenv = cuenv.with_implicit_tasks();
643
644        // User's task should not be overridden
645        let task_def = cuenv.tasks.get("bun.install").unwrap();
646        let task = task_def.as_single().unwrap();
647        assert_eq!(task.command, "custom-bun");
648    }
649
650    #[test]
651    fn test_no_override_user_defined_nested_install_task() {
652        let mut cuenv = Cuenv::new("test");
653        cuenv.workspaces = Some(HashMap::from([(
654            "bun".into(),
655            WorkspaceConfig {
656                enabled: true,
657                root: None,
658                package_manager: None,
659                hooks: None,
660            },
661        )]));
662
663        // User defines nested bun.install via tasks: bun: install: {}
664        cuenv.tasks.insert(
665            "bun".into(),
666            TaskDefinition::Group(TaskGroup::Parallel(ParallelGroup {
667                tasks: HashMap::from([(
668                    "install".into(),
669                    TaskDefinition::Single(Box::new(Task {
670                        command: "custom-bun".to_string(),
671                        args: vec!["custom-install".to_string()],
672                        ..Default::default()
673                    })),
674                )]),
675                depends_on: vec![],
676            })),
677        );
678
679        // Add a task that uses the bun workspace (so implicit wiring runs)
680        cuenv.tasks.insert(
681            "dev".into(),
682            TaskDefinition::Single(Box::new(Task {
683                command: "echo".to_string(),
684                args: vec!["dev".to_string()],
685                workspaces: vec!["bun".to_string()],
686                ..Default::default()
687            })),
688        );
689
690        let cuenv = cuenv.with_implicit_tasks();
691
692        // Should not have created a top-level bun.install (nested one should count).
693        assert!(!cuenv.tasks.contains_key("bun.install"));
694
695        // The nested bun.install should remain.
696        let idx = TaskIndex::build(&cuenv.tasks).unwrap();
697        let bun_install = idx.resolve("bun.install").unwrap();
698        let TaskDefinition::Single(t) = &bun_install.definition else {
699            panic!("expected bun.install to be a single task");
700        };
701        assert_eq!(t.command, "custom-bun");
702    }
703
704    #[test]
705    fn test_disabled_workspace_no_implicit_task() {
706        let mut cuenv = Cuenv::new("test");
707        cuenv.workspaces = Some(HashMap::from([(
708            "bun".into(),
709            WorkspaceConfig {
710                enabled: false,
711                root: None,
712                package_manager: None,
713                hooks: None,
714            },
715        )]));
716
717        let cuenv = cuenv.with_implicit_tasks();
718        assert!(!cuenv.tasks.contains_key("bun.install"));
719    }
720
721    #[test]
722    fn test_unknown_workspace_no_implicit_task() {
723        let mut cuenv = Cuenv::new("test");
724        cuenv.workspaces = Some(HashMap::from([(
725            "unknown-package-manager".into(),
726            WorkspaceConfig {
727                enabled: true,
728                root: None,
729                package_manager: None,
730                hooks: None,
731            },
732        )]));
733
734        let cuenv = cuenv.with_implicit_tasks();
735        assert!(!cuenv.tasks.contains_key("unknown-package-manager.install"));
736    }
737
738    #[test]
739    fn test_no_workspaces_unchanged() {
740        let cuenv = Cuenv::new("test");
741        let cuenv = cuenv.with_implicit_tasks();
742        assert!(cuenv.tasks.is_empty());
743    }
744
745    #[test]
746    fn test_no_workspace_tasks_when_unused() {
747        // When no task uses a workspace, the implicit install tasks should not be created
748        let mut cuenv = Cuenv::new("test");
749        cuenv.workspaces = Some(HashMap::from([(
750            "bun".into(),
751            WorkspaceConfig {
752                enabled: true,
753                root: None,
754                package_manager: None,
755                hooks: None,
756            },
757        )]));
758
759        // Add a task that does NOT use the bun workspace
760        cuenv.tasks.insert(
761            "build".into(),
762            TaskDefinition::Single(Box::new(Task {
763                command: "cargo".to_string(),
764                args: vec!["build".to_string()],
765                workspaces: vec![], // No workspace usage
766                ..Default::default()
767            })),
768        );
769
770        let cuenv = cuenv.with_implicit_tasks();
771
772        // bun.install should NOT be created since no task uses it
773        assert!(
774            !cuenv.tasks.contains_key("bun.install"),
775            "Should not create bun.install when no task uses bun workspace"
776        );
777    }
778}