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