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::environment::EnvValue;
12use crate::module::Instance;
13use crate::secrets::Secret;
14use crate::tasks::Task;
15use crate::tasks::{
16    Input, Mapping, ProjectReference, ScriptShell, ShellOptions, TaskDependency, TaskNode,
17};
18use cuenv_hooks::{Hook, Hooks};
19
20/// A hook step to run as part of task dependencies.
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
22#[serde(untagged)]
23pub enum HookItem {
24    /// Reference to a task in another project
25    TaskRef(TaskRef),
26    /// Discovery-based hook step that expands a TaskMatcher into concrete tasks
27    Match(MatchHook),
28    /// Inline task definition
29    Task(Box<Task>),
30}
31
32/// Hook step that expands to tasks discovered via TaskMatcher.
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
34#[serde(rename_all = "camelCase")]
35pub struct MatchHook {
36    /// Optional stable name used for task naming/logging
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub name: Option<String>,
39
40    /// Task matcher to select tasks across the workspace
41    #[serde(rename = "match")]
42    pub matcher: TaskMatcher,
43}
44
45/// Reference to a task in another env.cue project by its name property
46#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
47pub struct TaskRef {
48    /// Format: "#project-name:task-name" where project-name is the `name` field in env.cue
49    /// Example: "#projen-generator:bun.install"
50    #[serde(rename = "ref")]
51    pub ref_: String,
52}
53
54impl TaskRef {
55    /// Parse the TaskRef into project name and task name
56    /// Returns None if the format is invalid or if project/task names are empty
57    pub fn parse(&self) -> Option<(String, String)> {
58        let ref_str = self.ref_.strip_prefix('#')?;
59        let parts: Vec<&str> = ref_str.splitn(2, ':').collect();
60        if parts.len() == 2 {
61            let project = parts[0];
62            let task = parts[1];
63            if !project.is_empty() && !task.is_empty() {
64                Some((project.to_string(), task.to_string()))
65            } else {
66                None
67            }
68        } else {
69            None
70        }
71    }
72}
73
74/// Match tasks across projects by metadata for discovery-based execution
75#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
76pub struct TaskMatcher {
77    /// Match tasks with these labels (all must match)
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub labels: Option<Vec<String>>,
80
81    /// Match tasks whose command matches this value
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub command: Option<String>,
84
85    /// Match tasks whose args contain specific patterns
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub args: Option<Vec<ArgMatcher>>,
88
89    /// Run matched tasks in parallel (default: true)
90    #[serde(default = "default_true")]
91    pub parallel: bool,
92}
93
94/// Pattern matcher for task arguments
95#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
96pub struct ArgMatcher {
97    /// Match if any arg contains this substring
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub contains: Option<String>,
100
101    /// Match if any arg matches this regex pattern
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub matches: Option<String>,
104}
105
106fn default_true() -> bool {
107    true
108}
109
110/// Base configuration structure (composable across directories)
111#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
112pub struct Base {
113    /// Configuration settings
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub config: Option<Config>,
116
117    /// Environment variables configuration
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub env: Option<Env>,
120
121    /// Formatters configuration
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub formatters: Option<Formatters>,
124
125    /// Runtime configuration (devenv, nix, tools, etc.)
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub runtime: Option<Runtime>,
128
129    /// Hooks configuration
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub hooks: Option<Hooks>,
132}
133
134// ============================================================================
135// Formatter Types
136// ============================================================================
137
138/// Formatters configuration for code formatting tools.
139#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
140#[serde(rename_all = "camelCase")]
141pub struct Formatters {
142    /// Rust formatter configuration (rustfmt)
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub rust: Option<RustFormatter>,
145
146    /// Nix formatter configuration (nixfmt or alejandra)
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub nix: Option<NixFormatter>,
149
150    /// Go formatter configuration (gofmt)
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pub go: Option<GoFormatter>,
153
154    /// CUE formatter configuration (cue fmt)
155    #[serde(skip_serializing_if = "Option::is_none")]
156    pub cue: Option<CueFormatter>,
157}
158
159/// Rust formatter configuration
160#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
161#[serde(rename_all = "camelCase")]
162pub struct RustFormatter {
163    /// Whether this formatter is enabled (default: true)
164    #[serde(default = "default_true")]
165    pub enabled: bool,
166
167    /// Glob patterns for files to format (default: ["*.rs"])
168    #[serde(default = "default_rs_includes")]
169    pub includes: Vec<String>,
170
171    /// Rust edition for formatting rules
172    #[serde(skip_serializing_if = "Option::is_none")]
173    pub edition: Option<String>,
174}
175
176impl Default for RustFormatter {
177    fn default() -> Self {
178        Self {
179            enabled: true,
180            includes: default_rs_includes(),
181            edition: None,
182        }
183    }
184}
185
186fn default_rs_includes() -> Vec<String> {
187    vec!["*.rs".to_string()]
188}
189
190/// Nix formatter tool selection
191#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
192#[serde(rename_all = "lowercase")]
193pub enum NixFormatterTool {
194    /// Use nixfmt (default)
195    #[default]
196    Nixfmt,
197    /// Use alejandra
198    Alejandra,
199}
200
201impl NixFormatterTool {
202    /// Get the command name for this tool
203    #[must_use]
204    pub fn command(&self) -> &'static str {
205        match self {
206            Self::Nixfmt => "nixfmt",
207            Self::Alejandra => "alejandra",
208        }
209    }
210
211    /// Get the check flag for this tool
212    #[must_use]
213    pub fn check_flag(&self) -> &'static str {
214        match self {
215            Self::Nixfmt => "--check",
216            Self::Alejandra => "-c",
217        }
218    }
219}
220
221/// Nix formatter configuration
222#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
223#[serde(rename_all = "camelCase")]
224pub struct NixFormatter {
225    /// Whether this formatter is enabled (default: true)
226    #[serde(default = "default_true")]
227    pub enabled: bool,
228
229    /// Glob patterns for files to format (default: ["*.nix"])
230    #[serde(default = "default_nix_includes")]
231    pub includes: Vec<String>,
232
233    /// Which Nix formatter tool to use (nixfmt or alejandra)
234    #[serde(default)]
235    pub tool: NixFormatterTool,
236}
237
238impl Default for NixFormatter {
239    fn default() -> Self {
240        Self {
241            enabled: true,
242            includes: default_nix_includes(),
243            tool: NixFormatterTool::default(),
244        }
245    }
246}
247
248fn default_nix_includes() -> Vec<String> {
249    vec!["*.nix".to_string()]
250}
251
252/// Go formatter configuration
253#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
254#[serde(rename_all = "camelCase")]
255pub struct GoFormatter {
256    /// Whether this formatter is enabled (default: true)
257    #[serde(default = "default_true")]
258    pub enabled: bool,
259
260    /// Glob patterns for files to format (default: ["*.go"])
261    #[serde(default = "default_go_includes")]
262    pub includes: Vec<String>,
263}
264
265impl Default for GoFormatter {
266    fn default() -> Self {
267        Self {
268            enabled: true,
269            includes: default_go_includes(),
270        }
271    }
272}
273
274fn default_go_includes() -> Vec<String> {
275    vec!["*.go".to_string()]
276}
277
278/// CUE formatter configuration
279#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
280#[serde(rename_all = "camelCase")]
281pub struct CueFormatter {
282    /// Whether this formatter is enabled (default: true)
283    #[serde(default = "default_true")]
284    pub enabled: bool,
285
286    /// Glob patterns for files to format (default: ["*.cue"])
287    #[serde(default = "default_cue_includes")]
288    pub includes: Vec<String>,
289}
290
291impl Default for CueFormatter {
292    fn default() -> Self {
293        Self {
294            enabled: true,
295            includes: default_cue_includes(),
296        }
297    }
298}
299
300fn default_cue_includes() -> Vec<String> {
301    vec!["*.cue".to_string()]
302}
303
304/// Ignore patterns for tool-specific ignore files.
305/// Keys are tool names (e.g., "git", "docker", "prettier").
306/// Values can be either:
307/// - A list of patterns: `["node_modules/", ".env"]`
308/// - An object with patterns and optional filename override
309pub type Ignore = HashMap<String, IgnoreValue>;
310
311// ============================================================================
312// Codegen Types (for code generation)
313// ============================================================================
314
315/// File generation mode
316#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
317#[serde(rename_all = "lowercase")]
318pub enum FileMode {
319    /// Always regenerate this file (managed by codegen)
320    #[default]
321    Managed,
322    /// Generate only if file doesn't exist (user owns this file)
323    Scaffold,
324}
325
326/// Format configuration for a generated file
327#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
328#[serde(rename_all = "camelCase")]
329pub struct FormatConfig {
330    /// Indent style: "space" or "tab"
331    #[serde(default = "default_indent")]
332    pub indent: String,
333    /// Indent size (number of spaces or tab width)
334    #[serde(skip_serializing_if = "Option::is_none")]
335    pub indent_size: Option<usize>,
336    /// Maximum line width
337    #[serde(skip_serializing_if = "Option::is_none")]
338    pub line_width: Option<usize>,
339    /// Trailing comma style
340    #[serde(skip_serializing_if = "Option::is_none")]
341    pub trailing_comma: Option<String>,
342    /// Use semicolons
343    #[serde(skip_serializing_if = "Option::is_none")]
344    pub semicolons: Option<bool>,
345    /// Quote style: "single" or "double"
346    #[serde(skip_serializing_if = "Option::is_none")]
347    pub quotes: Option<String>,
348}
349
350fn default_indent() -> String {
351    "space".to_string()
352}
353
354/// A file definition from the codegen configuration
355#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
356pub struct ProjectFile {
357    /// Content of the file
358    pub content: String,
359    /// Programming language of the file
360    pub language: String,
361    /// Generation mode (managed or scaffold)
362    #[serde(default)]
363    pub mode: FileMode,
364    /// Formatting configuration
365    #[serde(default)]
366    pub format: FormatConfig,
367    /// Whether to add this file path to .gitignore.
368    /// Defaults based on mode (set in CUE schema):
369    ///   - managed: true (generated files should be ignored)
370    ///   - scaffold: false (user-owned files should be committed)
371    #[serde(default)]
372    pub gitignore: bool,
373}
374
375/// Codegen configuration containing file definitions for code generation
376#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
377pub struct CodegenConfig {
378    /// Map of file paths to their definitions
379    #[serde(default)]
380    pub files: HashMap<String, ProjectFile>,
381    /// Optional context data for templating
382    #[serde(default)]
383    pub context: serde_json::Value,
384}
385
386/// Value for an ignore entry - either a simple list of patterns or an extended config.
387#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
388#[serde(untagged)]
389pub enum IgnoreValue {
390    /// Simple list of patterns
391    Patterns(Vec<String>),
392    /// Extended config with patterns and optional filename override
393    Extended(IgnoreEntry),
394}
395
396/// Extended ignore configuration with patterns and optional filename override.
397#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
398pub struct IgnoreEntry {
399    /// List of patterns to include in the ignore file
400    pub patterns: Vec<String>,
401    /// Optional filename override (defaults to `.{tool}ignore`)
402    #[serde(skip_serializing_if = "Option::is_none")]
403    pub filename: Option<String>,
404}
405
406impl IgnoreValue {
407    /// Get the patterns from this ignore value.
408    #[must_use]
409    pub fn patterns(&self) -> &[String] {
410        match self {
411            Self::Patterns(patterns) => patterns,
412            Self::Extended(entry) => &entry.patterns,
413        }
414    }
415
416    /// Get the optional filename override.
417    #[must_use]
418    pub fn filename(&self) -> Option<&str> {
419        match self {
420            Self::Patterns(_) => None,
421            Self::Extended(entry) => entry.filename.as_deref(),
422        }
423    }
424}
425
426// ============================================================================
427// Directory Rules Types (for .rules.cue files)
428// ============================================================================
429
430/// Directory-scoped rules configuration from .rules.cue files.
431///
432/// Each .rules.cue file is evaluated independently (no CUE unification).
433#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
434#[serde(rename_all = "camelCase")]
435pub struct DirectoryRules {
436    /// Ignore patterns for tool-specific ignore files.
437    /// Generates files in the same directory as .rules.cue.
438    #[serde(skip_serializing_if = "Option::is_none")]
439    pub ignore: Option<Ignore>,
440
441    /// Code ownership rules.
442    /// Aggregated across all .rules.cue files to generate
443    /// a single CODEOWNERS file at the repository root.
444    #[serde(skip_serializing_if = "Option::is_none")]
445    pub owners: Option<RulesOwners>,
446
447    /// EditorConfig settings.
448    /// Generates .editorconfig in the same directory as .rules.cue.
449    #[serde(skip_serializing_if = "Option::is_none")]
450    pub editorconfig: Option<EditorConfig>,
451}
452
453/// Simplified owners for directory rules (no output config).
454#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
455pub struct RulesOwners {
456    /// Code ownership rules - maps rule names to rule definitions.
457    #[serde(default)]
458    pub rules: HashMap<String, crate::owners::OwnerRule>,
459}
460
461/// EditorConfig configuration.
462///
463/// Note: `root = true` is auto-injected for the .editorconfig at repo root.
464#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
465pub struct EditorConfig {
466    /// File-pattern specific settings.
467    #[serde(flatten)]
468    pub sections: std::collections::BTreeMap<String, EditorConfigSection>,
469}
470
471/// A section in an EditorConfig file.
472#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
473#[serde(rename_all = "snake_case")]
474pub struct EditorConfigSection {
475    /// Indentation style: "tab" or "space"
476    #[serde(skip_serializing_if = "Option::is_none")]
477    pub indent_style: Option<String>,
478
479    /// Number of columns for each indentation level, or "tab"
480    #[serde(skip_serializing_if = "Option::is_none")]
481    pub indent_size: Option<EditorConfigValue>,
482
483    /// Number of columns for tab character display
484    #[serde(skip_serializing_if = "Option::is_none")]
485    pub tab_width: Option<u32>,
486
487    /// Line ending style: "lf", "crlf", or "cr"
488    #[serde(skip_serializing_if = "Option::is_none")]
489    pub end_of_line: Option<String>,
490
491    /// Character encoding
492    #[serde(skip_serializing_if = "Option::is_none")]
493    pub charset: Option<String>,
494
495    /// Remove trailing whitespace on save
496    #[serde(skip_serializing_if = "Option::is_none")]
497    pub trim_trailing_whitespace: Option<bool>,
498
499    /// Ensure file ends with a newline
500    #[serde(skip_serializing_if = "Option::is_none")]
501    pub insert_final_newline: Option<bool>,
502
503    /// Maximum line length (soft limit), or "off"
504    #[serde(skip_serializing_if = "Option::is_none")]
505    pub max_line_length: Option<EditorConfigValue>,
506}
507
508/// A value that can be either an integer or a special string value.
509#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
510#[serde(untagged)]
511pub enum EditorConfigValue {
512    /// Integer value
513    Int(u32),
514    /// String value (e.g., "tab" for indent_size, "off" for max_line_length)
515    String(String),
516}
517
518// ============================================================================
519// Runtime Types
520// ============================================================================
521
522/// Runtime declares where/how a task executes.
523/// Set at project level as the default, override per-task as needed.
524#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
525#[serde(tag = "type", rename_all = "lowercase")]
526pub enum Runtime {
527    /// Activate Nix devShell before execution
528    Nix(NixRuntime),
529    /// Activate devenv shell before execution
530    Devenv(DevenvRuntime),
531    /// Simple container execution
532    Container(ContainerRuntime),
533    /// Advanced container with caching, secrets, chaining
534    Dagger(DaggerRuntime),
535    /// OCI-based binary fetching from container images
536    Oci(OciRuntime),
537    /// Multi-source tool management (GitHub, OCI, Nix)
538    Tools(Box<ToolsRuntime>),
539}
540
541/// Nix runtime configuration
542#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
543pub struct NixRuntime {
544    /// Flake reference (default: "." for local flake.nix)
545    #[serde(default = "default_flake")]
546    pub flake: String,
547    /// Output attribute path (default: devShells.${system}.default)
548    #[serde(skip_serializing_if = "Option::is_none")]
549    pub output: Option<String>,
550}
551
552impl Default for NixRuntime {
553    fn default() -> Self {
554        Self {
555            flake: default_flake(),
556            output: None,
557        }
558    }
559}
560
561fn default_flake() -> String {
562    ".".to_string()
563}
564
565/// Devenv runtime configuration
566#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
567pub struct DevenvRuntime {
568    /// Path to devenv config directory (default: ".")
569    #[serde(default = "default_flake")]
570    pub path: String,
571}
572
573/// Simple container runtime configuration
574#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
575pub struct ContainerRuntime {
576    /// Container image (e.g., "node:20-alpine", "rust:1.75-slim")
577    pub image: String,
578}
579
580/// Dagger runtime configuration (advanced container orchestration)
581#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
582pub struct DaggerRuntime {
583    /// Base container image (required unless 'from' is specified)
584    #[serde(skip_serializing_if = "Option::is_none")]
585    pub image: Option<String>,
586    /// Use container from a previous task as base
587    #[serde(skip_serializing_if = "Option::is_none")]
588    pub from: Option<String>,
589    /// Secrets to mount or expose as environment variables
590    #[serde(default, skip_serializing_if = "Vec::is_empty")]
591    pub secrets: Vec<DaggerSecret>,
592    /// Cache volumes for persistent build caching
593    #[serde(default, skip_serializing_if = "Vec::is_empty")]
594    pub cache: Vec<DaggerCacheMount>,
595}
596
597/// Secret configuration for Dagger containers
598#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
599pub struct DaggerSecret {
600    /// Name identifier for the secret
601    pub name: String,
602    /// Mount secret as a file at this path
603    #[serde(skip_serializing_if = "Option::is_none")]
604    pub path: Option<String>,
605    /// Expose secret as an environment variable with this name
606    #[serde(skip_serializing_if = "Option::is_none")]
607    pub env_var: Option<String>,
608    /// Secret resolver configuration
609    pub resolver: serde_json::Value,
610}
611
612/// Cache volume mount configuration for Dagger
613#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
614pub struct DaggerCacheMount {
615    /// Path inside the container to mount the cache
616    pub path: String,
617    /// Unique name for the cache volume
618    pub name: String,
619}
620
621/// OCI-based binary runtime configuration.
622///
623/// Fetches binaries from OCI images for hermetic, content-addressed binary management.
624/// Images require explicit `extract` paths to specify which binaries to extract.
625#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
626#[serde(rename_all = "camelCase")]
627pub struct OciRuntime {
628    /// Platforms to resolve and lock (e.g., "darwin-arm64", "linux-x86_64")
629    #[serde(default)]
630    pub platforms: Vec<String>,
631    /// OCI images to fetch binaries from
632    #[serde(default)]
633    pub images: Vec<OciImage>,
634    /// Cache directory (defaults to ~/.cache/cuenv/oci)
635    #[serde(skip_serializing_if = "Option::is_none")]
636    pub cache_dir: Option<String>,
637}
638
639/// An OCI image to extract binaries from.
640///
641/// Images require explicit `extract` paths to specify which binaries to extract.
642#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
643pub struct OciImage {
644    /// Full image reference (e.g., "nginx:1.25-alpine", "gcr.io/distroless/static:latest")
645    pub image: String,
646    /// Rename the extracted binary (when package name differs from binary name)
647    #[serde(rename = "as", skip_serializing_if = "Option::is_none")]
648    pub as_name: Option<String>,
649    /// Extraction paths specifying which binaries to extract from the image
650    #[serde(default, skip_serializing_if = "Vec::is_empty")]
651    pub extract: Vec<OciExtract>,
652}
653
654/// A binary to extract from a container image.
655#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
656pub struct OciExtract {
657    /// Path to the binary inside the container (e.g., "/usr/sbin/nginx")
658    pub path: String,
659    /// Name to expose the binary as in PATH (defaults to filename from path)
660    #[serde(rename = "as", skip_serializing_if = "Option::is_none")]
661    pub as_name: Option<String>,
662}
663
664/// GitHub provider configuration for runtime-level authentication.
665#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
666pub struct GitHubProviderConfig {
667    /// Authentication token (must use secret resolver like 1Password or exec)
668    #[serde(skip_serializing_if = "Option::is_none")]
669    pub token: Option<Secret>,
670}
671
672/// Multi-source tool runtime configuration.
673///
674/// Provides ergonomic tool management with platform-specific overrides.
675/// Simple case: `jq: "1.7.1"` requires a source to be defined.
676/// Complex case: Platform-specific sources with overrides.
677#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
678#[serde(rename_all = "camelCase")]
679pub struct ToolsRuntime {
680    /// Platforms to resolve and lock (e.g., "darwin-arm64", "linux-x86_64")
681    #[serde(default)]
682    pub platforms: Vec<String>,
683    /// Named Nix flake references for pinning
684    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
685    pub flakes: HashMap<String, String>,
686    /// GitHub provider configuration
687    #[serde(skip_serializing_if = "Option::is_none")]
688    pub github: Option<GitHubProviderConfig>,
689    /// Tool specifications (version string or full Tool config)
690    #[serde(default)]
691    pub tools: HashMap<String, ToolSpec>,
692    /// Cache directory (defaults to ~/.cache/cuenv/tools)
693    #[serde(skip_serializing_if = "Option::is_none")]
694    pub cache_dir: Option<String>,
695}
696
697/// Tool specification - either a simple version or full config.
698#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
699#[serde(untagged)]
700pub enum ToolSpec {
701    /// Simple version string (requires explicit source configuration)
702    Version(String),
703    /// Full tool configuration with source and overrides
704    Full(ToolConfig),
705}
706
707impl ToolSpec {
708    /// Get the version string.
709    #[must_use]
710    pub fn version(&self) -> &str {
711        match self {
712            Self::Version(v) => v,
713            Self::Full(c) => &c.version,
714        }
715    }
716}
717
718/// Full tool configuration with source and platform overrides.
719#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
720#[serde(rename_all = "camelCase")]
721pub struct ToolConfig {
722    /// Version string (e.g., "1.7.1", "latest")
723    pub version: String,
724    /// Rename the binary in PATH
725    #[serde(rename = "as", skip_serializing_if = "Option::is_none")]
726    pub as_name: Option<String>,
727    /// Default source for all platforms
728    #[serde(skip_serializing_if = "Option::is_none")]
729    pub source: Option<SourceConfig>,
730    /// Platform-specific source overrides
731    #[serde(default, skip_serializing_if = "Vec::is_empty")]
732    pub overrides: Vec<SourceOverride>,
733}
734
735/// Platform-specific source override.
736#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
737pub struct SourceOverride {
738    /// Match by OS (darwin, linux)
739    #[serde(skip_serializing_if = "Option::is_none")]
740    pub os: Option<String>,
741    /// Match by architecture (arm64, x86_64)
742    #[serde(skip_serializing_if = "Option::is_none")]
743    pub arch: Option<String>,
744    /// Source for matching platforms
745    pub source: SourceConfig,
746}
747
748/// Source configuration for fetching a tool.
749#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
750#[serde(tag = "type", rename_all = "lowercase")]
751pub enum SourceConfig {
752    /// Extract from OCI container image
753    Oci {
754        /// Image reference with optional {version}, {os}, {arch} templates
755        image: String,
756        /// Path to binary inside the container
757        path: String,
758    },
759    /// Download from GitHub Releases
760    #[serde(rename = "github")]
761    GitHub {
762        /// Repository (owner/repo)
763        repo: String,
764        /// Tag prefix (prepended to version, defaults to "")
765        #[serde(default, rename = "tagPrefix")]
766        tag_prefix: String,
767        /// Release tag override (if set, ignores tagPrefix)
768        #[serde(skip_serializing_if = "Option::is_none")]
769        tag: Option<String>,
770        /// Asset name with optional {version}, {os}, {arch} templates
771        asset: String,
772        /// Legacy single-file selector inside archive/pkg payloads.
773        #[serde(skip_serializing_if = "Option::is_none")]
774        path: Option<String>,
775        /// Optional typed extraction rules for archive/pkg assets.
776        #[serde(default, skip_serializing_if = "Vec::is_empty")]
777        extract: Vec<GitHubExtract>,
778    },
779    /// Build from Nix flake
780    Nix {
781        /// Named flake reference (key in runtime.flakes)
782        flake: String,
783        /// Package attribute (e.g., "jq", "python3")
784        package: String,
785        /// Output path if binary can't be auto-detected
786        #[serde(skip_serializing_if = "Option::is_none")]
787        output: Option<String>,
788    },
789    /// Install via rustup
790    Rustup {
791        /// Toolchain identifier (e.g., "stable", "1.83.0", "nightly-2024-01-01")
792        toolchain: String,
793        /// Installation profile: minimal, default, complete
794        #[serde(default = "default_rustup_profile")]
795        profile: String,
796        /// Additional components to install (e.g., "clippy", "rustfmt", "rust-src")
797        #[serde(default, skip_serializing_if = "Vec::is_empty")]
798        components: Vec<String>,
799        /// Additional targets to install (e.g., "x86_64-unknown-linux-gnu")
800        #[serde(default, skip_serializing_if = "Vec::is_empty")]
801        targets: Vec<String>,
802    },
803    /// Download from an arbitrary HTTP URL
804    #[serde(rename = "url")]
805    Url {
806        /// URL with optional {version}, {os}, {arch} templates
807        url: String,
808        /// Legacy single-file selector inside archive payloads.
809        #[serde(skip_serializing_if = "Option::is_none")]
810        path: Option<String>,
811        /// Optional typed extraction rules for archive assets.
812        #[serde(default, skip_serializing_if = "Vec::is_empty")]
813        extract: Vec<GitHubExtract>,
814    },
815}
816
817fn default_rustup_profile() -> String {
818    "default".to_string()
819}
820
821/// Typed extraction rule for GitHub release assets.
822#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
823#[serde(tag = "kind", rename_all = "lowercase")]
824pub enum GitHubExtract {
825    /// Extract a binary and place it in `bin/`.
826    Bin {
827        /// Path to file in the archive/pkg payload.
828        path: String,
829        /// Optional binary rename in cache/bin.
830        #[serde(rename = "as", skip_serializing_if = "Option::is_none")]
831        as_name: Option<String>,
832    },
833    /// Extract a dynamic library and place it in `lib/`.
834    Lib {
835        /// Path to file in the archive/pkg payload.
836        path: String,
837        /// Optional env var to export the absolute file path.
838        #[serde(skip_serializing_if = "Option::is_none")]
839        env: Option<String>,
840    },
841    /// Extract include/header material and place it in `include/`.
842    Include {
843        /// Path to file in the archive/pkg payload.
844        path: String,
845    },
846    /// Extract pkg-config metadata and place it in `lib/pkgconfig/`.
847    PkgConfig {
848        /// Path to file in the archive/pkg payload.
849        path: String,
850    },
851    /// Extract a generic file and place it in `files/`.
852    File {
853        /// Path to file in the archive/pkg payload.
854        path: String,
855        /// Optional env var to export the absolute file path.
856        #[serde(skip_serializing_if = "Option::is_none")]
857        env: Option<String>,
858    },
859}
860
861// ============================================================================
862// Service Types
863// ============================================================================
864
865/// Structured command invocation: a program plus its arguments.
866///
867/// Shared base type for tasks and service entrypoints. Arguments may be
868/// literal strings or runtime task output references.
869#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
870pub struct Command {
871    /// Program to execute.
872    pub command: String,
873
874    /// Arguments (may contain output refs).
875    #[serde(default)]
876    pub args: Vec<serde_json::Value>,
877}
878
879/// Inline script invocation: a script body interpreted by a shell.
880///
881/// Shared base type for tasks and service entrypoints.
882#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
883#[serde(rename_all = "camelCase")]
884pub struct Script {
885    /// Script body.
886    pub script: String,
887
888    /// Shell interpreter (defaults to bash on the CUE side).
889    #[serde(default, skip_serializing_if = "Option::is_none")]
890    pub script_shell: Option<ScriptShell>,
891
892    /// Shell options (errexit, nounset, pipefail, xtrace).
893    #[serde(default, skip_serializing_if = "Option::is_none")]
894    pub shell_options: Option<ShellOptions>,
895}
896
897/// How a [`Service`] is executed.
898///
899/// Either:
900/// - a full [`Task`] (lets a service reuse an existing task definition),
901/// - an inline [`Script`], or
902/// - an inline [`Command`].
903///
904/// Deserialized as an untagged enum, with the most specific variant
905/// (`Task`) attempted first.
906#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
907#[serde(untagged)]
908pub enum Entrypoint {
909    /// Full task reference (or inline task) reused as a service entrypoint.
910    Task(Box<Task>),
911    /// Inline script.
912    Script(Script),
913    /// Inline command.
914    Command(Command),
915}
916
917impl Default for Entrypoint {
918    fn default() -> Self {
919        Entrypoint::Command(Command::default())
920    }
921}
922
923/// Long-running supervised process definition.
924///
925/// Services live alongside tasks on a project but execute under different
926/// rules: they must reach a readiness state, are kept alive across the
927/// session, restart according to policy, and tear down on `cuenv down`.
928#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
929pub struct Service {
930    /// Type discriminator — always `"service"`.
931    #[serde(rename = "type", default = "default_service_type")]
932    pub service_type: String,
933
934    /// How the service process is launched.
935    #[serde(default)]
936    pub entrypoint: Entrypoint,
937
938    /// Environment variables (same shape as Task).
939    #[serde(default)]
940    pub env: HashMap<String, EnvValue>,
941
942    /// Working directory override.
943    #[serde(default, skip_serializing_if = "Option::is_none")]
944    pub dir: Option<String>,
945
946    /// Dependencies — may reference tasks OR services.
947    #[serde(default, rename = "dependsOn")]
948    pub depends_on: Vec<TaskDependency>,
949
950    /// Labels for discovery via ServiceMatcher.
951    #[serde(default)]
952    pub labels: Vec<String>,
953
954    /// Human-readable description.
955    #[serde(default, skip_serializing_if = "Option::is_none")]
956    pub description: Option<String>,
957
958    /// Runtime override for this service.
959    #[serde(default, skip_serializing_if = "Option::is_none")]
960    pub runtime: Option<Runtime>,
961
962    /// Readiness probe (single probe per service).
963    #[serde(default, skip_serializing_if = "Option::is_none")]
964    pub readiness: Option<Readiness>,
965
966    /// Restart policy.
967    #[serde(default, skip_serializing_if = "Option::is_none")]
968    pub restart: Option<RestartPolicy>,
969
970    /// File watcher for restart-on-change.
971    #[serde(default, skip_serializing_if = "Option::is_none")]
972    pub watch: Option<ServiceWatch>,
973
974    /// Log handling configuration.
975    #[serde(default, skip_serializing_if = "Option::is_none")]
976    pub logs: Option<ServiceLogs>,
977
978    /// Shutdown behavior.
979    #[serde(default, skip_serializing_if = "Option::is_none")]
980    pub shutdown: Option<Shutdown>,
981
982    /// Hard kill if startup-to-ready exceeds this duration.
983    #[serde(default, skip_serializing_if = "Option::is_none")]
984    pub timeout: Option<String>,
985}
986
987impl Service {
988    /// Return the primary program name for workspace-detection heuristics
989    /// (bun, cargo, etc.). Scripts have no single program.
990    #[must_use]
991    pub fn primary_command(&self) -> Option<&str> {
992        match &self.entrypoint {
993            Entrypoint::Task(task) => {
994                if task.command.is_empty() {
995                    None
996                } else {
997                    Some(task.command.as_str())
998                }
999            }
1000            Entrypoint::Command(cmd) => Some(cmd.command.as_str()),
1001            Entrypoint::Script(_) => None,
1002        }
1003    }
1004}
1005
1006fn default_service_type() -> String {
1007    "service".to_string()
1008}
1009
1010// ============================================================================
1011// Container Image Types
1012// ============================================================================
1013
1014/// Output reference for a container image (ref or digest).
1015///
1016/// Mirrors [`TaskOutputRef`] but for image build outputs. The executor
1017/// resolves these at runtime after the image is built.
1018#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1019pub struct ImageOutputRef {
1020    #[serde(rename = "cuenvOutputRef")]
1021    pub cuenv_output_ref: bool,
1022    #[serde(rename = "cuenvImage")]
1023    pub cuenv_image: String,
1024    #[serde(rename = "cuenvOutput")]
1025    pub cuenv_output: String,
1026}
1027
1028/// Container image build definition.
1029///
1030/// Declares a container image as a first-class project artifact. Images
1031/// participate in the task DAG and produce output references (`.ref`,
1032/// `.digest`) that downstream tasks can consume.
1033#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1034pub struct ContainerImage {
1035    /// Type discriminator — always `"image"`.
1036    #[serde(rename = "type", default = "default_image_type")]
1037    pub image_type: String,
1038
1039    /// Image reference output — resolved at runtime after build.
1040    #[serde(rename = "ref")]
1041    pub ref_output: ImageOutputRef,
1042
1043    /// Image digest output — resolved at runtime after build.
1044    pub digest: ImageOutputRef,
1045
1046    /// Build context directory (required).
1047    pub context: String,
1048
1049    /// Dockerfile path relative to context.
1050    #[serde(default = "default_dockerfile")]
1051    pub dockerfile: String,
1052
1053    /// Build arguments (values may be literal strings or image output refs).
1054    #[serde(
1055        default,
1056        rename = "buildArgs",
1057        skip_serializing_if = "HashMap::is_empty"
1058    )]
1059    pub build_args: HashMap<String, serde_json::Value>,
1060
1061    /// Target stage for multi-stage builds.
1062    #[serde(default, skip_serializing_if = "Option::is_none")]
1063    pub target: Option<String>,
1064
1065    /// Image tags (e.g., `["latest", "v1.0.0"]`).
1066    #[serde(default)]
1067    pub tags: Vec<String>,
1068
1069    /// Registry to push to (omit for local-only builds).
1070    #[serde(default, skip_serializing_if = "Option::is_none")]
1071    pub registry: Option<String>,
1072
1073    /// Repository name (defaults to image name if omitted).
1074    #[serde(default, skip_serializing_if = "Option::is_none")]
1075    pub repository: Option<String>,
1076
1077    /// Target platforms for multi-arch builds.
1078    #[serde(default)]
1079    pub platform: Vec<String>,
1080
1081    /// Dependencies on tasks or other images.
1082    #[serde(default, rename = "dependsOn")]
1083    pub depends_on: Vec<TaskDependency>,
1084
1085    /// Labels for discovery.
1086    #[serde(default)]
1087    pub labels: Vec<String>,
1088
1089    /// Input files/patterns for cache key derivation.
1090    #[serde(default)]
1091    pub inputs: Vec<Input>,
1092
1093    /// Human-readable description.
1094    #[serde(default, skip_serializing_if = "Option::is_none")]
1095    pub description: Option<String>,
1096}
1097
1098fn default_image_type() -> String {
1099    "image".to_string()
1100}
1101
1102fn default_dockerfile() -> String {
1103    "Dockerfile".to_string()
1104}
1105
1106/// Readiness probe — discriminated by `kind` field.
1107#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1108#[serde(tag = "kind")]
1109pub enum Readiness {
1110    /// TCP port connectivity check.
1111    #[serde(rename = "port")]
1112    Port(ReadinessPort),
1113    /// HTTP endpoint check.
1114    #[serde(rename = "http")]
1115    Http(ReadinessHttp),
1116    /// Regex match on service output.
1117    #[serde(rename = "log")]
1118    Log(ReadinessLog),
1119    /// External command check (exit 0 = ready).
1120    #[serde(rename = "command")]
1121    Command(ReadinessCommand),
1122    /// Simple delay before considering ready.
1123    #[serde(rename = "delay")]
1124    Delay(ReadinessDelay),
1125}
1126
1127/// Common readiness probe fields.
1128#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1129pub struct ReadinessCommon {
1130    /// Time between probe attempts (e.g., "500ms").
1131    #[serde(default, skip_serializing_if = "Option::is_none")]
1132    pub interval: Option<String>,
1133    /// Max time to reach ready (e.g., "60s").
1134    #[serde(default, skip_serializing_if = "Option::is_none")]
1135    pub timeout: Option<String>,
1136    /// Initial delay before first probe (e.g., "0s").
1137    #[serde(
1138        default,
1139        rename = "initialDelay",
1140        skip_serializing_if = "Option::is_none"
1141    )]
1142    pub initial_delay: Option<String>,
1143}
1144
1145/// TCP port readiness probe.
1146#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1147pub struct ReadinessPort {
1148    /// Common probe settings.
1149    #[serde(flatten)]
1150    pub common: ReadinessCommon,
1151    /// TCP port on localhost.
1152    pub port: u16,
1153    /// Host to connect to (default: 127.0.0.1).
1154    #[serde(default, skip_serializing_if = "Option::is_none")]
1155    pub host: Option<String>,
1156}
1157
1158/// HTTP readiness probe.
1159#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1160pub struct ReadinessHttp {
1161    /// Common probe settings.
1162    #[serde(flatten)]
1163    pub common: ReadinessCommon,
1164    /// URL to check.
1165    pub url: String,
1166    /// Expected status codes (default: 2xx).
1167    #[serde(
1168        default,
1169        rename = "expectStatus",
1170        skip_serializing_if = "Option::is_none"
1171    )]
1172    pub expect_status: Option<Vec<u16>>,
1173    /// HTTP method (default: GET).
1174    #[serde(default, skip_serializing_if = "Option::is_none")]
1175    pub method: Option<String>,
1176}
1177
1178/// Log pattern readiness probe.
1179#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1180pub struct ReadinessLog {
1181    /// Common probe settings.
1182    #[serde(flatten)]
1183    pub common: ReadinessCommon,
1184    /// Regex pattern — first match declares ready.
1185    pub pattern: String,
1186    /// Which stream to watch (default: "either").
1187    #[serde(default, skip_serializing_if = "Option::is_none")]
1188    pub source: Option<String>,
1189}
1190
1191/// External command readiness probe.
1192#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1193pub struct ReadinessCommand {
1194    /// Common probe settings.
1195    #[serde(flatten)]
1196    pub common: ReadinessCommon,
1197    /// Command to run (exit 0 = ready).
1198    pub command: String,
1199    /// Command arguments.
1200    #[serde(default)]
1201    pub args: Vec<String>,
1202}
1203
1204/// Simple delay readiness probe.
1205#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1206pub struct ReadinessDelay {
1207    /// Duration to wait before considering ready.
1208    pub delay: String,
1209}
1210
1211impl Readiness {
1212    /// Access the common probe fields shared by all readiness types.
1213    ///
1214    /// Returns `None` for `Delay`, which has no common fields.
1215    #[must_use]
1216    pub fn common_fields(&self) -> Option<&ReadinessCommon> {
1217        match self {
1218            Self::Port(p) => Some(&p.common),
1219            Self::Http(h) => Some(&h.common),
1220            Self::Log(l) => Some(&l.common),
1221            Self::Command(c) => Some(&c.common),
1222            Self::Delay(_) => None,
1223        }
1224    }
1225}
1226
1227/// Restart policy for services.
1228#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1229pub struct RestartPolicy {
1230    /// Restart mode (default: "onFailure").
1231    #[serde(default, skip_serializing_if = "Option::is_none")]
1232    pub mode: Option<String>,
1233    /// Exponential backoff between restarts.
1234    #[serde(default, skip_serializing_if = "Option::is_none")]
1235    pub backoff: Option<BackoffConfig>,
1236    /// Max restarts within the sliding window (default: 5).
1237    #[serde(
1238        default,
1239        rename = "maxRestarts",
1240        skip_serializing_if = "Option::is_none"
1241    )]
1242    pub max_restarts: Option<u32>,
1243    /// Sliding window for restart counting (default: "60s").
1244    #[serde(default, skip_serializing_if = "Option::is_none")]
1245    pub window: Option<String>,
1246}
1247
1248/// Exponential backoff configuration.
1249#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1250pub struct BackoffConfig {
1251    /// Initial delay (default: "1s").
1252    #[serde(default, skip_serializing_if = "Option::is_none")]
1253    pub initial: Option<String>,
1254    /// Maximum delay (default: "30s").
1255    #[serde(default, skip_serializing_if = "Option::is_none")]
1256    pub max: Option<String>,
1257    /// Backoff multiplier (default: 2.0).
1258    #[serde(default, skip_serializing_if = "Option::is_none")]
1259    pub factor: Option<f64>,
1260}
1261
1262/// File watcher configuration for services.
1263#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1264pub struct ServiceWatch {
1265    /// Glob patterns relative to project root.
1266    pub paths: Vec<String>,
1267    /// Patterns to ignore (gitignore syntax).
1268    #[serde(default, skip_serializing_if = "Option::is_none")]
1269    pub ignore: Option<Vec<String>>,
1270    /// Debounce window (default: "200ms").
1271    #[serde(default, skip_serializing_if = "Option::is_none")]
1272    pub debounce: Option<String>,
1273    /// Action on change (default: "restart").
1274    #[serde(default, skip_serializing_if = "Option::is_none")]
1275    pub on: Option<String>,
1276    /// Tasks to re-run before restart.
1277    #[serde(default, skip_serializing_if = "Option::is_none")]
1278    pub rebuild: Option<Vec<TaskDependency>>,
1279}
1280
1281/// Service log configuration.
1282#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1283pub struct ServiceLogs {
1284    /// Stream prefix shown in multiplexed output.
1285    #[serde(default, skip_serializing_if = "Option::is_none")]
1286    pub prefix: Option<String>,
1287    /// ANSI color hint for renderers.
1288    #[serde(default, skip_serializing_if = "Option::is_none")]
1289    pub color: Option<String>,
1290    /// Persist to file (default: true).
1291    #[serde(default, skip_serializing_if = "Option::is_none")]
1292    pub persist: Option<bool>,
1293}
1294
1295/// Shutdown behavior for services.
1296#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1297pub struct Shutdown {
1298    /// Signal to send (default: "SIGTERM").
1299    #[serde(default, skip_serializing_if = "Option::is_none")]
1300    pub signal: Option<String>,
1301    /// Grace period before SIGKILL (default: "10s").
1302    #[serde(default, skip_serializing_if = "Option::is_none")]
1303    pub timeout: Option<String>,
1304}
1305
1306// ============================================================================
1307// Project Type
1308// ============================================================================
1309
1310/// Root Project configuration structure (leaf node - cannot unify with other projects)
1311#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1312pub struct Project {
1313    /// Configuration settings
1314    #[serde(skip_serializing_if = "Option::is_none")]
1315    pub config: Option<Config>,
1316
1317    /// Project name (unique identifier, required by the CUE schema)
1318    pub name: String,
1319
1320    /// Environment variables configuration
1321    #[serde(skip_serializing_if = "Option::is_none")]
1322    pub env: Option<Env>,
1323
1324    /// Hooks configuration
1325    #[serde(skip_serializing_if = "Option::is_none")]
1326    pub hooks: Option<Hooks>,
1327
1328    /// CI configuration
1329    #[serde(skip_serializing_if = "Option::is_none")]
1330    pub ci: Option<CI>,
1331
1332    /// Tasks configuration
1333    #[serde(default)]
1334    pub tasks: HashMap<String, TaskNode>,
1335
1336    /// Services configuration — long-running supervised processes.
1337    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1338    pub services: HashMap<String, Service>,
1339
1340    /// Container image build definitions.
1341    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1342    pub images: HashMap<String, ContainerImage>,
1343
1344    /// Codegen configuration for code generation
1345    #[serde(skip_serializing_if = "Option::is_none")]
1346    pub codegen: Option<CodegenConfig>,
1347
1348    /// Runtime configuration (project-level default for all tasks)
1349    #[serde(skip_serializing_if = "Option::is_none")]
1350    pub runtime: Option<Runtime>,
1351
1352    /// Formatters configuration
1353    #[serde(skip_serializing_if = "Option::is_none")]
1354    pub formatters: Option<Formatters>,
1355}
1356
1357impl Project {
1358    /// Create a new Project configuration with a required name.
1359    pub fn new(name: impl Into<String>) -> Self {
1360        Self {
1361            name: name.into(),
1362            ..Self::default()
1363        }
1364    }
1365
1366    /// Get hooks to execute when entering environment as a map (name -> hook)
1367    pub fn on_enter_hooks_map(&self) -> HashMap<String, Hook> {
1368        self.hooks
1369            .as_ref()
1370            .and_then(|h| h.on_enter.as_ref())
1371            .cloned()
1372            .unwrap_or_default()
1373    }
1374
1375    /// Get hooks to execute when entering environment, sorted by (order, name)
1376    pub fn on_enter_hooks(&self) -> Vec<Hook> {
1377        let map = self.on_enter_hooks_map();
1378        let mut hooks: Vec<(String, Hook)> = map.into_iter().collect();
1379        hooks.sort_by(|a, b| a.1.order.cmp(&b.1.order).then(a.0.cmp(&b.0)));
1380        hooks.into_iter().map(|(_, h)| h).collect()
1381    }
1382
1383    /// Get hooks to execute when exiting environment as a map (name -> hook)
1384    pub fn on_exit_hooks_map(&self) -> HashMap<String, Hook> {
1385        self.hooks
1386            .as_ref()
1387            .and_then(|h| h.on_exit.as_ref())
1388            .cloned()
1389            .unwrap_or_default()
1390    }
1391
1392    /// Get hooks to execute when exiting environment, sorted by (order, name)
1393    pub fn on_exit_hooks(&self) -> Vec<Hook> {
1394        let map = self.on_exit_hooks_map();
1395        let mut hooks: Vec<(String, Hook)> = map.into_iter().collect();
1396        hooks.sort_by(|a, b| a.1.order.cmp(&b.1.order).then(a.0.cmp(&b.0)));
1397        hooks.into_iter().map(|(_, h)| h).collect()
1398    }
1399
1400    /// Get hooks to execute before git push as a map (name -> hook)
1401    pub fn pre_push_hooks_map(&self) -> HashMap<String, Hook> {
1402        self.hooks
1403            .as_ref()
1404            .and_then(|h| h.pre_push.as_ref())
1405            .cloned()
1406            .unwrap_or_default()
1407    }
1408
1409    /// Get hooks to execute before git push, sorted by (order, name)
1410    pub fn pre_push_hooks(&self) -> Vec<Hook> {
1411        let map = self.pre_push_hooks_map();
1412        let mut hooks: Vec<(String, Hook)> = map.into_iter().collect();
1413        hooks.sort_by(|a, b| a.1.order.cmp(&b.1.order).then(a.0.cmp(&b.0)));
1414        hooks.into_iter().map(|(_, h)| h).collect()
1415    }
1416
1417    /// Returns self unchanged.
1418    ///
1419    /// Workspace detection and task injection now happens via auto-detection
1420    /// from lockfiles in the task executor. This method is kept for API compatibility.
1421    #[must_use]
1422    pub fn with_implicit_tasks(self) -> Self {
1423        self
1424    }
1425
1426    /// Expand shorthand cross-project references in inputs and implicit dependencies.
1427    ///
1428    /// Handles inputs in the format: "#project:task:path/to/file"
1429    /// Converts them to explicit ProjectReference inputs.
1430    /// Also adds implicit dependsOn entries for all project references.
1431    pub fn expand_cross_project_references(&mut self) {
1432        for (_, task_node) in self.tasks.iter_mut() {
1433            Self::expand_task_node(task_node);
1434        }
1435    }
1436
1437    fn expand_task_node(node: &mut TaskNode) {
1438        match node {
1439            TaskNode::Task(task) => Self::expand_task(task),
1440            TaskNode::Group(group) => {
1441                for sub_node in group.children.values_mut() {
1442                    Self::expand_task_node(sub_node);
1443                }
1444            }
1445            TaskNode::Sequence(steps) => {
1446                for sub_node in steps {
1447                    Self::expand_task_node(sub_node);
1448                }
1449            }
1450        }
1451    }
1452
1453    fn expand_task(task: &mut Task) {
1454        let mut new_inputs = Vec::new();
1455        let mut implicit_deps = Vec::new();
1456
1457        // Process existing inputs
1458        for input in &task.inputs {
1459            match input {
1460                Input::Path(path) if path.starts_with('#') => {
1461                    // Parse "#project:task:path"
1462                    // Remove leading #
1463                    let parts: Vec<&str> = path[1..].split(':').collect();
1464                    if parts.len() >= 3 {
1465                        let project = parts[0].to_string();
1466                        let task_name = parts[1].to_string();
1467                        // Rejoin the rest as the path (it might contain colons)
1468                        let file_path = parts[2..].join(":");
1469
1470                        new_inputs.push(Input::Project(ProjectReference {
1471                            project: project.clone(),
1472                            task: task_name.clone(),
1473                            map: vec![Mapping {
1474                                from: file_path.clone(),
1475                                to: file_path,
1476                            }],
1477                        }));
1478
1479                        // Add implicit dependency
1480                        implicit_deps.push(format!("#{}:{}", project, task_name));
1481                    } else if parts.len() == 2 {
1482                        // Handle "#project:task" as pure dependency?
1483                        // The prompt says: `["#projectName:taskName"]` for dependsOn
1484                        // For inputs, it likely expects a file mapping.
1485                        // If user puts `["#p:t"]` in inputs, it's invalid as an input unless it maps something.
1486                        // Assuming `#p:t:f` is the requirement for inputs.
1487                        // Keeping original if not matching pattern (or maybe warning?)
1488                        new_inputs.push(input.clone());
1489                    } else {
1490                        new_inputs.push(input.clone());
1491                    }
1492                }
1493                Input::Project(proj_ref) => {
1494                    // Add implicit dependency for explicit project references too
1495                    implicit_deps.push(format!("#{}:{}", proj_ref.project, proj_ref.task));
1496                    new_inputs.push(input.clone());
1497                }
1498                _ => new_inputs.push(input.clone()),
1499            }
1500        }
1501
1502        task.inputs = new_inputs;
1503
1504        // Add unique implicit dependencies
1505        for dep in implicit_deps {
1506            if !task.depends_on.iter().any(|d| d.task_name() == dep) {
1507                task.depends_on
1508                    .push(crate::tasks::TaskDependency::from_name(dep));
1509            }
1510        }
1511    }
1512}
1513
1514impl TryFrom<&Instance> for Project {
1515    type Error = crate::Error;
1516
1517    fn try_from(instance: &Instance) -> Result<Self, Self::Error> {
1518        let mut project: Project = instance.deserialize()?;
1519        project.expand_cross_project_references();
1520        Ok(project)
1521    }
1522}
1523
1524#[cfg(test)]
1525mod tests {
1526    use super::*;
1527    use crate::tasks::{TaskDependency, TaskGroup, TaskNode};
1528    use crate::test_utils::create_test_hook;
1529
1530    #[test]
1531    fn test_service_type_defaults_to_service_when_omitted() {
1532        let service: Service = serde_json::from_value(serde_json::json!({
1533            "entrypoint": { "command": "echo", "args": ["hello"] }
1534        }))
1535        .expect("service should deserialize without explicit type");
1536
1537        assert_eq!(service.service_type, "service");
1538    }
1539
1540    #[test]
1541    fn test_service_entrypoint_command_variant() {
1542        let service: Service = serde_json::from_value(serde_json::json!({
1543            "entrypoint": { "command": "echo", "args": ["hi"] }
1544        }))
1545        .expect("should deserialize command entrypoint");
1546
1547        // Task is tried first and matches (Task accepts any {command,args})
1548        // so the command variant here is shaped like a Task with just command+args.
1549        match &service.entrypoint {
1550            Entrypoint::Task(task) => {
1551                assert_eq!(task.command, "echo");
1552            }
1553            Entrypoint::Command(cmd) => assert_eq!(cmd.command, "echo"),
1554            Entrypoint::Script(_) => panic!("expected Task or Command, got Script"),
1555        }
1556    }
1557
1558    #[test]
1559    fn test_service_entrypoint_script_variant() {
1560        let service: Service = serde_json::from_value(serde_json::json!({
1561            "entrypoint": { "script": "echo hi" }
1562        }))
1563        .expect("should deserialize script entrypoint");
1564
1565        match &service.entrypoint {
1566            Entrypoint::Task(task) => {
1567                assert_eq!(task.script.as_deref(), Some("echo hi"));
1568            }
1569            Entrypoint::Script(s) => assert_eq!(s.script, "echo hi"),
1570            Entrypoint::Command(_) => panic!("expected Task or Script, got Command"),
1571        }
1572    }
1573
1574    #[test]
1575    fn test_expand_cross_project_references() {
1576        let task = Task {
1577            inputs: vec![Input::Path("#myproj:build:dist/app.js".to_string())],
1578            ..Default::default()
1579        };
1580
1581        let mut cuenv = Project::new("test");
1582        cuenv
1583            .tasks
1584            .insert("deploy".into(), TaskNode::Task(Box::new(task)));
1585
1586        cuenv.expand_cross_project_references();
1587
1588        let task_def = cuenv.tasks.get("deploy").unwrap();
1589        let task = task_def.as_task().unwrap();
1590
1591        // Check inputs expansion
1592        assert_eq!(task.inputs.len(), 1);
1593        match &task.inputs[0] {
1594            Input::Project(proj_ref) => {
1595                assert_eq!(proj_ref.project, "myproj");
1596                assert_eq!(proj_ref.task, "build");
1597                assert_eq!(proj_ref.map.len(), 1);
1598                assert_eq!(proj_ref.map[0].from, "dist/app.js");
1599                assert_eq!(proj_ref.map[0].to, "dist/app.js");
1600            }
1601            _ => panic!("Expected ProjectReference"),
1602        }
1603
1604        // Check implicit dependency
1605        assert_eq!(task.depends_on.len(), 1);
1606        assert_eq!(task.depends_on[0].task_name(), "#myproj:build");
1607    }
1608
1609    // ============================================================================
1610    // HookItem and TaskRef Tests
1611    // ============================================================================
1612
1613    #[test]
1614    fn test_task_ref_parse_valid() {
1615        let task_ref = TaskRef {
1616            ref_: "#projen-generator:types".to_string(),
1617        };
1618
1619        let parsed = task_ref.parse();
1620        assert!(parsed.is_some());
1621
1622        let (project, task) = parsed.unwrap();
1623        assert_eq!(project, "projen-generator");
1624        assert_eq!(task, "types");
1625    }
1626
1627    #[test]
1628    fn test_task_ref_parse_with_dots() {
1629        let task_ref = TaskRef {
1630            ref_: "#my-project:bun.install".to_string(),
1631        };
1632
1633        let parsed = task_ref.parse();
1634        assert!(parsed.is_some());
1635
1636        let (project, task) = parsed.unwrap();
1637        assert_eq!(project, "my-project");
1638        assert_eq!(task, "bun.install");
1639    }
1640
1641    #[test]
1642    fn test_task_ref_parse_no_hash() {
1643        let task_ref = TaskRef {
1644            ref_: "project:task".to_string(),
1645        };
1646
1647        // Without leading #, parse should fail
1648        let parsed = task_ref.parse();
1649        assert!(parsed.is_none());
1650    }
1651
1652    #[test]
1653    fn test_task_ref_parse_no_colon() {
1654        let task_ref = TaskRef {
1655            ref_: "#project-only".to_string(),
1656        };
1657
1658        // Without colon separator, parse should fail
1659        let parsed = task_ref.parse();
1660        assert!(parsed.is_none());
1661    }
1662
1663    #[test]
1664    fn test_task_ref_parse_empty_project() {
1665        let task_ref = TaskRef {
1666            ref_: "#:task".to_string(),
1667        };
1668
1669        // Empty project name should be rejected
1670        assert!(task_ref.parse().is_none());
1671    }
1672
1673    #[test]
1674    fn test_task_ref_parse_empty_task() {
1675        let task_ref = TaskRef {
1676            ref_: "#project:".to_string(),
1677        };
1678
1679        // Empty task name should be rejected
1680        assert!(task_ref.parse().is_none());
1681    }
1682
1683    #[test]
1684    fn test_task_ref_parse_both_empty() {
1685        let task_ref = TaskRef {
1686            ref_: "#:".to_string(),
1687        };
1688
1689        // Both empty should be rejected
1690        assert!(task_ref.parse().is_none());
1691    }
1692
1693    #[test]
1694    fn test_task_ref_parse_multiple_colons() {
1695        let task_ref = TaskRef {
1696            ref_: "#project:task:extra".to_string(),
1697        };
1698
1699        // Multiple colons - first split wins
1700        let parsed = task_ref.parse();
1701        assert!(parsed.is_some());
1702        let (project, task) = parsed.unwrap();
1703        assert_eq!(project, "project");
1704        assert_eq!(task, "task:extra");
1705    }
1706
1707    #[test]
1708    fn test_task_ref_parse_unicode() {
1709        let task_ref = TaskRef {
1710            ref_: "#项目名:任务名".to_string(),
1711        };
1712
1713        let parsed = task_ref.parse();
1714        assert!(parsed.is_some());
1715        let (project, task) = parsed.unwrap();
1716        assert_eq!(project, "项目名");
1717        assert_eq!(task, "任务名");
1718    }
1719
1720    #[test]
1721    fn test_task_ref_parse_special_characters() {
1722        let task_ref = TaskRef {
1723            ref_: "#my-project_v2:build.ci-test".to_string(),
1724        };
1725
1726        let parsed = task_ref.parse();
1727        assert!(parsed.is_some());
1728        let (project, task) = parsed.unwrap();
1729        assert_eq!(project, "my-project_v2");
1730        assert_eq!(task, "build.ci-test");
1731    }
1732
1733    #[test]
1734    fn test_hook_item_task_ref_deserialization() {
1735        let json = "{\"ref\": \"#other-project:build\"}";
1736        let hook_item: HookItem = serde_json::from_str(json).unwrap();
1737
1738        match hook_item {
1739            HookItem::TaskRef(task_ref) => {
1740                assert_eq!(task_ref.ref_, "#other-project:build");
1741                let (project, task) = task_ref.parse().unwrap();
1742                assert_eq!(project, "other-project");
1743                assert_eq!(task, "build");
1744            }
1745            _ => panic!("Expected HookItem::TaskRef"),
1746        }
1747    }
1748
1749    #[test]
1750    fn test_hook_item_match_deserialization() {
1751        let json = r#"{
1752            "name": "projen",
1753            "match": {
1754                "labels": ["codegen", "projen"]
1755            }
1756        }"#;
1757        let hook_item: HookItem = serde_json::from_str(json).unwrap();
1758
1759        match hook_item {
1760            HookItem::Match(match_hook) => {
1761                assert_eq!(match_hook.name, Some("projen".to_string()));
1762                assert_eq!(
1763                    match_hook.matcher.labels,
1764                    Some(vec!["codegen".to_string(), "projen".to_string()])
1765                );
1766            }
1767            _ => panic!("Expected HookItem::Match"),
1768        }
1769    }
1770
1771    #[test]
1772    fn test_hook_item_match_with_parallel_false() {
1773        let json = r#"{
1774            "match": {
1775                "labels": ["build"],
1776                "parallel": false
1777            }
1778        }"#;
1779        let hook_item: HookItem = serde_json::from_str(json).unwrap();
1780
1781        match hook_item {
1782            HookItem::Match(match_hook) => {
1783                assert!(match_hook.name.is_none());
1784                assert!(!match_hook.matcher.parallel);
1785            }
1786            _ => panic!("Expected HookItem::Match"),
1787        }
1788    }
1789
1790    #[test]
1791    fn test_hook_item_inline_task_deserialization() {
1792        let json = r#"{
1793            "command": "echo",
1794            "args": ["hello"]
1795        }"#;
1796        let hook_item: HookItem = serde_json::from_str(json).unwrap();
1797
1798        match hook_item {
1799            HookItem::Task(task) => {
1800                assert_eq!(task.command, "echo");
1801                assert_eq!(task.args, vec!["hello"]);
1802            }
1803            _ => panic!("Expected HookItem::Task"),
1804        }
1805    }
1806
1807    #[test]
1808    fn test_task_matcher_deserialization() {
1809        let json = r#"{
1810            "labels": ["projen", "codegen"],
1811            "parallel": true
1812        }"#;
1813        let matcher: TaskMatcher = serde_json::from_str(json).unwrap();
1814
1815        assert_eq!(
1816            matcher.labels,
1817            Some(vec!["projen".to_string(), "codegen".to_string()])
1818        );
1819        assert!(matcher.parallel);
1820    }
1821
1822    #[test]
1823    fn test_task_matcher_defaults() {
1824        let json = r#"{}"#;
1825        let matcher: TaskMatcher = serde_json::from_str(json).unwrap();
1826
1827        assert!(matcher.labels.is_none());
1828        assert!(matcher.command.is_none());
1829        assert!(matcher.args.is_none());
1830        assert!(matcher.parallel); // default true
1831    }
1832
1833    #[test]
1834    fn test_task_matcher_with_command() {
1835        let json = r#"{
1836            "command": "prisma",
1837            "args": [{"contains": "generate"}]
1838        }"#;
1839        let matcher: TaskMatcher = serde_json::from_str(json).unwrap();
1840
1841        assert_eq!(matcher.command, Some("prisma".to_string()));
1842        let args = matcher.args.unwrap();
1843        assert_eq!(args.len(), 1);
1844        assert_eq!(args[0].contains, Some("generate".to_string()));
1845    }
1846
1847    // ============================================================================
1848    // Cross-Project Reference Expansion Tests
1849    // ============================================================================
1850
1851    #[test]
1852    fn test_expand_multiple_cross_project_references() {
1853        let task = Task {
1854            inputs: vec![
1855                Input::Path("#projA:build:dist/lib.js".to_string()),
1856                Input::Path("#projB:compile:out/types.d.ts".to_string()),
1857                Input::Path("src/**/*.ts".to_string()), // Local path
1858            ],
1859            ..Default::default()
1860        };
1861
1862        let mut cuenv = Project::new("test");
1863        cuenv
1864            .tasks
1865            .insert("bundle".into(), TaskNode::Task(Box::new(task)));
1866
1867        cuenv.expand_cross_project_references();
1868
1869        let task_def = cuenv.tasks.get("bundle").unwrap();
1870        let task = task_def.as_task().unwrap();
1871
1872        // Should have 3 inputs (2 project refs + 1 local)
1873        assert_eq!(task.inputs.len(), 3);
1874
1875        // Should have 2 implicit dependencies
1876        assert_eq!(task.depends_on.len(), 2);
1877        assert!(
1878            task.depends_on
1879                .iter()
1880                .any(|d| d.task_name() == "#projA:build")
1881        );
1882        assert!(
1883            task.depends_on
1884                .iter()
1885                .any(|d| d.task_name() == "#projB:compile")
1886        );
1887    }
1888
1889    #[test]
1890    fn test_expand_cross_project_in_task_group() {
1891        let task1 = Task {
1892            command: "step1".to_string(),
1893            inputs: vec![Input::Path("#projA:build:dist/lib.js".to_string())],
1894            ..Default::default()
1895        };
1896
1897        let task2 = Task {
1898            command: "step2".to_string(),
1899            inputs: vec![Input::Path("#projB:compile:out/types.d.ts".to_string())],
1900            ..Default::default()
1901        };
1902
1903        let mut cuenv = Project::new("test");
1904        cuenv.tasks.insert(
1905            "pipeline".into(),
1906            TaskNode::Sequence(vec![
1907                TaskNode::Task(Box::new(task1)),
1908                TaskNode::Task(Box::new(task2)),
1909            ]),
1910        );
1911
1912        cuenv.expand_cross_project_references();
1913
1914        // Verify expansion happened in both tasks
1915        match cuenv.tasks.get("pipeline").unwrap() {
1916            TaskNode::Sequence(steps) => {
1917                match &steps[0] {
1918                    TaskNode::Task(task) => {
1919                        assert!(
1920                            task.depends_on
1921                                .iter()
1922                                .any(|d| d.task_name() == "#projA:build")
1923                        );
1924                    }
1925                    _ => panic!("Expected single task"),
1926                }
1927                match &steps[1] {
1928                    TaskNode::Task(task) => {
1929                        assert!(
1930                            task.depends_on
1931                                .iter()
1932                                .any(|d| d.task_name() == "#projB:compile")
1933                        );
1934                    }
1935                    _ => panic!("Expected single task"),
1936                }
1937            }
1938            _ => panic!("Expected task list"),
1939        }
1940    }
1941
1942    #[test]
1943    fn test_expand_cross_project_in_parallel_group() {
1944        let task1 = Task {
1945            command: "taskA".to_string(),
1946            inputs: vec![Input::Path("#projA:build:lib.js".to_string())],
1947            ..Default::default()
1948        };
1949
1950        let task2 = Task {
1951            command: "taskB".to_string(),
1952            inputs: vec![Input::Path("#projB:build:types.d.ts".to_string())],
1953            ..Default::default()
1954        };
1955
1956        let mut parallel_tasks = HashMap::new();
1957        parallel_tasks.insert("a".to_string(), TaskNode::Task(Box::new(task1)));
1958        parallel_tasks.insert("b".to_string(), TaskNode::Task(Box::new(task2)));
1959
1960        let mut cuenv = Project::new("test");
1961        cuenv.tasks.insert(
1962            "parallel".into(),
1963            TaskNode::Group(TaskGroup {
1964                type_: "group".to_string(),
1965                children: parallel_tasks,
1966                depends_on: vec![],
1967                description: None,
1968                max_concurrency: None,
1969            }),
1970        );
1971
1972        cuenv.expand_cross_project_references();
1973
1974        // Verify expansion happened in both parallel tasks
1975        match cuenv.tasks.get("parallel").unwrap() {
1976            TaskNode::Group(group) => {
1977                match group.children.get("a").unwrap() {
1978                    TaskNode::Task(task) => {
1979                        assert!(
1980                            task.depends_on
1981                                .iter()
1982                                .any(|d| d.task_name() == "#projA:build")
1983                        );
1984                    }
1985                    _ => panic!("Expected single task"),
1986                }
1987                match group.children.get("b").unwrap() {
1988                    TaskNode::Task(task) => {
1989                        assert!(
1990                            task.depends_on
1991                                .iter()
1992                                .any(|d| d.task_name() == "#projB:build")
1993                        );
1994                    }
1995                    _ => panic!("Expected single task"),
1996                }
1997            }
1998            _ => panic!("Expected parallel group"),
1999        }
2000    }
2001
2002    #[test]
2003    fn test_no_duplicate_implicit_dependencies() {
2004        // Task already has the dependency explicitly
2005        let task = Task {
2006            depends_on: vec![TaskDependency::from_name("#myproj:build")],
2007            inputs: vec![Input::Path("#myproj:build:dist/app.js".to_string())],
2008            ..Default::default()
2009        };
2010
2011        let mut cuenv = Project::new("test");
2012        cuenv
2013            .tasks
2014            .insert("deploy".into(), TaskNode::Task(Box::new(task)));
2015
2016        cuenv.expand_cross_project_references();
2017
2018        let task_def = cuenv.tasks.get("deploy").unwrap();
2019        let task = task_def.as_task().unwrap();
2020
2021        // Should not duplicate the dependency
2022        assert_eq!(task.depends_on.len(), 1);
2023        assert_eq!(task.depends_on[0].task_name(), "#myproj:build");
2024    }
2025
2026    // ============================================================================
2027    // Project Hooks (onEnter, onExit) Tests
2028    // ============================================================================
2029
2030    #[test]
2031    fn test_on_enter_hooks_ordering() {
2032        let mut on_enter = HashMap::new();
2033        on_enter.insert("hook_c".to_string(), create_test_hook(300, "echo c"));
2034        on_enter.insert("hook_a".to_string(), create_test_hook(100, "echo a"));
2035        on_enter.insert("hook_b".to_string(), create_test_hook(200, "echo b"));
2036
2037        let mut cuenv = Project::new("test");
2038        cuenv.hooks = Some(Hooks {
2039            on_enter: Some(on_enter),
2040            on_exit: None,
2041            pre_push: None,
2042        });
2043
2044        let hooks = cuenv.on_enter_hooks();
2045        assert_eq!(hooks.len(), 3);
2046
2047        // Should be sorted by order
2048        assert_eq!(hooks[0].order, 100);
2049        assert_eq!(hooks[1].order, 200);
2050        assert_eq!(hooks[2].order, 300);
2051    }
2052
2053    #[test]
2054    fn test_on_enter_hooks_same_order_sort_by_name() {
2055        let mut on_enter = HashMap::new();
2056        on_enter.insert("z_hook".to_string(), create_test_hook(100, "echo z"));
2057        on_enter.insert("a_hook".to_string(), create_test_hook(100, "echo a"));
2058
2059        let cuenv = Project {
2060            name: "test".to_string(),
2061            hooks: Some(Hooks {
2062                on_enter: Some(on_enter),
2063                on_exit: None,
2064                pre_push: None,
2065            }),
2066            ..Default::default()
2067        };
2068
2069        let hooks = cuenv.on_enter_hooks();
2070        assert_eq!(hooks.len(), 2);
2071
2072        // Same order, should be sorted by name
2073        assert_eq!(hooks[0].command, "echo a");
2074        assert_eq!(hooks[1].command, "echo z");
2075    }
2076
2077    #[test]
2078    fn test_empty_hooks() {
2079        let cuenv = Project::new("test");
2080
2081        let on_enter = cuenv.on_enter_hooks();
2082        let on_exit = cuenv.on_exit_hooks();
2083
2084        assert!(on_enter.is_empty());
2085        assert!(on_exit.is_empty());
2086    }
2087
2088    #[test]
2089    fn test_project_deserialization_with_script_tasks() {
2090        // This test uses the new explicit API with type: "group" and flattened children
2091        let json = r#"{
2092            "name": "cuenv",
2093            "hooks": {
2094                "onEnter": {
2095                    "nix": {
2096                        "order": 10,
2097                        "propagate": false,
2098                        "command": "nix",
2099                        "args": ["print-dev-env"],
2100                        "inputs": ["flake.nix", "flake.lock"],
2101                        "source": true
2102                    }
2103                }
2104            },
2105            "tasks": {
2106                "pwd": { "command": "pwd" },
2107                "check": {
2108                    "command": "nix",
2109                    "args": ["flake", "check"],
2110                    "inputs": ["flake.nix"]
2111                },
2112                "fmt": {
2113                    "type": "group",
2114                    "fix": {
2115                        "command": "treefmt",
2116                        "inputs": [".config"]
2117                    },
2118                    "check": {
2119                        "command": "treefmt",
2120                        "args": ["--fail-on-change"],
2121                        "inputs": [".config"]
2122                    }
2123                },
2124                "cross": {
2125                    "type": "group",
2126                    "linux": {
2127                        "script": "echo building for linux",
2128                        "inputs": ["Cargo.toml"]
2129                    }
2130                },
2131                "docs": {
2132                    "type": "group",
2133                    "build": {
2134                        "command": "bash",
2135                        "args": ["-c", "bun install"],
2136                        "inputs": ["docs"],
2137                        "outputs": ["docs/dist"]
2138                    },
2139                    "deploy": {
2140                        "command": "bash",
2141                        "args": ["-c", "wrangler deploy"],
2142                        "dependsOn": ["docs.build"],
2143                        "inputs": [{"task": "docs.build"}]
2144                    }
2145                }
2146            }
2147        }"#;
2148
2149        let result: Result<Project, _> = serde_json::from_str(json);
2150        match result {
2151            Ok(project) => {
2152                assert_eq!(project.name, "cuenv");
2153                assert_eq!(project.tasks.len(), 5);
2154                assert!(project.tasks.contains_key("pwd"));
2155                assert!(project.tasks.contains_key("cross"));
2156                // Verify cross is a group with parallel subtasks
2157                let cross = project.tasks.get("cross").unwrap();
2158                assert!(cross.is_group());
2159            }
2160            Err(e) => {
2161                panic!("Failed to deserialize Project with script tasks: {}", e);
2162            }
2163        }
2164    }
2165
2166    #[test]
2167    fn test_deserialize_actual_cuenv_project() {
2168        // Read actual CUE output from /tmp/project.json (created by cue eval)
2169        let json = match std::fs::read_to_string("/tmp/project.json") {
2170            Ok(content) => content,
2171            Err(_) => return, // Skip if file doesn't exist
2172        };
2173        let result: Result<Project, _> = serde_json::from_str(&json);
2174        match result {
2175            Ok(project) => {
2176                eprintln!("Project name: {}", project.name);
2177                eprintln!("Tasks: {:?}", project.tasks.keys().collect::<Vec<_>>());
2178            }
2179            Err(e) => {
2180                eprintln!("Failed: {}", e);
2181                eprintln!("Line: {}, Col: {}", e.line(), e.column());
2182                // Read the JSON around the error line
2183                let lines: Vec<&str> = json.lines().collect();
2184                let line_num = e.line();
2185                let start = if line_num > 3 { line_num - 3 } else { 1 };
2186                let end = std::cmp::min(line_num + 3, lines.len());
2187                for i in start..=end {
2188                    if i <= lines.len() {
2189                        eprintln!("{}: {}", i, lines[i - 1]);
2190                    }
2191                }
2192                panic!("Deserialization failed");
2193            }
2194        }
2195    }
2196}