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(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 },
765 Nix {
767 flake: String,
769 package: String,
771 #[serde(skip_serializing_if = "Option::is_none")]
773 output: Option<String>,
774 },
775 Rustup {
777 toolchain: String,
779 #[serde(default = "default_rustup_profile")]
781 profile: String,
782 #[serde(default, skip_serializing_if = "Vec::is_empty")]
784 components: Vec<String>,
785 #[serde(default, skip_serializing_if = "Vec::is_empty")]
787 targets: Vec<String>,
788 },
789}
790
791fn default_rustup_profile() -> String {
792 "default".to_string()
793}
794
795#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
801pub struct Project {
802 #[serde(skip_serializing_if = "Option::is_none")]
804 pub config: Option<Config>,
805
806 pub name: String,
808
809 #[serde(skip_serializing_if = "Option::is_none")]
811 pub env: Option<Env>,
812
813 #[serde(skip_serializing_if = "Option::is_none")]
815 pub hooks: Option<Hooks>,
816
817 #[serde(skip_serializing_if = "Option::is_none")]
819 pub ci: Option<CI>,
820
821 #[serde(default)]
823 pub tasks: HashMap<String, TaskNode>,
824
825 #[serde(skip_serializing_if = "Option::is_none")]
827 pub codegen: Option<CodegenConfig>,
828
829 #[serde(skip_serializing_if = "Option::is_none")]
831 pub runtime: Option<Runtime>,
832
833 #[serde(skip_serializing_if = "Option::is_none")]
835 pub formatters: Option<Formatters>,
836}
837
838impl Project {
839 pub fn new(name: impl Into<String>) -> Self {
841 Self {
842 name: name.into(),
843 ..Self::default()
844 }
845 }
846
847 pub fn on_enter_hooks_map(&self) -> HashMap<String, Hook> {
849 self.hooks
850 .as_ref()
851 .and_then(|h| h.on_enter.as_ref())
852 .cloned()
853 .unwrap_or_default()
854 }
855
856 pub fn on_enter_hooks(&self) -> Vec<Hook> {
858 let map = self.on_enter_hooks_map();
859 let mut hooks: Vec<(String, Hook)> = map.into_iter().collect();
860 hooks.sort_by(|a, b| a.1.order.cmp(&b.1.order).then(a.0.cmp(&b.0)));
861 hooks.into_iter().map(|(_, h)| h).collect()
862 }
863
864 pub fn on_exit_hooks_map(&self) -> HashMap<String, Hook> {
866 self.hooks
867 .as_ref()
868 .and_then(|h| h.on_exit.as_ref())
869 .cloned()
870 .unwrap_or_default()
871 }
872
873 pub fn on_exit_hooks(&self) -> Vec<Hook> {
875 let map = self.on_exit_hooks_map();
876 let mut hooks: Vec<(String, Hook)> = map.into_iter().collect();
877 hooks.sort_by(|a, b| a.1.order.cmp(&b.1.order).then(a.0.cmp(&b.0)));
878 hooks.into_iter().map(|(_, h)| h).collect()
879 }
880
881 pub fn pre_push_hooks_map(&self) -> HashMap<String, Hook> {
883 self.hooks
884 .as_ref()
885 .and_then(|h| h.pre_push.as_ref())
886 .cloned()
887 .unwrap_or_default()
888 }
889
890 pub fn pre_push_hooks(&self) -> Vec<Hook> {
892 let map = self.pre_push_hooks_map();
893 let mut hooks: Vec<(String, Hook)> = map.into_iter().collect();
894 hooks.sort_by(|a, b| a.1.order.cmp(&b.1.order).then(a.0.cmp(&b.0)));
895 hooks.into_iter().map(|(_, h)| h).collect()
896 }
897
898 #[must_use]
903 pub fn with_implicit_tasks(self) -> Self {
904 self
905 }
906
907 pub fn expand_cross_project_references(&mut self) {
913 for (_, task_node) in self.tasks.iter_mut() {
914 Self::expand_task_node(task_node);
915 }
916 }
917
918 fn expand_task_node(node: &mut TaskNode) {
919 match node {
920 TaskNode::Task(task) => Self::expand_task(task),
921 TaskNode::Group(group) => {
922 for sub_node in group.children.values_mut() {
923 Self::expand_task_node(sub_node);
924 }
925 }
926 TaskNode::Sequence(steps) => {
927 for sub_node in steps {
928 Self::expand_task_node(sub_node);
929 }
930 }
931 }
932 }
933
934 fn expand_task(task: &mut Task) {
935 let mut new_inputs = Vec::new();
936 let mut implicit_deps = Vec::new();
937
938 for input in &task.inputs {
940 match input {
941 Input::Path(path) if path.starts_with('#') => {
942 let parts: Vec<&str> = path[1..].split(':').collect();
945 if parts.len() >= 3 {
946 let project = parts[0].to_string();
947 let task_name = parts[1].to_string();
948 let file_path = parts[2..].join(":");
950
951 new_inputs.push(Input::Project(ProjectReference {
952 project: project.clone(),
953 task: task_name.clone(),
954 map: vec![Mapping {
955 from: file_path.clone(),
956 to: file_path,
957 }],
958 }));
959
960 implicit_deps.push(format!("#{}:{}", project, task_name));
962 } else if parts.len() == 2 {
963 new_inputs.push(input.clone());
970 } else {
971 new_inputs.push(input.clone());
972 }
973 }
974 Input::Project(proj_ref) => {
975 implicit_deps.push(format!("#{}:{}", proj_ref.project, proj_ref.task));
977 new_inputs.push(input.clone());
978 }
979 _ => new_inputs.push(input.clone()),
980 }
981 }
982
983 task.inputs = new_inputs;
984
985 for dep in implicit_deps {
987 if !task.depends_on.iter().any(|d| d.task_name() == dep) {
988 task.depends_on
989 .push(crate::tasks::TaskDependency::from_name(dep));
990 }
991 }
992 }
993}
994
995impl TryFrom<&Instance> for Project {
996 type Error = crate::Error;
997
998 fn try_from(instance: &Instance) -> Result<Self, Self::Error> {
999 let mut project: Project = instance.deserialize()?;
1000 project.expand_cross_project_references();
1001 Ok(project)
1002 }
1003}
1004
1005#[cfg(test)]
1006mod tests {
1007 use super::*;
1008 use crate::tasks::{TaskDependency, TaskGroup, TaskNode};
1009 use crate::test_utils::create_test_hook;
1010
1011 #[test]
1012 fn test_expand_cross_project_references() {
1013 let task = Task {
1014 inputs: vec![Input::Path("#myproj:build:dist/app.js".to_string())],
1015 ..Default::default()
1016 };
1017
1018 let mut cuenv = Project::new("test");
1019 cuenv
1020 .tasks
1021 .insert("deploy".into(), TaskNode::Task(Box::new(task)));
1022
1023 cuenv.expand_cross_project_references();
1024
1025 let task_def = cuenv.tasks.get("deploy").unwrap();
1026 let task = task_def.as_task().unwrap();
1027
1028 assert_eq!(task.inputs.len(), 1);
1030 match &task.inputs[0] {
1031 Input::Project(proj_ref) => {
1032 assert_eq!(proj_ref.project, "myproj");
1033 assert_eq!(proj_ref.task, "build");
1034 assert_eq!(proj_ref.map.len(), 1);
1035 assert_eq!(proj_ref.map[0].from, "dist/app.js");
1036 assert_eq!(proj_ref.map[0].to, "dist/app.js");
1037 }
1038 _ => panic!("Expected ProjectReference"),
1039 }
1040
1041 assert_eq!(task.depends_on.len(), 1);
1043 assert_eq!(task.depends_on[0].task_name(), "#myproj:build");
1044 }
1045
1046 #[test]
1051 fn test_task_ref_parse_valid() {
1052 let task_ref = TaskRef {
1053 ref_: "#projen-generator:types".to_string(),
1054 };
1055
1056 let parsed = task_ref.parse();
1057 assert!(parsed.is_some());
1058
1059 let (project, task) = parsed.unwrap();
1060 assert_eq!(project, "projen-generator");
1061 assert_eq!(task, "types");
1062 }
1063
1064 #[test]
1065 fn test_task_ref_parse_with_dots() {
1066 let task_ref = TaskRef {
1067 ref_: "#my-project:bun.install".to_string(),
1068 };
1069
1070 let parsed = task_ref.parse();
1071 assert!(parsed.is_some());
1072
1073 let (project, task) = parsed.unwrap();
1074 assert_eq!(project, "my-project");
1075 assert_eq!(task, "bun.install");
1076 }
1077
1078 #[test]
1079 fn test_task_ref_parse_no_hash() {
1080 let task_ref = TaskRef {
1081 ref_: "project:task".to_string(),
1082 };
1083
1084 let parsed = task_ref.parse();
1086 assert!(parsed.is_none());
1087 }
1088
1089 #[test]
1090 fn test_task_ref_parse_no_colon() {
1091 let task_ref = TaskRef {
1092 ref_: "#project-only".to_string(),
1093 };
1094
1095 let parsed = task_ref.parse();
1097 assert!(parsed.is_none());
1098 }
1099
1100 #[test]
1101 fn test_task_ref_parse_empty_project() {
1102 let task_ref = TaskRef {
1103 ref_: "#:task".to_string(),
1104 };
1105
1106 assert!(task_ref.parse().is_none());
1108 }
1109
1110 #[test]
1111 fn test_task_ref_parse_empty_task() {
1112 let task_ref = TaskRef {
1113 ref_: "#project:".to_string(),
1114 };
1115
1116 assert!(task_ref.parse().is_none());
1118 }
1119
1120 #[test]
1121 fn test_task_ref_parse_both_empty() {
1122 let task_ref = TaskRef {
1123 ref_: "#:".to_string(),
1124 };
1125
1126 assert!(task_ref.parse().is_none());
1128 }
1129
1130 #[test]
1131 fn test_task_ref_parse_multiple_colons() {
1132 let task_ref = TaskRef {
1133 ref_: "#project:task:extra".to_string(),
1134 };
1135
1136 let parsed = task_ref.parse();
1138 assert!(parsed.is_some());
1139 let (project, task) = parsed.unwrap();
1140 assert_eq!(project, "project");
1141 assert_eq!(task, "task:extra");
1142 }
1143
1144 #[test]
1145 fn test_task_ref_parse_unicode() {
1146 let task_ref = TaskRef {
1147 ref_: "#项目名:任务名".to_string(),
1148 };
1149
1150 let parsed = task_ref.parse();
1151 assert!(parsed.is_some());
1152 let (project, task) = parsed.unwrap();
1153 assert_eq!(project, "项目名");
1154 assert_eq!(task, "任务名");
1155 }
1156
1157 #[test]
1158 fn test_task_ref_parse_special_characters() {
1159 let task_ref = TaskRef {
1160 ref_: "#my-project_v2:build.ci-test".to_string(),
1161 };
1162
1163 let parsed = task_ref.parse();
1164 assert!(parsed.is_some());
1165 let (project, task) = parsed.unwrap();
1166 assert_eq!(project, "my-project_v2");
1167 assert_eq!(task, "build.ci-test");
1168 }
1169
1170 #[test]
1171 fn test_hook_item_task_ref_deserialization() {
1172 let json = "{\"ref\": \"#other-project:build\"}";
1173 let hook_item: HookItem = serde_json::from_str(json).unwrap();
1174
1175 match hook_item {
1176 HookItem::TaskRef(task_ref) => {
1177 assert_eq!(task_ref.ref_, "#other-project:build");
1178 let (project, task) = task_ref.parse().unwrap();
1179 assert_eq!(project, "other-project");
1180 assert_eq!(task, "build");
1181 }
1182 _ => panic!("Expected HookItem::TaskRef"),
1183 }
1184 }
1185
1186 #[test]
1187 fn test_hook_item_match_deserialization() {
1188 let json = r#"{
1189 "name": "projen",
1190 "match": {
1191 "labels": ["codegen", "projen"]
1192 }
1193 }"#;
1194 let hook_item: HookItem = serde_json::from_str(json).unwrap();
1195
1196 match hook_item {
1197 HookItem::Match(match_hook) => {
1198 assert_eq!(match_hook.name, Some("projen".to_string()));
1199 assert_eq!(
1200 match_hook.matcher.labels,
1201 Some(vec!["codegen".to_string(), "projen".to_string()])
1202 );
1203 }
1204 _ => panic!("Expected HookItem::Match"),
1205 }
1206 }
1207
1208 #[test]
1209 fn test_hook_item_match_with_parallel_false() {
1210 let json = r#"{
1211 "match": {
1212 "labels": ["build"],
1213 "parallel": false
1214 }
1215 }"#;
1216 let hook_item: HookItem = serde_json::from_str(json).unwrap();
1217
1218 match hook_item {
1219 HookItem::Match(match_hook) => {
1220 assert!(match_hook.name.is_none());
1221 assert!(!match_hook.matcher.parallel);
1222 }
1223 _ => panic!("Expected HookItem::Match"),
1224 }
1225 }
1226
1227 #[test]
1228 fn test_hook_item_inline_task_deserialization() {
1229 let json = r#"{
1230 "command": "echo",
1231 "args": ["hello"]
1232 }"#;
1233 let hook_item: HookItem = serde_json::from_str(json).unwrap();
1234
1235 match hook_item {
1236 HookItem::Task(task) => {
1237 assert_eq!(task.command, "echo");
1238 assert_eq!(task.args, vec!["hello"]);
1239 }
1240 _ => panic!("Expected HookItem::Task"),
1241 }
1242 }
1243
1244 #[test]
1245 fn test_task_matcher_deserialization() {
1246 let json = r#"{
1247 "labels": ["projen", "codegen"],
1248 "parallel": true
1249 }"#;
1250 let matcher: TaskMatcher = serde_json::from_str(json).unwrap();
1251
1252 assert_eq!(
1253 matcher.labels,
1254 Some(vec!["projen".to_string(), "codegen".to_string()])
1255 );
1256 assert!(matcher.parallel);
1257 }
1258
1259 #[test]
1260 fn test_task_matcher_defaults() {
1261 let json = r#"{}"#;
1262 let matcher: TaskMatcher = serde_json::from_str(json).unwrap();
1263
1264 assert!(matcher.labels.is_none());
1265 assert!(matcher.command.is_none());
1266 assert!(matcher.args.is_none());
1267 assert!(matcher.parallel); }
1269
1270 #[test]
1271 fn test_task_matcher_with_command() {
1272 let json = r#"{
1273 "command": "prisma",
1274 "args": [{"contains": "generate"}]
1275 }"#;
1276 let matcher: TaskMatcher = serde_json::from_str(json).unwrap();
1277
1278 assert_eq!(matcher.command, Some("prisma".to_string()));
1279 let args = matcher.args.unwrap();
1280 assert_eq!(args.len(), 1);
1281 assert_eq!(args[0].contains, Some("generate".to_string()));
1282 }
1283
1284 #[test]
1289 fn test_expand_multiple_cross_project_references() {
1290 let task = Task {
1291 inputs: vec![
1292 Input::Path("#projA:build:dist/lib.js".to_string()),
1293 Input::Path("#projB:compile:out/types.d.ts".to_string()),
1294 Input::Path("src/**/*.ts".to_string()), ],
1296 ..Default::default()
1297 };
1298
1299 let mut cuenv = Project::new("test");
1300 cuenv
1301 .tasks
1302 .insert("bundle".into(), TaskNode::Task(Box::new(task)));
1303
1304 cuenv.expand_cross_project_references();
1305
1306 let task_def = cuenv.tasks.get("bundle").unwrap();
1307 let task = task_def.as_task().unwrap();
1308
1309 assert_eq!(task.inputs.len(), 3);
1311
1312 assert_eq!(task.depends_on.len(), 2);
1314 assert!(
1315 task.depends_on
1316 .iter()
1317 .any(|d| d.task_name() == "#projA:build")
1318 );
1319 assert!(
1320 task.depends_on
1321 .iter()
1322 .any(|d| d.task_name() == "#projB:compile")
1323 );
1324 }
1325
1326 #[test]
1327 fn test_expand_cross_project_in_task_group() {
1328 let task1 = Task {
1329 command: "step1".to_string(),
1330 inputs: vec![Input::Path("#projA:build:dist/lib.js".to_string())],
1331 ..Default::default()
1332 };
1333
1334 let task2 = Task {
1335 command: "step2".to_string(),
1336 inputs: vec![Input::Path("#projB:compile:out/types.d.ts".to_string())],
1337 ..Default::default()
1338 };
1339
1340 let mut cuenv = Project::new("test");
1341 cuenv.tasks.insert(
1342 "pipeline".into(),
1343 TaskNode::Sequence(vec![
1344 TaskNode::Task(Box::new(task1)),
1345 TaskNode::Task(Box::new(task2)),
1346 ]),
1347 );
1348
1349 cuenv.expand_cross_project_references();
1350
1351 match cuenv.tasks.get("pipeline").unwrap() {
1353 TaskNode::Sequence(steps) => {
1354 match &steps[0] {
1355 TaskNode::Task(task) => {
1356 assert!(
1357 task.depends_on
1358 .iter()
1359 .any(|d| d.task_name() == "#projA:build")
1360 );
1361 }
1362 _ => panic!("Expected single task"),
1363 }
1364 match &steps[1] {
1365 TaskNode::Task(task) => {
1366 assert!(
1367 task.depends_on
1368 .iter()
1369 .any(|d| d.task_name() == "#projB:compile")
1370 );
1371 }
1372 _ => panic!("Expected single task"),
1373 }
1374 }
1375 _ => panic!("Expected task list"),
1376 }
1377 }
1378
1379 #[test]
1380 fn test_expand_cross_project_in_parallel_group() {
1381 let task1 = Task {
1382 command: "taskA".to_string(),
1383 inputs: vec![Input::Path("#projA:build:lib.js".to_string())],
1384 ..Default::default()
1385 };
1386
1387 let task2 = Task {
1388 command: "taskB".to_string(),
1389 inputs: vec![Input::Path("#projB:build:types.d.ts".to_string())],
1390 ..Default::default()
1391 };
1392
1393 let mut parallel_tasks = HashMap::new();
1394 parallel_tasks.insert("a".to_string(), TaskNode::Task(Box::new(task1)));
1395 parallel_tasks.insert("b".to_string(), TaskNode::Task(Box::new(task2)));
1396
1397 let mut cuenv = Project::new("test");
1398 cuenv.tasks.insert(
1399 "parallel".into(),
1400 TaskNode::Group(TaskGroup {
1401 type_: "group".to_string(),
1402 children: parallel_tasks,
1403 depends_on: vec![],
1404 description: None,
1405 max_concurrency: None,
1406 }),
1407 );
1408
1409 cuenv.expand_cross_project_references();
1410
1411 match cuenv.tasks.get("parallel").unwrap() {
1413 TaskNode::Group(group) => {
1414 match group.children.get("a").unwrap() {
1415 TaskNode::Task(task) => {
1416 assert!(
1417 task.depends_on
1418 .iter()
1419 .any(|d| d.task_name() == "#projA:build")
1420 );
1421 }
1422 _ => panic!("Expected single task"),
1423 }
1424 match group.children.get("b").unwrap() {
1425 TaskNode::Task(task) => {
1426 assert!(
1427 task.depends_on
1428 .iter()
1429 .any(|d| d.task_name() == "#projB:build")
1430 );
1431 }
1432 _ => panic!("Expected single task"),
1433 }
1434 }
1435 _ => panic!("Expected parallel group"),
1436 }
1437 }
1438
1439 #[test]
1440 fn test_no_duplicate_implicit_dependencies() {
1441 let task = Task {
1443 depends_on: vec![TaskDependency::from_name("#myproj:build")],
1444 inputs: vec![Input::Path("#myproj:build:dist/app.js".to_string())],
1445 ..Default::default()
1446 };
1447
1448 let mut cuenv = Project::new("test");
1449 cuenv
1450 .tasks
1451 .insert("deploy".into(), TaskNode::Task(Box::new(task)));
1452
1453 cuenv.expand_cross_project_references();
1454
1455 let task_def = cuenv.tasks.get("deploy").unwrap();
1456 let task = task_def.as_task().unwrap();
1457
1458 assert_eq!(task.depends_on.len(), 1);
1460 assert_eq!(task.depends_on[0].task_name(), "#myproj:build");
1461 }
1462
1463 #[test]
1468 fn test_on_enter_hooks_ordering() {
1469 let mut on_enter = HashMap::new();
1470 on_enter.insert("hook_c".to_string(), create_test_hook(300, "echo c"));
1471 on_enter.insert("hook_a".to_string(), create_test_hook(100, "echo a"));
1472 on_enter.insert("hook_b".to_string(), create_test_hook(200, "echo b"));
1473
1474 let mut cuenv = Project::new("test");
1475 cuenv.hooks = Some(Hooks {
1476 on_enter: Some(on_enter),
1477 on_exit: None,
1478 pre_push: None,
1479 });
1480
1481 let hooks = cuenv.on_enter_hooks();
1482 assert_eq!(hooks.len(), 3);
1483
1484 assert_eq!(hooks[0].order, 100);
1486 assert_eq!(hooks[1].order, 200);
1487 assert_eq!(hooks[2].order, 300);
1488 }
1489
1490 #[test]
1491 fn test_on_enter_hooks_same_order_sort_by_name() {
1492 let mut on_enter = HashMap::new();
1493 on_enter.insert("z_hook".to_string(), create_test_hook(100, "echo z"));
1494 on_enter.insert("a_hook".to_string(), create_test_hook(100, "echo a"));
1495
1496 let cuenv = Project {
1497 name: "test".to_string(),
1498 hooks: Some(Hooks {
1499 on_enter: Some(on_enter),
1500 on_exit: None,
1501 pre_push: None,
1502 }),
1503 ..Default::default()
1504 };
1505
1506 let hooks = cuenv.on_enter_hooks();
1507 assert_eq!(hooks.len(), 2);
1508
1509 assert_eq!(hooks[0].command, "echo a");
1511 assert_eq!(hooks[1].command, "echo z");
1512 }
1513
1514 #[test]
1515 fn test_empty_hooks() {
1516 let cuenv = Project::new("test");
1517
1518 let on_enter = cuenv.on_enter_hooks();
1519 let on_exit = cuenv.on_exit_hooks();
1520
1521 assert!(on_enter.is_empty());
1522 assert!(on_exit.is_empty());
1523 }
1524
1525 #[test]
1526 fn test_project_deserialization_with_script_tasks() {
1527 let json = r#"{
1529 "name": "cuenv",
1530 "hooks": {
1531 "onEnter": {
1532 "nix": {
1533 "order": 10,
1534 "propagate": false,
1535 "command": "nix",
1536 "args": ["print-dev-env"],
1537 "inputs": ["flake.nix", "flake.lock"],
1538 "source": true
1539 }
1540 }
1541 },
1542 "tasks": {
1543 "pwd": { "command": "pwd" },
1544 "check": {
1545 "command": "nix",
1546 "args": ["flake", "check"],
1547 "inputs": ["flake.nix"]
1548 },
1549 "fmt": {
1550 "type": "group",
1551 "fix": {
1552 "command": "treefmt",
1553 "inputs": [".config"]
1554 },
1555 "check": {
1556 "command": "treefmt",
1557 "args": ["--fail-on-change"],
1558 "inputs": [".config"]
1559 }
1560 },
1561 "cross": {
1562 "type": "group",
1563 "linux": {
1564 "script": "echo building for linux",
1565 "inputs": ["Cargo.toml"]
1566 }
1567 },
1568 "docs": {
1569 "type": "group",
1570 "build": {
1571 "command": "bash",
1572 "args": ["-c", "bun install"],
1573 "inputs": ["docs"],
1574 "outputs": ["docs/dist"]
1575 },
1576 "deploy": {
1577 "command": "bash",
1578 "args": ["-c", "wrangler deploy"],
1579 "dependsOn": ["docs.build"],
1580 "inputs": [{"task": "docs.build"}]
1581 }
1582 }
1583 }
1584 }"#;
1585
1586 let result: Result<Project, _> = serde_json::from_str(json);
1587 match result {
1588 Ok(project) => {
1589 assert_eq!(project.name, "cuenv");
1590 assert_eq!(project.tasks.len(), 5);
1591 assert!(project.tasks.contains_key("pwd"));
1592 assert!(project.tasks.contains_key("cross"));
1593 let cross = project.tasks.get("cross").unwrap();
1595 assert!(cross.is_group());
1596 }
1597 Err(e) => {
1598 panic!("Failed to deserialize Project with script tasks: {}", e);
1599 }
1600 }
1601 }
1602
1603 #[test]
1604 fn test_deserialize_actual_cuenv_project() {
1605 let json = match std::fs::read_to_string("/tmp/project.json") {
1607 Ok(content) => content,
1608 Err(_) => return, };
1610 let result: Result<Project, _> = serde_json::from_str(&json);
1611 match result {
1612 Ok(project) => {
1613 eprintln!("Project name: {}", project.name);
1614 eprintln!("Tasks: {:?}", project.tasks.keys().collect::<Vec<_>>());
1615 }
1616 Err(e) => {
1617 eprintln!("Failed: {}", e);
1618 eprintln!("Line: {}, Col: {}", e.line(), e.column());
1619 let lines: Vec<&str> = json.lines().collect();
1621 let line_num = e.line();
1622 let start = if line_num > 3 { line_num - 3 } else { 1 };
1623 let end = std::cmp::min(line_num + 3, lines.len());
1624 for i in start..=end {
1625 if i <= lines.len() {
1626 eprintln!("{}: {}", i, lines[i - 1]);
1627 }
1628 }
1629 panic!("Deserialization failed");
1630 }
1631 }
1632 }
1633}