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