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