1use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9use crate::ci::CI;
10use crate::config::Config;
11use crate::environment::Env;
12use crate::hooks::Hook;
13use crate::owners::Owners;
14use crate::tasks::{Input, Mapping, ProjectReference, TaskGroup};
15use crate::tasks::{Task, TaskDefinition};
16
17#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
19#[serde(rename_all = "camelCase")]
20pub struct WorkspaceConfig {
21 #[serde(default = "default_true")]
23 pub enabled: bool,
24
25 pub root: Option<String>,
27
28 pub package_manager: Option<String>,
30
31 #[serde(skip_serializing_if = "Option::is_none")]
33 pub hooks: Option<WorkspaceHooks>,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
38#[serde(rename_all = "camelCase")]
39pub struct WorkspaceHooks {
40 #[serde(skip_serializing_if = "Option::is_none")]
42 pub before_install: Option<Vec<HookItem>>,
43
44 #[serde(skip_serializing_if = "Option::is_none")]
46 pub after_install: Option<Vec<HookItem>>,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
51#[serde(untagged)]
52pub enum HookItem {
53 TaskRef(TaskRef),
55 Match(MatchHook),
57 Task(Box<Task>),
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
63#[serde(rename_all = "camelCase")]
64pub struct MatchHook {
65 #[serde(skip_serializing_if = "Option::is_none")]
67 pub name: Option<String>,
68
69 #[serde(rename = "match")]
71 pub matcher: TaskMatcher,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
76pub struct TaskRef {
77 #[serde(rename = "ref")]
80 pub ref_: String,
81}
82
83impl TaskRef {
84 pub fn parse(&self) -> Option<(String, String)> {
87 let ref_str = self.ref_.strip_prefix('#')?;
88 let parts: Vec<&str> = ref_str.splitn(2, ':').collect();
89 if parts.len() == 2 {
90 let project = parts[0];
91 let task = parts[1];
92 if !project.is_empty() && !task.is_empty() {
93 Some((project.to_string(), task.to_string()))
94 } else {
95 None
96 }
97 } else {
98 None
99 }
100 }
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
105pub struct TaskMatcher {
106 #[serde(skip_serializing_if = "Option::is_none")]
108 pub workspaces: Option<Vec<String>>,
109
110 #[serde(skip_serializing_if = "Option::is_none")]
112 pub labels: Option<Vec<String>>,
113
114 #[serde(skip_serializing_if = "Option::is_none")]
116 pub command: Option<String>,
117
118 #[serde(skip_serializing_if = "Option::is_none")]
120 pub args: Option<Vec<ArgMatcher>>,
121
122 #[serde(default = "default_true")]
124 pub parallel: bool,
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
129pub struct ArgMatcher {
130 #[serde(skip_serializing_if = "Option::is_none")]
132 pub contains: Option<String>,
133
134 #[serde(skip_serializing_if = "Option::is_none")]
136 pub matches: Option<String>,
137}
138
139fn default_true() -> bool {
140 true
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
145pub struct Hooks {
146 #[serde(skip_serializing_if = "Option::is_none")]
148 #[serde(rename = "onEnter")]
149 pub on_enter: Option<HashMap<String, Hook>>,
150
151 #[serde(skip_serializing_if = "Option::is_none")]
153 #[serde(rename = "onExit")]
154 pub on_exit: Option<HashMap<String, Hook>>,
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
159pub struct Base {
160 #[serde(skip_serializing_if = "Option::is_none")]
162 pub config: Option<Config>,
163
164 #[serde(skip_serializing_if = "Option::is_none")]
166 pub env: Option<Env>,
167
168 #[serde(skip_serializing_if = "Option::is_none")]
170 pub workspaces: Option<HashMap<String, WorkspaceConfig>>,
171}
172
173pub type Ignore = HashMap<String, IgnoreValue>;
179
180#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
186#[serde(rename_all = "lowercase")]
187pub enum FileMode {
188 #[default]
190 Managed,
191 Scaffold,
193}
194
195#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq)]
197#[serde(rename_all = "camelCase")]
198pub struct FormatConfig {
199 #[serde(default = "default_indent")]
201 pub indent: String,
202 #[serde(skip_serializing_if = "Option::is_none")]
204 pub indent_size: Option<usize>,
205 #[serde(skip_serializing_if = "Option::is_none")]
207 pub line_width: Option<usize>,
208 #[serde(skip_serializing_if = "Option::is_none")]
210 pub trailing_comma: Option<String>,
211 #[serde(skip_serializing_if = "Option::is_none")]
213 pub semicolons: Option<bool>,
214 #[serde(skip_serializing_if = "Option::is_none")]
216 pub quotes: Option<String>,
217}
218
219fn default_indent() -> String {
220 "space".to_string()
221}
222
223#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
225pub struct ProjectFile {
226 pub content: String,
228 pub language: String,
230 #[serde(default)]
232 pub mode: FileMode,
233 #[serde(default)]
235 pub format: FormatConfig,
236 #[serde(default)]
241 pub gitignore: bool,
242}
243
244#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq)]
246pub struct CubeConfig {
247 #[serde(default)]
249 pub files: HashMap<String, ProjectFile>,
250 #[serde(default)]
252 pub context: serde_json::Value,
253}
254
255#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
257#[serde(untagged)]
258pub enum IgnoreValue {
259 Patterns(Vec<String>),
261 Extended(IgnoreEntry),
263}
264
265#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
267pub struct IgnoreEntry {
268 pub patterns: Vec<String>,
270 #[serde(skip_serializing_if = "Option::is_none")]
272 pub filename: Option<String>,
273}
274
275impl IgnoreValue {
276 #[must_use]
278 pub fn patterns(&self) -> &[String] {
279 match self {
280 Self::Patterns(patterns) => patterns,
281 Self::Extended(entry) => &entry.patterns,
282 }
283 }
284
285 #[must_use]
287 pub fn filename(&self) -> Option<&str> {
288 match self {
289 Self::Patterns(_) => None,
290 Self::Extended(entry) => entry.filename.as_deref(),
291 }
292 }
293}
294
295#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
297pub struct Project {
298 #[serde(skip_serializing_if = "Option::is_none")]
300 pub config: Option<Config>,
301
302 pub name: String,
304
305 #[serde(skip_serializing_if = "Option::is_none")]
307 pub env: Option<Env>,
308
309 #[serde(skip_serializing_if = "Option::is_none")]
311 pub hooks: Option<Hooks>,
312
313 #[serde(skip_serializing_if = "Option::is_none")]
315 pub workspaces: Option<HashMap<String, WorkspaceConfig>>,
316
317 #[serde(skip_serializing_if = "Option::is_none")]
319 pub ci: Option<CI>,
320
321 #[serde(skip_serializing_if = "Option::is_none")]
323 pub owners: Option<Owners>,
324
325 #[serde(default)]
327 pub tasks: HashMap<String, TaskDefinition>,
328
329 #[serde(skip_serializing_if = "Option::is_none")]
331 pub ignore: Option<Ignore>,
332
333 #[serde(skip_serializing_if = "Option::is_none")]
335 pub cube: Option<CubeConfig>,
336}
337
338pub type Cuenv = Project;
340
341impl Project {
342 pub fn new(name: impl Into<String>) -> Self {
344 Self {
345 name: name.into(),
346 ..Self::default()
347 }
348 }
349
350 pub fn on_enter_hooks_map(&self) -> HashMap<String, Hook> {
352 self.hooks
353 .as_ref()
354 .and_then(|h| h.on_enter.as_ref())
355 .cloned()
356 .unwrap_or_default()
357 }
358
359 pub fn on_enter_hooks(&self) -> Vec<Hook> {
361 let map = self.on_enter_hooks_map();
362 let mut hooks: Vec<(String, Hook)> = map.into_iter().collect();
363 hooks.sort_by(|a, b| a.1.order.cmp(&b.1.order).then(a.0.cmp(&b.0)));
364 hooks.into_iter().map(|(_, h)| h).collect()
365 }
366
367 pub fn on_exit_hooks_map(&self) -> HashMap<String, Hook> {
369 self.hooks
370 .as_ref()
371 .and_then(|h| h.on_exit.as_ref())
372 .cloned()
373 .unwrap_or_default()
374 }
375
376 pub fn on_exit_hooks(&self) -> Vec<Hook> {
378 let map = self.on_exit_hooks_map();
379 let mut hooks: Vec<(String, Hook)> = map.into_iter().collect();
380 hooks.sort_by(|a, b| a.1.order.cmp(&b.1.order).then(a.0.cmp(&b.0)));
381 hooks.into_iter().map(|(_, h)| h).collect()
382 }
383
384 pub fn with_implicit_tasks(mut self) -> Self {
392 fn get_task_mut_by_path<'a>(
393 tasks: &'a mut HashMap<String, TaskDefinition>,
394 raw_path: &str,
395 ) -> Option<&'a mut Task> {
396 let normalized = raw_path.replace(':', ".");
397 let mut segments = normalized
398 .split('.')
399 .filter(|s| !s.is_empty())
400 .map(str::trim)
401 .collect::<Vec<_>>();
402 if segments.is_empty() {
403 return None;
404 }
405
406 let first = segments.remove(0);
407 let mut current = tasks.get_mut(first)?;
408 for seg in segments {
409 match current {
410 TaskDefinition::Group(TaskGroup::Parallel(group)) => {
411 current = group.tasks.get_mut(seg)?;
412 }
413 _ => return None,
414 }
415 }
416
417 match current {
418 TaskDefinition::Single(task) => Some(task.as_mut()),
419 _ => None,
420 }
421 }
422
423 let Some(workspaces) = &self.workspaces else {
424 return self;
425 };
426
427 let workspaces = workspaces.clone();
429
430 for (name, config) in &workspaces {
431 if !config.enabled {
432 continue;
433 }
434
435 if !matches!(name.as_str(), "bun" | "npm" | "pnpm" | "yarn" | "cargo") {
437 continue;
438 }
439
440 let workspace_used = self
442 .tasks
443 .values()
444 .any(|task_def| task_def.uses_workspace(name));
445 if !workspace_used {
446 tracing::debug!("Skipping workspace '{}' - no tasks declare usage", name);
447 continue;
448 }
449
450 let install_task_name = format!("{}.install", name);
451
452 if get_task_mut_by_path(&mut self.tasks, &install_task_name).is_some() {
454 continue;
455 }
456
457 if let Some(task) = Self::create_implicit_install_task(name) {
459 self.tasks
460 .insert(install_task_name, TaskDefinition::Single(Box::new(task)));
461 }
462 }
463
464 self
465 }
466
467 fn create_implicit_install_task(workspace_name: &str) -> Option<Task> {
469 let (command, args, description, inputs, outputs) = match workspace_name {
470 "bun" => (
471 "bun",
472 vec!["install"],
473 "Install bun dependencies",
474 vec![
475 Input::Path("package.json".to_string()),
476 Input::Path("bun.lock".to_string()),
477 ],
478 vec!["node_modules".to_string()],
479 ),
480 "npm" => (
481 "npm",
482 vec!["install"],
483 "Install npm dependencies",
484 vec![
485 Input::Path("package.json".to_string()),
486 Input::Path("package-lock.json".to_string()),
487 ],
488 vec!["node_modules".to_string()],
489 ),
490 "pnpm" => (
491 "pnpm",
492 vec!["install"],
493 "Install pnpm dependencies",
494 vec![
495 Input::Path("package.json".to_string()),
496 Input::Path("pnpm-lock.yaml".to_string()),
497 ],
498 vec!["node_modules".to_string()],
499 ),
500 "yarn" => (
501 "yarn",
502 vec!["install"],
503 "Install yarn dependencies",
504 vec![
505 Input::Path("package.json".to_string()),
506 Input::Path("yarn.lock".to_string()),
507 ],
508 vec!["node_modules".to_string()],
509 ),
510 "cargo" => (
511 "cargo",
512 vec!["fetch"],
513 "Fetch cargo dependencies",
514 vec![
515 Input::Path("Cargo.toml".to_string()),
516 Input::Path("Cargo.lock".to_string()),
517 ],
518 vec![], ),
520 _ => return None, };
522
523 Some(Task {
524 command: command.to_string(),
525 args: args.into_iter().map(String::from).collect(),
526 workspaces: vec![workspace_name.to_string()],
527 hermetic: false, description: Some(description.to_string()),
529 inputs,
530 outputs,
531 ..Default::default()
532 })
533 }
534
535 pub fn expand_cross_project_references(&mut self) {
541 for (_, task_def) in self.tasks.iter_mut() {
542 Self::expand_task_definition(task_def);
543 }
544 }
545
546 fn expand_task_definition(task_def: &mut TaskDefinition) {
547 match task_def {
548 TaskDefinition::Single(task) => Self::expand_task(task),
549 TaskDefinition::Group(group) => match group {
550 TaskGroup::Sequential(tasks) => {
551 for sub_task in tasks {
552 Self::expand_task_definition(sub_task);
553 }
554 }
555 TaskGroup::Parallel(group) => {
556 for sub_task in group.tasks.values_mut() {
557 Self::expand_task_definition(sub_task);
558 }
559 }
560 },
561 }
562 }
563
564 fn expand_task(task: &mut Task) {
565 let mut new_inputs = Vec::new();
566 let mut implicit_deps = Vec::new();
567
568 for input in &task.inputs {
570 match input {
571 Input::Path(path) if path.starts_with('#') => {
572 let parts: Vec<&str> = path[1..].split(':').collect();
575 if parts.len() >= 3 {
576 let project = parts[0].to_string();
577 let task_name = parts[1].to_string();
578 let file_path = parts[2..].join(":");
580
581 new_inputs.push(Input::Project(ProjectReference {
582 project: project.clone(),
583 task: task_name.clone(),
584 map: vec![Mapping {
585 from: file_path.clone(),
586 to: file_path,
587 }],
588 }));
589
590 implicit_deps.push(format!("#{}:{}", project, task_name));
592 } else if parts.len() == 2 {
593 new_inputs.push(input.clone());
600 } else {
601 new_inputs.push(input.clone());
602 }
603 }
604 Input::Project(proj_ref) => {
605 implicit_deps.push(format!("#{}:{}", proj_ref.project, proj_ref.task));
607 new_inputs.push(input.clone());
608 }
609 _ => new_inputs.push(input.clone()),
610 }
611 }
612
613 task.inputs = new_inputs;
614
615 for dep in implicit_deps {
617 if !task.depends_on.contains(&dep) {
618 task.depends_on.push(dep);
619 }
620 }
621 }
622}
623
624#[cfg(test)]
625mod tests {
626 use super::*;
627 use crate::tasks::{ParallelGroup, TaskIndex};
628 use crate::test_utils::create_test_hook;
629
630 #[test]
631 fn test_expand_cross_project_references() {
632 let task = Task {
633 inputs: vec![Input::Path("#myproj:build:dist/app.js".to_string())],
634 ..Default::default()
635 };
636
637 let mut cuenv = Cuenv::new("test");
638 cuenv
639 .tasks
640 .insert("deploy".into(), TaskDefinition::Single(Box::new(task)));
641
642 cuenv.expand_cross_project_references();
643
644 let task_def = cuenv.tasks.get("deploy").unwrap();
645 let task = task_def.as_single().unwrap();
646
647 assert_eq!(task.inputs.len(), 1);
649 match &task.inputs[0] {
650 Input::Project(proj_ref) => {
651 assert_eq!(proj_ref.project, "myproj");
652 assert_eq!(proj_ref.task, "build");
653 assert_eq!(proj_ref.map.len(), 1);
654 assert_eq!(proj_ref.map[0].from, "dist/app.js");
655 assert_eq!(proj_ref.map[0].to, "dist/app.js");
656 }
657 _ => panic!("Expected ProjectReference"),
658 }
659
660 assert_eq!(task.depends_on.len(), 1);
662 assert_eq!(task.depends_on[0], "#myproj:build");
663 }
664
665 #[test]
666 fn test_implicit_bun_install_task() {
667 let mut cuenv = Cuenv::new("test");
668 cuenv.workspaces = Some(HashMap::from([(
669 "bun".into(),
670 WorkspaceConfig {
671 enabled: true,
672 root: None,
673 package_manager: None,
674 hooks: None,
675 },
676 )]));
677
678 cuenv.tasks.insert(
680 "dev".into(),
681 TaskDefinition::Single(Box::new(Task {
682 command: "bun".to_string(),
683 args: vec!["run".to_string(), "dev".to_string()],
684 workspaces: vec!["bun".to_string()],
685 ..Default::default()
686 })),
687 );
688
689 let cuenv = cuenv.with_implicit_tasks();
690 assert!(cuenv.tasks.contains_key("bun.install"));
691
692 let task_def = cuenv.tasks.get("bun.install").unwrap();
693 let task = task_def.as_single().unwrap();
694 assert_eq!(task.command, "bun");
695 assert_eq!(task.args, vec!["install"]);
696 assert_eq!(task.workspaces, vec!["bun"]);
697 }
698
699 #[test]
700 fn test_implicit_npm_install_task() {
701 let mut cuenv = Cuenv::new("test");
702 cuenv.workspaces = Some(HashMap::from([(
703 "npm".into(),
704 WorkspaceConfig {
705 enabled: true,
706 root: None,
707 package_manager: None,
708 hooks: None,
709 },
710 )]));
711
712 cuenv.tasks.insert(
714 "build".into(),
715 TaskDefinition::Single(Box::new(Task {
716 command: "npm".to_string(),
717 args: vec!["run".to_string(), "build".to_string()],
718 workspaces: vec!["npm".to_string()],
719 ..Default::default()
720 })),
721 );
722
723 let cuenv = cuenv.with_implicit_tasks();
724 assert!(cuenv.tasks.contains_key("npm.install"));
725 }
726
727 #[test]
728 fn test_implicit_cargo_fetch_task() {
729 let mut cuenv = Cuenv::new("test");
730 cuenv.workspaces = Some(HashMap::from([(
731 "cargo".into(),
732 WorkspaceConfig {
733 enabled: true,
734 root: None,
735 package_manager: None,
736 hooks: None,
737 },
738 )]));
739
740 cuenv.tasks.insert(
742 "build".into(),
743 TaskDefinition::Single(Box::new(Task {
744 command: "cargo".to_string(),
745 args: vec!["build".to_string()],
746 workspaces: vec!["cargo".to_string()],
747 ..Default::default()
748 })),
749 );
750
751 let cuenv = cuenv.with_implicit_tasks();
752 assert!(cuenv.tasks.contains_key("cargo.install"));
753
754 let task_def = cuenv.tasks.get("cargo.install").unwrap();
755 let task = task_def.as_single().unwrap();
756 assert_eq!(task.command, "cargo");
757 assert_eq!(task.args, vec!["fetch"]);
758 }
759
760 #[test]
761 fn test_no_override_user_defined_task() {
762 let mut cuenv = Cuenv::new("test");
763 cuenv.workspaces = Some(HashMap::from([(
764 "bun".into(),
765 WorkspaceConfig {
766 enabled: true,
767 root: None,
768 package_manager: None,
769 hooks: None,
770 },
771 )]));
772
773 let user_task = Task {
775 command: "custom-bun".to_string(),
776 args: vec!["custom-install".to_string()],
777 ..Default::default()
778 };
779 cuenv.tasks.insert(
780 "bun.install".into(),
781 TaskDefinition::Single(Box::new(user_task)),
782 );
783
784 let cuenv = cuenv.with_implicit_tasks();
785
786 let task_def = cuenv.tasks.get("bun.install").unwrap();
788 let task = task_def.as_single().unwrap();
789 assert_eq!(task.command, "custom-bun");
790 }
791
792 #[test]
793 fn test_no_override_user_defined_nested_install_task() {
794 let mut cuenv = Cuenv::new("test");
795 cuenv.workspaces = Some(HashMap::from([(
796 "bun".into(),
797 WorkspaceConfig {
798 enabled: true,
799 root: None,
800 package_manager: None,
801 hooks: None,
802 },
803 )]));
804
805 cuenv.tasks.insert(
807 "bun".into(),
808 TaskDefinition::Group(TaskGroup::Parallel(ParallelGroup {
809 tasks: HashMap::from([(
810 "install".into(),
811 TaskDefinition::Single(Box::new(Task {
812 command: "custom-bun".to_string(),
813 args: vec!["custom-install".to_string()],
814 ..Default::default()
815 })),
816 )]),
817 depends_on: vec![],
818 })),
819 );
820
821 cuenv.tasks.insert(
823 "dev".into(),
824 TaskDefinition::Single(Box::new(Task {
825 command: "echo".to_string(),
826 args: vec!["dev".to_string()],
827 workspaces: vec!["bun".to_string()],
828 ..Default::default()
829 })),
830 );
831
832 let cuenv = cuenv.with_implicit_tasks();
833
834 assert!(!cuenv.tasks.contains_key("bun.install"));
836
837 let idx = TaskIndex::build(&cuenv.tasks).unwrap();
839 let bun_install = idx.resolve("bun.install").unwrap();
840 let TaskDefinition::Single(t) = &bun_install.definition else {
841 panic!("expected bun.install to be a single task");
842 };
843 assert_eq!(t.command, "custom-bun");
844 }
845
846 #[test]
847 fn test_disabled_workspace_no_implicit_task() {
848 let mut cuenv = Cuenv::new("test");
849 cuenv.workspaces = Some(HashMap::from([(
850 "bun".into(),
851 WorkspaceConfig {
852 enabled: false,
853 root: None,
854 package_manager: None,
855 hooks: None,
856 },
857 )]));
858
859 let cuenv = cuenv.with_implicit_tasks();
860 assert!(!cuenv.tasks.contains_key("bun.install"));
861 }
862
863 #[test]
864 fn test_unknown_workspace_no_implicit_task() {
865 let mut cuenv = Cuenv::new("test");
866 cuenv.workspaces = Some(HashMap::from([(
867 "unknown-package-manager".into(),
868 WorkspaceConfig {
869 enabled: true,
870 root: None,
871 package_manager: None,
872 hooks: None,
873 },
874 )]));
875
876 let cuenv = cuenv.with_implicit_tasks();
877 assert!(!cuenv.tasks.contains_key("unknown-package-manager.install"));
878 }
879
880 #[test]
881 fn test_no_workspaces_unchanged() {
882 let cuenv = Cuenv::new("test");
883 let cuenv = cuenv.with_implicit_tasks();
884 assert!(cuenv.tasks.is_empty());
885 }
886
887 #[test]
888 fn test_no_workspace_tasks_when_unused() {
889 let mut cuenv = Cuenv::new("test");
891 cuenv.workspaces = Some(HashMap::from([(
892 "bun".into(),
893 WorkspaceConfig {
894 enabled: true,
895 root: None,
896 package_manager: None,
897 hooks: None,
898 },
899 )]));
900
901 cuenv.tasks.insert(
903 "build".into(),
904 TaskDefinition::Single(Box::new(Task {
905 command: "cargo".to_string(),
906 args: vec!["build".to_string()],
907 workspaces: vec![], ..Default::default()
909 })),
910 );
911
912 let cuenv = cuenv.with_implicit_tasks();
913
914 assert!(
916 !cuenv.tasks.contains_key("bun.install"),
917 "Should not create bun.install when no task uses bun workspace"
918 );
919 }
920
921 #[test]
926 fn test_task_ref_parse_valid() {
927 let task_ref = TaskRef {
928 ref_: "#projen-generator:types".to_string(),
929 };
930
931 let parsed = task_ref.parse();
932 assert!(parsed.is_some());
933
934 let (project, task) = parsed.unwrap();
935 assert_eq!(project, "projen-generator");
936 assert_eq!(task, "types");
937 }
938
939 #[test]
940 fn test_task_ref_parse_with_dots() {
941 let task_ref = TaskRef {
942 ref_: "#my-project:bun.install".to_string(),
943 };
944
945 let parsed = task_ref.parse();
946 assert!(parsed.is_some());
947
948 let (project, task) = parsed.unwrap();
949 assert_eq!(project, "my-project");
950 assert_eq!(task, "bun.install");
951 }
952
953 #[test]
954 fn test_task_ref_parse_no_hash() {
955 let task_ref = TaskRef {
956 ref_: "project:task".to_string(),
957 };
958
959 let parsed = task_ref.parse();
961 assert!(parsed.is_none());
962 }
963
964 #[test]
965 fn test_task_ref_parse_no_colon() {
966 let task_ref = TaskRef {
967 ref_: "#project-only".to_string(),
968 };
969
970 let parsed = task_ref.parse();
972 assert!(parsed.is_none());
973 }
974
975 #[test]
976 fn test_task_ref_parse_empty_project() {
977 let task_ref = TaskRef {
978 ref_: "#:task".to_string(),
979 };
980
981 assert!(task_ref.parse().is_none());
983 }
984
985 #[test]
986 fn test_task_ref_parse_empty_task() {
987 let task_ref = TaskRef {
988 ref_: "#project:".to_string(),
989 };
990
991 assert!(task_ref.parse().is_none());
993 }
994
995 #[test]
996 fn test_task_ref_parse_both_empty() {
997 let task_ref = TaskRef {
998 ref_: "#:".to_string(),
999 };
1000
1001 assert!(task_ref.parse().is_none());
1003 }
1004
1005 #[test]
1006 fn test_task_ref_parse_multiple_colons() {
1007 let task_ref = TaskRef {
1008 ref_: "#project:task:extra".to_string(),
1009 };
1010
1011 let parsed = task_ref.parse();
1013 assert!(parsed.is_some());
1014 let (project, task) = parsed.unwrap();
1015 assert_eq!(project, "project");
1016 assert_eq!(task, "task:extra");
1017 }
1018
1019 #[test]
1020 fn test_task_ref_parse_unicode() {
1021 let task_ref = TaskRef {
1022 ref_: "#项目名:任务名".to_string(),
1023 };
1024
1025 let parsed = task_ref.parse();
1026 assert!(parsed.is_some());
1027 let (project, task) = parsed.unwrap();
1028 assert_eq!(project, "项目名");
1029 assert_eq!(task, "任务名");
1030 }
1031
1032 #[test]
1033 fn test_task_ref_parse_special_characters() {
1034 let task_ref = TaskRef {
1035 ref_: "#my-project_v2:build.ci-test".to_string(),
1036 };
1037
1038 let parsed = task_ref.parse();
1039 assert!(parsed.is_some());
1040 let (project, task) = parsed.unwrap();
1041 assert_eq!(project, "my-project_v2");
1042 assert_eq!(task, "build.ci-test");
1043 }
1044
1045 #[test]
1046 fn test_hook_item_task_ref_deserialization() {
1047 let json = "{\"ref\": \"#other-project:build\"}";
1048 let hook_item: HookItem = serde_json::from_str(json).unwrap();
1049
1050 match hook_item {
1051 HookItem::TaskRef(task_ref) => {
1052 assert_eq!(task_ref.ref_, "#other-project:build");
1053 let (project, task) = task_ref.parse().unwrap();
1054 assert_eq!(project, "other-project");
1055 assert_eq!(task, "build");
1056 }
1057 _ => panic!("Expected HookItem::TaskRef"),
1058 }
1059 }
1060
1061 #[test]
1062 fn test_hook_item_match_deserialization() {
1063 let json = r#"{
1064 "name": "projen",
1065 "match": {
1066 "labels": ["codegen", "projen"]
1067 }
1068 }"#;
1069 let hook_item: HookItem = serde_json::from_str(json).unwrap();
1070
1071 match hook_item {
1072 HookItem::Match(match_hook) => {
1073 assert_eq!(match_hook.name, Some("projen".to_string()));
1074 assert_eq!(
1075 match_hook.matcher.labels,
1076 Some(vec!["codegen".to_string(), "projen".to_string()])
1077 );
1078 }
1079 _ => panic!("Expected HookItem::Match"),
1080 }
1081 }
1082
1083 #[test]
1084 fn test_hook_item_match_with_parallel_false() {
1085 let json = r#"{
1086 "match": {
1087 "labels": ["build"],
1088 "parallel": false
1089 }
1090 }"#;
1091 let hook_item: HookItem = serde_json::from_str(json).unwrap();
1092
1093 match hook_item {
1094 HookItem::Match(match_hook) => {
1095 assert!(match_hook.name.is_none());
1096 assert!(!match_hook.matcher.parallel);
1097 }
1098 _ => panic!("Expected HookItem::Match"),
1099 }
1100 }
1101
1102 #[test]
1103 fn test_hook_item_inline_task_deserialization() {
1104 let json = r#"{
1105 "command": "echo",
1106 "args": ["hello"]
1107 }"#;
1108 let hook_item: HookItem = serde_json::from_str(json).unwrap();
1109
1110 match hook_item {
1111 HookItem::Task(task) => {
1112 assert_eq!(task.command, "echo");
1113 assert_eq!(task.args, vec!["hello"]);
1114 }
1115 _ => panic!("Expected HookItem::Task"),
1116 }
1117 }
1118
1119 #[test]
1120 fn test_workspace_hooks_before_install() {
1121 let json = format!(
1122 r#"{{
1123 "beforeInstall": [
1124 {{"ref": "{}"}},
1125 {{"name": "codegen", "match": {{"labels": ["codegen"]}}}},
1126 {{"command": "echo", "args": ["ready"]}}
1127 ]
1128 }}"#,
1129 "#projen:types"
1130 );
1131 let hooks: WorkspaceHooks = serde_json::from_str(&json).unwrap();
1132
1133 let before_install = hooks.before_install.unwrap();
1134 assert_eq!(before_install.len(), 3);
1135
1136 match &before_install[0] {
1138 HookItem::TaskRef(task_ref) => {
1139 assert_eq!(task_ref.ref_, "#projen:types");
1140 }
1141 _ => panic!("Expected TaskRef"),
1142 }
1143
1144 match &before_install[1] {
1146 HookItem::Match(match_hook) => {
1147 assert_eq!(match_hook.name, Some("codegen".to_string()));
1148 }
1149 _ => panic!("Expected Match"),
1150 }
1151
1152 match &before_install[2] {
1154 HookItem::Task(task) => {
1155 assert_eq!(task.command, "echo");
1156 }
1157 _ => panic!("Expected Task"),
1158 }
1159 }
1160
1161 #[test]
1162 fn test_workspace_hooks_after_install() {
1163 let json = r#"{
1164 "afterInstall": [
1165 {"command": "prisma", "args": ["generate"]}
1166 ]
1167 }"#;
1168 let hooks: WorkspaceHooks = serde_json::from_str(json).unwrap();
1169
1170 assert!(hooks.before_install.is_none());
1171 let after_install = hooks.after_install.unwrap();
1172 assert_eq!(after_install.len(), 1);
1173
1174 match &after_install[0] {
1175 HookItem::Task(task) => {
1176 assert_eq!(task.command, "prisma");
1177 assert_eq!(task.args, vec!["generate"]);
1178 }
1179 _ => panic!("Expected Task"),
1180 }
1181 }
1182
1183 #[test]
1184 fn test_workspace_config_with_hooks() {
1185 let json = format!(
1186 r#"{{
1187 "enabled": true,
1188 "hooks": {{
1189 "beforeInstall": [
1190 {{"ref": "{}"}}
1191 ]
1192 }}
1193 }}"#,
1194 "#generator:types"
1195 );
1196 let config: WorkspaceConfig = serde_json::from_str(&json).unwrap();
1197
1198 assert!(config.enabled);
1199 assert!(config.hooks.is_some());
1200
1201 let hooks = config.hooks.unwrap();
1202 let before_install = hooks.before_install.unwrap();
1203 assert_eq!(before_install.len(), 1);
1204 }
1205
1206 #[test]
1207 fn test_task_matcher_deserialization() {
1208 let json = r#"{
1209 "workspaces": ["packages/lib"],
1210 "labels": ["projen", "codegen"],
1211 "parallel": true
1212 }"#;
1213 let matcher: TaskMatcher = serde_json::from_str(json).unwrap();
1214
1215 assert_eq!(matcher.workspaces, Some(vec!["packages/lib".to_string()]));
1216 assert_eq!(
1217 matcher.labels,
1218 Some(vec!["projen".to_string(), "codegen".to_string()])
1219 );
1220 assert!(matcher.parallel);
1221 }
1222
1223 #[test]
1224 fn test_task_matcher_defaults() {
1225 let json = r#"{}"#;
1226 let matcher: TaskMatcher = serde_json::from_str(json).unwrap();
1227
1228 assert!(matcher.workspaces.is_none());
1229 assert!(matcher.labels.is_none());
1230 assert!(matcher.command.is_none());
1231 assert!(matcher.args.is_none());
1232 assert!(matcher.parallel); }
1234
1235 #[test]
1236 fn test_task_matcher_with_command() {
1237 let json = r#"{
1238 "command": "prisma",
1239 "args": [{"contains": "generate"}]
1240 }"#;
1241 let matcher: TaskMatcher = serde_json::from_str(json).unwrap();
1242
1243 assert_eq!(matcher.command, Some("prisma".to_string()));
1244 let args = matcher.args.unwrap();
1245 assert_eq!(args.len(), 1);
1246 assert_eq!(args[0].contains, Some("generate".to_string()));
1247 }
1248
1249 #[test]
1254 fn test_cuenv_workspace_with_before_install_hooks() {
1255 let json = format!(
1256 r#"{{
1257 "name": "test-project",
1258 "workspaces": {{
1259 "bun": {{
1260 "enabled": true,
1261 "hooks": {{
1262 "beforeInstall": [
1263 {{"ref": "{}"}},
1264 {{"command": "sh", "args": ["-c", "echo setup"]}}
1265 ]
1266 }}
1267 }}
1268 }},
1269 "tasks": {{
1270 "dev": {{
1271 "command": "bun",
1272 "args": ["run", "dev"],
1273 "workspaces": ["bun"]
1274 }}
1275 }}
1276 }}"#,
1277 "#generator:types"
1278 );
1279 let cuenv: Cuenv = serde_json::from_str(&json).unwrap();
1280
1281 assert_eq!(cuenv.name, "test-project");
1282 let workspaces = cuenv.workspaces.unwrap();
1283 let bun_config = workspaces.get("bun").unwrap();
1284
1285 assert!(bun_config.enabled);
1286 let hooks = bun_config.hooks.as_ref().unwrap();
1287 let before_install = hooks.before_install.as_ref().unwrap();
1288 assert_eq!(before_install.len(), 2);
1289 }
1290
1291 #[test]
1292 fn test_cuenv_multiple_workspaces_with_hooks() {
1293 let json = format!(
1294 r#"{{
1295 "name": "multi-workspace",
1296 "workspaces": {{
1297 "bun": {{
1298 "enabled": true,
1299 "hooks": {{
1300 "beforeInstall": [{{"ref": "{}"}}]
1301 }}
1302 }},
1303 "cargo": {{
1304 "enabled": true,
1305 "hooks": {{
1306 "beforeInstall": [{{"command": "cargo", "args": ["generate"]}}]
1307 }}
1308 }}
1309 }},
1310 "tasks": {{}}
1311 }}"#,
1312 "#projen:types"
1313 );
1314 let cuenv: Cuenv = serde_json::from_str(&json).unwrap();
1315
1316 let workspaces = cuenv.workspaces.unwrap();
1317 assert!(workspaces.contains_key("bun"));
1318 assert!(workspaces.contains_key("cargo"));
1319
1320 let bun_hooks = workspaces["bun"].hooks.as_ref().unwrap();
1322 assert!(bun_hooks.before_install.is_some());
1323
1324 let cargo_hooks = workspaces["cargo"].hooks.as_ref().unwrap();
1326 assert!(cargo_hooks.before_install.is_some());
1327 }
1328
1329 #[test]
1334 fn test_expand_multiple_cross_project_references() {
1335 let task = Task {
1336 inputs: vec![
1337 Input::Path("#projA:build:dist/lib.js".to_string()),
1338 Input::Path("#projB:compile:out/types.d.ts".to_string()),
1339 Input::Path("src/**/*.ts".to_string()), ],
1341 ..Default::default()
1342 };
1343
1344 let mut cuenv = Cuenv::new("test");
1345 cuenv
1346 .tasks
1347 .insert("bundle".into(), TaskDefinition::Single(Box::new(task)));
1348
1349 cuenv.expand_cross_project_references();
1350
1351 let task_def = cuenv.tasks.get("bundle").unwrap();
1352 let task = task_def.as_single().unwrap();
1353
1354 assert_eq!(task.inputs.len(), 3);
1356
1357 assert_eq!(task.depends_on.len(), 2);
1359 assert!(task.depends_on.contains(&"#projA:build".to_string()));
1360 assert!(task.depends_on.contains(&"#projB:compile".to_string()));
1361 }
1362
1363 #[test]
1364 fn test_expand_cross_project_in_task_group() {
1365 let task1 = Task {
1366 command: "step1".to_string(),
1367 inputs: vec![Input::Path("#projA:build:dist/lib.js".to_string())],
1368 ..Default::default()
1369 };
1370
1371 let task2 = Task {
1372 command: "step2".to_string(),
1373 inputs: vec![Input::Path("#projB:compile:out/types.d.ts".to_string())],
1374 ..Default::default()
1375 };
1376
1377 let mut cuenv = Cuenv::new("test");
1378 cuenv.tasks.insert(
1379 "pipeline".into(),
1380 TaskDefinition::Group(TaskGroup::Sequential(vec![
1381 TaskDefinition::Single(Box::new(task1)),
1382 TaskDefinition::Single(Box::new(task2)),
1383 ])),
1384 );
1385
1386 cuenv.expand_cross_project_references();
1387
1388 match cuenv.tasks.get("pipeline").unwrap() {
1390 TaskDefinition::Group(TaskGroup::Sequential(tasks)) => {
1391 match &tasks[0] {
1392 TaskDefinition::Single(task) => {
1393 assert!(task.depends_on.contains(&"#projA:build".to_string()));
1394 }
1395 _ => panic!("Expected single task"),
1396 }
1397 match &tasks[1] {
1398 TaskDefinition::Single(task) => {
1399 assert!(task.depends_on.contains(&"#projB:compile".to_string()));
1400 }
1401 _ => panic!("Expected single task"),
1402 }
1403 }
1404 _ => panic!("Expected sequential group"),
1405 }
1406 }
1407
1408 #[test]
1409 fn test_expand_cross_project_in_parallel_group() {
1410 let task1 = Task {
1411 command: "taskA".to_string(),
1412 inputs: vec![Input::Path("#projA:build:lib.js".to_string())],
1413 ..Default::default()
1414 };
1415
1416 let task2 = Task {
1417 command: "taskB".to_string(),
1418 inputs: vec![Input::Path("#projB:build:types.d.ts".to_string())],
1419 ..Default::default()
1420 };
1421
1422 let mut parallel_tasks = HashMap::new();
1423 parallel_tasks.insert("a".to_string(), TaskDefinition::Single(Box::new(task1)));
1424 parallel_tasks.insert("b".to_string(), TaskDefinition::Single(Box::new(task2)));
1425
1426 let mut cuenv = Cuenv::new("test");
1427 cuenv.tasks.insert(
1428 "parallel".into(),
1429 TaskDefinition::Group(TaskGroup::Parallel(ParallelGroup {
1430 tasks: parallel_tasks,
1431 depends_on: vec![],
1432 })),
1433 );
1434
1435 cuenv.expand_cross_project_references();
1436
1437 match cuenv.tasks.get("parallel").unwrap() {
1439 TaskDefinition::Group(TaskGroup::Parallel(group)) => {
1440 match group.tasks.get("a").unwrap() {
1441 TaskDefinition::Single(task) => {
1442 assert!(task.depends_on.contains(&"#projA:build".to_string()));
1443 }
1444 _ => panic!("Expected single task"),
1445 }
1446 match group.tasks.get("b").unwrap() {
1447 TaskDefinition::Single(task) => {
1448 assert!(task.depends_on.contains(&"#projB:build".to_string()));
1449 }
1450 _ => panic!("Expected single task"),
1451 }
1452 }
1453 _ => panic!("Expected parallel group"),
1454 }
1455 }
1456
1457 #[test]
1458 fn test_no_duplicate_implicit_dependencies() {
1459 let task = Task {
1461 depends_on: vec!["#myproj:build".to_string()],
1462 inputs: vec![Input::Path("#myproj:build:dist/app.js".to_string())],
1463 ..Default::default()
1464 };
1465
1466 let mut cuenv = Cuenv::new("test");
1467 cuenv
1468 .tasks
1469 .insert("deploy".into(), TaskDefinition::Single(Box::new(task)));
1470
1471 cuenv.expand_cross_project_references();
1472
1473 let task_def = cuenv.tasks.get("deploy").unwrap();
1474 let task = task_def.as_single().unwrap();
1475
1476 assert_eq!(task.depends_on.len(), 1);
1478 assert_eq!(task.depends_on[0], "#myproj:build");
1479 }
1480
1481 #[test]
1486 fn test_on_enter_hooks_ordering() {
1487 let mut on_enter = HashMap::new();
1488 on_enter.insert("hook_c".to_string(), create_test_hook(300, "echo c"));
1489 on_enter.insert("hook_a".to_string(), create_test_hook(100, "echo a"));
1490 on_enter.insert("hook_b".to_string(), create_test_hook(200, "echo b"));
1491
1492 let mut cuenv = Cuenv::new("test");
1493 cuenv.hooks = Some(Hooks {
1494 on_enter: Some(on_enter),
1495 on_exit: None,
1496 });
1497
1498 let hooks = cuenv.on_enter_hooks();
1499 assert_eq!(hooks.len(), 3);
1500
1501 assert_eq!(hooks[0].order, 100);
1503 assert_eq!(hooks[1].order, 200);
1504 assert_eq!(hooks[2].order, 300);
1505 }
1506
1507 #[test]
1508 fn test_on_enter_hooks_same_order_sort_by_name() {
1509 let mut on_enter = HashMap::new();
1510 on_enter.insert("z_hook".to_string(), create_test_hook(100, "echo z"));
1511 on_enter.insert("a_hook".to_string(), create_test_hook(100, "echo a"));
1512
1513 let cuenv = Cuenv {
1514 name: "test".to_string(),
1515 hooks: Some(Hooks {
1516 on_enter: Some(on_enter),
1517 on_exit: None,
1518 }),
1519 ..Default::default()
1520 };
1521
1522 let hooks = cuenv.on_enter_hooks();
1523 assert_eq!(hooks.len(), 2);
1524
1525 assert_eq!(hooks[0].command, "echo a");
1527 assert_eq!(hooks[1].command, "echo z");
1528 }
1529
1530 #[test]
1531 fn test_empty_hooks() {
1532 let cuenv = Cuenv::new("test");
1533
1534 let on_enter = cuenv.on_enter_hooks();
1535 let on_exit = cuenv.on_exit_hooks();
1536
1537 assert!(on_enter.is_empty());
1538 assert!(on_exit.is_empty());
1539 }
1540}