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::owners::Owners;
14use crate::tasks::{Input, Mapping, ProjectReference, TaskGroup};
15use crate::tasks::{Task, TaskDefinition};
16
17/// Workspace configuration
18#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
19#[serde(rename_all = "camelCase")]
20pub struct WorkspaceConfig {
21    /// Enable or disable the workspace
22    #[serde(default = "default_true")]
23    pub enabled: bool,
24
25    /// Optional: manually specify the root of the workspace relative to env.cue
26    pub root: Option<String>,
27
28    /// Optional: manually specify the package manager
29    pub package_manager: Option<String>,
30
31    /// Workspace lifecycle hooks
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub hooks: Option<WorkspaceHooks>,
34}
35
36/// Workspace lifecycle hooks for pre/post install
37#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
38#[serde(rename_all = "camelCase")]
39pub struct WorkspaceHooks {
40    /// Tasks or references to run before workspace install
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub before_install: Option<Vec<HookItem>>,
43
44    /// Tasks or references to run after workspace install
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub after_install: Option<Vec<HookItem>>,
47}
48
49/// A hook step to run as part of workspace lifecycle hooks.
50#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
51#[serde(untagged)]
52pub enum HookItem {
53    /// Reference to a task in another project
54    TaskRef(TaskRef),
55    /// Discovery-based hook step that expands a TaskMatcher into concrete tasks
56    Match(MatchHook),
57    /// Inline task definition
58    Task(Box<Task>),
59}
60
61/// Hook step that expands to tasks discovered via TaskMatcher.
62#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
63#[serde(rename_all = "camelCase")]
64pub struct MatchHook {
65    /// Optional stable name used for task naming/logging
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub name: Option<String>,
68
69    /// Task matcher to select tasks across the workspace
70    #[serde(rename = "match")]
71    pub matcher: TaskMatcher,
72}
73
74/// Reference to a task in another env.cue project by its name property
75#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
76pub struct TaskRef {
77    /// Format: "#project-name:task-name" where project-name is the `name` field in env.cue
78    /// Example: "#projen-generator:bun.install"
79    #[serde(rename = "ref")]
80    pub ref_: String,
81}
82
83impl TaskRef {
84    /// Parse the TaskRef into project name and task name
85    /// Returns None if the format is invalid or if project/task names are empty
86    pub fn parse(&self) -> Option<(String, String)> {
87        let ref_str = self.ref_.strip_prefix('#')?;
88        let parts: Vec<&str> = ref_str.splitn(2, ':').collect();
89        if parts.len() == 2 {
90            let project = parts[0];
91            let task = parts[1];
92            if !project.is_empty() && !task.is_empty() {
93                Some((project.to_string(), task.to_string()))
94            } else {
95                None
96            }
97        } else {
98            None
99        }
100    }
101}
102
103/// Match tasks across workspace by metadata for discovery-based execution
104#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
105pub struct TaskMatcher {
106    /// Limit to specific workspaces (by name)
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub workspaces: Option<Vec<String>>,
109
110    /// Match tasks with these labels (all must match)
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub labels: Option<Vec<String>>,
113
114    /// Match tasks whose command matches this value
115    #[serde(skip_serializing_if = "Option::is_none")]
116    pub command: Option<String>,
117
118    /// Match tasks whose args contain specific patterns
119    #[serde(skip_serializing_if = "Option::is_none")]
120    pub args: Option<Vec<ArgMatcher>>,
121
122    /// Run matched tasks in parallel (default: true)
123    #[serde(default = "default_true")]
124    pub parallel: bool,
125}
126
127/// Pattern matcher for task arguments
128#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
129pub struct ArgMatcher {
130    /// Match if any arg contains this substring
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub contains: Option<String>,
133
134    /// Match if any arg matches this regex pattern
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub matches: Option<String>,
137}
138
139fn default_true() -> bool {
140    true
141}
142
143/// Collection of hooks that can be executed
144#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
145pub struct Hooks {
146    /// Named hooks to execute when entering an environment (map of name -> hook)
147    #[serde(skip_serializing_if = "Option::is_none")]
148    #[serde(rename = "onEnter")]
149    pub on_enter: Option<HashMap<String, Hook>>,
150
151    /// Named hooks to execute when exiting an environment (map of name -> hook)
152    #[serde(skip_serializing_if = "Option::is_none")]
153    #[serde(rename = "onExit")]
154    pub on_exit: Option<HashMap<String, Hook>>,
155}
156
157/// Base configuration structure (composable across directories)
158#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
159pub struct Base {
160    /// Configuration settings
161    #[serde(skip_serializing_if = "Option::is_none")]
162    pub config: Option<Config>,
163
164    /// Environment variables configuration
165    #[serde(skip_serializing_if = "Option::is_none")]
166    pub env: Option<Env>,
167
168    /// Workspaces configuration
169    #[serde(skip_serializing_if = "Option::is_none")]
170    pub workspaces: Option<HashMap<String, WorkspaceConfig>>,
171}
172
173/// Ignore patterns for tool-specific ignore files.
174/// Keys are tool names (e.g., "git", "docker", "prettier").
175/// Values can be either:
176/// - A list of patterns: `["node_modules/", ".env"]`
177/// - An object with patterns and optional filename override
178pub type Ignore = HashMap<String, IgnoreValue>;
179
180// ============================================================================
181// Cube Types (for code generation)
182// ============================================================================
183
184/// File generation mode
185#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
186#[serde(rename_all = "lowercase")]
187pub enum FileMode {
188    /// Always regenerate this file (managed by codegen)
189    #[default]
190    Managed,
191    /// Generate only if file doesn't exist (user owns this file)
192    Scaffold,
193}
194
195/// Format configuration for a generated file
196#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq)]
197#[serde(rename_all = "camelCase")]
198pub struct FormatConfig {
199    /// Indent style: "space" or "tab"
200    #[serde(default = "default_indent")]
201    pub indent: String,
202    /// Indent size (number of spaces or tab width)
203    #[serde(skip_serializing_if = "Option::is_none")]
204    pub indent_size: Option<usize>,
205    /// Maximum line width
206    #[serde(skip_serializing_if = "Option::is_none")]
207    pub line_width: Option<usize>,
208    /// Trailing comma style
209    #[serde(skip_serializing_if = "Option::is_none")]
210    pub trailing_comma: Option<String>,
211    /// Use semicolons
212    #[serde(skip_serializing_if = "Option::is_none")]
213    pub semicolons: Option<bool>,
214    /// Quote style: "single" or "double"
215    #[serde(skip_serializing_if = "Option::is_none")]
216    pub quotes: Option<String>,
217}
218
219fn default_indent() -> String {
220    "space".to_string()
221}
222
223/// A file definition from the cube
224#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
225pub struct ProjectFile {
226    /// Content of the file
227    pub content: String,
228    /// Programming language of the file
229    pub language: String,
230    /// Generation mode (managed or scaffold)
231    #[serde(default)]
232    pub mode: FileMode,
233    /// Formatting configuration
234    #[serde(default)]
235    pub format: FormatConfig,
236    /// Whether to add this file path to .gitignore.
237    /// Defaults based on mode (set in CUE schema):
238    ///   - managed: true (generated files should be ignored)
239    ///   - scaffold: false (user-owned files should be committed)
240    #[serde(default)]
241    pub gitignore: bool,
242}
243
244/// A CUE Cube containing file definitions for code generation
245#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq)]
246pub struct CubeConfig {
247    /// Map of file paths to their definitions
248    #[serde(default)]
249    pub files: HashMap<String, ProjectFile>,
250    /// Optional context data for templating
251    #[serde(default)]
252    pub context: serde_json::Value,
253}
254
255/// Value for an ignore entry - either a simple list of patterns or an extended config.
256#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
257#[serde(untagged)]
258pub enum IgnoreValue {
259    /// Simple list of patterns
260    Patterns(Vec<String>),
261    /// Extended config with patterns and optional filename override
262    Extended(IgnoreEntry),
263}
264
265/// Extended ignore configuration with patterns and optional filename override.
266#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
267pub struct IgnoreEntry {
268    /// List of patterns to include in the ignore file
269    pub patterns: Vec<String>,
270    /// Optional filename override (defaults to `.{tool}ignore`)
271    #[serde(skip_serializing_if = "Option::is_none")]
272    pub filename: Option<String>,
273}
274
275impl IgnoreValue {
276    /// Get the patterns from this ignore value.
277    #[must_use]
278    pub fn patterns(&self) -> &[String] {
279        match self {
280            Self::Patterns(patterns) => patterns,
281            Self::Extended(entry) => &entry.patterns,
282        }
283    }
284
285    /// Get the optional filename override.
286    #[must_use]
287    pub fn filename(&self) -> Option<&str> {
288        match self {
289            Self::Patterns(_) => None,
290            Self::Extended(entry) => entry.filename.as_deref(),
291        }
292    }
293}
294
295/// Root Project configuration structure (leaf node - cannot unify with other projects)
296#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
297pub struct Project {
298    /// Configuration settings
299    #[serde(skip_serializing_if = "Option::is_none")]
300    pub config: Option<Config>,
301
302    /// Project name (unique identifier, required by the CUE schema)
303    pub name: String,
304
305    /// Environment variables configuration
306    #[serde(skip_serializing_if = "Option::is_none")]
307    pub env: Option<Env>,
308
309    /// Hooks configuration
310    #[serde(skip_serializing_if = "Option::is_none")]
311    pub hooks: Option<Hooks>,
312
313    /// Workspaces configuration
314    #[serde(skip_serializing_if = "Option::is_none")]
315    pub workspaces: Option<HashMap<String, WorkspaceConfig>>,
316
317    /// CI configuration
318    #[serde(skip_serializing_if = "Option::is_none")]
319    pub ci: Option<CI>,
320
321    /// Code ownership configuration
322    #[serde(skip_serializing_if = "Option::is_none")]
323    pub owners: Option<Owners>,
324
325    /// Tasks configuration
326    #[serde(default)]
327    pub tasks: HashMap<String, TaskDefinition>,
328
329    /// Ignore patterns for tool-specific ignore files
330    #[serde(skip_serializing_if = "Option::is_none")]
331    pub ignore: Option<Ignore>,
332
333    /// Cube configuration for code generation
334    #[serde(skip_serializing_if = "Option::is_none")]
335    pub cube: Option<CubeConfig>,
336}
337
338/// Type alias for backward compatibility
339pub type Cuenv = Project;
340
341impl Project {
342    /// Create a new Project configuration with a required name.
343    pub fn new(name: impl Into<String>) -> Self {
344        Self {
345            name: name.into(),
346            ..Self::default()
347        }
348    }
349
350    /// Get hooks to execute when entering environment as a map (name -> hook)
351    pub fn on_enter_hooks_map(&self) -> HashMap<String, Hook> {
352        self.hooks
353            .as_ref()
354            .and_then(|h| h.on_enter.as_ref())
355            .cloned()
356            .unwrap_or_default()
357    }
358
359    /// Get hooks to execute when entering environment, sorted by (order, name)
360    pub fn on_enter_hooks(&self) -> Vec<Hook> {
361        let map = self.on_enter_hooks_map();
362        let mut hooks: Vec<(String, Hook)> = map.into_iter().collect();
363        hooks.sort_by(|a, b| a.1.order.cmp(&b.1.order).then(a.0.cmp(&b.0)));
364        hooks.into_iter().map(|(_, h)| h).collect()
365    }
366
367    /// Get hooks to execute when exiting environment as a map (name -> hook)
368    pub fn on_exit_hooks_map(&self) -> HashMap<String, Hook> {
369        self.hooks
370            .as_ref()
371            .and_then(|h| h.on_exit.as_ref())
372            .cloned()
373            .unwrap_or_default()
374    }
375
376    /// Get hooks to execute when exiting environment, sorted by (order, name)
377    pub fn on_exit_hooks(&self) -> Vec<Hook> {
378        let map = self.on_exit_hooks_map();
379        let mut hooks: Vec<(String, Hook)> = map.into_iter().collect();
380        hooks.sort_by(|a, b| a.1.order.cmp(&b.1.order).then(a.0.cmp(&b.0)));
381        hooks.into_iter().map(|(_, h)| h).collect()
382    }
383
384    /// Inject implicit tasks and dependencies based on workspace declarations.
385    ///
386    /// When a workspace is declared (e.g., `workspaces: bun: {}`), this method:
387    /// 1. Creates an install task for that workspace if one doesn't already exist
388    ///
389    /// This ensures users don't need to manually define common tasks like
390    /// `bun.install` or manually wire up dependencies.
391    pub fn with_implicit_tasks(mut self) -> Self {
392        fn get_task_mut_by_path<'a>(
393            tasks: &'a mut HashMap<String, TaskDefinition>,
394            raw_path: &str,
395        ) -> Option<&'a mut Task> {
396            let normalized = raw_path.replace(':', ".");
397            let mut segments = normalized
398                .split('.')
399                .filter(|s| !s.is_empty())
400                .map(str::trim)
401                .collect::<Vec<_>>();
402            if segments.is_empty() {
403                return None;
404            }
405
406            let first = segments.remove(0);
407            let mut current = tasks.get_mut(first)?;
408            for seg in segments {
409                match current {
410                    TaskDefinition::Group(TaskGroup::Parallel(group)) => {
411                        current = group.tasks.get_mut(seg)?;
412                    }
413                    _ => return None,
414                }
415            }
416
417            match current {
418                TaskDefinition::Single(task) => Some(task.as_mut()),
419                _ => None,
420            }
421        }
422
423        let Some(workspaces) = &self.workspaces else {
424            return self;
425        };
426
427        // Clone workspaces to avoid borrow issues
428        let workspaces = workspaces.clone();
429
430        for (name, config) in &workspaces {
431            if !config.enabled {
432                continue;
433            }
434
435            // Only known workspace types get implicit install tasks
436            if !matches!(name.as_str(), "bun" | "npm" | "pnpm" | "yarn" | "cargo") {
437                continue;
438            }
439
440            // Only process workspace if at least one task explicitly uses it
441            let workspace_used = self
442                .tasks
443                .values()
444                .any(|task_def| task_def.uses_workspace(name));
445            if !workspace_used {
446                tracing::debug!("Skipping workspace '{}' - no tasks declare usage", name);
447                continue;
448            }
449
450            let install_task_name = format!("{}.install", name);
451
452            // Don't override user-defined install tasks (including nested `tasks: bun: install: {}`)
453            if get_task_mut_by_path(&mut self.tasks, &install_task_name).is_some() {
454                continue;
455            }
456
457            // Create implicit install task
458            if let Some(task) = Self::create_implicit_install_task(name) {
459                self.tasks
460                    .insert(install_task_name, TaskDefinition::Single(Box::new(task)));
461            }
462        }
463
464        self
465    }
466
467    /// Create an implicit install task for a known workspace type.
468    fn create_implicit_install_task(workspace_name: &str) -> Option<Task> {
469        let (command, args, description, inputs, outputs) = match workspace_name {
470            "bun" => (
471                "bun",
472                vec!["install"],
473                "Install bun dependencies",
474                vec![
475                    Input::Path("package.json".to_string()),
476                    Input::Path("bun.lock".to_string()),
477                ],
478                vec!["node_modules".to_string()],
479            ),
480            "npm" => (
481                "npm",
482                vec!["install"],
483                "Install npm dependencies",
484                vec![
485                    Input::Path("package.json".to_string()),
486                    Input::Path("package-lock.json".to_string()),
487                ],
488                vec!["node_modules".to_string()],
489            ),
490            "pnpm" => (
491                "pnpm",
492                vec!["install"],
493                "Install pnpm dependencies",
494                vec![
495                    Input::Path("package.json".to_string()),
496                    Input::Path("pnpm-lock.yaml".to_string()),
497                ],
498                vec!["node_modules".to_string()],
499            ),
500            "yarn" => (
501                "yarn",
502                vec!["install"],
503                "Install yarn dependencies",
504                vec![
505                    Input::Path("package.json".to_string()),
506                    Input::Path("yarn.lock".to_string()),
507                ],
508                vec!["node_modules".to_string()],
509            ),
510            "cargo" => (
511                "cargo",
512                vec!["fetch"],
513                "Fetch cargo dependencies",
514                vec![
515                    Input::Path("Cargo.toml".to_string()),
516                    Input::Path("Cargo.lock".to_string()),
517                ],
518                vec![], // cargo fetch doesn't produce local outputs (uses shared cache)
519            ),
520            _ => return None, // Unknown workspace type, don't create implicit task
521        };
522
523        Some(Task {
524            command: command.to_string(),
525            args: args.into_iter().map(String::from).collect(),
526            workspaces: vec![workspace_name.to_string()],
527            hermetic: false, // Install tasks must run in real workspace root
528            description: Some(description.to_string()),
529            inputs,
530            outputs,
531            ..Default::default()
532        })
533    }
534
535    /// Expand shorthand cross-project references in inputs and implicit dependencies.
536    ///
537    /// Handles inputs in the format: "#project:task:path/to/file"
538    /// Converts them to explicit ProjectReference inputs.
539    /// Also adds implicit dependsOn entries for all project references.
540    pub fn expand_cross_project_references(&mut self) {
541        for (_, task_def) in self.tasks.iter_mut() {
542            Self::expand_task_definition(task_def);
543        }
544    }
545
546    fn expand_task_definition(task_def: &mut TaskDefinition) {
547        match task_def {
548            TaskDefinition::Single(task) => Self::expand_task(task),
549            TaskDefinition::Group(group) => match group {
550                TaskGroup::Sequential(tasks) => {
551                    for sub_task in tasks {
552                        Self::expand_task_definition(sub_task);
553                    }
554                }
555                TaskGroup::Parallel(group) => {
556                    for sub_task in group.tasks.values_mut() {
557                        Self::expand_task_definition(sub_task);
558                    }
559                }
560            },
561        }
562    }
563
564    fn expand_task(task: &mut Task) {
565        let mut new_inputs = Vec::new();
566        let mut implicit_deps = Vec::new();
567
568        // Process existing inputs
569        for input in &task.inputs {
570            match input {
571                Input::Path(path) if path.starts_with('#') => {
572                    // Parse "#project:task:path"
573                    // Remove leading #
574                    let parts: Vec<&str> = path[1..].split(':').collect();
575                    if parts.len() >= 3 {
576                        let project = parts[0].to_string();
577                        let task_name = parts[1].to_string();
578                        // Rejoin the rest as the path (it might contain colons)
579                        let file_path = parts[2..].join(":");
580
581                        new_inputs.push(Input::Project(ProjectReference {
582                            project: project.clone(),
583                            task: task_name.clone(),
584                            map: vec![Mapping {
585                                from: file_path.clone(),
586                                to: file_path,
587                            }],
588                        }));
589
590                        // Add implicit dependency
591                        implicit_deps.push(format!("#{}:{}", project, task_name));
592                    } else if parts.len() == 2 {
593                        // Handle "#project:task" as pure dependency?
594                        // The prompt says: `["#projectName:taskName"]` for dependsOn
595                        // For inputs, it likely expects a file mapping.
596                        // If user puts `["#p:t"]` in inputs, it's invalid as an input unless it maps something.
597                        // Assuming `#p:t:f` is the requirement for inputs.
598                        // Keeping original if not matching pattern (or maybe warning?)
599                        new_inputs.push(input.clone());
600                    } else {
601                        new_inputs.push(input.clone());
602                    }
603                }
604                Input::Project(proj_ref) => {
605                    // Add implicit dependency for explicit project references too
606                    implicit_deps.push(format!("#{}:{}", proj_ref.project, proj_ref.task));
607                    new_inputs.push(input.clone());
608                }
609                _ => new_inputs.push(input.clone()),
610            }
611        }
612
613        task.inputs = new_inputs;
614
615        // Add unique implicit dependencies
616        for dep in implicit_deps {
617            if !task.depends_on.contains(&dep) {
618                task.depends_on.push(dep);
619            }
620        }
621    }
622}
623
624#[cfg(test)]
625mod tests {
626    use super::*;
627    use crate::tasks::{ParallelGroup, TaskIndex};
628    use crate::test_utils::create_test_hook;
629
630    #[test]
631    fn test_expand_cross_project_references() {
632        let task = Task {
633            inputs: vec![Input::Path("#myproj:build:dist/app.js".to_string())],
634            ..Default::default()
635        };
636
637        let mut cuenv = Cuenv::new("test");
638        cuenv
639            .tasks
640            .insert("deploy".into(), TaskDefinition::Single(Box::new(task)));
641
642        cuenv.expand_cross_project_references();
643
644        let task_def = cuenv.tasks.get("deploy").unwrap();
645        let task = task_def.as_single().unwrap();
646
647        // Check inputs expansion
648        assert_eq!(task.inputs.len(), 1);
649        match &task.inputs[0] {
650            Input::Project(proj_ref) => {
651                assert_eq!(proj_ref.project, "myproj");
652                assert_eq!(proj_ref.task, "build");
653                assert_eq!(proj_ref.map.len(), 1);
654                assert_eq!(proj_ref.map[0].from, "dist/app.js");
655                assert_eq!(proj_ref.map[0].to, "dist/app.js");
656            }
657            _ => panic!("Expected ProjectReference"),
658        }
659
660        // Check implicit dependency
661        assert_eq!(task.depends_on.len(), 1);
662        assert_eq!(task.depends_on[0], "#myproj:build");
663    }
664
665    #[test]
666    fn test_implicit_bun_install_task() {
667        let mut cuenv = Cuenv::new("test");
668        cuenv.workspaces = Some(HashMap::from([(
669            "bun".into(),
670            WorkspaceConfig {
671                enabled: true,
672                root: None,
673                package_manager: None,
674                hooks: None,
675            },
676        )]));
677
678        // Add a task that uses the bun workspace
679        cuenv.tasks.insert(
680            "dev".into(),
681            TaskDefinition::Single(Box::new(Task {
682                command: "bun".to_string(),
683                args: vec!["run".to_string(), "dev".to_string()],
684                workspaces: vec!["bun".to_string()],
685                ..Default::default()
686            })),
687        );
688
689        let cuenv = cuenv.with_implicit_tasks();
690        assert!(cuenv.tasks.contains_key("bun.install"));
691
692        let task_def = cuenv.tasks.get("bun.install").unwrap();
693        let task = task_def.as_single().unwrap();
694        assert_eq!(task.command, "bun");
695        assert_eq!(task.args, vec!["install"]);
696        assert_eq!(task.workspaces, vec!["bun"]);
697    }
698
699    #[test]
700    fn test_implicit_npm_install_task() {
701        let mut cuenv = Cuenv::new("test");
702        cuenv.workspaces = Some(HashMap::from([(
703            "npm".into(),
704            WorkspaceConfig {
705                enabled: true,
706                root: None,
707                package_manager: None,
708                hooks: None,
709            },
710        )]));
711
712        // Add a task that uses the npm workspace
713        cuenv.tasks.insert(
714            "build".into(),
715            TaskDefinition::Single(Box::new(Task {
716                command: "npm".to_string(),
717                args: vec!["run".to_string(), "build".to_string()],
718                workspaces: vec!["npm".to_string()],
719                ..Default::default()
720            })),
721        );
722
723        let cuenv = cuenv.with_implicit_tasks();
724        assert!(cuenv.tasks.contains_key("npm.install"));
725    }
726
727    #[test]
728    fn test_implicit_cargo_fetch_task() {
729        let mut cuenv = Cuenv::new("test");
730        cuenv.workspaces = Some(HashMap::from([(
731            "cargo".into(),
732            WorkspaceConfig {
733                enabled: true,
734                root: None,
735                package_manager: None,
736                hooks: None,
737            },
738        )]));
739
740        // Add a task that uses the cargo workspace
741        cuenv.tasks.insert(
742            "build".into(),
743            TaskDefinition::Single(Box::new(Task {
744                command: "cargo".to_string(),
745                args: vec!["build".to_string()],
746                workspaces: vec!["cargo".to_string()],
747                ..Default::default()
748            })),
749        );
750
751        let cuenv = cuenv.with_implicit_tasks();
752        assert!(cuenv.tasks.contains_key("cargo.install"));
753
754        let task_def = cuenv.tasks.get("cargo.install").unwrap();
755        let task = task_def.as_single().unwrap();
756        assert_eq!(task.command, "cargo");
757        assert_eq!(task.args, vec!["fetch"]);
758    }
759
760    #[test]
761    fn test_no_override_user_defined_task() {
762        let mut cuenv = Cuenv::new("test");
763        cuenv.workspaces = Some(HashMap::from([(
764            "bun".into(),
765            WorkspaceConfig {
766                enabled: true,
767                root: None,
768                package_manager: None,
769                hooks: None,
770            },
771        )]));
772
773        // User defines their own bun.install task
774        let user_task = Task {
775            command: "custom-bun".to_string(),
776            args: vec!["custom-install".to_string()],
777            ..Default::default()
778        };
779        cuenv.tasks.insert(
780            "bun.install".into(),
781            TaskDefinition::Single(Box::new(user_task)),
782        );
783
784        let cuenv = cuenv.with_implicit_tasks();
785
786        // User's task should not be overridden
787        let task_def = cuenv.tasks.get("bun.install").unwrap();
788        let task = task_def.as_single().unwrap();
789        assert_eq!(task.command, "custom-bun");
790    }
791
792    #[test]
793    fn test_no_override_user_defined_nested_install_task() {
794        let mut cuenv = Cuenv::new("test");
795        cuenv.workspaces = Some(HashMap::from([(
796            "bun".into(),
797            WorkspaceConfig {
798                enabled: true,
799                root: None,
800                package_manager: None,
801                hooks: None,
802            },
803        )]));
804
805        // User defines nested bun.install via tasks: bun: install: {}
806        cuenv.tasks.insert(
807            "bun".into(),
808            TaskDefinition::Group(TaskGroup::Parallel(ParallelGroup {
809                tasks: HashMap::from([(
810                    "install".into(),
811                    TaskDefinition::Single(Box::new(Task {
812                        command: "custom-bun".to_string(),
813                        args: vec!["custom-install".to_string()],
814                        ..Default::default()
815                    })),
816                )]),
817                depends_on: vec![],
818            })),
819        );
820
821        // Add a task that uses the bun workspace (so implicit wiring runs)
822        cuenv.tasks.insert(
823            "dev".into(),
824            TaskDefinition::Single(Box::new(Task {
825                command: "echo".to_string(),
826                args: vec!["dev".to_string()],
827                workspaces: vec!["bun".to_string()],
828                ..Default::default()
829            })),
830        );
831
832        let cuenv = cuenv.with_implicit_tasks();
833
834        // Should not have created a top-level bun.install (nested one should count).
835        assert!(!cuenv.tasks.contains_key("bun.install"));
836
837        // The nested bun.install should remain.
838        let idx = TaskIndex::build(&cuenv.tasks).unwrap();
839        let bun_install = idx.resolve("bun.install").unwrap();
840        let TaskDefinition::Single(t) = &bun_install.definition else {
841            panic!("expected bun.install to be a single task");
842        };
843        assert_eq!(t.command, "custom-bun");
844    }
845
846    #[test]
847    fn test_disabled_workspace_no_implicit_task() {
848        let mut cuenv = Cuenv::new("test");
849        cuenv.workspaces = Some(HashMap::from([(
850            "bun".into(),
851            WorkspaceConfig {
852                enabled: false,
853                root: None,
854                package_manager: None,
855                hooks: None,
856            },
857        )]));
858
859        let cuenv = cuenv.with_implicit_tasks();
860        assert!(!cuenv.tasks.contains_key("bun.install"));
861    }
862
863    #[test]
864    fn test_unknown_workspace_no_implicit_task() {
865        let mut cuenv = Cuenv::new("test");
866        cuenv.workspaces = Some(HashMap::from([(
867            "unknown-package-manager".into(),
868            WorkspaceConfig {
869                enabled: true,
870                root: None,
871                package_manager: None,
872                hooks: None,
873            },
874        )]));
875
876        let cuenv = cuenv.with_implicit_tasks();
877        assert!(!cuenv.tasks.contains_key("unknown-package-manager.install"));
878    }
879
880    #[test]
881    fn test_no_workspaces_unchanged() {
882        let cuenv = Cuenv::new("test");
883        let cuenv = cuenv.with_implicit_tasks();
884        assert!(cuenv.tasks.is_empty());
885    }
886
887    #[test]
888    fn test_no_workspace_tasks_when_unused() {
889        // When no task uses a workspace, the implicit install tasks should not be created
890        let mut cuenv = Cuenv::new("test");
891        cuenv.workspaces = Some(HashMap::from([(
892            "bun".into(),
893            WorkspaceConfig {
894                enabled: true,
895                root: None,
896                package_manager: None,
897                hooks: None,
898            },
899        )]));
900
901        // Add a task that does NOT use the bun workspace
902        cuenv.tasks.insert(
903            "build".into(),
904            TaskDefinition::Single(Box::new(Task {
905                command: "cargo".to_string(),
906                args: vec!["build".to_string()],
907                workspaces: vec![], // No workspace usage
908                ..Default::default()
909            })),
910        );
911
912        let cuenv = cuenv.with_implicit_tasks();
913
914        // bun.install should NOT be created since no task uses it
915        assert!(
916            !cuenv.tasks.contains_key("bun.install"),
917            "Should not create bun.install when no task uses bun workspace"
918        );
919    }
920
921    // ============================================================================
922    // HookItem and TaskRef Tests
923    // ============================================================================
924
925    #[test]
926    fn test_task_ref_parse_valid() {
927        let task_ref = TaskRef {
928            ref_: "#projen-generator:types".to_string(),
929        };
930
931        let parsed = task_ref.parse();
932        assert!(parsed.is_some());
933
934        let (project, task) = parsed.unwrap();
935        assert_eq!(project, "projen-generator");
936        assert_eq!(task, "types");
937    }
938
939    #[test]
940    fn test_task_ref_parse_with_dots() {
941        let task_ref = TaskRef {
942            ref_: "#my-project:bun.install".to_string(),
943        };
944
945        let parsed = task_ref.parse();
946        assert!(parsed.is_some());
947
948        let (project, task) = parsed.unwrap();
949        assert_eq!(project, "my-project");
950        assert_eq!(task, "bun.install");
951    }
952
953    #[test]
954    fn test_task_ref_parse_no_hash() {
955        let task_ref = TaskRef {
956            ref_: "project:task".to_string(),
957        };
958
959        // Without leading #, parse should fail
960        let parsed = task_ref.parse();
961        assert!(parsed.is_none());
962    }
963
964    #[test]
965    fn test_task_ref_parse_no_colon() {
966        let task_ref = TaskRef {
967            ref_: "#project-only".to_string(),
968        };
969
970        // Without colon separator, parse should fail
971        let parsed = task_ref.parse();
972        assert!(parsed.is_none());
973    }
974
975    #[test]
976    fn test_task_ref_parse_empty_project() {
977        let task_ref = TaskRef {
978            ref_: "#:task".to_string(),
979        };
980
981        // Empty project name should be rejected
982        assert!(task_ref.parse().is_none());
983    }
984
985    #[test]
986    fn test_task_ref_parse_empty_task() {
987        let task_ref = TaskRef {
988            ref_: "#project:".to_string(),
989        };
990
991        // Empty task name should be rejected
992        assert!(task_ref.parse().is_none());
993    }
994
995    #[test]
996    fn test_task_ref_parse_both_empty() {
997        let task_ref = TaskRef {
998            ref_: "#:".to_string(),
999        };
1000
1001        // Both empty should be rejected
1002        assert!(task_ref.parse().is_none());
1003    }
1004
1005    #[test]
1006    fn test_task_ref_parse_multiple_colons() {
1007        let task_ref = TaskRef {
1008            ref_: "#project:task:extra".to_string(),
1009        };
1010
1011        // Multiple colons - first split wins
1012        let parsed = task_ref.parse();
1013        assert!(parsed.is_some());
1014        let (project, task) = parsed.unwrap();
1015        assert_eq!(project, "project");
1016        assert_eq!(task, "task:extra");
1017    }
1018
1019    #[test]
1020    fn test_task_ref_parse_unicode() {
1021        let task_ref = TaskRef {
1022            ref_: "#项目名:任务名".to_string(),
1023        };
1024
1025        let parsed = task_ref.parse();
1026        assert!(parsed.is_some());
1027        let (project, task) = parsed.unwrap();
1028        assert_eq!(project, "项目名");
1029        assert_eq!(task, "任务名");
1030    }
1031
1032    #[test]
1033    fn test_task_ref_parse_special_characters() {
1034        let task_ref = TaskRef {
1035            ref_: "#my-project_v2:build.ci-test".to_string(),
1036        };
1037
1038        let parsed = task_ref.parse();
1039        assert!(parsed.is_some());
1040        let (project, task) = parsed.unwrap();
1041        assert_eq!(project, "my-project_v2");
1042        assert_eq!(task, "build.ci-test");
1043    }
1044
1045    #[test]
1046    fn test_hook_item_task_ref_deserialization() {
1047        let json = "{\"ref\": \"#other-project:build\"}";
1048        let hook_item: HookItem = serde_json::from_str(json).unwrap();
1049
1050        match hook_item {
1051            HookItem::TaskRef(task_ref) => {
1052                assert_eq!(task_ref.ref_, "#other-project:build");
1053                let (project, task) = task_ref.parse().unwrap();
1054                assert_eq!(project, "other-project");
1055                assert_eq!(task, "build");
1056            }
1057            _ => panic!("Expected HookItem::TaskRef"),
1058        }
1059    }
1060
1061    #[test]
1062    fn test_hook_item_match_deserialization() {
1063        let json = r#"{
1064            "name": "projen",
1065            "match": {
1066                "labels": ["codegen", "projen"]
1067            }
1068        }"#;
1069        let hook_item: HookItem = serde_json::from_str(json).unwrap();
1070
1071        match hook_item {
1072            HookItem::Match(match_hook) => {
1073                assert_eq!(match_hook.name, Some("projen".to_string()));
1074                assert_eq!(
1075                    match_hook.matcher.labels,
1076                    Some(vec!["codegen".to_string(), "projen".to_string()])
1077                );
1078            }
1079            _ => panic!("Expected HookItem::Match"),
1080        }
1081    }
1082
1083    #[test]
1084    fn test_hook_item_match_with_parallel_false() {
1085        let json = r#"{
1086            "match": {
1087                "labels": ["build"],
1088                "parallel": false
1089            }
1090        }"#;
1091        let hook_item: HookItem = serde_json::from_str(json).unwrap();
1092
1093        match hook_item {
1094            HookItem::Match(match_hook) => {
1095                assert!(match_hook.name.is_none());
1096                assert!(!match_hook.matcher.parallel);
1097            }
1098            _ => panic!("Expected HookItem::Match"),
1099        }
1100    }
1101
1102    #[test]
1103    fn test_hook_item_inline_task_deserialization() {
1104        let json = r#"{
1105            "command": "echo",
1106            "args": ["hello"]
1107        }"#;
1108        let hook_item: HookItem = serde_json::from_str(json).unwrap();
1109
1110        match hook_item {
1111            HookItem::Task(task) => {
1112                assert_eq!(task.command, "echo");
1113                assert_eq!(task.args, vec!["hello"]);
1114            }
1115            _ => panic!("Expected HookItem::Task"),
1116        }
1117    }
1118
1119    #[test]
1120    fn test_workspace_hooks_before_install() {
1121        let json = format!(
1122            r#"{{
1123            "beforeInstall": [
1124                {{"ref": "{}"}},
1125                {{"name": "codegen", "match": {{"labels": ["codegen"]}}}},
1126                {{"command": "echo", "args": ["ready"]}}
1127            ]
1128        }}"#,
1129            "#projen:types"
1130        );
1131        let hooks: WorkspaceHooks = serde_json::from_str(&json).unwrap();
1132
1133        let before_install = hooks.before_install.unwrap();
1134        assert_eq!(before_install.len(), 3);
1135
1136        // First item: TaskRef
1137        match &before_install[0] {
1138            HookItem::TaskRef(task_ref) => {
1139                assert_eq!(task_ref.ref_, "#projen:types");
1140            }
1141            _ => panic!("Expected TaskRef"),
1142        }
1143
1144        // Second item: Match
1145        match &before_install[1] {
1146            HookItem::Match(match_hook) => {
1147                assert_eq!(match_hook.name, Some("codegen".to_string()));
1148            }
1149            _ => panic!("Expected Match"),
1150        }
1151
1152        // Third item: Inline Task
1153        match &before_install[2] {
1154            HookItem::Task(task) => {
1155                assert_eq!(task.command, "echo");
1156            }
1157            _ => panic!("Expected Task"),
1158        }
1159    }
1160
1161    #[test]
1162    fn test_workspace_hooks_after_install() {
1163        let json = r#"{
1164            "afterInstall": [
1165                {"command": "prisma", "args": ["generate"]}
1166            ]
1167        }"#;
1168        let hooks: WorkspaceHooks = serde_json::from_str(json).unwrap();
1169
1170        assert!(hooks.before_install.is_none());
1171        let after_install = hooks.after_install.unwrap();
1172        assert_eq!(after_install.len(), 1);
1173
1174        match &after_install[0] {
1175            HookItem::Task(task) => {
1176                assert_eq!(task.command, "prisma");
1177                assert_eq!(task.args, vec!["generate"]);
1178            }
1179            _ => panic!("Expected Task"),
1180        }
1181    }
1182
1183    #[test]
1184    fn test_workspace_config_with_hooks() {
1185        let json = format!(
1186            r#"{{
1187            "enabled": true,
1188            "hooks": {{
1189                "beforeInstall": [
1190                    {{"ref": "{}"}}
1191                ]
1192            }}
1193        }}"#,
1194            "#generator:types"
1195        );
1196        let config: WorkspaceConfig = serde_json::from_str(&json).unwrap();
1197
1198        assert!(config.enabled);
1199        assert!(config.hooks.is_some());
1200
1201        let hooks = config.hooks.unwrap();
1202        let before_install = hooks.before_install.unwrap();
1203        assert_eq!(before_install.len(), 1);
1204    }
1205
1206    #[test]
1207    fn test_task_matcher_deserialization() {
1208        let json = r#"{
1209            "workspaces": ["packages/lib"],
1210            "labels": ["projen", "codegen"],
1211            "parallel": true
1212        }"#;
1213        let matcher: TaskMatcher = serde_json::from_str(json).unwrap();
1214
1215        assert_eq!(matcher.workspaces, Some(vec!["packages/lib".to_string()]));
1216        assert_eq!(
1217            matcher.labels,
1218            Some(vec!["projen".to_string(), "codegen".to_string()])
1219        );
1220        assert!(matcher.parallel);
1221    }
1222
1223    #[test]
1224    fn test_task_matcher_defaults() {
1225        let json = r#"{}"#;
1226        let matcher: TaskMatcher = serde_json::from_str(json).unwrap();
1227
1228        assert!(matcher.workspaces.is_none());
1229        assert!(matcher.labels.is_none());
1230        assert!(matcher.command.is_none());
1231        assert!(matcher.args.is_none());
1232        assert!(matcher.parallel); // default true
1233    }
1234
1235    #[test]
1236    fn test_task_matcher_with_command() {
1237        let json = r#"{
1238            "command": "prisma",
1239            "args": [{"contains": "generate"}]
1240        }"#;
1241        let matcher: TaskMatcher = serde_json::from_str(json).unwrap();
1242
1243        assert_eq!(matcher.command, Some("prisma".to_string()));
1244        let args = matcher.args.unwrap();
1245        assert_eq!(args.len(), 1);
1246        assert_eq!(args[0].contains, Some("generate".to_string()));
1247    }
1248
1249    // ============================================================================
1250    // WorkspaceHooks with Cuenv Integration Tests
1251    // ============================================================================
1252
1253    #[test]
1254    fn test_cuenv_workspace_with_before_install_hooks() {
1255        let json = format!(
1256            r#"{{
1257            "name": "test-project",
1258            "workspaces": {{
1259                "bun": {{
1260                    "enabled": true,
1261                    "hooks": {{
1262                        "beforeInstall": [
1263                            {{"ref": "{}"}},
1264                            {{"command": "sh", "args": ["-c", "echo setup"]}}
1265                        ]
1266                    }}
1267                }}
1268            }},
1269            "tasks": {{
1270                "dev": {{
1271                    "command": "bun",
1272                    "args": ["run", "dev"],
1273                    "workspaces": ["bun"]
1274                }}
1275            }}
1276        }}"#,
1277            "#generator:types"
1278        );
1279        let cuenv: Cuenv = serde_json::from_str(&json).unwrap();
1280
1281        assert_eq!(cuenv.name, "test-project");
1282        let workspaces = cuenv.workspaces.unwrap();
1283        let bun_config = workspaces.get("bun").unwrap();
1284
1285        assert!(bun_config.enabled);
1286        let hooks = bun_config.hooks.as_ref().unwrap();
1287        let before_install = hooks.before_install.as_ref().unwrap();
1288        assert_eq!(before_install.len(), 2);
1289    }
1290
1291    #[test]
1292    fn test_cuenv_multiple_workspaces_with_hooks() {
1293        let json = format!(
1294            r#"{{
1295            "name": "multi-workspace",
1296            "workspaces": {{
1297                "bun": {{
1298                    "enabled": true,
1299                    "hooks": {{
1300                        "beforeInstall": [{{"ref": "{}"}}]
1301                    }}
1302                }},
1303                "cargo": {{
1304                    "enabled": true,
1305                    "hooks": {{
1306                        "beforeInstall": [{{"command": "cargo", "args": ["generate"]}}]
1307                    }}
1308                }}
1309            }},
1310            "tasks": {{}}
1311        }}"#,
1312            "#projen:types"
1313        );
1314        let cuenv: Cuenv = serde_json::from_str(&json).unwrap();
1315
1316        let workspaces = cuenv.workspaces.unwrap();
1317        assert!(workspaces.contains_key("bun"));
1318        assert!(workspaces.contains_key("cargo"));
1319
1320        // Verify bun hooks
1321        let bun_hooks = workspaces["bun"].hooks.as_ref().unwrap();
1322        assert!(bun_hooks.before_install.is_some());
1323
1324        // Verify cargo hooks
1325        let cargo_hooks = workspaces["cargo"].hooks.as_ref().unwrap();
1326        assert!(cargo_hooks.before_install.is_some());
1327    }
1328
1329    // ============================================================================
1330    // Cross-Project Reference Expansion Tests
1331    // ============================================================================
1332
1333    #[test]
1334    fn test_expand_multiple_cross_project_references() {
1335        let task = Task {
1336            inputs: vec![
1337                Input::Path("#projA:build:dist/lib.js".to_string()),
1338                Input::Path("#projB:compile:out/types.d.ts".to_string()),
1339                Input::Path("src/**/*.ts".to_string()), // Local path
1340            ],
1341            ..Default::default()
1342        };
1343
1344        let mut cuenv = Cuenv::new("test");
1345        cuenv
1346            .tasks
1347            .insert("bundle".into(), TaskDefinition::Single(Box::new(task)));
1348
1349        cuenv.expand_cross_project_references();
1350
1351        let task_def = cuenv.tasks.get("bundle").unwrap();
1352        let task = task_def.as_single().unwrap();
1353
1354        // Should have 3 inputs (2 project refs + 1 local)
1355        assert_eq!(task.inputs.len(), 3);
1356
1357        // Should have 2 implicit dependencies
1358        assert_eq!(task.depends_on.len(), 2);
1359        assert!(task.depends_on.contains(&"#projA:build".to_string()));
1360        assert!(task.depends_on.contains(&"#projB:compile".to_string()));
1361    }
1362
1363    #[test]
1364    fn test_expand_cross_project_in_task_group() {
1365        let task1 = Task {
1366            command: "step1".to_string(),
1367            inputs: vec![Input::Path("#projA:build:dist/lib.js".to_string())],
1368            ..Default::default()
1369        };
1370
1371        let task2 = Task {
1372            command: "step2".to_string(),
1373            inputs: vec![Input::Path("#projB:compile:out/types.d.ts".to_string())],
1374            ..Default::default()
1375        };
1376
1377        let mut cuenv = Cuenv::new("test");
1378        cuenv.tasks.insert(
1379            "pipeline".into(),
1380            TaskDefinition::Group(TaskGroup::Sequential(vec![
1381                TaskDefinition::Single(Box::new(task1)),
1382                TaskDefinition::Single(Box::new(task2)),
1383            ])),
1384        );
1385
1386        cuenv.expand_cross_project_references();
1387
1388        // Verify expansion happened in both tasks
1389        match cuenv.tasks.get("pipeline").unwrap() {
1390            TaskDefinition::Group(TaskGroup::Sequential(tasks)) => {
1391                match &tasks[0] {
1392                    TaskDefinition::Single(task) => {
1393                        assert!(task.depends_on.contains(&"#projA:build".to_string()));
1394                    }
1395                    _ => panic!("Expected single task"),
1396                }
1397                match &tasks[1] {
1398                    TaskDefinition::Single(task) => {
1399                        assert!(task.depends_on.contains(&"#projB:compile".to_string()));
1400                    }
1401                    _ => panic!("Expected single task"),
1402                }
1403            }
1404            _ => panic!("Expected sequential group"),
1405        }
1406    }
1407
1408    #[test]
1409    fn test_expand_cross_project_in_parallel_group() {
1410        let task1 = Task {
1411            command: "taskA".to_string(),
1412            inputs: vec![Input::Path("#projA:build:lib.js".to_string())],
1413            ..Default::default()
1414        };
1415
1416        let task2 = Task {
1417            command: "taskB".to_string(),
1418            inputs: vec![Input::Path("#projB:build:types.d.ts".to_string())],
1419            ..Default::default()
1420        };
1421
1422        let mut parallel_tasks = HashMap::new();
1423        parallel_tasks.insert("a".to_string(), TaskDefinition::Single(Box::new(task1)));
1424        parallel_tasks.insert("b".to_string(), TaskDefinition::Single(Box::new(task2)));
1425
1426        let mut cuenv = Cuenv::new("test");
1427        cuenv.tasks.insert(
1428            "parallel".into(),
1429            TaskDefinition::Group(TaskGroup::Parallel(ParallelGroup {
1430                tasks: parallel_tasks,
1431                depends_on: vec![],
1432            })),
1433        );
1434
1435        cuenv.expand_cross_project_references();
1436
1437        // Verify expansion happened in both parallel tasks
1438        match cuenv.tasks.get("parallel").unwrap() {
1439            TaskDefinition::Group(TaskGroup::Parallel(group)) => {
1440                match group.tasks.get("a").unwrap() {
1441                    TaskDefinition::Single(task) => {
1442                        assert!(task.depends_on.contains(&"#projA:build".to_string()));
1443                    }
1444                    _ => panic!("Expected single task"),
1445                }
1446                match group.tasks.get("b").unwrap() {
1447                    TaskDefinition::Single(task) => {
1448                        assert!(task.depends_on.contains(&"#projB:build".to_string()));
1449                    }
1450                    _ => panic!("Expected single task"),
1451                }
1452            }
1453            _ => panic!("Expected parallel group"),
1454        }
1455    }
1456
1457    #[test]
1458    fn test_no_duplicate_implicit_dependencies() {
1459        // Task already has the dependency explicitly
1460        let task = Task {
1461            depends_on: vec!["#myproj:build".to_string()],
1462            inputs: vec![Input::Path("#myproj:build:dist/app.js".to_string())],
1463            ..Default::default()
1464        };
1465
1466        let mut cuenv = Cuenv::new("test");
1467        cuenv
1468            .tasks
1469            .insert("deploy".into(), TaskDefinition::Single(Box::new(task)));
1470
1471        cuenv.expand_cross_project_references();
1472
1473        let task_def = cuenv.tasks.get("deploy").unwrap();
1474        let task = task_def.as_single().unwrap();
1475
1476        // Should not duplicate the dependency
1477        assert_eq!(task.depends_on.len(), 1);
1478        assert_eq!(task.depends_on[0], "#myproj:build");
1479    }
1480
1481    // ============================================================================
1482    // Project Hooks (onEnter, onExit) Tests
1483    // ============================================================================
1484
1485    #[test]
1486    fn test_on_enter_hooks_ordering() {
1487        let mut on_enter = HashMap::new();
1488        on_enter.insert("hook_c".to_string(), create_test_hook(300, "echo c"));
1489        on_enter.insert("hook_a".to_string(), create_test_hook(100, "echo a"));
1490        on_enter.insert("hook_b".to_string(), create_test_hook(200, "echo b"));
1491
1492        let mut cuenv = Cuenv::new("test");
1493        cuenv.hooks = Some(Hooks {
1494            on_enter: Some(on_enter),
1495            on_exit: None,
1496        });
1497
1498        let hooks = cuenv.on_enter_hooks();
1499        assert_eq!(hooks.len(), 3);
1500
1501        // Should be sorted by order
1502        assert_eq!(hooks[0].order, 100);
1503        assert_eq!(hooks[1].order, 200);
1504        assert_eq!(hooks[2].order, 300);
1505    }
1506
1507    #[test]
1508    fn test_on_enter_hooks_same_order_sort_by_name() {
1509        let mut on_enter = HashMap::new();
1510        on_enter.insert("z_hook".to_string(), create_test_hook(100, "echo z"));
1511        on_enter.insert("a_hook".to_string(), create_test_hook(100, "echo a"));
1512
1513        let cuenv = Cuenv {
1514            name: "test".to_string(),
1515            hooks: Some(Hooks {
1516                on_enter: Some(on_enter),
1517                on_exit: None,
1518            }),
1519            ..Default::default()
1520        };
1521
1522        let hooks = cuenv.on_enter_hooks();
1523        assert_eq!(hooks.len(), 2);
1524
1525        // Same order, should be sorted by name
1526        assert_eq!(hooks[0].command, "echo a");
1527        assert_eq!(hooks[1].command, "echo z");
1528    }
1529
1530    #[test]
1531    fn test_empty_hooks() {
1532        let cuenv = Cuenv::new("test");
1533
1534        let on_enter = cuenv.on_enter_hooks();
1535        let on_exit = cuenv.on_exit_hooks();
1536
1537        assert!(on_enter.is_empty());
1538        assert!(on_exit.is_empty());
1539    }
1540}