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