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 CubeFile {
226 pub content: String,
228 pub language: String,
230 #[serde(default)]
232 pub mode: FileMode,
233 #[serde(default)]
235 pub format: FormatConfig,
236}
237
238#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq)]
240pub struct CubeConfig {
241 #[serde(default)]
243 pub files: HashMap<String, CubeFile>,
244 #[serde(default)]
246 pub context: serde_json::Value,
247}
248
249#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
251#[serde(untagged)]
252pub enum IgnoreValue {
253 Patterns(Vec<String>),
255 Extended(IgnoreEntry),
257}
258
259#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
261pub struct IgnoreEntry {
262 pub patterns: Vec<String>,
264 #[serde(skip_serializing_if = "Option::is_none")]
266 pub filename: Option<String>,
267}
268
269impl IgnoreValue {
270 #[must_use]
272 pub fn patterns(&self) -> &[String] {
273 match self {
274 Self::Patterns(patterns) => patterns,
275 Self::Extended(entry) => &entry.patterns,
276 }
277 }
278
279 #[must_use]
281 pub fn filename(&self) -> Option<&str> {
282 match self {
283 Self::Patterns(_) => None,
284 Self::Extended(entry) => entry.filename.as_deref(),
285 }
286 }
287}
288
289#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
291pub struct Project {
292 #[serde(skip_serializing_if = "Option::is_none")]
294 pub config: Option<Config>,
295
296 pub name: String,
298
299 #[serde(skip_serializing_if = "Option::is_none")]
301 pub env: Option<Env>,
302
303 #[serde(skip_serializing_if = "Option::is_none")]
305 pub hooks: Option<Hooks>,
306
307 #[serde(skip_serializing_if = "Option::is_none")]
309 pub workspaces: Option<HashMap<String, WorkspaceConfig>>,
310
311 #[serde(skip_serializing_if = "Option::is_none")]
313 pub ci: Option<CI>,
314
315 #[serde(skip_serializing_if = "Option::is_none")]
317 pub owners: Option<Owners>,
318
319 #[serde(default)]
321 pub tasks: HashMap<String, TaskDefinition>,
322
323 #[serde(skip_serializing_if = "Option::is_none")]
325 pub ignore: Option<Ignore>,
326
327 #[serde(skip_serializing_if = "Option::is_none")]
329 pub cube: Option<CubeConfig>,
330}
331
332pub type Cuenv = Project;
334
335impl Project {
336 pub fn new(name: impl Into<String>) -> Self {
338 Self {
339 name: name.into(),
340 ..Self::default()
341 }
342 }
343
344 pub fn on_enter_hooks_map(&self) -> HashMap<String, Hook> {
346 self.hooks
347 .as_ref()
348 .and_then(|h| h.on_enter.as_ref())
349 .cloned()
350 .unwrap_or_default()
351 }
352
353 pub fn on_enter_hooks(&self) -> Vec<Hook> {
355 let map = self.on_enter_hooks_map();
356 let mut hooks: Vec<(String, Hook)> = map.into_iter().collect();
357 hooks.sort_by(|a, b| a.1.order.cmp(&b.1.order).then(a.0.cmp(&b.0)));
358 hooks.into_iter().map(|(_, h)| h).collect()
359 }
360
361 pub fn on_exit_hooks_map(&self) -> HashMap<String, Hook> {
363 self.hooks
364 .as_ref()
365 .and_then(|h| h.on_exit.as_ref())
366 .cloned()
367 .unwrap_or_default()
368 }
369
370 pub fn on_exit_hooks(&self) -> Vec<Hook> {
372 let map = self.on_exit_hooks_map();
373 let mut hooks: Vec<(String, Hook)> = map.into_iter().collect();
374 hooks.sort_by(|a, b| a.1.order.cmp(&b.1.order).then(a.0.cmp(&b.0)));
375 hooks.into_iter().map(|(_, h)| h).collect()
376 }
377
378 pub fn with_implicit_tasks(mut self) -> Self {
386 fn get_task_mut_by_path<'a>(
387 tasks: &'a mut HashMap<String, TaskDefinition>,
388 raw_path: &str,
389 ) -> Option<&'a mut Task> {
390 let normalized = raw_path.replace(':', ".");
391 let mut segments = normalized
392 .split('.')
393 .filter(|s| !s.is_empty())
394 .map(str::trim)
395 .collect::<Vec<_>>();
396 if segments.is_empty() {
397 return None;
398 }
399
400 let first = segments.remove(0);
401 let mut current = tasks.get_mut(first)?;
402 for seg in segments {
403 match current {
404 TaskDefinition::Group(TaskGroup::Parallel(group)) => {
405 current = group.tasks.get_mut(seg)?;
406 }
407 _ => return None,
408 }
409 }
410
411 match current {
412 TaskDefinition::Single(task) => Some(task.as_mut()),
413 _ => None,
414 }
415 }
416
417 let Some(workspaces) = &self.workspaces else {
418 return self;
419 };
420
421 let workspaces = workspaces.clone();
423
424 for (name, config) in &workspaces {
425 if !config.enabled {
426 continue;
427 }
428
429 if !matches!(name.as_str(), "bun" | "npm" | "pnpm" | "yarn" | "cargo") {
431 continue;
432 }
433
434 let workspace_used = self
436 .tasks
437 .values()
438 .any(|task_def| task_def.uses_workspace(name));
439 if !workspace_used {
440 tracing::debug!("Skipping workspace '{}' - no tasks declare usage", name);
441 continue;
442 }
443
444 let install_task_name = format!("{}.install", name);
445
446 if get_task_mut_by_path(&mut self.tasks, &install_task_name).is_some() {
448 continue;
449 }
450
451 if let Some(task) = Self::create_implicit_install_task(name) {
453 self.tasks
454 .insert(install_task_name, TaskDefinition::Single(Box::new(task)));
455 }
456 }
457
458 self
459 }
460
461 fn create_implicit_install_task(workspace_name: &str) -> Option<Task> {
463 let (command, args, description, inputs, outputs) = match workspace_name {
464 "bun" => (
465 "bun",
466 vec!["install"],
467 "Install bun dependencies",
468 vec![
469 Input::Path("package.json".to_string()),
470 Input::Path("bun.lock".to_string()),
471 ],
472 vec!["node_modules".to_string()],
473 ),
474 "npm" => (
475 "npm",
476 vec!["install"],
477 "Install npm dependencies",
478 vec![
479 Input::Path("package.json".to_string()),
480 Input::Path("package-lock.json".to_string()),
481 ],
482 vec!["node_modules".to_string()],
483 ),
484 "pnpm" => (
485 "pnpm",
486 vec!["install"],
487 "Install pnpm dependencies",
488 vec![
489 Input::Path("package.json".to_string()),
490 Input::Path("pnpm-lock.yaml".to_string()),
491 ],
492 vec!["node_modules".to_string()],
493 ),
494 "yarn" => (
495 "yarn",
496 vec!["install"],
497 "Install yarn dependencies",
498 vec![
499 Input::Path("package.json".to_string()),
500 Input::Path("yarn.lock".to_string()),
501 ],
502 vec!["node_modules".to_string()],
503 ),
504 "cargo" => (
505 "cargo",
506 vec!["fetch"],
507 "Fetch cargo dependencies",
508 vec![
509 Input::Path("Cargo.toml".to_string()),
510 Input::Path("Cargo.lock".to_string()),
511 ],
512 vec![], ),
514 _ => return None, };
516
517 Some(Task {
518 command: command.to_string(),
519 args: args.into_iter().map(String::from).collect(),
520 workspaces: vec![workspace_name.to_string()],
521 hermetic: false, description: Some(description.to_string()),
523 inputs,
524 outputs,
525 ..Default::default()
526 })
527 }
528
529 pub fn expand_cross_project_references(&mut self) {
535 for (_, task_def) in self.tasks.iter_mut() {
536 Self::expand_task_definition(task_def);
537 }
538 }
539
540 fn expand_task_definition(task_def: &mut TaskDefinition) {
541 match task_def {
542 TaskDefinition::Single(task) => Self::expand_task(task),
543 TaskDefinition::Group(group) => match group {
544 TaskGroup::Sequential(tasks) => {
545 for sub_task in tasks {
546 Self::expand_task_definition(sub_task);
547 }
548 }
549 TaskGroup::Parallel(group) => {
550 for sub_task in group.tasks.values_mut() {
551 Self::expand_task_definition(sub_task);
552 }
553 }
554 },
555 }
556 }
557
558 fn expand_task(task: &mut Task) {
559 let mut new_inputs = Vec::new();
560 let mut implicit_deps = Vec::new();
561
562 for input in &task.inputs {
564 match input {
565 Input::Path(path) if path.starts_with('#') => {
566 let parts: Vec<&str> = path[1..].split(':').collect();
569 if parts.len() >= 3 {
570 let project = parts[0].to_string();
571 let task_name = parts[1].to_string();
572 let file_path = parts[2..].join(":");
574
575 new_inputs.push(Input::Project(ProjectReference {
576 project: project.clone(),
577 task: task_name.clone(),
578 map: vec![Mapping {
579 from: file_path.clone(),
580 to: file_path,
581 }],
582 }));
583
584 implicit_deps.push(format!("#{}:{}", project, task_name));
586 } else if parts.len() == 2 {
587 new_inputs.push(input.clone());
594 } else {
595 new_inputs.push(input.clone());
596 }
597 }
598 Input::Project(proj_ref) => {
599 implicit_deps.push(format!("#{}:{}", proj_ref.project, proj_ref.task));
601 new_inputs.push(input.clone());
602 }
603 _ => new_inputs.push(input.clone()),
604 }
605 }
606
607 task.inputs = new_inputs;
608
609 for dep in implicit_deps {
611 if !task.depends_on.contains(&dep) {
612 task.depends_on.push(dep);
613 }
614 }
615 }
616}
617
618#[cfg(test)]
619mod tests {
620 use super::*;
621 use crate::tasks::{ParallelGroup, TaskIndex};
622 use crate::test_utils::create_test_hook;
623
624 #[test]
625 fn test_expand_cross_project_references() {
626 let task = Task {
627 inputs: vec![Input::Path("#myproj:build:dist/app.js".to_string())],
628 ..Default::default()
629 };
630
631 let mut cuenv = Cuenv::new("test");
632 cuenv
633 .tasks
634 .insert("deploy".into(), TaskDefinition::Single(Box::new(task)));
635
636 cuenv.expand_cross_project_references();
637
638 let task_def = cuenv.tasks.get("deploy").unwrap();
639 let task = task_def.as_single().unwrap();
640
641 assert_eq!(task.inputs.len(), 1);
643 match &task.inputs[0] {
644 Input::Project(proj_ref) => {
645 assert_eq!(proj_ref.project, "myproj");
646 assert_eq!(proj_ref.task, "build");
647 assert_eq!(proj_ref.map.len(), 1);
648 assert_eq!(proj_ref.map[0].from, "dist/app.js");
649 assert_eq!(proj_ref.map[0].to, "dist/app.js");
650 }
651 _ => panic!("Expected ProjectReference"),
652 }
653
654 assert_eq!(task.depends_on.len(), 1);
656 assert_eq!(task.depends_on[0], "#myproj:build");
657 }
658
659 #[test]
660 fn test_implicit_bun_install_task() {
661 let mut cuenv = Cuenv::new("test");
662 cuenv.workspaces = Some(HashMap::from([(
663 "bun".into(),
664 WorkspaceConfig {
665 enabled: true,
666 root: None,
667 package_manager: None,
668 hooks: None,
669 },
670 )]));
671
672 cuenv.tasks.insert(
674 "dev".into(),
675 TaskDefinition::Single(Box::new(Task {
676 command: "bun".to_string(),
677 args: vec!["run".to_string(), "dev".to_string()],
678 workspaces: vec!["bun".to_string()],
679 ..Default::default()
680 })),
681 );
682
683 let cuenv = cuenv.with_implicit_tasks();
684 assert!(cuenv.tasks.contains_key("bun.install"));
685
686 let task_def = cuenv.tasks.get("bun.install").unwrap();
687 let task = task_def.as_single().unwrap();
688 assert_eq!(task.command, "bun");
689 assert_eq!(task.args, vec!["install"]);
690 assert_eq!(task.workspaces, vec!["bun"]);
691 }
692
693 #[test]
694 fn test_implicit_npm_install_task() {
695 let mut cuenv = Cuenv::new("test");
696 cuenv.workspaces = Some(HashMap::from([(
697 "npm".into(),
698 WorkspaceConfig {
699 enabled: true,
700 root: None,
701 package_manager: None,
702 hooks: None,
703 },
704 )]));
705
706 cuenv.tasks.insert(
708 "build".into(),
709 TaskDefinition::Single(Box::new(Task {
710 command: "npm".to_string(),
711 args: vec!["run".to_string(), "build".to_string()],
712 workspaces: vec!["npm".to_string()],
713 ..Default::default()
714 })),
715 );
716
717 let cuenv = cuenv.with_implicit_tasks();
718 assert!(cuenv.tasks.contains_key("npm.install"));
719 }
720
721 #[test]
722 fn test_implicit_cargo_fetch_task() {
723 let mut cuenv = Cuenv::new("test");
724 cuenv.workspaces = Some(HashMap::from([(
725 "cargo".into(),
726 WorkspaceConfig {
727 enabled: true,
728 root: None,
729 package_manager: None,
730 hooks: None,
731 },
732 )]));
733
734 cuenv.tasks.insert(
736 "build".into(),
737 TaskDefinition::Single(Box::new(Task {
738 command: "cargo".to_string(),
739 args: vec!["build".to_string()],
740 workspaces: vec!["cargo".to_string()],
741 ..Default::default()
742 })),
743 );
744
745 let cuenv = cuenv.with_implicit_tasks();
746 assert!(cuenv.tasks.contains_key("cargo.install"));
747
748 let task_def = cuenv.tasks.get("cargo.install").unwrap();
749 let task = task_def.as_single().unwrap();
750 assert_eq!(task.command, "cargo");
751 assert_eq!(task.args, vec!["fetch"]);
752 }
753
754 #[test]
755 fn test_no_override_user_defined_task() {
756 let mut cuenv = Cuenv::new("test");
757 cuenv.workspaces = Some(HashMap::from([(
758 "bun".into(),
759 WorkspaceConfig {
760 enabled: true,
761 root: None,
762 package_manager: None,
763 hooks: None,
764 },
765 )]));
766
767 let user_task = Task {
769 command: "custom-bun".to_string(),
770 args: vec!["custom-install".to_string()],
771 ..Default::default()
772 };
773 cuenv.tasks.insert(
774 "bun.install".into(),
775 TaskDefinition::Single(Box::new(user_task)),
776 );
777
778 let cuenv = cuenv.with_implicit_tasks();
779
780 let task_def = cuenv.tasks.get("bun.install").unwrap();
782 let task = task_def.as_single().unwrap();
783 assert_eq!(task.command, "custom-bun");
784 }
785
786 #[test]
787 fn test_no_override_user_defined_nested_install_task() {
788 let mut cuenv = Cuenv::new("test");
789 cuenv.workspaces = Some(HashMap::from([(
790 "bun".into(),
791 WorkspaceConfig {
792 enabled: true,
793 root: None,
794 package_manager: None,
795 hooks: None,
796 },
797 )]));
798
799 cuenv.tasks.insert(
801 "bun".into(),
802 TaskDefinition::Group(TaskGroup::Parallel(ParallelGroup {
803 tasks: HashMap::from([(
804 "install".into(),
805 TaskDefinition::Single(Box::new(Task {
806 command: "custom-bun".to_string(),
807 args: vec!["custom-install".to_string()],
808 ..Default::default()
809 })),
810 )]),
811 depends_on: vec![],
812 })),
813 );
814
815 cuenv.tasks.insert(
817 "dev".into(),
818 TaskDefinition::Single(Box::new(Task {
819 command: "echo".to_string(),
820 args: vec!["dev".to_string()],
821 workspaces: vec!["bun".to_string()],
822 ..Default::default()
823 })),
824 );
825
826 let cuenv = cuenv.with_implicit_tasks();
827
828 assert!(!cuenv.tasks.contains_key("bun.install"));
830
831 let idx = TaskIndex::build(&cuenv.tasks).unwrap();
833 let bun_install = idx.resolve("bun.install").unwrap();
834 let TaskDefinition::Single(t) = &bun_install.definition else {
835 panic!("expected bun.install to be a single task");
836 };
837 assert_eq!(t.command, "custom-bun");
838 }
839
840 #[test]
841 fn test_disabled_workspace_no_implicit_task() {
842 let mut cuenv = Cuenv::new("test");
843 cuenv.workspaces = Some(HashMap::from([(
844 "bun".into(),
845 WorkspaceConfig {
846 enabled: false,
847 root: None,
848 package_manager: None,
849 hooks: None,
850 },
851 )]));
852
853 let cuenv = cuenv.with_implicit_tasks();
854 assert!(!cuenv.tasks.contains_key("bun.install"));
855 }
856
857 #[test]
858 fn test_unknown_workspace_no_implicit_task() {
859 let mut cuenv = Cuenv::new("test");
860 cuenv.workspaces = Some(HashMap::from([(
861 "unknown-package-manager".into(),
862 WorkspaceConfig {
863 enabled: true,
864 root: None,
865 package_manager: None,
866 hooks: None,
867 },
868 )]));
869
870 let cuenv = cuenv.with_implicit_tasks();
871 assert!(!cuenv.tasks.contains_key("unknown-package-manager.install"));
872 }
873
874 #[test]
875 fn test_no_workspaces_unchanged() {
876 let cuenv = Cuenv::new("test");
877 let cuenv = cuenv.with_implicit_tasks();
878 assert!(cuenv.tasks.is_empty());
879 }
880
881 #[test]
882 fn test_no_workspace_tasks_when_unused() {
883 let mut cuenv = Cuenv::new("test");
885 cuenv.workspaces = Some(HashMap::from([(
886 "bun".into(),
887 WorkspaceConfig {
888 enabled: true,
889 root: None,
890 package_manager: None,
891 hooks: None,
892 },
893 )]));
894
895 cuenv.tasks.insert(
897 "build".into(),
898 TaskDefinition::Single(Box::new(Task {
899 command: "cargo".to_string(),
900 args: vec!["build".to_string()],
901 workspaces: vec![], ..Default::default()
903 })),
904 );
905
906 let cuenv = cuenv.with_implicit_tasks();
907
908 assert!(
910 !cuenv.tasks.contains_key("bun.install"),
911 "Should not create bun.install when no task uses bun workspace"
912 );
913 }
914
915 #[test]
920 fn test_task_ref_parse_valid() {
921 let task_ref = TaskRef {
922 ref_: "#projen-generator:types".to_string(),
923 };
924
925 let parsed = task_ref.parse();
926 assert!(parsed.is_some());
927
928 let (project, task) = parsed.unwrap();
929 assert_eq!(project, "projen-generator");
930 assert_eq!(task, "types");
931 }
932
933 #[test]
934 fn test_task_ref_parse_with_dots() {
935 let task_ref = TaskRef {
936 ref_: "#my-project:bun.install".to_string(),
937 };
938
939 let parsed = task_ref.parse();
940 assert!(parsed.is_some());
941
942 let (project, task) = parsed.unwrap();
943 assert_eq!(project, "my-project");
944 assert_eq!(task, "bun.install");
945 }
946
947 #[test]
948 fn test_task_ref_parse_no_hash() {
949 let task_ref = TaskRef {
950 ref_: "project:task".to_string(),
951 };
952
953 let parsed = task_ref.parse();
955 assert!(parsed.is_none());
956 }
957
958 #[test]
959 fn test_task_ref_parse_no_colon() {
960 let task_ref = TaskRef {
961 ref_: "#project-only".to_string(),
962 };
963
964 let parsed = task_ref.parse();
966 assert!(parsed.is_none());
967 }
968
969 #[test]
970 fn test_task_ref_parse_empty_project() {
971 let task_ref = TaskRef {
972 ref_: "#:task".to_string(),
973 };
974
975 assert!(task_ref.parse().is_none());
977 }
978
979 #[test]
980 fn test_task_ref_parse_empty_task() {
981 let task_ref = TaskRef {
982 ref_: "#project:".to_string(),
983 };
984
985 assert!(task_ref.parse().is_none());
987 }
988
989 #[test]
990 fn test_task_ref_parse_both_empty() {
991 let task_ref = TaskRef {
992 ref_: "#:".to_string(),
993 };
994
995 assert!(task_ref.parse().is_none());
997 }
998
999 #[test]
1000 fn test_task_ref_parse_multiple_colons() {
1001 let task_ref = TaskRef {
1002 ref_: "#project:task:extra".to_string(),
1003 };
1004
1005 let parsed = task_ref.parse();
1007 assert!(parsed.is_some());
1008 let (project, task) = parsed.unwrap();
1009 assert_eq!(project, "project");
1010 assert_eq!(task, "task:extra");
1011 }
1012
1013 #[test]
1014 fn test_task_ref_parse_unicode() {
1015 let task_ref = TaskRef {
1016 ref_: "#项目名:任务名".to_string(),
1017 };
1018
1019 let parsed = task_ref.parse();
1020 assert!(parsed.is_some());
1021 let (project, task) = parsed.unwrap();
1022 assert_eq!(project, "项目名");
1023 assert_eq!(task, "任务名");
1024 }
1025
1026 #[test]
1027 fn test_task_ref_parse_special_characters() {
1028 let task_ref = TaskRef {
1029 ref_: "#my-project_v2:build.ci-test".to_string(),
1030 };
1031
1032 let parsed = task_ref.parse();
1033 assert!(parsed.is_some());
1034 let (project, task) = parsed.unwrap();
1035 assert_eq!(project, "my-project_v2");
1036 assert_eq!(task, "build.ci-test");
1037 }
1038
1039 #[test]
1040 fn test_hook_item_task_ref_deserialization() {
1041 let json = "{\"ref\": \"#other-project:build\"}";
1042 let hook_item: HookItem = serde_json::from_str(json).unwrap();
1043
1044 match hook_item {
1045 HookItem::TaskRef(task_ref) => {
1046 assert_eq!(task_ref.ref_, "#other-project:build");
1047 let (project, task) = task_ref.parse().unwrap();
1048 assert_eq!(project, "other-project");
1049 assert_eq!(task, "build");
1050 }
1051 _ => panic!("Expected HookItem::TaskRef"),
1052 }
1053 }
1054
1055 #[test]
1056 fn test_hook_item_match_deserialization() {
1057 let json = r#"{
1058 "name": "projen",
1059 "match": {
1060 "labels": ["codegen", "projen"]
1061 }
1062 }"#;
1063 let hook_item: HookItem = serde_json::from_str(json).unwrap();
1064
1065 match hook_item {
1066 HookItem::Match(match_hook) => {
1067 assert_eq!(match_hook.name, Some("projen".to_string()));
1068 assert_eq!(
1069 match_hook.matcher.labels,
1070 Some(vec!["codegen".to_string(), "projen".to_string()])
1071 );
1072 }
1073 _ => panic!("Expected HookItem::Match"),
1074 }
1075 }
1076
1077 #[test]
1078 fn test_hook_item_match_with_parallel_false() {
1079 let json = r#"{
1080 "match": {
1081 "labels": ["build"],
1082 "parallel": false
1083 }
1084 }"#;
1085 let hook_item: HookItem = serde_json::from_str(json).unwrap();
1086
1087 match hook_item {
1088 HookItem::Match(match_hook) => {
1089 assert!(match_hook.name.is_none());
1090 assert!(!match_hook.matcher.parallel);
1091 }
1092 _ => panic!("Expected HookItem::Match"),
1093 }
1094 }
1095
1096 #[test]
1097 fn test_hook_item_inline_task_deserialization() {
1098 let json = r#"{
1099 "command": "echo",
1100 "args": ["hello"]
1101 }"#;
1102 let hook_item: HookItem = serde_json::from_str(json).unwrap();
1103
1104 match hook_item {
1105 HookItem::Task(task) => {
1106 assert_eq!(task.command, "echo");
1107 assert_eq!(task.args, vec!["hello"]);
1108 }
1109 _ => panic!("Expected HookItem::Task"),
1110 }
1111 }
1112
1113 #[test]
1114 fn test_workspace_hooks_before_install() {
1115 let json = format!(
1116 r#"{{
1117 "beforeInstall": [
1118 {{"ref": "{}"}},
1119 {{"name": "codegen", "match": {{"labels": ["codegen"]}}}},
1120 {{"command": "echo", "args": ["ready"]}}
1121 ]
1122 }}"#,
1123 "#projen:types"
1124 );
1125 let hooks: WorkspaceHooks = serde_json::from_str(&json).unwrap();
1126
1127 let before_install = hooks.before_install.unwrap();
1128 assert_eq!(before_install.len(), 3);
1129
1130 match &before_install[0] {
1132 HookItem::TaskRef(task_ref) => {
1133 assert_eq!(task_ref.ref_, "#projen:types");
1134 }
1135 _ => panic!("Expected TaskRef"),
1136 }
1137
1138 match &before_install[1] {
1140 HookItem::Match(match_hook) => {
1141 assert_eq!(match_hook.name, Some("codegen".to_string()));
1142 }
1143 _ => panic!("Expected Match"),
1144 }
1145
1146 match &before_install[2] {
1148 HookItem::Task(task) => {
1149 assert_eq!(task.command, "echo");
1150 }
1151 _ => panic!("Expected Task"),
1152 }
1153 }
1154
1155 #[test]
1156 fn test_workspace_hooks_after_install() {
1157 let json = r#"{
1158 "afterInstall": [
1159 {"command": "prisma", "args": ["generate"]}
1160 ]
1161 }"#;
1162 let hooks: WorkspaceHooks = serde_json::from_str(json).unwrap();
1163
1164 assert!(hooks.before_install.is_none());
1165 let after_install = hooks.after_install.unwrap();
1166 assert_eq!(after_install.len(), 1);
1167
1168 match &after_install[0] {
1169 HookItem::Task(task) => {
1170 assert_eq!(task.command, "prisma");
1171 assert_eq!(task.args, vec!["generate"]);
1172 }
1173 _ => panic!("Expected Task"),
1174 }
1175 }
1176
1177 #[test]
1178 fn test_workspace_config_with_hooks() {
1179 let json = format!(
1180 r#"{{
1181 "enabled": true,
1182 "hooks": {{
1183 "beforeInstall": [
1184 {{"ref": "{}"}}
1185 ]
1186 }}
1187 }}"#,
1188 "#generator:types"
1189 );
1190 let config: WorkspaceConfig = serde_json::from_str(&json).unwrap();
1191
1192 assert!(config.enabled);
1193 assert!(config.hooks.is_some());
1194
1195 let hooks = config.hooks.unwrap();
1196 let before_install = hooks.before_install.unwrap();
1197 assert_eq!(before_install.len(), 1);
1198 }
1199
1200 #[test]
1201 fn test_task_matcher_deserialization() {
1202 let json = r#"{
1203 "workspaces": ["packages/lib"],
1204 "labels": ["projen", "codegen"],
1205 "parallel": true
1206 }"#;
1207 let matcher: TaskMatcher = serde_json::from_str(json).unwrap();
1208
1209 assert_eq!(matcher.workspaces, Some(vec!["packages/lib".to_string()]));
1210 assert_eq!(
1211 matcher.labels,
1212 Some(vec!["projen".to_string(), "codegen".to_string()])
1213 );
1214 assert!(matcher.parallel);
1215 }
1216
1217 #[test]
1218 fn test_task_matcher_defaults() {
1219 let json = r#"{}"#;
1220 let matcher: TaskMatcher = serde_json::from_str(json).unwrap();
1221
1222 assert!(matcher.workspaces.is_none());
1223 assert!(matcher.labels.is_none());
1224 assert!(matcher.command.is_none());
1225 assert!(matcher.args.is_none());
1226 assert!(matcher.parallel); }
1228
1229 #[test]
1230 fn test_task_matcher_with_command() {
1231 let json = r#"{
1232 "command": "prisma",
1233 "args": [{"contains": "generate"}]
1234 }"#;
1235 let matcher: TaskMatcher = serde_json::from_str(json).unwrap();
1236
1237 assert_eq!(matcher.command, Some("prisma".to_string()));
1238 let args = matcher.args.unwrap();
1239 assert_eq!(args.len(), 1);
1240 assert_eq!(args[0].contains, Some("generate".to_string()));
1241 }
1242
1243 #[test]
1248 fn test_cuenv_workspace_with_before_install_hooks() {
1249 let json = format!(
1250 r#"{{
1251 "name": "test-project",
1252 "workspaces": {{
1253 "bun": {{
1254 "enabled": true,
1255 "hooks": {{
1256 "beforeInstall": [
1257 {{"ref": "{}"}},
1258 {{"command": "sh", "args": ["-c", "echo setup"]}}
1259 ]
1260 }}
1261 }}
1262 }},
1263 "tasks": {{
1264 "dev": {{
1265 "command": "bun",
1266 "args": ["run", "dev"],
1267 "workspaces": ["bun"]
1268 }}
1269 }}
1270 }}"#,
1271 "#generator:types"
1272 );
1273 let cuenv: Cuenv = serde_json::from_str(&json).unwrap();
1274
1275 assert_eq!(cuenv.name, "test-project");
1276 let workspaces = cuenv.workspaces.unwrap();
1277 let bun_config = workspaces.get("bun").unwrap();
1278
1279 assert!(bun_config.enabled);
1280 let hooks = bun_config.hooks.as_ref().unwrap();
1281 let before_install = hooks.before_install.as_ref().unwrap();
1282 assert_eq!(before_install.len(), 2);
1283 }
1284
1285 #[test]
1286 fn test_cuenv_multiple_workspaces_with_hooks() {
1287 let json = format!(
1288 r#"{{
1289 "name": "multi-workspace",
1290 "workspaces": {{
1291 "bun": {{
1292 "enabled": true,
1293 "hooks": {{
1294 "beforeInstall": [{{"ref": "{}"}}]
1295 }}
1296 }},
1297 "cargo": {{
1298 "enabled": true,
1299 "hooks": {{
1300 "beforeInstall": [{{"command": "cargo", "args": ["generate"]}}]
1301 }}
1302 }}
1303 }},
1304 "tasks": {{}}
1305 }}"#,
1306 "#projen:types"
1307 );
1308 let cuenv: Cuenv = serde_json::from_str(&json).unwrap();
1309
1310 let workspaces = cuenv.workspaces.unwrap();
1311 assert!(workspaces.contains_key("bun"));
1312 assert!(workspaces.contains_key("cargo"));
1313
1314 let bun_hooks = workspaces["bun"].hooks.as_ref().unwrap();
1316 assert!(bun_hooks.before_install.is_some());
1317
1318 let cargo_hooks = workspaces["cargo"].hooks.as_ref().unwrap();
1320 assert!(cargo_hooks.before_install.is_some());
1321 }
1322
1323 #[test]
1328 fn test_expand_multiple_cross_project_references() {
1329 let task = Task {
1330 inputs: vec![
1331 Input::Path("#projA:build:dist/lib.js".to_string()),
1332 Input::Path("#projB:compile:out/types.d.ts".to_string()),
1333 Input::Path("src/**/*.ts".to_string()), ],
1335 ..Default::default()
1336 };
1337
1338 let mut cuenv = Cuenv::new("test");
1339 cuenv
1340 .tasks
1341 .insert("bundle".into(), TaskDefinition::Single(Box::new(task)));
1342
1343 cuenv.expand_cross_project_references();
1344
1345 let task_def = cuenv.tasks.get("bundle").unwrap();
1346 let task = task_def.as_single().unwrap();
1347
1348 assert_eq!(task.inputs.len(), 3);
1350
1351 assert_eq!(task.depends_on.len(), 2);
1353 assert!(task.depends_on.contains(&"#projA:build".to_string()));
1354 assert!(task.depends_on.contains(&"#projB:compile".to_string()));
1355 }
1356
1357 #[test]
1358 fn test_expand_cross_project_in_task_group() {
1359 let task1 = Task {
1360 command: "step1".to_string(),
1361 inputs: vec![Input::Path("#projA:build:dist/lib.js".to_string())],
1362 ..Default::default()
1363 };
1364
1365 let task2 = Task {
1366 command: "step2".to_string(),
1367 inputs: vec![Input::Path("#projB:compile:out/types.d.ts".to_string())],
1368 ..Default::default()
1369 };
1370
1371 let mut cuenv = Cuenv::new("test");
1372 cuenv.tasks.insert(
1373 "pipeline".into(),
1374 TaskDefinition::Group(TaskGroup::Sequential(vec![
1375 TaskDefinition::Single(Box::new(task1)),
1376 TaskDefinition::Single(Box::new(task2)),
1377 ])),
1378 );
1379
1380 cuenv.expand_cross_project_references();
1381
1382 match cuenv.tasks.get("pipeline").unwrap() {
1384 TaskDefinition::Group(TaskGroup::Sequential(tasks)) => {
1385 match &tasks[0] {
1386 TaskDefinition::Single(task) => {
1387 assert!(task.depends_on.contains(&"#projA:build".to_string()));
1388 }
1389 _ => panic!("Expected single task"),
1390 }
1391 match &tasks[1] {
1392 TaskDefinition::Single(task) => {
1393 assert!(task.depends_on.contains(&"#projB:compile".to_string()));
1394 }
1395 _ => panic!("Expected single task"),
1396 }
1397 }
1398 _ => panic!("Expected sequential group"),
1399 }
1400 }
1401
1402 #[test]
1403 fn test_expand_cross_project_in_parallel_group() {
1404 let task1 = Task {
1405 command: "taskA".to_string(),
1406 inputs: vec![Input::Path("#projA:build:lib.js".to_string())],
1407 ..Default::default()
1408 };
1409
1410 let task2 = Task {
1411 command: "taskB".to_string(),
1412 inputs: vec![Input::Path("#projB:build:types.d.ts".to_string())],
1413 ..Default::default()
1414 };
1415
1416 let mut parallel_tasks = HashMap::new();
1417 parallel_tasks.insert("a".to_string(), TaskDefinition::Single(Box::new(task1)));
1418 parallel_tasks.insert("b".to_string(), TaskDefinition::Single(Box::new(task2)));
1419
1420 let mut cuenv = Cuenv::new("test");
1421 cuenv.tasks.insert(
1422 "parallel".into(),
1423 TaskDefinition::Group(TaskGroup::Parallel(ParallelGroup {
1424 tasks: parallel_tasks,
1425 depends_on: vec![],
1426 })),
1427 );
1428
1429 cuenv.expand_cross_project_references();
1430
1431 match cuenv.tasks.get("parallel").unwrap() {
1433 TaskDefinition::Group(TaskGroup::Parallel(group)) => {
1434 match group.tasks.get("a").unwrap() {
1435 TaskDefinition::Single(task) => {
1436 assert!(task.depends_on.contains(&"#projA:build".to_string()));
1437 }
1438 _ => panic!("Expected single task"),
1439 }
1440 match group.tasks.get("b").unwrap() {
1441 TaskDefinition::Single(task) => {
1442 assert!(task.depends_on.contains(&"#projB:build".to_string()));
1443 }
1444 _ => panic!("Expected single task"),
1445 }
1446 }
1447 _ => panic!("Expected parallel group"),
1448 }
1449 }
1450
1451 #[test]
1452 fn test_no_duplicate_implicit_dependencies() {
1453 let task = Task {
1455 depends_on: vec!["#myproj:build".to_string()],
1456 inputs: vec![Input::Path("#myproj:build:dist/app.js".to_string())],
1457 ..Default::default()
1458 };
1459
1460 let mut cuenv = Cuenv::new("test");
1461 cuenv
1462 .tasks
1463 .insert("deploy".into(), TaskDefinition::Single(Box::new(task)));
1464
1465 cuenv.expand_cross_project_references();
1466
1467 let task_def = cuenv.tasks.get("deploy").unwrap();
1468 let task = task_def.as_single().unwrap();
1469
1470 assert_eq!(task.depends_on.len(), 1);
1472 assert_eq!(task.depends_on[0], "#myproj:build");
1473 }
1474
1475 #[test]
1480 fn test_on_enter_hooks_ordering() {
1481 let mut on_enter = HashMap::new();
1482 on_enter.insert("hook_c".to_string(), create_test_hook(300, "echo c"));
1483 on_enter.insert("hook_a".to_string(), create_test_hook(100, "echo a"));
1484 on_enter.insert("hook_b".to_string(), create_test_hook(200, "echo b"));
1485
1486 let mut cuenv = Cuenv::new("test");
1487 cuenv.hooks = Some(Hooks {
1488 on_enter: Some(on_enter),
1489 on_exit: None,
1490 });
1491
1492 let hooks = cuenv.on_enter_hooks();
1493 assert_eq!(hooks.len(), 3);
1494
1495 assert_eq!(hooks[0].order, 100);
1497 assert_eq!(hooks[1].order, 200);
1498 assert_eq!(hooks[2].order, 300);
1499 }
1500
1501 #[test]
1502 fn test_on_enter_hooks_same_order_sort_by_name() {
1503 let mut on_enter = HashMap::new();
1504 on_enter.insert("z_hook".to_string(), create_test_hook(100, "echo z"));
1505 on_enter.insert("a_hook".to_string(), create_test_hook(100, "echo a"));
1506
1507 let cuenv = Cuenv {
1508 name: "test".to_string(),
1509 hooks: Some(Hooks {
1510 on_enter: Some(on_enter),
1511 on_exit: None,
1512 }),
1513 ..Default::default()
1514 };
1515
1516 let hooks = cuenv.on_enter_hooks();
1517 assert_eq!(hooks.len(), 2);
1518
1519 assert_eq!(hooks[0].command, "echo a");
1521 assert_eq!(hooks[1].command, "echo z");
1522 }
1523
1524 #[test]
1525 fn test_empty_hooks() {
1526 let cuenv = Cuenv::new("test");
1527
1528 let on_enter = cuenv.on_enter_hooks();
1529 let on_exit = cuenv.on_exit_hooks();
1530
1531 assert!(on_enter.is_empty());
1532 assert!(on_exit.is_empty());
1533 }
1534}