Skip to main content

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::module::Instance;
12use crate::secrets::Secret;
13use crate::tasks::Task;
14use crate::tasks::{Input, Mapping, ProjectReference, TaskNode};
15use cuenv_hooks::{Hook, Hooks};
16
17/// A hook step to run as part of task dependencies.
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
19#[serde(untagged)]
20pub enum HookItem {
21    /// Reference to a task in another project
22    TaskRef(TaskRef),
23    /// Discovery-based hook step that expands a TaskMatcher into concrete tasks
24    Match(MatchHook),
25    /// Inline task definition
26    Task(Box<Task>),
27}
28
29/// Hook step that expands to tasks discovered via TaskMatcher.
30#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
31#[serde(rename_all = "camelCase")]
32pub struct MatchHook {
33    /// Optional stable name used for task naming/logging
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub name: Option<String>,
36
37    /// Task matcher to select tasks across the workspace
38    #[serde(rename = "match")]
39    pub matcher: TaskMatcher,
40}
41
42/// Reference to a task in another env.cue project by its name property
43#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
44pub struct TaskRef {
45    /// Format: "#project-name:task-name" where project-name is the `name` field in env.cue
46    /// Example: "#projen-generator:bun.install"
47    #[serde(rename = "ref")]
48    pub ref_: String,
49}
50
51impl TaskRef {
52    /// Parse the TaskRef into project name and task name
53    /// Returns None if the format is invalid or if project/task names are empty
54    pub fn parse(&self) -> Option<(String, String)> {
55        let ref_str = self.ref_.strip_prefix('#')?;
56        let parts: Vec<&str> = ref_str.splitn(2, ':').collect();
57        if parts.len() == 2 {
58            let project = parts[0];
59            let task = parts[1];
60            if !project.is_empty() && !task.is_empty() {
61                Some((project.to_string(), task.to_string()))
62            } else {
63                None
64            }
65        } else {
66            None
67        }
68    }
69}
70
71/// Match tasks across projects by metadata for discovery-based execution
72#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
73pub struct TaskMatcher {
74    /// Match tasks with these labels (all must match)
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub labels: Option<Vec<String>>,
77
78    /// Match tasks whose command matches this value
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub command: Option<String>,
81
82    /// Match tasks whose args contain specific patterns
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub args: Option<Vec<ArgMatcher>>,
85
86    /// Run matched tasks in parallel (default: true)
87    #[serde(default = "default_true")]
88    pub parallel: bool,
89}
90
91/// Pattern matcher for task arguments
92#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
93pub struct ArgMatcher {
94    /// Match if any arg contains this substring
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub contains: Option<String>,
97
98    /// Match if any arg matches this regex pattern
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub matches: Option<String>,
101}
102
103fn default_true() -> bool {
104    true
105}
106
107/// Base configuration structure (composable across directories)
108#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
109pub struct Base {
110    /// Configuration settings
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub config: Option<Config>,
113
114    /// Environment variables configuration
115    #[serde(skip_serializing_if = "Option::is_none")]
116    pub env: Option<Env>,
117
118    /// Formatters configuration
119    #[serde(skip_serializing_if = "Option::is_none")]
120    pub formatters: Option<Formatters>,
121}
122
123// ============================================================================
124// Formatter Types
125// ============================================================================
126
127/// Formatters configuration for code formatting tools.
128#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
129#[serde(rename_all = "camelCase")]
130pub struct Formatters {
131    /// Rust formatter configuration (rustfmt)
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub rust: Option<RustFormatter>,
134
135    /// Nix formatter configuration (nixfmt or alejandra)
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub nix: Option<NixFormatter>,
138
139    /// Go formatter configuration (gofmt)
140    #[serde(skip_serializing_if = "Option::is_none")]
141    pub go: Option<GoFormatter>,
142
143    /// CUE formatter configuration (cue fmt)
144    #[serde(skip_serializing_if = "Option::is_none")]
145    pub cue: Option<CueFormatter>,
146}
147
148/// Rust formatter configuration
149#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
150#[serde(rename_all = "camelCase")]
151pub struct RustFormatter {
152    /// Whether this formatter is enabled (default: true)
153    #[serde(default = "default_true")]
154    pub enabled: bool,
155
156    /// Glob patterns for files to format (default: ["*.rs"])
157    #[serde(default = "default_rs_includes")]
158    pub includes: Vec<String>,
159
160    /// Rust edition for formatting rules
161    #[serde(skip_serializing_if = "Option::is_none")]
162    pub edition: Option<String>,
163}
164
165impl Default for RustFormatter {
166    fn default() -> Self {
167        Self {
168            enabled: true,
169            includes: default_rs_includes(),
170            edition: None,
171        }
172    }
173}
174
175fn default_rs_includes() -> Vec<String> {
176    vec!["*.rs".to_string()]
177}
178
179/// Nix formatter tool selection
180#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
181#[serde(rename_all = "lowercase")]
182pub enum NixFormatterTool {
183    /// Use nixfmt (default)
184    #[default]
185    Nixfmt,
186    /// Use alejandra
187    Alejandra,
188}
189
190impl NixFormatterTool {
191    /// Get the command name for this tool
192    #[must_use]
193    pub fn command(&self) -> &'static str {
194        match self {
195            Self::Nixfmt => "nixfmt",
196            Self::Alejandra => "alejandra",
197        }
198    }
199
200    /// Get the check flag for this tool
201    #[must_use]
202    pub fn check_flag(&self) -> &'static str {
203        match self {
204            Self::Nixfmt => "--check",
205            Self::Alejandra => "-c",
206        }
207    }
208}
209
210/// Nix formatter configuration
211#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
212#[serde(rename_all = "camelCase")]
213pub struct NixFormatter {
214    /// Whether this formatter is enabled (default: true)
215    #[serde(default = "default_true")]
216    pub enabled: bool,
217
218    /// Glob patterns for files to format (default: ["*.nix"])
219    #[serde(default = "default_nix_includes")]
220    pub includes: Vec<String>,
221
222    /// Which Nix formatter tool to use (nixfmt or alejandra)
223    #[serde(default)]
224    pub tool: NixFormatterTool,
225}
226
227impl Default for NixFormatter {
228    fn default() -> Self {
229        Self {
230            enabled: true,
231            includes: default_nix_includes(),
232            tool: NixFormatterTool::default(),
233        }
234    }
235}
236
237fn default_nix_includes() -> Vec<String> {
238    vec!["*.nix".to_string()]
239}
240
241/// Go formatter configuration
242#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
243#[serde(rename_all = "camelCase")]
244pub struct GoFormatter {
245    /// Whether this formatter is enabled (default: true)
246    #[serde(default = "default_true")]
247    pub enabled: bool,
248
249    /// Glob patterns for files to format (default: ["*.go"])
250    #[serde(default = "default_go_includes")]
251    pub includes: Vec<String>,
252}
253
254impl Default for GoFormatter {
255    fn default() -> Self {
256        Self {
257            enabled: true,
258            includes: default_go_includes(),
259        }
260    }
261}
262
263fn default_go_includes() -> Vec<String> {
264    vec!["*.go".to_string()]
265}
266
267/// CUE formatter configuration
268#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
269#[serde(rename_all = "camelCase")]
270pub struct CueFormatter {
271    /// Whether this formatter is enabled (default: true)
272    #[serde(default = "default_true")]
273    pub enabled: bool,
274
275    /// Glob patterns for files to format (default: ["*.cue"])
276    #[serde(default = "default_cue_includes")]
277    pub includes: Vec<String>,
278}
279
280impl Default for CueFormatter {
281    fn default() -> Self {
282        Self {
283            enabled: true,
284            includes: default_cue_includes(),
285        }
286    }
287}
288
289fn default_cue_includes() -> Vec<String> {
290    vec!["*.cue".to_string()]
291}
292
293/// Ignore patterns for tool-specific ignore files.
294/// Keys are tool names (e.g., "git", "docker", "prettier").
295/// Values can be either:
296/// - A list of patterns: `["node_modules/", ".env"]`
297/// - An object with patterns and optional filename override
298pub type Ignore = HashMap<String, IgnoreValue>;
299
300// ============================================================================
301// Codegen Types (for code generation)
302// ============================================================================
303
304/// File generation mode
305#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
306#[serde(rename_all = "lowercase")]
307pub enum FileMode {
308    /// Always regenerate this file (managed by codegen)
309    #[default]
310    Managed,
311    /// Generate only if file doesn't exist (user owns this file)
312    Scaffold,
313}
314
315/// Format configuration for a generated file
316#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
317#[serde(rename_all = "camelCase")]
318pub struct FormatConfig {
319    /// Indent style: "space" or "tab"
320    #[serde(default = "default_indent")]
321    pub indent: String,
322    /// Indent size (number of spaces or tab width)
323    #[serde(skip_serializing_if = "Option::is_none")]
324    pub indent_size: Option<usize>,
325    /// Maximum line width
326    #[serde(skip_serializing_if = "Option::is_none")]
327    pub line_width: Option<usize>,
328    /// Trailing comma style
329    #[serde(skip_serializing_if = "Option::is_none")]
330    pub trailing_comma: Option<String>,
331    /// Use semicolons
332    #[serde(skip_serializing_if = "Option::is_none")]
333    pub semicolons: Option<bool>,
334    /// Quote style: "single" or "double"
335    #[serde(skip_serializing_if = "Option::is_none")]
336    pub quotes: Option<String>,
337}
338
339fn default_indent() -> String {
340    "space".to_string()
341}
342
343/// A file definition from the codegen configuration
344#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
345pub struct ProjectFile {
346    /// Content of the file
347    pub content: String,
348    /// Programming language of the file
349    pub language: String,
350    /// Generation mode (managed or scaffold)
351    #[serde(default)]
352    pub mode: FileMode,
353    /// Formatting configuration
354    #[serde(default)]
355    pub format: FormatConfig,
356    /// Whether to add this file path to .gitignore.
357    /// Defaults based on mode (set in CUE schema):
358    ///   - managed: true (generated files should be ignored)
359    ///   - scaffold: false (user-owned files should be committed)
360    #[serde(default)]
361    pub gitignore: bool,
362}
363
364/// Codegen configuration containing file definitions for code generation
365#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
366pub struct CodegenConfig {
367    /// Map of file paths to their definitions
368    #[serde(default)]
369    pub files: HashMap<String, ProjectFile>,
370    /// Optional context data for templating
371    #[serde(default)]
372    pub context: serde_json::Value,
373}
374
375/// Value for an ignore entry - either a simple list of patterns or an extended config.
376#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
377#[serde(untagged)]
378pub enum IgnoreValue {
379    /// Simple list of patterns
380    Patterns(Vec<String>),
381    /// Extended config with patterns and optional filename override
382    Extended(IgnoreEntry),
383}
384
385/// Extended ignore configuration with patterns and optional filename override.
386#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
387pub struct IgnoreEntry {
388    /// List of patterns to include in the ignore file
389    pub patterns: Vec<String>,
390    /// Optional filename override (defaults to `.{tool}ignore`)
391    #[serde(skip_serializing_if = "Option::is_none")]
392    pub filename: Option<String>,
393}
394
395impl IgnoreValue {
396    /// Get the patterns from this ignore value.
397    #[must_use]
398    pub fn patterns(&self) -> &[String] {
399        match self {
400            Self::Patterns(patterns) => patterns,
401            Self::Extended(entry) => &entry.patterns,
402        }
403    }
404
405    /// Get the optional filename override.
406    #[must_use]
407    pub fn filename(&self) -> Option<&str> {
408        match self {
409            Self::Patterns(_) => None,
410            Self::Extended(entry) => entry.filename.as_deref(),
411        }
412    }
413}
414
415// ============================================================================
416// Directory Rules Types (for .rules.cue files)
417// ============================================================================
418
419/// Directory-scoped rules configuration from .rules.cue files.
420///
421/// Each .rules.cue file is evaluated independently (no CUE unification).
422#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
423#[serde(rename_all = "camelCase")]
424pub struct DirectoryRules {
425    /// Ignore patterns for tool-specific ignore files.
426    /// Generates files in the same directory as .rules.cue.
427    #[serde(skip_serializing_if = "Option::is_none")]
428    pub ignore: Option<Ignore>,
429
430    /// Code ownership rules.
431    /// Aggregated across all .rules.cue files to generate
432    /// a single CODEOWNERS file at the repository root.
433    #[serde(skip_serializing_if = "Option::is_none")]
434    pub owners: Option<RulesOwners>,
435
436    /// EditorConfig settings.
437    /// Generates .editorconfig in the same directory as .rules.cue.
438    #[serde(skip_serializing_if = "Option::is_none")]
439    pub editorconfig: Option<EditorConfig>,
440}
441
442/// Simplified owners for directory rules (no output config).
443#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
444pub struct RulesOwners {
445    /// Code ownership rules - maps rule names to rule definitions.
446    #[serde(default)]
447    pub rules: HashMap<String, crate::owners::OwnerRule>,
448}
449
450/// EditorConfig configuration.
451///
452/// Note: `root = true` is auto-injected for the .editorconfig at repo root.
453#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
454pub struct EditorConfig {
455    /// File-pattern specific settings.
456    #[serde(flatten)]
457    pub sections: HashMap<String, EditorConfigSection>,
458}
459
460/// A section in an EditorConfig file.
461#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
462#[serde(rename_all = "snake_case")]
463pub struct EditorConfigSection {
464    /// Indentation style: "tab" or "space"
465    #[serde(skip_serializing_if = "Option::is_none")]
466    pub indent_style: Option<String>,
467
468    /// Number of columns for each indentation level, or "tab"
469    #[serde(skip_serializing_if = "Option::is_none")]
470    pub indent_size: Option<EditorConfigValue>,
471
472    /// Number of columns for tab character display
473    #[serde(skip_serializing_if = "Option::is_none")]
474    pub tab_width: Option<u32>,
475
476    /// Line ending style: "lf", "crlf", or "cr"
477    #[serde(skip_serializing_if = "Option::is_none")]
478    pub end_of_line: Option<String>,
479
480    /// Character encoding
481    #[serde(skip_serializing_if = "Option::is_none")]
482    pub charset: Option<String>,
483
484    /// Remove trailing whitespace on save
485    #[serde(skip_serializing_if = "Option::is_none")]
486    pub trim_trailing_whitespace: Option<bool>,
487
488    /// Ensure file ends with a newline
489    #[serde(skip_serializing_if = "Option::is_none")]
490    pub insert_final_newline: Option<bool>,
491
492    /// Maximum line length (soft limit), or "off"
493    #[serde(skip_serializing_if = "Option::is_none")]
494    pub max_line_length: Option<EditorConfigValue>,
495}
496
497/// A value that can be either an integer or a special string value.
498#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
499#[serde(untagged)]
500pub enum EditorConfigValue {
501    /// Integer value
502    Int(u32),
503    /// String value (e.g., "tab" for indent_size, "off" for max_line_length)
504    String(String),
505}
506
507// ============================================================================
508// Runtime Types
509// ============================================================================
510
511/// Runtime declares where/how a task executes.
512/// Set at project level as the default, override per-task as needed.
513#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
514#[serde(tag = "type", rename_all = "lowercase")]
515pub enum Runtime {
516    /// Activate Nix devShell before execution
517    Nix(NixRuntime),
518    /// Activate devenv shell before execution
519    Devenv(DevenvRuntime),
520    /// Simple container execution
521    Container(ContainerRuntime),
522    /// Advanced container with caching, secrets, chaining
523    Dagger(DaggerRuntime),
524    /// OCI-based binary fetching from container images
525    Oci(OciRuntime),
526    /// Multi-source tool management (GitHub, OCI, Nix)
527    Tools(ToolsRuntime),
528}
529
530/// Nix runtime configuration
531#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
532pub struct NixRuntime {
533    /// Flake reference (default: "." for local flake.nix)
534    #[serde(default = "default_flake")]
535    pub flake: String,
536    /// Output attribute path (default: devShells.${system}.default)
537    #[serde(skip_serializing_if = "Option::is_none")]
538    pub output: Option<String>,
539}
540
541impl Default for NixRuntime {
542    fn default() -> Self {
543        Self {
544            flake: default_flake(),
545            output: None,
546        }
547    }
548}
549
550fn default_flake() -> String {
551    ".".to_string()
552}
553
554/// Devenv runtime configuration
555#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
556pub struct DevenvRuntime {
557    /// Path to devenv config directory (default: ".")
558    #[serde(default = "default_flake")]
559    pub path: String,
560}
561
562/// Simple container runtime configuration
563#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
564pub struct ContainerRuntime {
565    /// Container image (e.g., "node:20-alpine", "rust:1.75-slim")
566    pub image: String,
567}
568
569/// Dagger runtime configuration (advanced container orchestration)
570#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
571pub struct DaggerRuntime {
572    /// Base container image (required unless 'from' is specified)
573    #[serde(skip_serializing_if = "Option::is_none")]
574    pub image: Option<String>,
575    /// Use container from a previous task as base
576    #[serde(skip_serializing_if = "Option::is_none")]
577    pub from: Option<String>,
578    /// Secrets to mount or expose as environment variables
579    #[serde(default, skip_serializing_if = "Vec::is_empty")]
580    pub secrets: Vec<DaggerSecret>,
581    /// Cache volumes for persistent build caching
582    #[serde(default, skip_serializing_if = "Vec::is_empty")]
583    pub cache: Vec<DaggerCacheMount>,
584}
585
586/// Secret configuration for Dagger containers
587#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
588pub struct DaggerSecret {
589    /// Name identifier for the secret
590    pub name: String,
591    /// Mount secret as a file at this path
592    #[serde(skip_serializing_if = "Option::is_none")]
593    pub path: Option<String>,
594    /// Expose secret as an environment variable with this name
595    #[serde(skip_serializing_if = "Option::is_none")]
596    pub env_var: Option<String>,
597    /// Secret resolver configuration
598    pub resolver: serde_json::Value,
599}
600
601/// Cache volume mount configuration for Dagger
602#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
603pub struct DaggerCacheMount {
604    /// Path inside the container to mount the cache
605    pub path: String,
606    /// Unique name for the cache volume
607    pub name: String,
608}
609
610/// OCI-based binary runtime configuration.
611///
612/// Fetches binaries from OCI images for hermetic, content-addressed binary management.
613/// Images require explicit `extract` paths to specify which binaries to extract.
614#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
615#[serde(rename_all = "camelCase")]
616pub struct OciRuntime {
617    /// Platforms to resolve and lock (e.g., "darwin-arm64", "linux-x86_64")
618    #[serde(default)]
619    pub platforms: Vec<String>,
620    /// OCI images to fetch binaries from
621    #[serde(default)]
622    pub images: Vec<OciImage>,
623    /// Cache directory (defaults to ~/.cache/cuenv/oci)
624    #[serde(skip_serializing_if = "Option::is_none")]
625    pub cache_dir: Option<String>,
626}
627
628/// An OCI image to extract binaries from.
629///
630/// Images require explicit `extract` paths to specify which binaries to extract.
631#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
632pub struct OciImage {
633    /// Full image reference (e.g., "nginx:1.25-alpine", "gcr.io/distroless/static:latest")
634    pub image: String,
635    /// Rename the extracted binary (when package name differs from binary name)
636    #[serde(rename = "as", skip_serializing_if = "Option::is_none")]
637    pub as_name: Option<String>,
638    /// Extraction paths specifying which binaries to extract from the image
639    #[serde(default, skip_serializing_if = "Vec::is_empty")]
640    pub extract: Vec<OciExtract>,
641}
642
643/// A binary to extract from a container image.
644#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
645pub struct OciExtract {
646    /// Path to the binary inside the container (e.g., "/usr/sbin/nginx")
647    pub path: String,
648    /// Name to expose the binary as in PATH (defaults to filename from path)
649    #[serde(rename = "as", skip_serializing_if = "Option::is_none")]
650    pub as_name: Option<String>,
651}
652
653/// GitHub provider configuration for runtime-level authentication.
654#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
655pub struct GitHubProviderConfig {
656    /// Authentication token (must use secret resolver like 1Password or exec)
657    #[serde(skip_serializing_if = "Option::is_none")]
658    pub token: Option<Secret>,
659}
660
661/// Multi-source tool runtime configuration.
662///
663/// Provides ergonomic tool management with platform-specific overrides.
664/// Simple case: `jq: "1.7.1"` requires a source to be defined.
665/// Complex case: Platform-specific sources with overrides.
666#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
667#[serde(rename_all = "camelCase")]
668pub struct ToolsRuntime {
669    /// Platforms to resolve and lock (e.g., "darwin-arm64", "linux-x86_64")
670    #[serde(default)]
671    pub platforms: Vec<String>,
672    /// Named Nix flake references for pinning
673    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
674    pub flakes: HashMap<String, String>,
675    /// GitHub provider configuration
676    #[serde(skip_serializing_if = "Option::is_none")]
677    pub github: Option<GitHubProviderConfig>,
678    /// Tool specifications (version string or full Tool config)
679    #[serde(default)]
680    pub tools: HashMap<String, ToolSpec>,
681    /// Cache directory (defaults to ~/.cache/cuenv/tools)
682    #[serde(skip_serializing_if = "Option::is_none")]
683    pub cache_dir: Option<String>,
684}
685
686/// Tool specification - either a simple version or full config.
687#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
688#[serde(untagged)]
689pub enum ToolSpec {
690    /// Simple version string (requires explicit source configuration)
691    Version(String),
692    /// Full tool configuration with source and overrides
693    Full(ToolConfig),
694}
695
696impl ToolSpec {
697    /// Get the version string.
698    #[must_use]
699    pub fn version(&self) -> &str {
700        match self {
701            Self::Version(v) => v,
702            Self::Full(c) => &c.version,
703        }
704    }
705}
706
707/// Full tool configuration with source and platform overrides.
708#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
709#[serde(rename_all = "camelCase")]
710pub struct ToolConfig {
711    /// Version string (e.g., "1.7.1", "latest")
712    pub version: String,
713    /// Rename the binary in PATH
714    #[serde(rename = "as", skip_serializing_if = "Option::is_none")]
715    pub as_name: Option<String>,
716    /// Default source for all platforms
717    #[serde(skip_serializing_if = "Option::is_none")]
718    pub source: Option<SourceConfig>,
719    /// Platform-specific source overrides
720    #[serde(default, skip_serializing_if = "Vec::is_empty")]
721    pub overrides: Vec<SourceOverride>,
722}
723
724/// Platform-specific source override.
725#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
726pub struct SourceOverride {
727    /// Match by OS (darwin, linux)
728    #[serde(skip_serializing_if = "Option::is_none")]
729    pub os: Option<String>,
730    /// Match by architecture (arm64, x86_64)
731    #[serde(skip_serializing_if = "Option::is_none")]
732    pub arch: Option<String>,
733    /// Source for matching platforms
734    pub source: SourceConfig,
735}
736
737/// Source configuration for fetching a tool.
738#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
739#[serde(tag = "type", rename_all = "lowercase")]
740pub enum SourceConfig {
741    /// Extract from OCI container image
742    Oci {
743        /// Image reference with optional {version}, {os}, {arch} templates
744        image: String,
745        /// Path to binary inside the container
746        path: String,
747    },
748    /// Download from GitHub Releases
749    #[serde(rename = "github")]
750    GitHub {
751        /// Repository (owner/repo)
752        repo: String,
753        /// Tag prefix (prepended to version, defaults to "")
754        #[serde(default, rename = "tagPrefix")]
755        tag_prefix: String,
756        /// Release tag override (if set, ignores tagPrefix)
757        #[serde(skip_serializing_if = "Option::is_none")]
758        tag: Option<String>,
759        /// Asset name with optional {version}, {os}, {arch} templates
760        asset: String,
761        /// Path to binary within archive (if archived)
762        #[serde(skip_serializing_if = "Option::is_none")]
763        path: Option<String>,
764    },
765    /// Build from Nix flake
766    Nix {
767        /// Named flake reference (key in runtime.flakes)
768        flake: String,
769        /// Package attribute (e.g., "jq", "python3")
770        package: String,
771        /// Output path if binary can't be auto-detected
772        #[serde(skip_serializing_if = "Option::is_none")]
773        output: Option<String>,
774    },
775    /// Install via rustup
776    Rustup {
777        /// Toolchain identifier (e.g., "stable", "1.83.0", "nightly-2024-01-01")
778        toolchain: String,
779        /// Installation profile: minimal, default, complete
780        #[serde(default = "default_rustup_profile")]
781        profile: String,
782        /// Additional components to install (e.g., "clippy", "rustfmt", "rust-src")
783        #[serde(default, skip_serializing_if = "Vec::is_empty")]
784        components: Vec<String>,
785        /// Additional targets to install (e.g., "x86_64-unknown-linux-gnu")
786        #[serde(default, skip_serializing_if = "Vec::is_empty")]
787        targets: Vec<String>,
788    },
789}
790
791fn default_rustup_profile() -> String {
792    "default".to_string()
793}
794
795// ============================================================================
796// Project Type
797// ============================================================================
798
799/// Root Project configuration structure (leaf node - cannot unify with other projects)
800#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
801pub struct Project {
802    /// Configuration settings
803    #[serde(skip_serializing_if = "Option::is_none")]
804    pub config: Option<Config>,
805
806    /// Project name (unique identifier, required by the CUE schema)
807    pub name: String,
808
809    /// Environment variables configuration
810    #[serde(skip_serializing_if = "Option::is_none")]
811    pub env: Option<Env>,
812
813    /// Hooks configuration
814    #[serde(skip_serializing_if = "Option::is_none")]
815    pub hooks: Option<Hooks>,
816
817    /// CI configuration
818    #[serde(skip_serializing_if = "Option::is_none")]
819    pub ci: Option<CI>,
820
821    /// Tasks configuration
822    #[serde(default)]
823    pub tasks: HashMap<String, TaskNode>,
824
825    /// Codegen configuration for code generation
826    #[serde(skip_serializing_if = "Option::is_none")]
827    pub codegen: Option<CodegenConfig>,
828
829    /// Runtime configuration (project-level default for all tasks)
830    #[serde(skip_serializing_if = "Option::is_none")]
831    pub runtime: Option<Runtime>,
832
833    /// Formatters configuration
834    #[serde(skip_serializing_if = "Option::is_none")]
835    pub formatters: Option<Formatters>,
836}
837
838impl Project {
839    /// Create a new Project configuration with a required name.
840    pub fn new(name: impl Into<String>) -> Self {
841        Self {
842            name: name.into(),
843            ..Self::default()
844        }
845    }
846
847    /// Get hooks to execute when entering environment as a map (name -> hook)
848    pub fn on_enter_hooks_map(&self) -> HashMap<String, Hook> {
849        self.hooks
850            .as_ref()
851            .and_then(|h| h.on_enter.as_ref())
852            .cloned()
853            .unwrap_or_default()
854    }
855
856    /// Get hooks to execute when entering environment, sorted by (order, name)
857    pub fn on_enter_hooks(&self) -> Vec<Hook> {
858        let map = self.on_enter_hooks_map();
859        let mut hooks: Vec<(String, Hook)> = map.into_iter().collect();
860        hooks.sort_by(|a, b| a.1.order.cmp(&b.1.order).then(a.0.cmp(&b.0)));
861        hooks.into_iter().map(|(_, h)| h).collect()
862    }
863
864    /// Get hooks to execute when exiting environment as a map (name -> hook)
865    pub fn on_exit_hooks_map(&self) -> HashMap<String, Hook> {
866        self.hooks
867            .as_ref()
868            .and_then(|h| h.on_exit.as_ref())
869            .cloned()
870            .unwrap_or_default()
871    }
872
873    /// Get hooks to execute when exiting environment, sorted by (order, name)
874    pub fn on_exit_hooks(&self) -> Vec<Hook> {
875        let map = self.on_exit_hooks_map();
876        let mut hooks: Vec<(String, Hook)> = map.into_iter().collect();
877        hooks.sort_by(|a, b| a.1.order.cmp(&b.1.order).then(a.0.cmp(&b.0)));
878        hooks.into_iter().map(|(_, h)| h).collect()
879    }
880
881    /// Get hooks to execute before git push as a map (name -> hook)
882    pub fn pre_push_hooks_map(&self) -> HashMap<String, Hook> {
883        self.hooks
884            .as_ref()
885            .and_then(|h| h.pre_push.as_ref())
886            .cloned()
887            .unwrap_or_default()
888    }
889
890    /// Get hooks to execute before git push, sorted by (order, name)
891    pub fn pre_push_hooks(&self) -> Vec<Hook> {
892        let map = self.pre_push_hooks_map();
893        let mut hooks: Vec<(String, Hook)> = map.into_iter().collect();
894        hooks.sort_by(|a, b| a.1.order.cmp(&b.1.order).then(a.0.cmp(&b.0)));
895        hooks.into_iter().map(|(_, h)| h).collect()
896    }
897
898    /// Returns self unchanged.
899    ///
900    /// Workspace detection and task injection now happens via auto-detection
901    /// from lockfiles in the task executor. This method is kept for API compatibility.
902    #[must_use]
903    pub fn with_implicit_tasks(self) -> Self {
904        self
905    }
906
907    /// Expand shorthand cross-project references in inputs and implicit dependencies.
908    ///
909    /// Handles inputs in the format: "#project:task:path/to/file"
910    /// Converts them to explicit ProjectReference inputs.
911    /// Also adds implicit dependsOn entries for all project references.
912    pub fn expand_cross_project_references(&mut self) {
913        for (_, task_node) in self.tasks.iter_mut() {
914            Self::expand_task_node(task_node);
915        }
916    }
917
918    fn expand_task_node(node: &mut TaskNode) {
919        match node {
920            TaskNode::Task(task) => Self::expand_task(task),
921            TaskNode::Group(group) => {
922                for sub_node in group.children.values_mut() {
923                    Self::expand_task_node(sub_node);
924                }
925            }
926            TaskNode::Sequence(steps) => {
927                for sub_node in steps {
928                    Self::expand_task_node(sub_node);
929                }
930            }
931        }
932    }
933
934    fn expand_task(task: &mut Task) {
935        let mut new_inputs = Vec::new();
936        let mut implicit_deps = Vec::new();
937
938        // Process existing inputs
939        for input in &task.inputs {
940            match input {
941                Input::Path(path) if path.starts_with('#') => {
942                    // Parse "#project:task:path"
943                    // Remove leading #
944                    let parts: Vec<&str> = path[1..].split(':').collect();
945                    if parts.len() >= 3 {
946                        let project = parts[0].to_string();
947                        let task_name = parts[1].to_string();
948                        // Rejoin the rest as the path (it might contain colons)
949                        let file_path = parts[2..].join(":");
950
951                        new_inputs.push(Input::Project(ProjectReference {
952                            project: project.clone(),
953                            task: task_name.clone(),
954                            map: vec![Mapping {
955                                from: file_path.clone(),
956                                to: file_path,
957                            }],
958                        }));
959
960                        // Add implicit dependency
961                        implicit_deps.push(format!("#{}:{}", project, task_name));
962                    } else if parts.len() == 2 {
963                        // Handle "#project:task" as pure dependency?
964                        // The prompt says: `["#projectName:taskName"]` for dependsOn
965                        // For inputs, it likely expects a file mapping.
966                        // If user puts `["#p:t"]` in inputs, it's invalid as an input unless it maps something.
967                        // Assuming `#p:t:f` is the requirement for inputs.
968                        // Keeping original if not matching pattern (or maybe warning?)
969                        new_inputs.push(input.clone());
970                    } else {
971                        new_inputs.push(input.clone());
972                    }
973                }
974                Input::Project(proj_ref) => {
975                    // Add implicit dependency for explicit project references too
976                    implicit_deps.push(format!("#{}:{}", proj_ref.project, proj_ref.task));
977                    new_inputs.push(input.clone());
978                }
979                _ => new_inputs.push(input.clone()),
980            }
981        }
982
983        task.inputs = new_inputs;
984
985        // Add unique implicit dependencies
986        for dep in implicit_deps {
987            if !task.depends_on.iter().any(|d| d.task_name() == dep) {
988                task.depends_on
989                    .push(crate::tasks::TaskDependency::from_name(dep));
990            }
991        }
992    }
993}
994
995impl TryFrom<&Instance> for Project {
996    type Error = crate::Error;
997
998    fn try_from(instance: &Instance) -> Result<Self, Self::Error> {
999        let mut project: Project = instance.deserialize()?;
1000        project.expand_cross_project_references();
1001        Ok(project)
1002    }
1003}
1004
1005#[cfg(test)]
1006mod tests {
1007    use super::*;
1008    use crate::tasks::{TaskDependency, TaskGroup, TaskNode};
1009    use crate::test_utils::create_test_hook;
1010
1011    #[test]
1012    fn test_expand_cross_project_references() {
1013        let task = Task {
1014            inputs: vec![Input::Path("#myproj:build:dist/app.js".to_string())],
1015            ..Default::default()
1016        };
1017
1018        let mut cuenv = Project::new("test");
1019        cuenv
1020            .tasks
1021            .insert("deploy".into(), TaskNode::Task(Box::new(task)));
1022
1023        cuenv.expand_cross_project_references();
1024
1025        let task_def = cuenv.tasks.get("deploy").unwrap();
1026        let task = task_def.as_task().unwrap();
1027
1028        // Check inputs expansion
1029        assert_eq!(task.inputs.len(), 1);
1030        match &task.inputs[0] {
1031            Input::Project(proj_ref) => {
1032                assert_eq!(proj_ref.project, "myproj");
1033                assert_eq!(proj_ref.task, "build");
1034                assert_eq!(proj_ref.map.len(), 1);
1035                assert_eq!(proj_ref.map[0].from, "dist/app.js");
1036                assert_eq!(proj_ref.map[0].to, "dist/app.js");
1037            }
1038            _ => panic!("Expected ProjectReference"),
1039        }
1040
1041        // Check implicit dependency
1042        assert_eq!(task.depends_on.len(), 1);
1043        assert_eq!(task.depends_on[0].task_name(), "#myproj:build");
1044    }
1045
1046    // ============================================================================
1047    // HookItem and TaskRef Tests
1048    // ============================================================================
1049
1050    #[test]
1051    fn test_task_ref_parse_valid() {
1052        let task_ref = TaskRef {
1053            ref_: "#projen-generator:types".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, "projen-generator");
1061        assert_eq!(task, "types");
1062    }
1063
1064    #[test]
1065    fn test_task_ref_parse_with_dots() {
1066        let task_ref = TaskRef {
1067            ref_: "#my-project:bun.install".to_string(),
1068        };
1069
1070        let parsed = task_ref.parse();
1071        assert!(parsed.is_some());
1072
1073        let (project, task) = parsed.unwrap();
1074        assert_eq!(project, "my-project");
1075        assert_eq!(task, "bun.install");
1076    }
1077
1078    #[test]
1079    fn test_task_ref_parse_no_hash() {
1080        let task_ref = TaskRef {
1081            ref_: "project:task".to_string(),
1082        };
1083
1084        // Without leading #, parse should fail
1085        let parsed = task_ref.parse();
1086        assert!(parsed.is_none());
1087    }
1088
1089    #[test]
1090    fn test_task_ref_parse_no_colon() {
1091        let task_ref = TaskRef {
1092            ref_: "#project-only".to_string(),
1093        };
1094
1095        // Without colon separator, parse should fail
1096        let parsed = task_ref.parse();
1097        assert!(parsed.is_none());
1098    }
1099
1100    #[test]
1101    fn test_task_ref_parse_empty_project() {
1102        let task_ref = TaskRef {
1103            ref_: "#:task".to_string(),
1104        };
1105
1106        // Empty project name should be rejected
1107        assert!(task_ref.parse().is_none());
1108    }
1109
1110    #[test]
1111    fn test_task_ref_parse_empty_task() {
1112        let task_ref = TaskRef {
1113            ref_: "#project:".to_string(),
1114        };
1115
1116        // Empty task name should be rejected
1117        assert!(task_ref.parse().is_none());
1118    }
1119
1120    #[test]
1121    fn test_task_ref_parse_both_empty() {
1122        let task_ref = TaskRef {
1123            ref_: "#:".to_string(),
1124        };
1125
1126        // Both empty should be rejected
1127        assert!(task_ref.parse().is_none());
1128    }
1129
1130    #[test]
1131    fn test_task_ref_parse_multiple_colons() {
1132        let task_ref = TaskRef {
1133            ref_: "#project:task:extra".to_string(),
1134        };
1135
1136        // Multiple colons - first split wins
1137        let parsed = task_ref.parse();
1138        assert!(parsed.is_some());
1139        let (project, task) = parsed.unwrap();
1140        assert_eq!(project, "project");
1141        assert_eq!(task, "task:extra");
1142    }
1143
1144    #[test]
1145    fn test_task_ref_parse_unicode() {
1146        let task_ref = TaskRef {
1147            ref_: "#项目名:任务名".to_string(),
1148        };
1149
1150        let parsed = task_ref.parse();
1151        assert!(parsed.is_some());
1152        let (project, task) = parsed.unwrap();
1153        assert_eq!(project, "项目名");
1154        assert_eq!(task, "任务名");
1155    }
1156
1157    #[test]
1158    fn test_task_ref_parse_special_characters() {
1159        let task_ref = TaskRef {
1160            ref_: "#my-project_v2:build.ci-test".to_string(),
1161        };
1162
1163        let parsed = task_ref.parse();
1164        assert!(parsed.is_some());
1165        let (project, task) = parsed.unwrap();
1166        assert_eq!(project, "my-project_v2");
1167        assert_eq!(task, "build.ci-test");
1168    }
1169
1170    #[test]
1171    fn test_hook_item_task_ref_deserialization() {
1172        let json = "{\"ref\": \"#other-project:build\"}";
1173        let hook_item: HookItem = serde_json::from_str(json).unwrap();
1174
1175        match hook_item {
1176            HookItem::TaskRef(task_ref) => {
1177                assert_eq!(task_ref.ref_, "#other-project:build");
1178                let (project, task) = task_ref.parse().unwrap();
1179                assert_eq!(project, "other-project");
1180                assert_eq!(task, "build");
1181            }
1182            _ => panic!("Expected HookItem::TaskRef"),
1183        }
1184    }
1185
1186    #[test]
1187    fn test_hook_item_match_deserialization() {
1188        let json = r#"{
1189            "name": "projen",
1190            "match": {
1191                "labels": ["codegen", "projen"]
1192            }
1193        }"#;
1194        let hook_item: HookItem = serde_json::from_str(json).unwrap();
1195
1196        match hook_item {
1197            HookItem::Match(match_hook) => {
1198                assert_eq!(match_hook.name, Some("projen".to_string()));
1199                assert_eq!(
1200                    match_hook.matcher.labels,
1201                    Some(vec!["codegen".to_string(), "projen".to_string()])
1202                );
1203            }
1204            _ => panic!("Expected HookItem::Match"),
1205        }
1206    }
1207
1208    #[test]
1209    fn test_hook_item_match_with_parallel_false() {
1210        let json = r#"{
1211            "match": {
1212                "labels": ["build"],
1213                "parallel": false
1214            }
1215        }"#;
1216        let hook_item: HookItem = serde_json::from_str(json).unwrap();
1217
1218        match hook_item {
1219            HookItem::Match(match_hook) => {
1220                assert!(match_hook.name.is_none());
1221                assert!(!match_hook.matcher.parallel);
1222            }
1223            _ => panic!("Expected HookItem::Match"),
1224        }
1225    }
1226
1227    #[test]
1228    fn test_hook_item_inline_task_deserialization() {
1229        let json = r#"{
1230            "command": "echo",
1231            "args": ["hello"]
1232        }"#;
1233        let hook_item: HookItem = serde_json::from_str(json).unwrap();
1234
1235        match hook_item {
1236            HookItem::Task(task) => {
1237                assert_eq!(task.command, "echo");
1238                assert_eq!(task.args, vec!["hello"]);
1239            }
1240            _ => panic!("Expected HookItem::Task"),
1241        }
1242    }
1243
1244    #[test]
1245    fn test_task_matcher_deserialization() {
1246        let json = r#"{
1247            "labels": ["projen", "codegen"],
1248            "parallel": true
1249        }"#;
1250        let matcher: TaskMatcher = serde_json::from_str(json).unwrap();
1251
1252        assert_eq!(
1253            matcher.labels,
1254            Some(vec!["projen".to_string(), "codegen".to_string()])
1255        );
1256        assert!(matcher.parallel);
1257    }
1258
1259    #[test]
1260    fn test_task_matcher_defaults() {
1261        let json = r#"{}"#;
1262        let matcher: TaskMatcher = serde_json::from_str(json).unwrap();
1263
1264        assert!(matcher.labels.is_none());
1265        assert!(matcher.command.is_none());
1266        assert!(matcher.args.is_none());
1267        assert!(matcher.parallel); // default true
1268    }
1269
1270    #[test]
1271    fn test_task_matcher_with_command() {
1272        let json = r#"{
1273            "command": "prisma",
1274            "args": [{"contains": "generate"}]
1275        }"#;
1276        let matcher: TaskMatcher = serde_json::from_str(json).unwrap();
1277
1278        assert_eq!(matcher.command, Some("prisma".to_string()));
1279        let args = matcher.args.unwrap();
1280        assert_eq!(args.len(), 1);
1281        assert_eq!(args[0].contains, Some("generate".to_string()));
1282    }
1283
1284    // ============================================================================
1285    // Cross-Project Reference Expansion Tests
1286    // ============================================================================
1287
1288    #[test]
1289    fn test_expand_multiple_cross_project_references() {
1290        let task = Task {
1291            inputs: vec![
1292                Input::Path("#projA:build:dist/lib.js".to_string()),
1293                Input::Path("#projB:compile:out/types.d.ts".to_string()),
1294                Input::Path("src/**/*.ts".to_string()), // Local path
1295            ],
1296            ..Default::default()
1297        };
1298
1299        let mut cuenv = Project::new("test");
1300        cuenv
1301            .tasks
1302            .insert("bundle".into(), TaskNode::Task(Box::new(task)));
1303
1304        cuenv.expand_cross_project_references();
1305
1306        let task_def = cuenv.tasks.get("bundle").unwrap();
1307        let task = task_def.as_task().unwrap();
1308
1309        // Should have 3 inputs (2 project refs + 1 local)
1310        assert_eq!(task.inputs.len(), 3);
1311
1312        // Should have 2 implicit dependencies
1313        assert_eq!(task.depends_on.len(), 2);
1314        assert!(
1315            task.depends_on
1316                .iter()
1317                .any(|d| d.task_name() == "#projA:build")
1318        );
1319        assert!(
1320            task.depends_on
1321                .iter()
1322                .any(|d| d.task_name() == "#projB:compile")
1323        );
1324    }
1325
1326    #[test]
1327    fn test_expand_cross_project_in_task_group() {
1328        let task1 = Task {
1329            command: "step1".to_string(),
1330            inputs: vec![Input::Path("#projA:build:dist/lib.js".to_string())],
1331            ..Default::default()
1332        };
1333
1334        let task2 = Task {
1335            command: "step2".to_string(),
1336            inputs: vec![Input::Path("#projB:compile:out/types.d.ts".to_string())],
1337            ..Default::default()
1338        };
1339
1340        let mut cuenv = Project::new("test");
1341        cuenv.tasks.insert(
1342            "pipeline".into(),
1343            TaskNode::Sequence(vec![
1344                TaskNode::Task(Box::new(task1)),
1345                TaskNode::Task(Box::new(task2)),
1346            ]),
1347        );
1348
1349        cuenv.expand_cross_project_references();
1350
1351        // Verify expansion happened in both tasks
1352        match cuenv.tasks.get("pipeline").unwrap() {
1353            TaskNode::Sequence(steps) => {
1354                match &steps[0] {
1355                    TaskNode::Task(task) => {
1356                        assert!(
1357                            task.depends_on
1358                                .iter()
1359                                .any(|d| d.task_name() == "#projA:build")
1360                        );
1361                    }
1362                    _ => panic!("Expected single task"),
1363                }
1364                match &steps[1] {
1365                    TaskNode::Task(task) => {
1366                        assert!(
1367                            task.depends_on
1368                                .iter()
1369                                .any(|d| d.task_name() == "#projB:compile")
1370                        );
1371                    }
1372                    _ => panic!("Expected single task"),
1373                }
1374            }
1375            _ => panic!("Expected task list"),
1376        }
1377    }
1378
1379    #[test]
1380    fn test_expand_cross_project_in_parallel_group() {
1381        let task1 = Task {
1382            command: "taskA".to_string(),
1383            inputs: vec![Input::Path("#projA:build:lib.js".to_string())],
1384            ..Default::default()
1385        };
1386
1387        let task2 = Task {
1388            command: "taskB".to_string(),
1389            inputs: vec![Input::Path("#projB:build:types.d.ts".to_string())],
1390            ..Default::default()
1391        };
1392
1393        let mut parallel_tasks = HashMap::new();
1394        parallel_tasks.insert("a".to_string(), TaskNode::Task(Box::new(task1)));
1395        parallel_tasks.insert("b".to_string(), TaskNode::Task(Box::new(task2)));
1396
1397        let mut cuenv = Project::new("test");
1398        cuenv.tasks.insert(
1399            "parallel".into(),
1400            TaskNode::Group(TaskGroup {
1401                type_: "group".to_string(),
1402                children: parallel_tasks,
1403                depends_on: vec![],
1404                description: None,
1405                max_concurrency: None,
1406            }),
1407        );
1408
1409        cuenv.expand_cross_project_references();
1410
1411        // Verify expansion happened in both parallel tasks
1412        match cuenv.tasks.get("parallel").unwrap() {
1413            TaskNode::Group(group) => {
1414                match group.children.get("a").unwrap() {
1415                    TaskNode::Task(task) => {
1416                        assert!(
1417                            task.depends_on
1418                                .iter()
1419                                .any(|d| d.task_name() == "#projA:build")
1420                        );
1421                    }
1422                    _ => panic!("Expected single task"),
1423                }
1424                match group.children.get("b").unwrap() {
1425                    TaskNode::Task(task) => {
1426                        assert!(
1427                            task.depends_on
1428                                .iter()
1429                                .any(|d| d.task_name() == "#projB:build")
1430                        );
1431                    }
1432                    _ => panic!("Expected single task"),
1433                }
1434            }
1435            _ => panic!("Expected parallel group"),
1436        }
1437    }
1438
1439    #[test]
1440    fn test_no_duplicate_implicit_dependencies() {
1441        // Task already has the dependency explicitly
1442        let task = Task {
1443            depends_on: vec![TaskDependency::from_name("#myproj:build")],
1444            inputs: vec![Input::Path("#myproj:build:dist/app.js".to_string())],
1445            ..Default::default()
1446        };
1447
1448        let mut cuenv = Project::new("test");
1449        cuenv
1450            .tasks
1451            .insert("deploy".into(), TaskNode::Task(Box::new(task)));
1452
1453        cuenv.expand_cross_project_references();
1454
1455        let task_def = cuenv.tasks.get("deploy").unwrap();
1456        let task = task_def.as_task().unwrap();
1457
1458        // Should not duplicate the dependency
1459        assert_eq!(task.depends_on.len(), 1);
1460        assert_eq!(task.depends_on[0].task_name(), "#myproj:build");
1461    }
1462
1463    // ============================================================================
1464    // Project Hooks (onEnter, onExit) Tests
1465    // ============================================================================
1466
1467    #[test]
1468    fn test_on_enter_hooks_ordering() {
1469        let mut on_enter = HashMap::new();
1470        on_enter.insert("hook_c".to_string(), create_test_hook(300, "echo c"));
1471        on_enter.insert("hook_a".to_string(), create_test_hook(100, "echo a"));
1472        on_enter.insert("hook_b".to_string(), create_test_hook(200, "echo b"));
1473
1474        let mut cuenv = Project::new("test");
1475        cuenv.hooks = Some(Hooks {
1476            on_enter: Some(on_enter),
1477            on_exit: None,
1478            pre_push: None,
1479        });
1480
1481        let hooks = cuenv.on_enter_hooks();
1482        assert_eq!(hooks.len(), 3);
1483
1484        // Should be sorted by order
1485        assert_eq!(hooks[0].order, 100);
1486        assert_eq!(hooks[1].order, 200);
1487        assert_eq!(hooks[2].order, 300);
1488    }
1489
1490    #[test]
1491    fn test_on_enter_hooks_same_order_sort_by_name() {
1492        let mut on_enter = HashMap::new();
1493        on_enter.insert("z_hook".to_string(), create_test_hook(100, "echo z"));
1494        on_enter.insert("a_hook".to_string(), create_test_hook(100, "echo a"));
1495
1496        let cuenv = Project {
1497            name: "test".to_string(),
1498            hooks: Some(Hooks {
1499                on_enter: Some(on_enter),
1500                on_exit: None,
1501                pre_push: None,
1502            }),
1503            ..Default::default()
1504        };
1505
1506        let hooks = cuenv.on_enter_hooks();
1507        assert_eq!(hooks.len(), 2);
1508
1509        // Same order, should be sorted by name
1510        assert_eq!(hooks[0].command, "echo a");
1511        assert_eq!(hooks[1].command, "echo z");
1512    }
1513
1514    #[test]
1515    fn test_empty_hooks() {
1516        let cuenv = Project::new("test");
1517
1518        let on_enter = cuenv.on_enter_hooks();
1519        let on_exit = cuenv.on_exit_hooks();
1520
1521        assert!(on_enter.is_empty());
1522        assert!(on_exit.is_empty());
1523    }
1524
1525    #[test]
1526    fn test_project_deserialization_with_script_tasks() {
1527        // This test uses the new explicit API with type: "group" and flattened children
1528        let json = r#"{
1529            "name": "cuenv",
1530            "hooks": {
1531                "onEnter": {
1532                    "nix": {
1533                        "order": 10,
1534                        "propagate": false,
1535                        "command": "nix",
1536                        "args": ["print-dev-env"],
1537                        "inputs": ["flake.nix", "flake.lock"],
1538                        "source": true
1539                    }
1540                }
1541            },
1542            "tasks": {
1543                "pwd": { "command": "pwd" },
1544                "check": {
1545                    "command": "nix",
1546                    "args": ["flake", "check"],
1547                    "inputs": ["flake.nix"]
1548                },
1549                "fmt": {
1550                    "type": "group",
1551                    "fix": {
1552                        "command": "treefmt",
1553                        "inputs": [".config"]
1554                    },
1555                    "check": {
1556                        "command": "treefmt",
1557                        "args": ["--fail-on-change"],
1558                        "inputs": [".config"]
1559                    }
1560                },
1561                "cross": {
1562                    "type": "group",
1563                    "linux": {
1564                        "script": "echo building for linux",
1565                        "inputs": ["Cargo.toml"]
1566                    }
1567                },
1568                "docs": {
1569                    "type": "group",
1570                    "build": {
1571                        "command": "bash",
1572                        "args": ["-c", "bun install"],
1573                        "inputs": ["docs"],
1574                        "outputs": ["docs/dist"]
1575                    },
1576                    "deploy": {
1577                        "command": "bash",
1578                        "args": ["-c", "wrangler deploy"],
1579                        "dependsOn": ["docs.build"],
1580                        "inputs": [{"task": "docs.build"}]
1581                    }
1582                }
1583            }
1584        }"#;
1585
1586        let result: Result<Project, _> = serde_json::from_str(json);
1587        match result {
1588            Ok(project) => {
1589                assert_eq!(project.name, "cuenv");
1590                assert_eq!(project.tasks.len(), 5);
1591                assert!(project.tasks.contains_key("pwd"));
1592                assert!(project.tasks.contains_key("cross"));
1593                // Verify cross is a group with parallel subtasks
1594                let cross = project.tasks.get("cross").unwrap();
1595                assert!(cross.is_group());
1596            }
1597            Err(e) => {
1598                panic!("Failed to deserialize Project with script tasks: {}", e);
1599            }
1600        }
1601    }
1602
1603    #[test]
1604    fn test_deserialize_actual_cuenv_project() {
1605        // Read actual CUE output from /tmp/project.json (created by cue eval)
1606        let json = match std::fs::read_to_string("/tmp/project.json") {
1607            Ok(content) => content,
1608            Err(_) => return, // Skip if file doesn't exist
1609        };
1610        let result: Result<Project, _> = serde_json::from_str(&json);
1611        match result {
1612            Ok(project) => {
1613                eprintln!("Project name: {}", project.name);
1614                eprintln!("Tasks: {:?}", project.tasks.keys().collect::<Vec<_>>());
1615            }
1616            Err(e) => {
1617                eprintln!("Failed: {}", e);
1618                eprintln!("Line: {}, Col: {}", e.line(), e.column());
1619                // Read the JSON around the error line
1620                let lines: Vec<&str> = json.lines().collect();
1621                let line_num = e.line();
1622                let start = if line_num > 3 { line_num - 3 } else { 1 };
1623                let end = std::cmp::min(line_num + 3, lines.len());
1624                for i in start..=end {
1625                    if i <= lines.len() {
1626                        eprintln!("{}: {}", i, lines[i - 1]);
1627                    }
1628                }
1629                panic!("Deserialization failed");
1630            }
1631        }
1632    }
1633}