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