cuenv_core/manifest/
mod.rs

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