1use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8use crate::ci::CI;
9use crate::config::Config;
10use crate::environment::Env;
11use crate::module::Instance;
12use crate::secrets::Secret;
13use crate::tasks::Task;
14use crate::tasks::{Input, Mapping, ProjectReference, TaskNode};
15use cuenv_hooks::{Hook, Hooks};
16
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
19#[serde(untagged)]
20pub enum HookItem {
21 TaskRef(TaskRef),
23 Match(MatchHook),
25 Task(Box<Task>),
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
31#[serde(rename_all = "camelCase")]
32pub struct MatchHook {
33 #[serde(skip_serializing_if = "Option::is_none")]
35 pub name: Option<String>,
36
37 #[serde(rename = "match")]
39 pub matcher: TaskMatcher,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
44pub struct TaskRef {
45 #[serde(rename = "ref")]
48 pub ref_: String,
49}
50
51impl TaskRef {
52 pub fn parse(&self) -> Option<(String, String)> {
55 let ref_str = self.ref_.strip_prefix('#')?;
56 let parts: Vec<&str> = ref_str.splitn(2, ':').collect();
57 if parts.len() == 2 {
58 let project = parts[0];
59 let task = parts[1];
60 if !project.is_empty() && !task.is_empty() {
61 Some((project.to_string(), task.to_string()))
62 } else {
63 None
64 }
65 } else {
66 None
67 }
68 }
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
73pub struct TaskMatcher {
74 #[serde(skip_serializing_if = "Option::is_none")]
76 pub labels: Option<Vec<String>>,
77
78 #[serde(skip_serializing_if = "Option::is_none")]
80 pub command: Option<String>,
81
82 #[serde(skip_serializing_if = "Option::is_none")]
84 pub args: Option<Vec<ArgMatcher>>,
85
86 #[serde(default = "default_true")]
88 pub parallel: bool,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
93pub struct ArgMatcher {
94 #[serde(skip_serializing_if = "Option::is_none")]
96 pub contains: Option<String>,
97
98 #[serde(skip_serializing_if = "Option::is_none")]
100 pub matches: Option<String>,
101}
102
103fn default_true() -> bool {
104 true
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
109pub struct Base {
110 #[serde(skip_serializing_if = "Option::is_none")]
112 pub config: Option<Config>,
113
114 #[serde(skip_serializing_if = "Option::is_none")]
116 pub env: Option<Env>,
117
118 #[serde(skip_serializing_if = "Option::is_none")]
120 pub formatters: Option<Formatters>,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
129#[serde(rename_all = "camelCase")]
130pub struct Formatters {
131 #[serde(skip_serializing_if = "Option::is_none")]
133 pub rust: Option<RustFormatter>,
134
135 #[serde(skip_serializing_if = "Option::is_none")]
137 pub nix: Option<NixFormatter>,
138
139 #[serde(skip_serializing_if = "Option::is_none")]
141 pub go: Option<GoFormatter>,
142
143 #[serde(skip_serializing_if = "Option::is_none")]
145 pub cue: Option<CueFormatter>,
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
150#[serde(rename_all = "camelCase")]
151pub struct RustFormatter {
152 #[serde(default = "default_true")]
154 pub enabled: bool,
155
156 #[serde(default = "default_rs_includes")]
158 pub includes: Vec<String>,
159
160 #[serde(skip_serializing_if = "Option::is_none")]
162 pub edition: Option<String>,
163}
164
165impl Default for RustFormatter {
166 fn default() -> Self {
167 Self {
168 enabled: true,
169 includes: default_rs_includes(),
170 edition: None,
171 }
172 }
173}
174
175fn default_rs_includes() -> Vec<String> {
176 vec!["*.rs".to_string()]
177}
178
179#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
181#[serde(rename_all = "lowercase")]
182pub enum NixFormatterTool {
183 #[default]
185 Nixfmt,
186 Alejandra,
188}
189
190impl NixFormatterTool {
191 #[must_use]
193 pub fn command(&self) -> &'static str {
194 match self {
195 Self::Nixfmt => "nixfmt",
196 Self::Alejandra => "alejandra",
197 }
198 }
199
200 #[must_use]
202 pub fn check_flag(&self) -> &'static str {
203 match self {
204 Self::Nixfmt => "--check",
205 Self::Alejandra => "-c",
206 }
207 }
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
212#[serde(rename_all = "camelCase")]
213pub struct NixFormatter {
214 #[serde(default = "default_true")]
216 pub enabled: bool,
217
218 #[serde(default = "default_nix_includes")]
220 pub includes: Vec<String>,
221
222 #[serde(default)]
224 pub tool: NixFormatterTool,
225}
226
227impl Default for NixFormatter {
228 fn default() -> Self {
229 Self {
230 enabled: true,
231 includes: default_nix_includes(),
232 tool: NixFormatterTool::default(),
233 }
234 }
235}
236
237fn default_nix_includes() -> Vec<String> {
238 vec!["*.nix".to_string()]
239}
240
241#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
243#[serde(rename_all = "camelCase")]
244pub struct GoFormatter {
245 #[serde(default = "default_true")]
247 pub enabled: bool,
248
249 #[serde(default = "default_go_includes")]
251 pub includes: Vec<String>,
252}
253
254impl Default for GoFormatter {
255 fn default() -> Self {
256 Self {
257 enabled: true,
258 includes: default_go_includes(),
259 }
260 }
261}
262
263fn default_go_includes() -> Vec<String> {
264 vec!["*.go".to_string()]
265}
266
267#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
269#[serde(rename_all = "camelCase")]
270pub struct CueFormatter {
271 #[serde(default = "default_true")]
273 pub enabled: bool,
274
275 #[serde(default = "default_cue_includes")]
277 pub includes: Vec<String>,
278}
279
280impl Default for CueFormatter {
281 fn default() -> Self {
282 Self {
283 enabled: true,
284 includes: default_cue_includes(),
285 }
286 }
287}
288
289fn default_cue_includes() -> Vec<String> {
290 vec!["*.cue".to_string()]
291}
292
293pub type Ignore = HashMap<String, IgnoreValue>;
299
300#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
306#[serde(rename_all = "lowercase")]
307pub enum FileMode {
308 #[default]
310 Managed,
311 Scaffold,
313}
314
315#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
317#[serde(rename_all = "camelCase")]
318pub struct FormatConfig {
319 #[serde(default = "default_indent")]
321 pub indent: String,
322 #[serde(skip_serializing_if = "Option::is_none")]
324 pub indent_size: Option<usize>,
325 #[serde(skip_serializing_if = "Option::is_none")]
327 pub line_width: Option<usize>,
328 #[serde(skip_serializing_if = "Option::is_none")]
330 pub trailing_comma: Option<String>,
331 #[serde(skip_serializing_if = "Option::is_none")]
333 pub semicolons: Option<bool>,
334 #[serde(skip_serializing_if = "Option::is_none")]
336 pub quotes: Option<String>,
337}
338
339fn default_indent() -> String {
340 "space".to_string()
341}
342
343#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
345pub struct ProjectFile {
346 pub content: String,
348 pub language: String,
350 #[serde(default)]
352 pub mode: FileMode,
353 #[serde(default)]
355 pub format: FormatConfig,
356 #[serde(default)]
361 pub gitignore: bool,
362}
363
364#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
366pub struct CodegenConfig {
367 #[serde(default)]
369 pub files: HashMap<String, ProjectFile>,
370 #[serde(default)]
372 pub context: serde_json::Value,
373}
374
375#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
377#[serde(untagged)]
378pub enum IgnoreValue {
379 Patterns(Vec<String>),
381 Extended(IgnoreEntry),
383}
384
385#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
387pub struct IgnoreEntry {
388 pub patterns: Vec<String>,
390 #[serde(skip_serializing_if = "Option::is_none")]
392 pub filename: Option<String>,
393}
394
395impl IgnoreValue {
396 #[must_use]
398 pub fn patterns(&self) -> &[String] {
399 match self {
400 Self::Patterns(patterns) => patterns,
401 Self::Extended(entry) => &entry.patterns,
402 }
403 }
404
405 #[must_use]
407 pub fn filename(&self) -> Option<&str> {
408 match self {
409 Self::Patterns(_) => None,
410 Self::Extended(entry) => entry.filename.as_deref(),
411 }
412 }
413}
414
415#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
423#[serde(rename_all = "camelCase")]
424pub struct DirectoryRules {
425 #[serde(skip_serializing_if = "Option::is_none")]
428 pub ignore: Option<Ignore>,
429
430 #[serde(skip_serializing_if = "Option::is_none")]
434 pub owners: Option<RulesOwners>,
435
436 #[serde(skip_serializing_if = "Option::is_none")]
439 pub editorconfig: Option<EditorConfig>,
440}
441
442#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
444pub struct RulesOwners {
445 #[serde(default)]
447 pub rules: HashMap<String, crate::owners::OwnerRule>,
448}
449
450#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
454pub struct EditorConfig {
455 #[serde(flatten)]
457 pub sections: HashMap<String, EditorConfigSection>,
458}
459
460#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
462#[serde(rename_all = "snake_case")]
463pub struct EditorConfigSection {
464 #[serde(skip_serializing_if = "Option::is_none")]
466 pub indent_style: Option<String>,
467
468 #[serde(skip_serializing_if = "Option::is_none")]
470 pub indent_size: Option<EditorConfigValue>,
471
472 #[serde(skip_serializing_if = "Option::is_none")]
474 pub tab_width: Option<u32>,
475
476 #[serde(skip_serializing_if = "Option::is_none")]
478 pub end_of_line: Option<String>,
479
480 #[serde(skip_serializing_if = "Option::is_none")]
482 pub charset: Option<String>,
483
484 #[serde(skip_serializing_if = "Option::is_none")]
486 pub trim_trailing_whitespace: Option<bool>,
487
488 #[serde(skip_serializing_if = "Option::is_none")]
490 pub insert_final_newline: Option<bool>,
491
492 #[serde(skip_serializing_if = "Option::is_none")]
494 pub max_line_length: Option<EditorConfigValue>,
495}
496
497#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
499#[serde(untagged)]
500pub enum EditorConfigValue {
501 Int(u32),
503 String(String),
505}
506
507#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
514#[serde(tag = "type", rename_all = "lowercase")]
515pub enum Runtime {
516 Nix(NixRuntime),
518 Devenv(DevenvRuntime),
520 Container(ContainerRuntime),
522 Dagger(DaggerRuntime),
524 Oci(OciRuntime),
526 Tools(Box<ToolsRuntime>),
528}
529
530#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
532pub struct NixRuntime {
533 #[serde(default = "default_flake")]
535 pub flake: String,
536 #[serde(skip_serializing_if = "Option::is_none")]
538 pub output: Option<String>,
539}
540
541impl Default for NixRuntime {
542 fn default() -> Self {
543 Self {
544 flake: default_flake(),
545 output: None,
546 }
547 }
548}
549
550fn default_flake() -> String {
551 ".".to_string()
552}
553
554#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
556pub struct DevenvRuntime {
557 #[serde(default = "default_flake")]
559 pub path: String,
560}
561
562#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
564pub struct ContainerRuntime {
565 pub image: String,
567}
568
569#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
571pub struct DaggerRuntime {
572 #[serde(skip_serializing_if = "Option::is_none")]
574 pub image: Option<String>,
575 #[serde(skip_serializing_if = "Option::is_none")]
577 pub from: Option<String>,
578 #[serde(default, skip_serializing_if = "Vec::is_empty")]
580 pub secrets: Vec<DaggerSecret>,
581 #[serde(default, skip_serializing_if = "Vec::is_empty")]
583 pub cache: Vec<DaggerCacheMount>,
584}
585
586#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
588pub struct DaggerSecret {
589 pub name: String,
591 #[serde(skip_serializing_if = "Option::is_none")]
593 pub path: Option<String>,
594 #[serde(skip_serializing_if = "Option::is_none")]
596 pub env_var: Option<String>,
597 pub resolver: serde_json::Value,
599}
600
601#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
603pub struct DaggerCacheMount {
604 pub path: String,
606 pub name: String,
608}
609
610#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
615#[serde(rename_all = "camelCase")]
616pub struct OciRuntime {
617 #[serde(default)]
619 pub platforms: Vec<String>,
620 #[serde(default)]
622 pub images: Vec<OciImage>,
623 #[serde(skip_serializing_if = "Option::is_none")]
625 pub cache_dir: Option<String>,
626}
627
628#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
632pub struct OciImage {
633 pub image: String,
635 #[serde(rename = "as", skip_serializing_if = "Option::is_none")]
637 pub as_name: Option<String>,
638 #[serde(default, skip_serializing_if = "Vec::is_empty")]
640 pub extract: Vec<OciExtract>,
641}
642
643#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
645pub struct OciExtract {
646 pub path: String,
648 #[serde(rename = "as", skip_serializing_if = "Option::is_none")]
650 pub as_name: Option<String>,
651}
652
653#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
655pub struct GitHubProviderConfig {
656 #[serde(skip_serializing_if = "Option::is_none")]
658 pub token: Option<Secret>,
659}
660
661#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
667#[serde(rename_all = "camelCase")]
668pub struct ToolsRuntime {
669 #[serde(default)]
671 pub platforms: Vec<String>,
672 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
674 pub flakes: HashMap<String, String>,
675 #[serde(skip_serializing_if = "Option::is_none")]
677 pub github: Option<GitHubProviderConfig>,
678 #[serde(default)]
680 pub tools: HashMap<String, ToolSpec>,
681 #[serde(skip_serializing_if = "Option::is_none")]
683 pub cache_dir: Option<String>,
684}
685
686#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
688#[serde(untagged)]
689pub enum ToolSpec {
690 Version(String),
692 Full(ToolConfig),
694}
695
696impl ToolSpec {
697 #[must_use]
699 pub fn version(&self) -> &str {
700 match self {
701 Self::Version(v) => v,
702 Self::Full(c) => &c.version,
703 }
704 }
705}
706
707#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
709#[serde(rename_all = "camelCase")]
710pub struct ToolConfig {
711 pub version: String,
713 #[serde(rename = "as", skip_serializing_if = "Option::is_none")]
715 pub as_name: Option<String>,
716 #[serde(skip_serializing_if = "Option::is_none")]
718 pub source: Option<SourceConfig>,
719 #[serde(default, skip_serializing_if = "Vec::is_empty")]
721 pub overrides: Vec<SourceOverride>,
722}
723
724#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
726pub struct SourceOverride {
727 #[serde(skip_serializing_if = "Option::is_none")]
729 pub os: Option<String>,
730 #[serde(skip_serializing_if = "Option::is_none")]
732 pub arch: Option<String>,
733 pub source: SourceConfig,
735}
736
737#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
739#[serde(tag = "type", rename_all = "lowercase")]
740pub enum SourceConfig {
741 Oci {
743 image: String,
745 path: String,
747 },
748 #[serde(rename = "github")]
750 GitHub {
751 repo: String,
753 #[serde(default, rename = "tagPrefix")]
755 tag_prefix: String,
756 #[serde(skip_serializing_if = "Option::is_none")]
758 tag: Option<String>,
759 asset: String,
761 #[serde(skip_serializing_if = "Option::is_none")]
763 path: Option<String>,
764 #[serde(default, skip_serializing_if = "Vec::is_empty")]
766 extract: Vec<GitHubExtract>,
767 },
768 Nix {
770 flake: String,
772 package: String,
774 #[serde(skip_serializing_if = "Option::is_none")]
776 output: Option<String>,
777 },
778 Rustup {
780 toolchain: String,
782 #[serde(default = "default_rustup_profile")]
784 profile: String,
785 #[serde(default, skip_serializing_if = "Vec::is_empty")]
787 components: Vec<String>,
788 #[serde(default, skip_serializing_if = "Vec::is_empty")]
790 targets: Vec<String>,
791 },
792 #[serde(rename = "url")]
794 Url {
795 url: String,
797 #[serde(skip_serializing_if = "Option::is_none")]
799 path: Option<String>,
800 #[serde(default, skip_serializing_if = "Vec::is_empty")]
802 extract: Vec<GitHubExtract>,
803 },
804}
805
806fn default_rustup_profile() -> String {
807 "default".to_string()
808}
809
810#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
812#[serde(tag = "kind", rename_all = "lowercase")]
813pub enum GitHubExtract {
814 Bin {
816 path: String,
818 #[serde(rename = "as", skip_serializing_if = "Option::is_none")]
820 as_name: Option<String>,
821 },
822 Lib {
824 path: String,
826 #[serde(skip_serializing_if = "Option::is_none")]
828 env: Option<String>,
829 },
830 Include {
832 path: String,
834 },
835 PkgConfig {
837 path: String,
839 },
840 File {
842 path: String,
844 #[serde(skip_serializing_if = "Option::is_none")]
846 env: Option<String>,
847 },
848}
849
850#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
856pub struct Project {
857 #[serde(skip_serializing_if = "Option::is_none")]
859 pub config: Option<Config>,
860
861 pub name: String,
863
864 #[serde(skip_serializing_if = "Option::is_none")]
866 pub env: Option<Env>,
867
868 #[serde(skip_serializing_if = "Option::is_none")]
870 pub hooks: Option<Hooks>,
871
872 #[serde(skip_serializing_if = "Option::is_none")]
874 pub ci: Option<CI>,
875
876 #[serde(default)]
878 pub tasks: HashMap<String, TaskNode>,
879
880 #[serde(skip_serializing_if = "Option::is_none")]
882 pub codegen: Option<CodegenConfig>,
883
884 #[serde(skip_serializing_if = "Option::is_none")]
886 pub runtime: Option<Runtime>,
887
888 #[serde(skip_serializing_if = "Option::is_none")]
890 pub formatters: Option<Formatters>,
891}
892
893impl Project {
894 pub fn new(name: impl Into<String>) -> Self {
896 Self {
897 name: name.into(),
898 ..Self::default()
899 }
900 }
901
902 pub fn on_enter_hooks_map(&self) -> HashMap<String, Hook> {
904 self.hooks
905 .as_ref()
906 .and_then(|h| h.on_enter.as_ref())
907 .cloned()
908 .unwrap_or_default()
909 }
910
911 pub fn on_enter_hooks(&self) -> Vec<Hook> {
913 let map = self.on_enter_hooks_map();
914 let mut hooks: Vec<(String, Hook)> = map.into_iter().collect();
915 hooks.sort_by(|a, b| a.1.order.cmp(&b.1.order).then(a.0.cmp(&b.0)));
916 hooks.into_iter().map(|(_, h)| h).collect()
917 }
918
919 pub fn on_exit_hooks_map(&self) -> HashMap<String, Hook> {
921 self.hooks
922 .as_ref()
923 .and_then(|h| h.on_exit.as_ref())
924 .cloned()
925 .unwrap_or_default()
926 }
927
928 pub fn on_exit_hooks(&self) -> Vec<Hook> {
930 let map = self.on_exit_hooks_map();
931 let mut hooks: Vec<(String, Hook)> = map.into_iter().collect();
932 hooks.sort_by(|a, b| a.1.order.cmp(&b.1.order).then(a.0.cmp(&b.0)));
933 hooks.into_iter().map(|(_, h)| h).collect()
934 }
935
936 pub fn pre_push_hooks_map(&self) -> HashMap<String, Hook> {
938 self.hooks
939 .as_ref()
940 .and_then(|h| h.pre_push.as_ref())
941 .cloned()
942 .unwrap_or_default()
943 }
944
945 pub fn pre_push_hooks(&self) -> Vec<Hook> {
947 let map = self.pre_push_hooks_map();
948 let mut hooks: Vec<(String, Hook)> = map.into_iter().collect();
949 hooks.sort_by(|a, b| a.1.order.cmp(&b.1.order).then(a.0.cmp(&b.0)));
950 hooks.into_iter().map(|(_, h)| h).collect()
951 }
952
953 #[must_use]
958 pub fn with_implicit_tasks(self) -> Self {
959 self
960 }
961
962 pub fn expand_cross_project_references(&mut self) {
968 for (_, task_node) in self.tasks.iter_mut() {
969 Self::expand_task_node(task_node);
970 }
971 }
972
973 fn expand_task_node(node: &mut TaskNode) {
974 match node {
975 TaskNode::Task(task) => Self::expand_task(task),
976 TaskNode::Group(group) => {
977 for sub_node in group.children.values_mut() {
978 Self::expand_task_node(sub_node);
979 }
980 }
981 TaskNode::Sequence(steps) => {
982 for sub_node in steps {
983 Self::expand_task_node(sub_node);
984 }
985 }
986 }
987 }
988
989 fn expand_task(task: &mut Task) {
990 let mut new_inputs = Vec::new();
991 let mut implicit_deps = Vec::new();
992
993 for input in &task.inputs {
995 match input {
996 Input::Path(path) if path.starts_with('#') => {
997 let parts: Vec<&str> = path[1..].split(':').collect();
1000 if parts.len() >= 3 {
1001 let project = parts[0].to_string();
1002 let task_name = parts[1].to_string();
1003 let file_path = parts[2..].join(":");
1005
1006 new_inputs.push(Input::Project(ProjectReference {
1007 project: project.clone(),
1008 task: task_name.clone(),
1009 map: vec![Mapping {
1010 from: file_path.clone(),
1011 to: file_path,
1012 }],
1013 }));
1014
1015 implicit_deps.push(format!("#{}:{}", project, task_name));
1017 } else if parts.len() == 2 {
1018 new_inputs.push(input.clone());
1025 } else {
1026 new_inputs.push(input.clone());
1027 }
1028 }
1029 Input::Project(proj_ref) => {
1030 implicit_deps.push(format!("#{}:{}", proj_ref.project, proj_ref.task));
1032 new_inputs.push(input.clone());
1033 }
1034 _ => new_inputs.push(input.clone()),
1035 }
1036 }
1037
1038 task.inputs = new_inputs;
1039
1040 for dep in implicit_deps {
1042 if !task.depends_on.iter().any(|d| d.task_name() == dep) {
1043 task.depends_on
1044 .push(crate::tasks::TaskDependency::from_name(dep));
1045 }
1046 }
1047 }
1048}
1049
1050impl TryFrom<&Instance> for Project {
1051 type Error = crate::Error;
1052
1053 fn try_from(instance: &Instance) -> Result<Self, Self::Error> {
1054 let mut project: Project = instance.deserialize()?;
1055 project.expand_cross_project_references();
1056 Ok(project)
1057 }
1058}
1059
1060#[cfg(test)]
1061mod tests {
1062 use super::*;
1063 use crate::tasks::{TaskDependency, TaskGroup, TaskNode};
1064 use crate::test_utils::create_test_hook;
1065
1066 #[test]
1067 fn test_expand_cross_project_references() {
1068 let task = Task {
1069 inputs: vec![Input::Path("#myproj:build:dist/app.js".to_string())],
1070 ..Default::default()
1071 };
1072
1073 let mut cuenv = Project::new("test");
1074 cuenv
1075 .tasks
1076 .insert("deploy".into(), TaskNode::Task(Box::new(task)));
1077
1078 cuenv.expand_cross_project_references();
1079
1080 let task_def = cuenv.tasks.get("deploy").unwrap();
1081 let task = task_def.as_task().unwrap();
1082
1083 assert_eq!(task.inputs.len(), 1);
1085 match &task.inputs[0] {
1086 Input::Project(proj_ref) => {
1087 assert_eq!(proj_ref.project, "myproj");
1088 assert_eq!(proj_ref.task, "build");
1089 assert_eq!(proj_ref.map.len(), 1);
1090 assert_eq!(proj_ref.map[0].from, "dist/app.js");
1091 assert_eq!(proj_ref.map[0].to, "dist/app.js");
1092 }
1093 _ => panic!("Expected ProjectReference"),
1094 }
1095
1096 assert_eq!(task.depends_on.len(), 1);
1098 assert_eq!(task.depends_on[0].task_name(), "#myproj:build");
1099 }
1100
1101 #[test]
1106 fn test_task_ref_parse_valid() {
1107 let task_ref = TaskRef {
1108 ref_: "#projen-generator:types".to_string(),
1109 };
1110
1111 let parsed = task_ref.parse();
1112 assert!(parsed.is_some());
1113
1114 let (project, task) = parsed.unwrap();
1115 assert_eq!(project, "projen-generator");
1116 assert_eq!(task, "types");
1117 }
1118
1119 #[test]
1120 fn test_task_ref_parse_with_dots() {
1121 let task_ref = TaskRef {
1122 ref_: "#my-project:bun.install".to_string(),
1123 };
1124
1125 let parsed = task_ref.parse();
1126 assert!(parsed.is_some());
1127
1128 let (project, task) = parsed.unwrap();
1129 assert_eq!(project, "my-project");
1130 assert_eq!(task, "bun.install");
1131 }
1132
1133 #[test]
1134 fn test_task_ref_parse_no_hash() {
1135 let task_ref = TaskRef {
1136 ref_: "project:task".to_string(),
1137 };
1138
1139 let parsed = task_ref.parse();
1141 assert!(parsed.is_none());
1142 }
1143
1144 #[test]
1145 fn test_task_ref_parse_no_colon() {
1146 let task_ref = TaskRef {
1147 ref_: "#project-only".to_string(),
1148 };
1149
1150 let parsed = task_ref.parse();
1152 assert!(parsed.is_none());
1153 }
1154
1155 #[test]
1156 fn test_task_ref_parse_empty_project() {
1157 let task_ref = TaskRef {
1158 ref_: "#:task".to_string(),
1159 };
1160
1161 assert!(task_ref.parse().is_none());
1163 }
1164
1165 #[test]
1166 fn test_task_ref_parse_empty_task() {
1167 let task_ref = TaskRef {
1168 ref_: "#project:".to_string(),
1169 };
1170
1171 assert!(task_ref.parse().is_none());
1173 }
1174
1175 #[test]
1176 fn test_task_ref_parse_both_empty() {
1177 let task_ref = TaskRef {
1178 ref_: "#:".to_string(),
1179 };
1180
1181 assert!(task_ref.parse().is_none());
1183 }
1184
1185 #[test]
1186 fn test_task_ref_parse_multiple_colons() {
1187 let task_ref = TaskRef {
1188 ref_: "#project:task:extra".to_string(),
1189 };
1190
1191 let parsed = task_ref.parse();
1193 assert!(parsed.is_some());
1194 let (project, task) = parsed.unwrap();
1195 assert_eq!(project, "project");
1196 assert_eq!(task, "task:extra");
1197 }
1198
1199 #[test]
1200 fn test_task_ref_parse_unicode() {
1201 let task_ref = TaskRef {
1202 ref_: "#项目名:任务名".to_string(),
1203 };
1204
1205 let parsed = task_ref.parse();
1206 assert!(parsed.is_some());
1207 let (project, task) = parsed.unwrap();
1208 assert_eq!(project, "项目名");
1209 assert_eq!(task, "任务名");
1210 }
1211
1212 #[test]
1213 fn test_task_ref_parse_special_characters() {
1214 let task_ref = TaskRef {
1215 ref_: "#my-project_v2:build.ci-test".to_string(),
1216 };
1217
1218 let parsed = task_ref.parse();
1219 assert!(parsed.is_some());
1220 let (project, task) = parsed.unwrap();
1221 assert_eq!(project, "my-project_v2");
1222 assert_eq!(task, "build.ci-test");
1223 }
1224
1225 #[test]
1226 fn test_hook_item_task_ref_deserialization() {
1227 let json = "{\"ref\": \"#other-project:build\"}";
1228 let hook_item: HookItem = serde_json::from_str(json).unwrap();
1229
1230 match hook_item {
1231 HookItem::TaskRef(task_ref) => {
1232 assert_eq!(task_ref.ref_, "#other-project:build");
1233 let (project, task) = task_ref.parse().unwrap();
1234 assert_eq!(project, "other-project");
1235 assert_eq!(task, "build");
1236 }
1237 _ => panic!("Expected HookItem::TaskRef"),
1238 }
1239 }
1240
1241 #[test]
1242 fn test_hook_item_match_deserialization() {
1243 let json = r#"{
1244 "name": "projen",
1245 "match": {
1246 "labels": ["codegen", "projen"]
1247 }
1248 }"#;
1249 let hook_item: HookItem = serde_json::from_str(json).unwrap();
1250
1251 match hook_item {
1252 HookItem::Match(match_hook) => {
1253 assert_eq!(match_hook.name, Some("projen".to_string()));
1254 assert_eq!(
1255 match_hook.matcher.labels,
1256 Some(vec!["codegen".to_string(), "projen".to_string()])
1257 );
1258 }
1259 _ => panic!("Expected HookItem::Match"),
1260 }
1261 }
1262
1263 #[test]
1264 fn test_hook_item_match_with_parallel_false() {
1265 let json = r#"{
1266 "match": {
1267 "labels": ["build"],
1268 "parallel": false
1269 }
1270 }"#;
1271 let hook_item: HookItem = serde_json::from_str(json).unwrap();
1272
1273 match hook_item {
1274 HookItem::Match(match_hook) => {
1275 assert!(match_hook.name.is_none());
1276 assert!(!match_hook.matcher.parallel);
1277 }
1278 _ => panic!("Expected HookItem::Match"),
1279 }
1280 }
1281
1282 #[test]
1283 fn test_hook_item_inline_task_deserialization() {
1284 let json = r#"{
1285 "command": "echo",
1286 "args": ["hello"]
1287 }"#;
1288 let hook_item: HookItem = serde_json::from_str(json).unwrap();
1289
1290 match hook_item {
1291 HookItem::Task(task) => {
1292 assert_eq!(task.command, "echo");
1293 assert_eq!(task.args, vec!["hello"]);
1294 }
1295 _ => panic!("Expected HookItem::Task"),
1296 }
1297 }
1298
1299 #[test]
1300 fn test_task_matcher_deserialization() {
1301 let json = r#"{
1302 "labels": ["projen", "codegen"],
1303 "parallel": true
1304 }"#;
1305 let matcher: TaskMatcher = serde_json::from_str(json).unwrap();
1306
1307 assert_eq!(
1308 matcher.labels,
1309 Some(vec!["projen".to_string(), "codegen".to_string()])
1310 );
1311 assert!(matcher.parallel);
1312 }
1313
1314 #[test]
1315 fn test_task_matcher_defaults() {
1316 let json = r#"{}"#;
1317 let matcher: TaskMatcher = serde_json::from_str(json).unwrap();
1318
1319 assert!(matcher.labels.is_none());
1320 assert!(matcher.command.is_none());
1321 assert!(matcher.args.is_none());
1322 assert!(matcher.parallel); }
1324
1325 #[test]
1326 fn test_task_matcher_with_command() {
1327 let json = r#"{
1328 "command": "prisma",
1329 "args": [{"contains": "generate"}]
1330 }"#;
1331 let matcher: TaskMatcher = serde_json::from_str(json).unwrap();
1332
1333 assert_eq!(matcher.command, Some("prisma".to_string()));
1334 let args = matcher.args.unwrap();
1335 assert_eq!(args.len(), 1);
1336 assert_eq!(args[0].contains, Some("generate".to_string()));
1337 }
1338
1339 #[test]
1344 fn test_expand_multiple_cross_project_references() {
1345 let task = Task {
1346 inputs: vec![
1347 Input::Path("#projA:build:dist/lib.js".to_string()),
1348 Input::Path("#projB:compile:out/types.d.ts".to_string()),
1349 Input::Path("src/**/*.ts".to_string()), ],
1351 ..Default::default()
1352 };
1353
1354 let mut cuenv = Project::new("test");
1355 cuenv
1356 .tasks
1357 .insert("bundle".into(), TaskNode::Task(Box::new(task)));
1358
1359 cuenv.expand_cross_project_references();
1360
1361 let task_def = cuenv.tasks.get("bundle").unwrap();
1362 let task = task_def.as_task().unwrap();
1363
1364 assert_eq!(task.inputs.len(), 3);
1366
1367 assert_eq!(task.depends_on.len(), 2);
1369 assert!(
1370 task.depends_on
1371 .iter()
1372 .any(|d| d.task_name() == "#projA:build")
1373 );
1374 assert!(
1375 task.depends_on
1376 .iter()
1377 .any(|d| d.task_name() == "#projB:compile")
1378 );
1379 }
1380
1381 #[test]
1382 fn test_expand_cross_project_in_task_group() {
1383 let task1 = Task {
1384 command: "step1".to_string(),
1385 inputs: vec![Input::Path("#projA:build:dist/lib.js".to_string())],
1386 ..Default::default()
1387 };
1388
1389 let task2 = Task {
1390 command: "step2".to_string(),
1391 inputs: vec![Input::Path("#projB:compile:out/types.d.ts".to_string())],
1392 ..Default::default()
1393 };
1394
1395 let mut cuenv = Project::new("test");
1396 cuenv.tasks.insert(
1397 "pipeline".into(),
1398 TaskNode::Sequence(vec![
1399 TaskNode::Task(Box::new(task1)),
1400 TaskNode::Task(Box::new(task2)),
1401 ]),
1402 );
1403
1404 cuenv.expand_cross_project_references();
1405
1406 match cuenv.tasks.get("pipeline").unwrap() {
1408 TaskNode::Sequence(steps) => {
1409 match &steps[0] {
1410 TaskNode::Task(task) => {
1411 assert!(
1412 task.depends_on
1413 .iter()
1414 .any(|d| d.task_name() == "#projA:build")
1415 );
1416 }
1417 _ => panic!("Expected single task"),
1418 }
1419 match &steps[1] {
1420 TaskNode::Task(task) => {
1421 assert!(
1422 task.depends_on
1423 .iter()
1424 .any(|d| d.task_name() == "#projB:compile")
1425 );
1426 }
1427 _ => panic!("Expected single task"),
1428 }
1429 }
1430 _ => panic!("Expected task list"),
1431 }
1432 }
1433
1434 #[test]
1435 fn test_expand_cross_project_in_parallel_group() {
1436 let task1 = Task {
1437 command: "taskA".to_string(),
1438 inputs: vec![Input::Path("#projA:build:lib.js".to_string())],
1439 ..Default::default()
1440 };
1441
1442 let task2 = Task {
1443 command: "taskB".to_string(),
1444 inputs: vec![Input::Path("#projB:build:types.d.ts".to_string())],
1445 ..Default::default()
1446 };
1447
1448 let mut parallel_tasks = HashMap::new();
1449 parallel_tasks.insert("a".to_string(), TaskNode::Task(Box::new(task1)));
1450 parallel_tasks.insert("b".to_string(), TaskNode::Task(Box::new(task2)));
1451
1452 let mut cuenv = Project::new("test");
1453 cuenv.tasks.insert(
1454 "parallel".into(),
1455 TaskNode::Group(TaskGroup {
1456 type_: "group".to_string(),
1457 children: parallel_tasks,
1458 depends_on: vec![],
1459 description: None,
1460 max_concurrency: None,
1461 }),
1462 );
1463
1464 cuenv.expand_cross_project_references();
1465
1466 match cuenv.tasks.get("parallel").unwrap() {
1468 TaskNode::Group(group) => {
1469 match group.children.get("a").unwrap() {
1470 TaskNode::Task(task) => {
1471 assert!(
1472 task.depends_on
1473 .iter()
1474 .any(|d| d.task_name() == "#projA:build")
1475 );
1476 }
1477 _ => panic!("Expected single task"),
1478 }
1479 match group.children.get("b").unwrap() {
1480 TaskNode::Task(task) => {
1481 assert!(
1482 task.depends_on
1483 .iter()
1484 .any(|d| d.task_name() == "#projB:build")
1485 );
1486 }
1487 _ => panic!("Expected single task"),
1488 }
1489 }
1490 _ => panic!("Expected parallel group"),
1491 }
1492 }
1493
1494 #[test]
1495 fn test_no_duplicate_implicit_dependencies() {
1496 let task = Task {
1498 depends_on: vec![TaskDependency::from_name("#myproj:build")],
1499 inputs: vec![Input::Path("#myproj:build:dist/app.js".to_string())],
1500 ..Default::default()
1501 };
1502
1503 let mut cuenv = Project::new("test");
1504 cuenv
1505 .tasks
1506 .insert("deploy".into(), TaskNode::Task(Box::new(task)));
1507
1508 cuenv.expand_cross_project_references();
1509
1510 let task_def = cuenv.tasks.get("deploy").unwrap();
1511 let task = task_def.as_task().unwrap();
1512
1513 assert_eq!(task.depends_on.len(), 1);
1515 assert_eq!(task.depends_on[0].task_name(), "#myproj:build");
1516 }
1517
1518 #[test]
1523 fn test_on_enter_hooks_ordering() {
1524 let mut on_enter = HashMap::new();
1525 on_enter.insert("hook_c".to_string(), create_test_hook(300, "echo c"));
1526 on_enter.insert("hook_a".to_string(), create_test_hook(100, "echo a"));
1527 on_enter.insert("hook_b".to_string(), create_test_hook(200, "echo b"));
1528
1529 let mut cuenv = Project::new("test");
1530 cuenv.hooks = Some(Hooks {
1531 on_enter: Some(on_enter),
1532 on_exit: None,
1533 pre_push: None,
1534 });
1535
1536 let hooks = cuenv.on_enter_hooks();
1537 assert_eq!(hooks.len(), 3);
1538
1539 assert_eq!(hooks[0].order, 100);
1541 assert_eq!(hooks[1].order, 200);
1542 assert_eq!(hooks[2].order, 300);
1543 }
1544
1545 #[test]
1546 fn test_on_enter_hooks_same_order_sort_by_name() {
1547 let mut on_enter = HashMap::new();
1548 on_enter.insert("z_hook".to_string(), create_test_hook(100, "echo z"));
1549 on_enter.insert("a_hook".to_string(), create_test_hook(100, "echo a"));
1550
1551 let cuenv = Project {
1552 name: "test".to_string(),
1553 hooks: Some(Hooks {
1554 on_enter: Some(on_enter),
1555 on_exit: None,
1556 pre_push: None,
1557 }),
1558 ..Default::default()
1559 };
1560
1561 let hooks = cuenv.on_enter_hooks();
1562 assert_eq!(hooks.len(), 2);
1563
1564 assert_eq!(hooks[0].command, "echo a");
1566 assert_eq!(hooks[1].command, "echo z");
1567 }
1568
1569 #[test]
1570 fn test_empty_hooks() {
1571 let cuenv = Project::new("test");
1572
1573 let on_enter = cuenv.on_enter_hooks();
1574 let on_exit = cuenv.on_exit_hooks();
1575
1576 assert!(on_enter.is_empty());
1577 assert!(on_exit.is_empty());
1578 }
1579
1580 #[test]
1581 fn test_project_deserialization_with_script_tasks() {
1582 let json = r#"{
1584 "name": "cuenv",
1585 "hooks": {
1586 "onEnter": {
1587 "nix": {
1588 "order": 10,
1589 "propagate": false,
1590 "command": "nix",
1591 "args": ["print-dev-env"],
1592 "inputs": ["flake.nix", "flake.lock"],
1593 "source": true
1594 }
1595 }
1596 },
1597 "tasks": {
1598 "pwd": { "command": "pwd" },
1599 "check": {
1600 "command": "nix",
1601 "args": ["flake", "check"],
1602 "inputs": ["flake.nix"]
1603 },
1604 "fmt": {
1605 "type": "group",
1606 "fix": {
1607 "command": "treefmt",
1608 "inputs": [".config"]
1609 },
1610 "check": {
1611 "command": "treefmt",
1612 "args": ["--fail-on-change"],
1613 "inputs": [".config"]
1614 }
1615 },
1616 "cross": {
1617 "type": "group",
1618 "linux": {
1619 "script": "echo building for linux",
1620 "inputs": ["Cargo.toml"]
1621 }
1622 },
1623 "docs": {
1624 "type": "group",
1625 "build": {
1626 "command": "bash",
1627 "args": ["-c", "bun install"],
1628 "inputs": ["docs"],
1629 "outputs": ["docs/dist"]
1630 },
1631 "deploy": {
1632 "command": "bash",
1633 "args": ["-c", "wrangler deploy"],
1634 "dependsOn": ["docs.build"],
1635 "inputs": [{"task": "docs.build"}]
1636 }
1637 }
1638 }
1639 }"#;
1640
1641 let result: Result<Project, _> = serde_json::from_str(json);
1642 match result {
1643 Ok(project) => {
1644 assert_eq!(project.name, "cuenv");
1645 assert_eq!(project.tasks.len(), 5);
1646 assert!(project.tasks.contains_key("pwd"));
1647 assert!(project.tasks.contains_key("cross"));
1648 let cross = project.tasks.get("cross").unwrap();
1650 assert!(cross.is_group());
1651 }
1652 Err(e) => {
1653 panic!("Failed to deserialize Project with script tasks: {}", e);
1654 }
1655 }
1656 }
1657
1658 #[test]
1659 fn test_deserialize_actual_cuenv_project() {
1660 let json = match std::fs::read_to_string("/tmp/project.json") {
1662 Ok(content) => content,
1663 Err(_) => return, };
1665 let result: Result<Project, _> = serde_json::from_str(&json);
1666 match result {
1667 Ok(project) => {
1668 eprintln!("Project name: {}", project.name);
1669 eprintln!("Tasks: {:?}", project.tasks.keys().collect::<Vec<_>>());
1670 }
1671 Err(e) => {
1672 eprintln!("Failed: {}", e);
1673 eprintln!("Line: {}, Col: {}", e.line(), e.column());
1674 let lines: Vec<&str> = json.lines().collect();
1676 let line_num = e.line();
1677 let start = if line_num > 3 { line_num - 3 } else { 1 };
1678 let end = std::cmp::min(line_num + 3, lines.len());
1679 for i in start..=end {
1680 if i <= lines.len() {
1681 eprintln!("{}: {}", i, lines[i - 1]);
1682 }
1683 }
1684 panic!("Deserialization failed");
1685 }
1686 }
1687 }
1688}