1use 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
22#[serde(untagged)]
23pub enum HookItem {
24 TaskRef(TaskRef),
26 Match(MatchHook),
28 Task(Box<Task>),
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
34#[serde(rename_all = "camelCase")]
35pub struct MatchHook {
36 #[serde(skip_serializing_if = "Option::is_none")]
38 pub name: Option<String>,
39
40 #[serde(rename = "match")]
42 pub matcher: TaskMatcher,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
47pub struct TaskRef {
48 #[serde(rename = "ref")]
51 pub ref_: String,
52}
53
54impl TaskRef {
55 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
76pub struct TaskMatcher {
77 #[serde(skip_serializing_if = "Option::is_none")]
79 pub labels: Option<Vec<String>>,
80
81 #[serde(skip_serializing_if = "Option::is_none")]
83 pub command: Option<String>,
84
85 #[serde(skip_serializing_if = "Option::is_none")]
87 pub args: Option<Vec<ArgMatcher>>,
88
89 #[serde(default = "default_true")]
91 pub parallel: bool,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
96pub struct ArgMatcher {
97 #[serde(skip_serializing_if = "Option::is_none")]
99 pub contains: Option<String>,
100
101 #[serde(skip_serializing_if = "Option::is_none")]
103 pub matches: Option<String>,
104}
105
106fn default_true() -> bool {
107 true
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
112pub struct Base {
113 #[serde(skip_serializing_if = "Option::is_none")]
115 pub config: Option<Config>,
116
117 #[serde(skip_serializing_if = "Option::is_none")]
119 pub env: Option<Env>,
120
121 #[serde(skip_serializing_if = "Option::is_none")]
123 pub formatters: Option<Formatters>,
124
125 #[serde(skip_serializing_if = "Option::is_none")]
127 pub runtime: Option<Runtime>,
128
129 #[serde(skip_serializing_if = "Option::is_none")]
131 pub hooks: Option<Hooks>,
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
140#[serde(rename_all = "camelCase")]
141pub struct Formatters {
142 #[serde(skip_serializing_if = "Option::is_none")]
144 pub rust: Option<RustFormatter>,
145
146 #[serde(skip_serializing_if = "Option::is_none")]
148 pub nix: Option<NixFormatter>,
149
150 #[serde(skip_serializing_if = "Option::is_none")]
152 pub go: Option<GoFormatter>,
153
154 #[serde(skip_serializing_if = "Option::is_none")]
156 pub cue: Option<CueFormatter>,
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
161#[serde(rename_all = "camelCase")]
162pub struct RustFormatter {
163 #[serde(default = "default_true")]
165 pub enabled: bool,
166
167 #[serde(default = "default_rs_includes")]
169 pub includes: Vec<String>,
170
171 #[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#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
192#[serde(rename_all = "lowercase")]
193pub enum NixFormatterTool {
194 #[default]
196 Nixfmt,
197 Alejandra,
199}
200
201impl NixFormatterTool {
202 #[must_use]
204 pub fn command(&self) -> &'static str {
205 match self {
206 Self::Nixfmt => "nixfmt",
207 Self::Alejandra => "alejandra",
208 }
209 }
210
211 #[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
223#[serde(rename_all = "camelCase")]
224pub struct NixFormatter {
225 #[serde(default = "default_true")]
227 pub enabled: bool,
228
229 #[serde(default = "default_nix_includes")]
231 pub includes: Vec<String>,
232
233 #[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
254#[serde(rename_all = "camelCase")]
255pub struct GoFormatter {
256 #[serde(default = "default_true")]
258 pub enabled: bool,
259
260 #[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
280#[serde(rename_all = "camelCase")]
281pub struct CueFormatter {
282 #[serde(default = "default_true")]
284 pub enabled: bool,
285
286 #[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
304pub type Ignore = HashMap<String, IgnoreValue>;
310
311#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
317#[serde(rename_all = "lowercase")]
318pub enum FileMode {
319 #[default]
321 Managed,
322 Scaffold,
324}
325
326#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
328#[serde(rename_all = "camelCase")]
329pub struct FormatConfig {
330 #[serde(default = "default_indent")]
332 pub indent: String,
333 #[serde(skip_serializing_if = "Option::is_none")]
335 pub indent_size: Option<usize>,
336 #[serde(skip_serializing_if = "Option::is_none")]
338 pub line_width: Option<usize>,
339 #[serde(skip_serializing_if = "Option::is_none")]
341 pub trailing_comma: Option<String>,
342 #[serde(skip_serializing_if = "Option::is_none")]
344 pub semicolons: Option<bool>,
345 #[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
356pub struct ProjectFile {
357 pub content: String,
359 pub language: String,
361 #[serde(default)]
363 pub mode: FileMode,
364 #[serde(default)]
366 pub format: FormatConfig,
367 #[serde(default)]
372 pub gitignore: bool,
373}
374
375#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
377pub struct CodegenConfig {
378 #[serde(default)]
380 pub files: HashMap<String, ProjectFile>,
381 #[serde(default)]
383 pub context: serde_json::Value,
384}
385
386#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
388#[serde(untagged)]
389pub enum IgnoreValue {
390 Patterns(Vec<String>),
392 Extended(IgnoreEntry),
394}
395
396#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
398pub struct IgnoreEntry {
399 pub patterns: Vec<String>,
401 #[serde(skip_serializing_if = "Option::is_none")]
403 pub filename: Option<String>,
404}
405
406impl IgnoreValue {
407 #[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 #[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
434#[serde(rename_all = "camelCase")]
435pub struct DirectoryRules {
436 #[serde(skip_serializing_if = "Option::is_none")]
439 pub ignore: Option<Ignore>,
440
441 #[serde(skip_serializing_if = "Option::is_none")]
445 pub owners: Option<RulesOwners>,
446
447 #[serde(skip_serializing_if = "Option::is_none")]
450 pub editorconfig: Option<EditorConfig>,
451}
452
453#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
455pub struct RulesOwners {
456 #[serde(default)]
458 pub rules: HashMap<String, crate::owners::OwnerRule>,
459}
460
461#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
465pub struct EditorConfig {
466 #[serde(flatten)]
468 pub sections: std::collections::BTreeMap<String, EditorConfigSection>,
469}
470
471#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
473#[serde(rename_all = "snake_case")]
474pub struct EditorConfigSection {
475 #[serde(skip_serializing_if = "Option::is_none")]
477 pub indent_style: Option<String>,
478
479 #[serde(skip_serializing_if = "Option::is_none")]
481 pub indent_size: Option<EditorConfigValue>,
482
483 #[serde(skip_serializing_if = "Option::is_none")]
485 pub tab_width: Option<u32>,
486
487 #[serde(skip_serializing_if = "Option::is_none")]
489 pub end_of_line: Option<String>,
490
491 #[serde(skip_serializing_if = "Option::is_none")]
493 pub charset: Option<String>,
494
495 #[serde(skip_serializing_if = "Option::is_none")]
497 pub trim_trailing_whitespace: Option<bool>,
498
499 #[serde(skip_serializing_if = "Option::is_none")]
501 pub insert_final_newline: Option<bool>,
502
503 #[serde(skip_serializing_if = "Option::is_none")]
505 pub max_line_length: Option<EditorConfigValue>,
506}
507
508#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
510#[serde(untagged)]
511pub enum EditorConfigValue {
512 Int(u32),
514 String(String),
516}
517
518#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
525#[serde(tag = "type", rename_all = "lowercase")]
526pub enum Runtime {
527 Nix(NixRuntime),
529 Devenv(DevenvRuntime),
531 Container(ContainerRuntime),
533 Dagger(DaggerRuntime),
535 Oci(OciRuntime),
537 Tools(Box<ToolsRuntime>),
539}
540
541#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
543pub struct NixRuntime {
544 #[serde(default = "default_flake")]
546 pub flake: String,
547 #[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
567pub struct DevenvRuntime {
568 #[serde(default = "default_flake")]
570 pub path: String,
571}
572
573#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
575pub struct ContainerRuntime {
576 pub image: String,
578}
579
580#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
582pub struct DaggerRuntime {
583 #[serde(skip_serializing_if = "Option::is_none")]
585 pub image: Option<String>,
586 #[serde(skip_serializing_if = "Option::is_none")]
588 pub from: Option<String>,
589 #[serde(default, skip_serializing_if = "Vec::is_empty")]
591 pub secrets: Vec<DaggerSecret>,
592 #[serde(default, skip_serializing_if = "Vec::is_empty")]
594 pub cache: Vec<DaggerCacheMount>,
595}
596
597#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
599pub struct DaggerSecret {
600 pub name: String,
602 #[serde(skip_serializing_if = "Option::is_none")]
604 pub path: Option<String>,
605 #[serde(skip_serializing_if = "Option::is_none")]
607 pub env_var: Option<String>,
608 pub resolver: serde_json::Value,
610}
611
612#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
614pub struct DaggerCacheMount {
615 pub path: String,
617 pub name: String,
619}
620
621#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
626#[serde(rename_all = "camelCase")]
627pub struct OciRuntime {
628 #[serde(default)]
630 pub platforms: Vec<String>,
631 #[serde(default)]
633 pub images: Vec<OciImage>,
634 #[serde(skip_serializing_if = "Option::is_none")]
636 pub cache_dir: Option<String>,
637}
638
639#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
643pub struct OciImage {
644 pub image: String,
646 #[serde(rename = "as", skip_serializing_if = "Option::is_none")]
648 pub as_name: Option<String>,
649 #[serde(default, skip_serializing_if = "Vec::is_empty")]
651 pub extract: Vec<OciExtract>,
652}
653
654#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
656pub struct OciExtract {
657 pub path: String,
659 #[serde(rename = "as", skip_serializing_if = "Option::is_none")]
661 pub as_name: Option<String>,
662}
663
664#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
666pub struct GitHubProviderConfig {
667 #[serde(skip_serializing_if = "Option::is_none")]
669 pub token: Option<Secret>,
670}
671
672#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
678#[serde(rename_all = "camelCase")]
679pub struct ToolsRuntime {
680 #[serde(default)]
682 pub platforms: Vec<String>,
683 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
685 pub flakes: HashMap<String, String>,
686 #[serde(skip_serializing_if = "Option::is_none")]
688 pub github: Option<GitHubProviderConfig>,
689 #[serde(default)]
691 pub tools: HashMap<String, ToolSpec>,
692 #[serde(skip_serializing_if = "Option::is_none")]
694 pub cache_dir: Option<String>,
695}
696
697#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
699#[serde(untagged)]
700pub enum ToolSpec {
701 Version(String),
703 Full(ToolConfig),
705}
706
707impl ToolSpec {
708 #[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
720#[serde(rename_all = "camelCase")]
721pub struct ToolConfig {
722 pub version: String,
724 #[serde(rename = "as", skip_serializing_if = "Option::is_none")]
726 pub as_name: Option<String>,
727 #[serde(skip_serializing_if = "Option::is_none")]
729 pub source: Option<SourceConfig>,
730 #[serde(default, skip_serializing_if = "Vec::is_empty")]
732 pub overrides: Vec<SourceOverride>,
733}
734
735#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
737pub struct SourceOverride {
738 #[serde(skip_serializing_if = "Option::is_none")]
740 pub os: Option<String>,
741 #[serde(skip_serializing_if = "Option::is_none")]
743 pub arch: Option<String>,
744 pub source: SourceConfig,
746}
747
748#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
750#[serde(tag = "type", rename_all = "lowercase")]
751pub enum SourceConfig {
752 Oci {
754 image: String,
756 path: String,
758 },
759 #[serde(rename = "github")]
761 GitHub {
762 repo: String,
764 #[serde(default, rename = "tagPrefix")]
766 tag_prefix: String,
767 #[serde(skip_serializing_if = "Option::is_none")]
769 tag: Option<String>,
770 asset: String,
772 #[serde(skip_serializing_if = "Option::is_none")]
774 path: Option<String>,
775 #[serde(default, skip_serializing_if = "Vec::is_empty")]
777 extract: Vec<GitHubExtract>,
778 },
779 Nix {
781 flake: String,
783 package: String,
785 #[serde(skip_serializing_if = "Option::is_none")]
787 output: Option<String>,
788 },
789 Rustup {
791 toolchain: String,
793 #[serde(default = "default_rustup_profile")]
795 profile: String,
796 #[serde(default, skip_serializing_if = "Vec::is_empty")]
798 components: Vec<String>,
799 #[serde(default, skip_serializing_if = "Vec::is_empty")]
801 targets: Vec<String>,
802 },
803 #[serde(rename = "url")]
805 Url {
806 url: String,
808 #[serde(skip_serializing_if = "Option::is_none")]
810 path: Option<String>,
811 #[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
823#[serde(tag = "kind", rename_all = "lowercase")]
824pub enum GitHubExtract {
825 Bin {
827 path: String,
829 #[serde(rename = "as", skip_serializing_if = "Option::is_none")]
831 as_name: Option<String>,
832 },
833 Lib {
835 path: String,
837 #[serde(skip_serializing_if = "Option::is_none")]
839 env: Option<String>,
840 },
841 Include {
843 path: String,
845 },
846 PkgConfig {
848 path: String,
850 },
851 File {
853 path: String,
855 #[serde(skip_serializing_if = "Option::is_none")]
857 env: Option<String>,
858 },
859}
860
861#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
870pub struct Command {
871 pub command: String,
873
874 #[serde(default)]
876 pub args: Vec<serde_json::Value>,
877}
878
879#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
883#[serde(rename_all = "camelCase")]
884pub struct Script {
885 pub script: String,
887
888 #[serde(default, skip_serializing_if = "Option::is_none")]
890 pub script_shell: Option<ScriptShell>,
891
892 #[serde(default, skip_serializing_if = "Option::is_none")]
894 pub shell_options: Option<ShellOptions>,
895}
896
897#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
907#[serde(untagged)]
908pub enum Entrypoint {
909 Task(Box<Task>),
911 Script(Script),
913 Command(Command),
915}
916
917impl Default for Entrypoint {
918 fn default() -> Self {
919 Entrypoint::Command(Command::default())
920 }
921}
922
923#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
929pub struct Service {
930 #[serde(rename = "type", default = "default_service_type")]
932 pub service_type: String,
933
934 #[serde(default)]
936 pub entrypoint: Entrypoint,
937
938 #[serde(default)]
940 pub env: HashMap<String, EnvValue>,
941
942 #[serde(default, skip_serializing_if = "Option::is_none")]
944 pub dir: Option<String>,
945
946 #[serde(default, rename = "dependsOn")]
948 pub depends_on: Vec<TaskDependency>,
949
950 #[serde(default)]
952 pub labels: Vec<String>,
953
954 #[serde(default, skip_serializing_if = "Option::is_none")]
956 pub description: Option<String>,
957
958 #[serde(default, skip_serializing_if = "Option::is_none")]
960 pub runtime: Option<Runtime>,
961
962 #[serde(default, skip_serializing_if = "Option::is_none")]
964 pub readiness: Option<Readiness>,
965
966 #[serde(default, skip_serializing_if = "Option::is_none")]
968 pub restart: Option<RestartPolicy>,
969
970 #[serde(default, skip_serializing_if = "Option::is_none")]
972 pub watch: Option<ServiceWatch>,
973
974 #[serde(default, skip_serializing_if = "Option::is_none")]
976 pub logs: Option<ServiceLogs>,
977
978 #[serde(default, skip_serializing_if = "Option::is_none")]
980 pub shutdown: Option<Shutdown>,
981
982 #[serde(default, skip_serializing_if = "Option::is_none")]
984 pub timeout: Option<String>,
985}
986
987impl Service {
988 #[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#[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1034pub struct ContainerImage {
1035 #[serde(rename = "type", default = "default_image_type")]
1037 pub image_type: String,
1038
1039 #[serde(rename = "ref")]
1041 pub ref_output: ImageOutputRef,
1042
1043 pub digest: ImageOutputRef,
1045
1046 pub context: String,
1048
1049 #[serde(default = "default_dockerfile")]
1051 pub dockerfile: String,
1052
1053 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
1063 pub target: Option<String>,
1064
1065 #[serde(default)]
1067 pub tags: Vec<String>,
1068
1069 #[serde(default, skip_serializing_if = "Option::is_none")]
1071 pub registry: Option<String>,
1072
1073 #[serde(default, skip_serializing_if = "Option::is_none")]
1075 pub repository: Option<String>,
1076
1077 #[serde(default)]
1079 pub platform: Vec<String>,
1080
1081 #[serde(default, rename = "dependsOn")]
1083 pub depends_on: Vec<TaskDependency>,
1084
1085 #[serde(default)]
1087 pub labels: Vec<String>,
1088
1089 #[serde(default)]
1091 pub inputs: Vec<Input>,
1092
1093 #[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1108#[serde(tag = "kind")]
1109pub enum Readiness {
1110 #[serde(rename = "port")]
1112 Port(ReadinessPort),
1113 #[serde(rename = "http")]
1115 Http(ReadinessHttp),
1116 #[serde(rename = "log")]
1118 Log(ReadinessLog),
1119 #[serde(rename = "command")]
1121 Command(ReadinessCommand),
1122 #[serde(rename = "delay")]
1124 Delay(ReadinessDelay),
1125}
1126
1127#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1129pub struct ReadinessCommon {
1130 #[serde(default, skip_serializing_if = "Option::is_none")]
1132 pub interval: Option<String>,
1133 #[serde(default, skip_serializing_if = "Option::is_none")]
1135 pub timeout: Option<String>,
1136 #[serde(
1138 default,
1139 rename = "initialDelay",
1140 skip_serializing_if = "Option::is_none"
1141 )]
1142 pub initial_delay: Option<String>,
1143}
1144
1145#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1147pub struct ReadinessPort {
1148 #[serde(flatten)]
1150 pub common: ReadinessCommon,
1151 pub port: u16,
1153 #[serde(default, skip_serializing_if = "Option::is_none")]
1155 pub host: Option<String>,
1156}
1157
1158#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1160pub struct ReadinessHttp {
1161 #[serde(flatten)]
1163 pub common: ReadinessCommon,
1164 pub url: String,
1166 #[serde(
1168 default,
1169 rename = "expectStatus",
1170 skip_serializing_if = "Option::is_none"
1171 )]
1172 pub expect_status: Option<Vec<u16>>,
1173 #[serde(default, skip_serializing_if = "Option::is_none")]
1175 pub method: Option<String>,
1176}
1177
1178#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1180pub struct ReadinessLog {
1181 #[serde(flatten)]
1183 pub common: ReadinessCommon,
1184 pub pattern: String,
1186 #[serde(default, skip_serializing_if = "Option::is_none")]
1188 pub source: Option<String>,
1189}
1190
1191#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1193pub struct ReadinessCommand {
1194 #[serde(flatten)]
1196 pub common: ReadinessCommon,
1197 pub command: String,
1199 #[serde(default)]
1201 pub args: Vec<String>,
1202}
1203
1204#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1206pub struct ReadinessDelay {
1207 pub delay: String,
1209}
1210
1211impl Readiness {
1212 #[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1229pub struct RestartPolicy {
1230 #[serde(default, skip_serializing_if = "Option::is_none")]
1232 pub mode: Option<String>,
1233 #[serde(default, skip_serializing_if = "Option::is_none")]
1235 pub backoff: Option<BackoffConfig>,
1236 #[serde(
1238 default,
1239 rename = "maxRestarts",
1240 skip_serializing_if = "Option::is_none"
1241 )]
1242 pub max_restarts: Option<u32>,
1243 #[serde(default, skip_serializing_if = "Option::is_none")]
1245 pub window: Option<String>,
1246}
1247
1248#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1250pub struct BackoffConfig {
1251 #[serde(default, skip_serializing_if = "Option::is_none")]
1253 pub initial: Option<String>,
1254 #[serde(default, skip_serializing_if = "Option::is_none")]
1256 pub max: Option<String>,
1257 #[serde(default, skip_serializing_if = "Option::is_none")]
1259 pub factor: Option<f64>,
1260}
1261
1262#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1264pub struct ServiceWatch {
1265 pub paths: Vec<String>,
1267 #[serde(default, skip_serializing_if = "Option::is_none")]
1269 pub ignore: Option<Vec<String>>,
1270 #[serde(default, skip_serializing_if = "Option::is_none")]
1272 pub debounce: Option<String>,
1273 #[serde(default, skip_serializing_if = "Option::is_none")]
1275 pub on: Option<String>,
1276 #[serde(default, skip_serializing_if = "Option::is_none")]
1278 pub rebuild: Option<Vec<TaskDependency>>,
1279}
1280
1281#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1283pub struct ServiceLogs {
1284 #[serde(default, skip_serializing_if = "Option::is_none")]
1286 pub prefix: Option<String>,
1287 #[serde(default, skip_serializing_if = "Option::is_none")]
1289 pub color: Option<String>,
1290 #[serde(default, skip_serializing_if = "Option::is_none")]
1292 pub persist: Option<bool>,
1293}
1294
1295#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1297pub struct Shutdown {
1298 #[serde(default, skip_serializing_if = "Option::is_none")]
1300 pub signal: Option<String>,
1301 #[serde(default, skip_serializing_if = "Option::is_none")]
1303 pub timeout: Option<String>,
1304}
1305
1306#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1312pub struct Project {
1313 #[serde(skip_serializing_if = "Option::is_none")]
1315 pub config: Option<Config>,
1316
1317 pub name: String,
1319
1320 #[serde(skip_serializing_if = "Option::is_none")]
1322 pub env: Option<Env>,
1323
1324 #[serde(skip_serializing_if = "Option::is_none")]
1326 pub hooks: Option<Hooks>,
1327
1328 #[serde(skip_serializing_if = "Option::is_none")]
1330 pub ci: Option<CI>,
1331
1332 #[serde(default)]
1334 pub tasks: HashMap<String, TaskNode>,
1335
1336 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1338 pub services: HashMap<String, Service>,
1339
1340 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1342 pub images: HashMap<String, ContainerImage>,
1343
1344 #[serde(skip_serializing_if = "Option::is_none")]
1346 pub codegen: Option<CodegenConfig>,
1347
1348 #[serde(skip_serializing_if = "Option::is_none")]
1350 pub runtime: Option<Runtime>,
1351
1352 #[serde(skip_serializing_if = "Option::is_none")]
1354 pub formatters: Option<Formatters>,
1355}
1356
1357impl Project {
1358 pub fn new(name: impl Into<String>) -> Self {
1360 Self {
1361 name: name.into(),
1362 ..Self::default()
1363 }
1364 }
1365
1366 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 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 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 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 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 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 #[must_use]
1422 pub fn with_implicit_tasks(self) -> Self {
1423 self
1424 }
1425
1426 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 for input in &task.inputs {
1459 match input {
1460 Input::Path(path) if path.starts_with('#') => {
1461 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 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 implicit_deps.push(format!("#{}:{}", project, task_name));
1481 } else if parts.len() == 2 {
1482 new_inputs.push(input.clone());
1489 } else {
1490 new_inputs.push(input.clone());
1491 }
1492 }
1493 Input::Project(proj_ref) => {
1494 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 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 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 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 assert_eq!(task.depends_on.len(), 1);
1606 assert_eq!(task.depends_on[0].task_name(), "#myproj:build");
1607 }
1608
1609 #[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 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 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 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 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 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 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); }
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 #[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()), ],
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 assert_eq!(task.inputs.len(), 3);
1874
1875 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 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 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 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 assert_eq!(task.depends_on.len(), 1);
2023 assert_eq!(task.depends_on[0].task_name(), "#myproj:build");
2024 }
2025
2026 #[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 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 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 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 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 let json = match std::fs::read_to_string("/tmp/project.json") {
2170 Ok(content) => content,
2171 Err(_) => return, };
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 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}