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(Box<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        /// Legacy single-file selector inside archive/pkg payloads.
762        #[serde(skip_serializing_if = "Option::is_none")]
763        path: Option<String>,
764        /// Optional typed extraction rules for archive/pkg assets.
765        #[serde(default, skip_serializing_if = "Vec::is_empty")]
766        extract: Vec<GitHubExtract>,
767    },
768    /// Build from Nix flake
769    Nix {
770        /// Named flake reference (key in runtime.flakes)
771        flake: String,
772        /// Package attribute (e.g., "jq", "python3")
773        package: String,
774        /// Output path if binary can't be auto-detected
775        #[serde(skip_serializing_if = "Option::is_none")]
776        output: Option<String>,
777    },
778    /// Install via rustup
779    Rustup {
780        /// Toolchain identifier (e.g., "stable", "1.83.0", "nightly-2024-01-01")
781        toolchain: String,
782        /// Installation profile: minimal, default, complete
783        #[serde(default = "default_rustup_profile")]
784        profile: String,
785        /// Additional components to install (e.g., "clippy", "rustfmt", "rust-src")
786        #[serde(default, skip_serializing_if = "Vec::is_empty")]
787        components: Vec<String>,
788        /// Additional targets to install (e.g., "x86_64-unknown-linux-gnu")
789        #[serde(default, skip_serializing_if = "Vec::is_empty")]
790        targets: Vec<String>,
791    },
792    /// Download from an arbitrary HTTP URL
793    #[serde(rename = "url")]
794    Url {
795        /// URL with optional {version}, {os}, {arch} templates
796        url: String,
797        /// Legacy single-file selector inside archive payloads.
798        #[serde(skip_serializing_if = "Option::is_none")]
799        path: Option<String>,
800        /// Optional typed extraction rules for archive assets.
801        #[serde(default, skip_serializing_if = "Vec::is_empty")]
802        extract: Vec<GitHubExtract>,
803    },
804}
805
806fn default_rustup_profile() -> String {
807    "default".to_string()
808}
809
810/// Typed extraction rule for GitHub release assets.
811#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
812#[serde(tag = "kind", rename_all = "lowercase")]
813pub enum GitHubExtract {
814    /// Extract a binary and place it in `bin/`.
815    Bin {
816        /// Path to file in the archive/pkg payload.
817        path: String,
818        /// Optional binary rename in cache/bin.
819        #[serde(rename = "as", skip_serializing_if = "Option::is_none")]
820        as_name: Option<String>,
821    },
822    /// Extract a dynamic library and place it in `lib/`.
823    Lib {
824        /// Path to file in the archive/pkg payload.
825        path: String,
826        /// Optional env var to export the absolute file path.
827        #[serde(skip_serializing_if = "Option::is_none")]
828        env: Option<String>,
829    },
830    /// Extract include/header material and place it in `include/`.
831    Include {
832        /// Path to file in the archive/pkg payload.
833        path: String,
834    },
835    /// Extract pkg-config metadata and place it in `lib/pkgconfig/`.
836    PkgConfig {
837        /// Path to file in the archive/pkg payload.
838        path: String,
839    },
840    /// Extract a generic file and place it in `files/`.
841    File {
842        /// Path to file in the archive/pkg payload.
843        path: String,
844        /// Optional env var to export the absolute file path.
845        #[serde(skip_serializing_if = "Option::is_none")]
846        env: Option<String>,
847    },
848}
849
850// ============================================================================
851// Project Type
852// ============================================================================
853
854/// Root Project configuration structure (leaf node - cannot unify with other projects)
855#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
856pub struct Project {
857    /// Configuration settings
858    #[serde(skip_serializing_if = "Option::is_none")]
859    pub config: Option<Config>,
860
861    /// Project name (unique identifier, required by the CUE schema)
862    pub name: String,
863
864    /// Environment variables configuration
865    #[serde(skip_serializing_if = "Option::is_none")]
866    pub env: Option<Env>,
867
868    /// Hooks configuration
869    #[serde(skip_serializing_if = "Option::is_none")]
870    pub hooks: Option<Hooks>,
871
872    /// CI configuration
873    #[serde(skip_serializing_if = "Option::is_none")]
874    pub ci: Option<CI>,
875
876    /// Tasks configuration
877    #[serde(default)]
878    pub tasks: HashMap<String, TaskNode>,
879
880    /// Codegen configuration for code generation
881    #[serde(skip_serializing_if = "Option::is_none")]
882    pub codegen: Option<CodegenConfig>,
883
884    /// Runtime configuration (project-level default for all tasks)
885    #[serde(skip_serializing_if = "Option::is_none")]
886    pub runtime: Option<Runtime>,
887
888    /// Formatters configuration
889    #[serde(skip_serializing_if = "Option::is_none")]
890    pub formatters: Option<Formatters>,
891}
892
893impl Project {
894    /// Create a new Project configuration with a required name.
895    pub fn new(name: impl Into<String>) -> Self {
896        Self {
897            name: name.into(),
898            ..Self::default()
899        }
900    }
901
902    /// Get hooks to execute when entering environment as a map (name -> hook)
903    pub fn on_enter_hooks_map(&self) -> HashMap<String, Hook> {
904        self.hooks
905            .as_ref()
906            .and_then(|h| h.on_enter.as_ref())
907            .cloned()
908            .unwrap_or_default()
909    }
910
911    /// Get hooks to execute when entering environment, sorted by (order, name)
912    pub fn on_enter_hooks(&self) -> Vec<Hook> {
913        let map = self.on_enter_hooks_map();
914        let mut hooks: Vec<(String, Hook)> = map.into_iter().collect();
915        hooks.sort_by(|a, b| a.1.order.cmp(&b.1.order).then(a.0.cmp(&b.0)));
916        hooks.into_iter().map(|(_, h)| h).collect()
917    }
918
919    /// Get hooks to execute when exiting environment as a map (name -> hook)
920    pub fn on_exit_hooks_map(&self) -> HashMap<String, Hook> {
921        self.hooks
922            .as_ref()
923            .and_then(|h| h.on_exit.as_ref())
924            .cloned()
925            .unwrap_or_default()
926    }
927
928    /// Get hooks to execute when exiting environment, sorted by (order, name)
929    pub fn on_exit_hooks(&self) -> Vec<Hook> {
930        let map = self.on_exit_hooks_map();
931        let mut hooks: Vec<(String, Hook)> = map.into_iter().collect();
932        hooks.sort_by(|a, b| a.1.order.cmp(&b.1.order).then(a.0.cmp(&b.0)));
933        hooks.into_iter().map(|(_, h)| h).collect()
934    }
935
936    /// Get hooks to execute before git push as a map (name -> hook)
937    pub fn pre_push_hooks_map(&self) -> HashMap<String, Hook> {
938        self.hooks
939            .as_ref()
940            .and_then(|h| h.pre_push.as_ref())
941            .cloned()
942            .unwrap_or_default()
943    }
944
945    /// Get hooks to execute before git push, sorted by (order, name)
946    pub fn pre_push_hooks(&self) -> Vec<Hook> {
947        let map = self.pre_push_hooks_map();
948        let mut hooks: Vec<(String, Hook)> = map.into_iter().collect();
949        hooks.sort_by(|a, b| a.1.order.cmp(&b.1.order).then(a.0.cmp(&b.0)));
950        hooks.into_iter().map(|(_, h)| h).collect()
951    }
952
953    /// Returns self unchanged.
954    ///
955    /// Workspace detection and task injection now happens via auto-detection
956    /// from lockfiles in the task executor. This method is kept for API compatibility.
957    #[must_use]
958    pub fn with_implicit_tasks(self) -> Self {
959        self
960    }
961
962    /// Expand shorthand cross-project references in inputs and implicit dependencies.
963    ///
964    /// Handles inputs in the format: "#project:task:path/to/file"
965    /// Converts them to explicit ProjectReference inputs.
966    /// Also adds implicit dependsOn entries for all project references.
967    pub fn expand_cross_project_references(&mut self) {
968        for (_, task_node) in self.tasks.iter_mut() {
969            Self::expand_task_node(task_node);
970        }
971    }
972
973    fn expand_task_node(node: &mut TaskNode) {
974        match node {
975            TaskNode::Task(task) => Self::expand_task(task),
976            TaskNode::Group(group) => {
977                for sub_node in group.children.values_mut() {
978                    Self::expand_task_node(sub_node);
979                }
980            }
981            TaskNode::Sequence(steps) => {
982                for sub_node in steps {
983                    Self::expand_task_node(sub_node);
984                }
985            }
986        }
987    }
988
989    fn expand_task(task: &mut Task) {
990        let mut new_inputs = Vec::new();
991        let mut implicit_deps = Vec::new();
992
993        // Process existing inputs
994        for input in &task.inputs {
995            match input {
996                Input::Path(path) if path.starts_with('#') => {
997                    // Parse "#project:task:path"
998                    // Remove leading #
999                    let parts: Vec<&str> = path[1..].split(':').collect();
1000                    if parts.len() >= 3 {
1001                        let project = parts[0].to_string();
1002                        let task_name = parts[1].to_string();
1003                        // Rejoin the rest as the path (it might contain colons)
1004                        let file_path = parts[2..].join(":");
1005
1006                        new_inputs.push(Input::Project(ProjectReference {
1007                            project: project.clone(),
1008                            task: task_name.clone(),
1009                            map: vec![Mapping {
1010                                from: file_path.clone(),
1011                                to: file_path,
1012                            }],
1013                        }));
1014
1015                        // Add implicit dependency
1016                        implicit_deps.push(format!("#{}:{}", project, task_name));
1017                    } else if parts.len() == 2 {
1018                        // Handle "#project:task" as pure dependency?
1019                        // The prompt says: `["#projectName:taskName"]` for dependsOn
1020                        // For inputs, it likely expects a file mapping.
1021                        // If user puts `["#p:t"]` in inputs, it's invalid as an input unless it maps something.
1022                        // Assuming `#p:t:f` is the requirement for inputs.
1023                        // Keeping original if not matching pattern (or maybe warning?)
1024                        new_inputs.push(input.clone());
1025                    } else {
1026                        new_inputs.push(input.clone());
1027                    }
1028                }
1029                Input::Project(proj_ref) => {
1030                    // Add implicit dependency for explicit project references too
1031                    implicit_deps.push(format!("#{}:{}", proj_ref.project, proj_ref.task));
1032                    new_inputs.push(input.clone());
1033                }
1034                _ => new_inputs.push(input.clone()),
1035            }
1036        }
1037
1038        task.inputs = new_inputs;
1039
1040        // Add unique implicit dependencies
1041        for dep in implicit_deps {
1042            if !task.depends_on.iter().any(|d| d.task_name() == dep) {
1043                task.depends_on
1044                    .push(crate::tasks::TaskDependency::from_name(dep));
1045            }
1046        }
1047    }
1048}
1049
1050impl TryFrom<&Instance> for Project {
1051    type Error = crate::Error;
1052
1053    fn try_from(instance: &Instance) -> Result<Self, Self::Error> {
1054        let mut project: Project = instance.deserialize()?;
1055        project.expand_cross_project_references();
1056        Ok(project)
1057    }
1058}
1059
1060#[cfg(test)]
1061mod tests {
1062    use super::*;
1063    use crate::tasks::{TaskDependency, TaskGroup, TaskNode};
1064    use crate::test_utils::create_test_hook;
1065
1066    #[test]
1067    fn test_expand_cross_project_references() {
1068        let task = Task {
1069            inputs: vec![Input::Path("#myproj:build:dist/app.js".to_string())],
1070            ..Default::default()
1071        };
1072
1073        let mut cuenv = Project::new("test");
1074        cuenv
1075            .tasks
1076            .insert("deploy".into(), TaskNode::Task(Box::new(task)));
1077
1078        cuenv.expand_cross_project_references();
1079
1080        let task_def = cuenv.tasks.get("deploy").unwrap();
1081        let task = task_def.as_task().unwrap();
1082
1083        // Check inputs expansion
1084        assert_eq!(task.inputs.len(), 1);
1085        match &task.inputs[0] {
1086            Input::Project(proj_ref) => {
1087                assert_eq!(proj_ref.project, "myproj");
1088                assert_eq!(proj_ref.task, "build");
1089                assert_eq!(proj_ref.map.len(), 1);
1090                assert_eq!(proj_ref.map[0].from, "dist/app.js");
1091                assert_eq!(proj_ref.map[0].to, "dist/app.js");
1092            }
1093            _ => panic!("Expected ProjectReference"),
1094        }
1095
1096        // Check implicit dependency
1097        assert_eq!(task.depends_on.len(), 1);
1098        assert_eq!(task.depends_on[0].task_name(), "#myproj:build");
1099    }
1100
1101    // ============================================================================
1102    // HookItem and TaskRef Tests
1103    // ============================================================================
1104
1105    #[test]
1106    fn test_task_ref_parse_valid() {
1107        let task_ref = TaskRef {
1108            ref_: "#projen-generator:types".to_string(),
1109        };
1110
1111        let parsed = task_ref.parse();
1112        assert!(parsed.is_some());
1113
1114        let (project, task) = parsed.unwrap();
1115        assert_eq!(project, "projen-generator");
1116        assert_eq!(task, "types");
1117    }
1118
1119    #[test]
1120    fn test_task_ref_parse_with_dots() {
1121        let task_ref = TaskRef {
1122            ref_: "#my-project:bun.install".to_string(),
1123        };
1124
1125        let parsed = task_ref.parse();
1126        assert!(parsed.is_some());
1127
1128        let (project, task) = parsed.unwrap();
1129        assert_eq!(project, "my-project");
1130        assert_eq!(task, "bun.install");
1131    }
1132
1133    #[test]
1134    fn test_task_ref_parse_no_hash() {
1135        let task_ref = TaskRef {
1136            ref_: "project:task".to_string(),
1137        };
1138
1139        // Without leading #, parse should fail
1140        let parsed = task_ref.parse();
1141        assert!(parsed.is_none());
1142    }
1143
1144    #[test]
1145    fn test_task_ref_parse_no_colon() {
1146        let task_ref = TaskRef {
1147            ref_: "#project-only".to_string(),
1148        };
1149
1150        // Without colon separator, parse should fail
1151        let parsed = task_ref.parse();
1152        assert!(parsed.is_none());
1153    }
1154
1155    #[test]
1156    fn test_task_ref_parse_empty_project() {
1157        let task_ref = TaskRef {
1158            ref_: "#:task".to_string(),
1159        };
1160
1161        // Empty project name should be rejected
1162        assert!(task_ref.parse().is_none());
1163    }
1164
1165    #[test]
1166    fn test_task_ref_parse_empty_task() {
1167        let task_ref = TaskRef {
1168            ref_: "#project:".to_string(),
1169        };
1170
1171        // Empty task name should be rejected
1172        assert!(task_ref.parse().is_none());
1173    }
1174
1175    #[test]
1176    fn test_task_ref_parse_both_empty() {
1177        let task_ref = TaskRef {
1178            ref_: "#:".to_string(),
1179        };
1180
1181        // Both empty should be rejected
1182        assert!(task_ref.parse().is_none());
1183    }
1184
1185    #[test]
1186    fn test_task_ref_parse_multiple_colons() {
1187        let task_ref = TaskRef {
1188            ref_: "#project:task:extra".to_string(),
1189        };
1190
1191        // Multiple colons - first split wins
1192        let parsed = task_ref.parse();
1193        assert!(parsed.is_some());
1194        let (project, task) = parsed.unwrap();
1195        assert_eq!(project, "project");
1196        assert_eq!(task, "task:extra");
1197    }
1198
1199    #[test]
1200    fn test_task_ref_parse_unicode() {
1201        let task_ref = TaskRef {
1202            ref_: "#项目名:任务名".to_string(),
1203        };
1204
1205        let parsed = task_ref.parse();
1206        assert!(parsed.is_some());
1207        let (project, task) = parsed.unwrap();
1208        assert_eq!(project, "项目名");
1209        assert_eq!(task, "任务名");
1210    }
1211
1212    #[test]
1213    fn test_task_ref_parse_special_characters() {
1214        let task_ref = TaskRef {
1215            ref_: "#my-project_v2:build.ci-test".to_string(),
1216        };
1217
1218        let parsed = task_ref.parse();
1219        assert!(parsed.is_some());
1220        let (project, task) = parsed.unwrap();
1221        assert_eq!(project, "my-project_v2");
1222        assert_eq!(task, "build.ci-test");
1223    }
1224
1225    #[test]
1226    fn test_hook_item_task_ref_deserialization() {
1227        let json = "{\"ref\": \"#other-project:build\"}";
1228        let hook_item: HookItem = serde_json::from_str(json).unwrap();
1229
1230        match hook_item {
1231            HookItem::TaskRef(task_ref) => {
1232                assert_eq!(task_ref.ref_, "#other-project:build");
1233                let (project, task) = task_ref.parse().unwrap();
1234                assert_eq!(project, "other-project");
1235                assert_eq!(task, "build");
1236            }
1237            _ => panic!("Expected HookItem::TaskRef"),
1238        }
1239    }
1240
1241    #[test]
1242    fn test_hook_item_match_deserialization() {
1243        let json = r#"{
1244            "name": "projen",
1245            "match": {
1246                "labels": ["codegen", "projen"]
1247            }
1248        }"#;
1249        let hook_item: HookItem = serde_json::from_str(json).unwrap();
1250
1251        match hook_item {
1252            HookItem::Match(match_hook) => {
1253                assert_eq!(match_hook.name, Some("projen".to_string()));
1254                assert_eq!(
1255                    match_hook.matcher.labels,
1256                    Some(vec!["codegen".to_string(), "projen".to_string()])
1257                );
1258            }
1259            _ => panic!("Expected HookItem::Match"),
1260        }
1261    }
1262
1263    #[test]
1264    fn test_hook_item_match_with_parallel_false() {
1265        let json = r#"{
1266            "match": {
1267                "labels": ["build"],
1268                "parallel": false
1269            }
1270        }"#;
1271        let hook_item: HookItem = serde_json::from_str(json).unwrap();
1272
1273        match hook_item {
1274            HookItem::Match(match_hook) => {
1275                assert!(match_hook.name.is_none());
1276                assert!(!match_hook.matcher.parallel);
1277            }
1278            _ => panic!("Expected HookItem::Match"),
1279        }
1280    }
1281
1282    #[test]
1283    fn test_hook_item_inline_task_deserialization() {
1284        let json = r#"{
1285            "command": "echo",
1286            "args": ["hello"]
1287        }"#;
1288        let hook_item: HookItem = serde_json::from_str(json).unwrap();
1289
1290        match hook_item {
1291            HookItem::Task(task) => {
1292                assert_eq!(task.command, "echo");
1293                assert_eq!(task.args, vec!["hello"]);
1294            }
1295            _ => panic!("Expected HookItem::Task"),
1296        }
1297    }
1298
1299    #[test]
1300    fn test_task_matcher_deserialization() {
1301        let json = r#"{
1302            "labels": ["projen", "codegen"],
1303            "parallel": true
1304        }"#;
1305        let matcher: TaskMatcher = serde_json::from_str(json).unwrap();
1306
1307        assert_eq!(
1308            matcher.labels,
1309            Some(vec!["projen".to_string(), "codegen".to_string()])
1310        );
1311        assert!(matcher.parallel);
1312    }
1313
1314    #[test]
1315    fn test_task_matcher_defaults() {
1316        let json = r#"{}"#;
1317        let matcher: TaskMatcher = serde_json::from_str(json).unwrap();
1318
1319        assert!(matcher.labels.is_none());
1320        assert!(matcher.command.is_none());
1321        assert!(matcher.args.is_none());
1322        assert!(matcher.parallel); // default true
1323    }
1324
1325    #[test]
1326    fn test_task_matcher_with_command() {
1327        let json = r#"{
1328            "command": "prisma",
1329            "args": [{"contains": "generate"}]
1330        }"#;
1331        let matcher: TaskMatcher = serde_json::from_str(json).unwrap();
1332
1333        assert_eq!(matcher.command, Some("prisma".to_string()));
1334        let args = matcher.args.unwrap();
1335        assert_eq!(args.len(), 1);
1336        assert_eq!(args[0].contains, Some("generate".to_string()));
1337    }
1338
1339    // ============================================================================
1340    // Cross-Project Reference Expansion Tests
1341    // ============================================================================
1342
1343    #[test]
1344    fn test_expand_multiple_cross_project_references() {
1345        let task = Task {
1346            inputs: vec![
1347                Input::Path("#projA:build:dist/lib.js".to_string()),
1348                Input::Path("#projB:compile:out/types.d.ts".to_string()),
1349                Input::Path("src/**/*.ts".to_string()), // Local path
1350            ],
1351            ..Default::default()
1352        };
1353
1354        let mut cuenv = Project::new("test");
1355        cuenv
1356            .tasks
1357            .insert("bundle".into(), TaskNode::Task(Box::new(task)));
1358
1359        cuenv.expand_cross_project_references();
1360
1361        let task_def = cuenv.tasks.get("bundle").unwrap();
1362        let task = task_def.as_task().unwrap();
1363
1364        // Should have 3 inputs (2 project refs + 1 local)
1365        assert_eq!(task.inputs.len(), 3);
1366
1367        // Should have 2 implicit dependencies
1368        assert_eq!(task.depends_on.len(), 2);
1369        assert!(
1370            task.depends_on
1371                .iter()
1372                .any(|d| d.task_name() == "#projA:build")
1373        );
1374        assert!(
1375            task.depends_on
1376                .iter()
1377                .any(|d| d.task_name() == "#projB:compile")
1378        );
1379    }
1380
1381    #[test]
1382    fn test_expand_cross_project_in_task_group() {
1383        let task1 = Task {
1384            command: "step1".to_string(),
1385            inputs: vec![Input::Path("#projA:build:dist/lib.js".to_string())],
1386            ..Default::default()
1387        };
1388
1389        let task2 = Task {
1390            command: "step2".to_string(),
1391            inputs: vec![Input::Path("#projB:compile:out/types.d.ts".to_string())],
1392            ..Default::default()
1393        };
1394
1395        let mut cuenv = Project::new("test");
1396        cuenv.tasks.insert(
1397            "pipeline".into(),
1398            TaskNode::Sequence(vec![
1399                TaskNode::Task(Box::new(task1)),
1400                TaskNode::Task(Box::new(task2)),
1401            ]),
1402        );
1403
1404        cuenv.expand_cross_project_references();
1405
1406        // Verify expansion happened in both tasks
1407        match cuenv.tasks.get("pipeline").unwrap() {
1408            TaskNode::Sequence(steps) => {
1409                match &steps[0] {
1410                    TaskNode::Task(task) => {
1411                        assert!(
1412                            task.depends_on
1413                                .iter()
1414                                .any(|d| d.task_name() == "#projA:build")
1415                        );
1416                    }
1417                    _ => panic!("Expected single task"),
1418                }
1419                match &steps[1] {
1420                    TaskNode::Task(task) => {
1421                        assert!(
1422                            task.depends_on
1423                                .iter()
1424                                .any(|d| d.task_name() == "#projB:compile")
1425                        );
1426                    }
1427                    _ => panic!("Expected single task"),
1428                }
1429            }
1430            _ => panic!("Expected task list"),
1431        }
1432    }
1433
1434    #[test]
1435    fn test_expand_cross_project_in_parallel_group() {
1436        let task1 = Task {
1437            command: "taskA".to_string(),
1438            inputs: vec![Input::Path("#projA:build:lib.js".to_string())],
1439            ..Default::default()
1440        };
1441
1442        let task2 = Task {
1443            command: "taskB".to_string(),
1444            inputs: vec![Input::Path("#projB:build:types.d.ts".to_string())],
1445            ..Default::default()
1446        };
1447
1448        let mut parallel_tasks = HashMap::new();
1449        parallel_tasks.insert("a".to_string(), TaskNode::Task(Box::new(task1)));
1450        parallel_tasks.insert("b".to_string(), TaskNode::Task(Box::new(task2)));
1451
1452        let mut cuenv = Project::new("test");
1453        cuenv.tasks.insert(
1454            "parallel".into(),
1455            TaskNode::Group(TaskGroup {
1456                type_: "group".to_string(),
1457                children: parallel_tasks,
1458                depends_on: vec![],
1459                description: None,
1460                max_concurrency: None,
1461            }),
1462        );
1463
1464        cuenv.expand_cross_project_references();
1465
1466        // Verify expansion happened in both parallel tasks
1467        match cuenv.tasks.get("parallel").unwrap() {
1468            TaskNode::Group(group) => {
1469                match group.children.get("a").unwrap() {
1470                    TaskNode::Task(task) => {
1471                        assert!(
1472                            task.depends_on
1473                                .iter()
1474                                .any(|d| d.task_name() == "#projA:build")
1475                        );
1476                    }
1477                    _ => panic!("Expected single task"),
1478                }
1479                match group.children.get("b").unwrap() {
1480                    TaskNode::Task(task) => {
1481                        assert!(
1482                            task.depends_on
1483                                .iter()
1484                                .any(|d| d.task_name() == "#projB:build")
1485                        );
1486                    }
1487                    _ => panic!("Expected single task"),
1488                }
1489            }
1490            _ => panic!("Expected parallel group"),
1491        }
1492    }
1493
1494    #[test]
1495    fn test_no_duplicate_implicit_dependencies() {
1496        // Task already has the dependency explicitly
1497        let task = Task {
1498            depends_on: vec![TaskDependency::from_name("#myproj:build")],
1499            inputs: vec![Input::Path("#myproj:build:dist/app.js".to_string())],
1500            ..Default::default()
1501        };
1502
1503        let mut cuenv = Project::new("test");
1504        cuenv
1505            .tasks
1506            .insert("deploy".into(), TaskNode::Task(Box::new(task)));
1507
1508        cuenv.expand_cross_project_references();
1509
1510        let task_def = cuenv.tasks.get("deploy").unwrap();
1511        let task = task_def.as_task().unwrap();
1512
1513        // Should not duplicate the dependency
1514        assert_eq!(task.depends_on.len(), 1);
1515        assert_eq!(task.depends_on[0].task_name(), "#myproj:build");
1516    }
1517
1518    // ============================================================================
1519    // Project Hooks (onEnter, onExit) Tests
1520    // ============================================================================
1521
1522    #[test]
1523    fn test_on_enter_hooks_ordering() {
1524        let mut on_enter = HashMap::new();
1525        on_enter.insert("hook_c".to_string(), create_test_hook(300, "echo c"));
1526        on_enter.insert("hook_a".to_string(), create_test_hook(100, "echo a"));
1527        on_enter.insert("hook_b".to_string(), create_test_hook(200, "echo b"));
1528
1529        let mut cuenv = Project::new("test");
1530        cuenv.hooks = Some(Hooks {
1531            on_enter: Some(on_enter),
1532            on_exit: None,
1533            pre_push: None,
1534        });
1535
1536        let hooks = cuenv.on_enter_hooks();
1537        assert_eq!(hooks.len(), 3);
1538
1539        // Should be sorted by order
1540        assert_eq!(hooks[0].order, 100);
1541        assert_eq!(hooks[1].order, 200);
1542        assert_eq!(hooks[2].order, 300);
1543    }
1544
1545    #[test]
1546    fn test_on_enter_hooks_same_order_sort_by_name() {
1547        let mut on_enter = HashMap::new();
1548        on_enter.insert("z_hook".to_string(), create_test_hook(100, "echo z"));
1549        on_enter.insert("a_hook".to_string(), create_test_hook(100, "echo a"));
1550
1551        let cuenv = Project {
1552            name: "test".to_string(),
1553            hooks: Some(Hooks {
1554                on_enter: Some(on_enter),
1555                on_exit: None,
1556                pre_push: None,
1557            }),
1558            ..Default::default()
1559        };
1560
1561        let hooks = cuenv.on_enter_hooks();
1562        assert_eq!(hooks.len(), 2);
1563
1564        // Same order, should be sorted by name
1565        assert_eq!(hooks[0].command, "echo a");
1566        assert_eq!(hooks[1].command, "echo z");
1567    }
1568
1569    #[test]
1570    fn test_empty_hooks() {
1571        let cuenv = Project::new("test");
1572
1573        let on_enter = cuenv.on_enter_hooks();
1574        let on_exit = cuenv.on_exit_hooks();
1575
1576        assert!(on_enter.is_empty());
1577        assert!(on_exit.is_empty());
1578    }
1579
1580    #[test]
1581    fn test_project_deserialization_with_script_tasks() {
1582        // This test uses the new explicit API with type: "group" and flattened children
1583        let json = r#"{
1584            "name": "cuenv",
1585            "hooks": {
1586                "onEnter": {
1587                    "nix": {
1588                        "order": 10,
1589                        "propagate": false,
1590                        "command": "nix",
1591                        "args": ["print-dev-env"],
1592                        "inputs": ["flake.nix", "flake.lock"],
1593                        "source": true
1594                    }
1595                }
1596            },
1597            "tasks": {
1598                "pwd": { "command": "pwd" },
1599                "check": {
1600                    "command": "nix",
1601                    "args": ["flake", "check"],
1602                    "inputs": ["flake.nix"]
1603                },
1604                "fmt": {
1605                    "type": "group",
1606                    "fix": {
1607                        "command": "treefmt",
1608                        "inputs": [".config"]
1609                    },
1610                    "check": {
1611                        "command": "treefmt",
1612                        "args": ["--fail-on-change"],
1613                        "inputs": [".config"]
1614                    }
1615                },
1616                "cross": {
1617                    "type": "group",
1618                    "linux": {
1619                        "script": "echo building for linux",
1620                        "inputs": ["Cargo.toml"]
1621                    }
1622                },
1623                "docs": {
1624                    "type": "group",
1625                    "build": {
1626                        "command": "bash",
1627                        "args": ["-c", "bun install"],
1628                        "inputs": ["docs"],
1629                        "outputs": ["docs/dist"]
1630                    },
1631                    "deploy": {
1632                        "command": "bash",
1633                        "args": ["-c", "wrangler deploy"],
1634                        "dependsOn": ["docs.build"],
1635                        "inputs": [{"task": "docs.build"}]
1636                    }
1637                }
1638            }
1639        }"#;
1640
1641        let result: Result<Project, _> = serde_json::from_str(json);
1642        match result {
1643            Ok(project) => {
1644                assert_eq!(project.name, "cuenv");
1645                assert_eq!(project.tasks.len(), 5);
1646                assert!(project.tasks.contains_key("pwd"));
1647                assert!(project.tasks.contains_key("cross"));
1648                // Verify cross is a group with parallel subtasks
1649                let cross = project.tasks.get("cross").unwrap();
1650                assert!(cross.is_group());
1651            }
1652            Err(e) => {
1653                panic!("Failed to deserialize Project with script tasks: {}", e);
1654            }
1655        }
1656    }
1657
1658    #[test]
1659    fn test_deserialize_actual_cuenv_project() {
1660        // Read actual CUE output from /tmp/project.json (created by cue eval)
1661        let json = match std::fs::read_to_string("/tmp/project.json") {
1662            Ok(content) => content,
1663            Err(_) => return, // Skip if file doesn't exist
1664        };
1665        let result: Result<Project, _> = serde_json::from_str(&json);
1666        match result {
1667            Ok(project) => {
1668                eprintln!("Project name: {}", project.name);
1669                eprintln!("Tasks: {:?}", project.tasks.keys().collect::<Vec<_>>());
1670            }
1671            Err(e) => {
1672                eprintln!("Failed: {}", e);
1673                eprintln!("Line: {}, Col: {}", e.line(), e.column());
1674                // Read the JSON around the error line
1675                let lines: Vec<&str> = json.lines().collect();
1676                let line_num = e.line();
1677                let start = if line_num > 3 { line_num - 3 } else { 1 };
1678                let end = std::cmp::min(line_num + 3, lines.len());
1679                for i in start..=end {
1680                    if i <= lines.len() {
1681                        eprintln!("{}: {}", i, lines[i - 1]);
1682                    }
1683                }
1684                panic!("Deserialization failed");
1685            }
1686        }
1687    }
1688}