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)]
309#[serde(tag = "type", rename_all = "lowercase")]
310pub enum Runtime {
311 Nix(NixRuntime),
313 Devenv(DevenvRuntime),
315 Container(ContainerRuntime),
317 Dagger(DaggerRuntime),
319}
320
321#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
323pub struct NixRuntime {
324 #[serde(default = "default_flake")]
326 pub flake: String,
327 #[serde(skip_serializing_if = "Option::is_none")]
329 pub output: Option<String>,
330}
331
332impl Default for NixRuntime {
333 fn default() -> Self {
334 Self {
335 flake: default_flake(),
336 output: None,
337 }
338 }
339}
340
341fn default_flake() -> String {
342 ".".to_string()
343}
344
345#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
347pub struct DevenvRuntime {
348 #[serde(default = "default_flake")]
350 pub path: String,
351}
352
353#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
355pub struct ContainerRuntime {
356 pub image: String,
358}
359
360#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
362pub struct DaggerRuntime {
363 #[serde(skip_serializing_if = "Option::is_none")]
365 pub image: Option<String>,
366 #[serde(skip_serializing_if = "Option::is_none")]
368 pub from: Option<String>,
369 #[serde(default, skip_serializing_if = "Vec::is_empty")]
371 pub secrets: Vec<DaggerSecret>,
372 #[serde(default, skip_serializing_if = "Vec::is_empty")]
374 pub cache: Vec<DaggerCacheMount>,
375}
376
377#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
379pub struct DaggerSecret {
380 pub name: String,
382 #[serde(skip_serializing_if = "Option::is_none")]
384 pub path: Option<String>,
385 #[serde(skip_serializing_if = "Option::is_none")]
387 pub env_var: Option<String>,
388 pub resolver: serde_json::Value,
390}
391
392#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
394pub struct DaggerCacheMount {
395 pub path: String,
397 pub name: String,
399}
400
401#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
407pub struct Project {
408 #[serde(skip_serializing_if = "Option::is_none")]
410 pub config: Option<Config>,
411
412 pub name: String,
414
415 #[serde(skip_serializing_if = "Option::is_none")]
417 pub env: Option<Env>,
418
419 #[serde(skip_serializing_if = "Option::is_none")]
421 pub hooks: Option<Hooks>,
422
423 #[serde(skip_serializing_if = "Option::is_none")]
425 pub workspaces: Option<HashMap<String, WorkspaceConfig>>,
426
427 #[serde(skip_serializing_if = "Option::is_none")]
429 pub ci: Option<CI>,
430
431 #[serde(skip_serializing_if = "Option::is_none")]
433 pub owners: Option<Owners>,
434
435 #[serde(default)]
437 pub tasks: HashMap<String, TaskDefinition>,
438
439 #[serde(skip_serializing_if = "Option::is_none")]
441 pub ignore: Option<Ignore>,
442
443 #[serde(skip_serializing_if = "Option::is_none")]
445 pub cube: Option<CubeConfig>,
446
447 #[serde(skip_serializing_if = "Option::is_none")]
449 pub runtime: Option<Runtime>,
450}
451
452impl Project {
453 pub fn new(name: impl Into<String>) -> Self {
455 Self {
456 name: name.into(),
457 ..Self::default()
458 }
459 }
460
461 pub fn on_enter_hooks_map(&self) -> HashMap<String, Hook> {
463 self.hooks
464 .as_ref()
465 .and_then(|h| h.on_enter.as_ref())
466 .cloned()
467 .unwrap_or_default()
468 }
469
470 pub fn on_enter_hooks(&self) -> Vec<Hook> {
472 let map = self.on_enter_hooks_map();
473 let mut hooks: Vec<(String, Hook)> = map.into_iter().collect();
474 hooks.sort_by(|a, b| a.1.order.cmp(&b.1.order).then(a.0.cmp(&b.0)));
475 hooks.into_iter().map(|(_, h)| h).collect()
476 }
477
478 pub fn on_exit_hooks_map(&self) -> HashMap<String, Hook> {
480 self.hooks
481 .as_ref()
482 .and_then(|h| h.on_exit.as_ref())
483 .cloned()
484 .unwrap_or_default()
485 }
486
487 pub fn on_exit_hooks(&self) -> Vec<Hook> {
489 let map = self.on_exit_hooks_map();
490 let mut hooks: Vec<(String, Hook)> = map.into_iter().collect();
491 hooks.sort_by(|a, b| a.1.order.cmp(&b.1.order).then(a.0.cmp(&b.0)));
492 hooks.into_iter().map(|(_, h)| h).collect()
493 }
494
495 pub fn with_implicit_tasks(mut self) -> Self {
503 fn get_task_mut_by_path<'a>(
504 tasks: &'a mut HashMap<String, TaskDefinition>,
505 raw_path: &str,
506 ) -> Option<&'a mut Task> {
507 let normalized = raw_path.replace(':', ".");
508 let mut segments = normalized
509 .split('.')
510 .filter(|s| !s.is_empty())
511 .map(str::trim)
512 .collect::<Vec<_>>();
513 if segments.is_empty() {
514 return None;
515 }
516
517 let first = segments.remove(0);
518 let mut current = tasks.get_mut(first)?;
519 for seg in segments {
520 match current {
521 TaskDefinition::Group(TaskGroup::Parallel(group)) => {
522 current = group.tasks.get_mut(seg)?;
523 }
524 _ => return None,
525 }
526 }
527
528 match current {
529 TaskDefinition::Single(task) => Some(task.as_mut()),
530 _ => None,
531 }
532 }
533
534 let Some(workspaces) = &self.workspaces else {
535 return self;
536 };
537
538 let workspaces = workspaces.clone();
540
541 for (name, config) in &workspaces {
542 if !config.enabled {
543 continue;
544 }
545
546 if !matches!(name.as_str(), "bun" | "npm" | "pnpm" | "yarn" | "cargo") {
548 continue;
549 }
550
551 let workspace_used = self
553 .tasks
554 .values()
555 .any(|task_def| task_def.uses_workspace(name));
556 if !workspace_used {
557 tracing::debug!("Skipping workspace '{}' - no tasks declare usage", name);
558 continue;
559 }
560
561 let install_task_name = format!("{}.install", name);
562
563 if get_task_mut_by_path(&mut self.tasks, &install_task_name).is_some() {
565 continue;
566 }
567
568 if let Some(task) = Self::create_implicit_install_task(name) {
570 self.tasks
571 .insert(install_task_name, TaskDefinition::Single(Box::new(task)));
572 }
573 }
574
575 self
576 }
577
578 fn create_implicit_install_task(workspace_name: &str) -> Option<Task> {
580 let (command, args, description, inputs, outputs) = match workspace_name {
581 "bun" => (
582 "bun",
583 vec!["install"],
584 "Install bun dependencies",
585 vec![
586 Input::Path("package.json".to_string()),
587 Input::Path("bun.lock".to_string()),
588 ],
589 vec!["node_modules".to_string()],
590 ),
591 "npm" => (
592 "npm",
593 vec!["install"],
594 "Install npm dependencies",
595 vec![
596 Input::Path("package.json".to_string()),
597 Input::Path("package-lock.json".to_string()),
598 ],
599 vec!["node_modules".to_string()],
600 ),
601 "pnpm" => (
602 "pnpm",
603 vec!["install"],
604 "Install pnpm dependencies",
605 vec![
606 Input::Path("package.json".to_string()),
607 Input::Path("pnpm-lock.yaml".to_string()),
608 ],
609 vec!["node_modules".to_string()],
610 ),
611 "yarn" => (
612 "yarn",
613 vec!["install"],
614 "Install yarn dependencies",
615 vec![
616 Input::Path("package.json".to_string()),
617 Input::Path("yarn.lock".to_string()),
618 ],
619 vec!["node_modules".to_string()],
620 ),
621 "cargo" => (
622 "cargo",
623 vec!["fetch"],
624 "Fetch cargo dependencies",
625 vec![
626 Input::Path("Cargo.toml".to_string()),
627 Input::Path("Cargo.lock".to_string()),
628 ],
629 vec![], ),
631 _ => return None, };
633
634 Some(Task {
635 command: command.to_string(),
636 args: args.into_iter().map(String::from).collect(),
637 workspaces: vec![workspace_name.to_string()],
638 hermetic: false, description: Some(description.to_string()),
640 inputs,
641 outputs,
642 ..Default::default()
643 })
644 }
645
646 pub fn expand_cross_project_references(&mut self) {
652 for (_, task_def) in self.tasks.iter_mut() {
653 Self::expand_task_definition(task_def);
654 }
655 }
656
657 fn expand_task_definition(task_def: &mut TaskDefinition) {
658 match task_def {
659 TaskDefinition::Single(task) => Self::expand_task(task),
660 TaskDefinition::Group(group) => match group {
661 TaskGroup::Sequential(tasks) => {
662 for sub_task in tasks {
663 Self::expand_task_definition(sub_task);
664 }
665 }
666 TaskGroup::Parallel(group) => {
667 for sub_task in group.tasks.values_mut() {
668 Self::expand_task_definition(sub_task);
669 }
670 }
671 },
672 }
673 }
674
675 fn expand_task(task: &mut Task) {
676 let mut new_inputs = Vec::new();
677 let mut implicit_deps = Vec::new();
678
679 for input in &task.inputs {
681 match input {
682 Input::Path(path) if path.starts_with('#') => {
683 let parts: Vec<&str> = path[1..].split(':').collect();
686 if parts.len() >= 3 {
687 let project = parts[0].to_string();
688 let task_name = parts[1].to_string();
689 let file_path = parts[2..].join(":");
691
692 new_inputs.push(Input::Project(ProjectReference {
693 project: project.clone(),
694 task: task_name.clone(),
695 map: vec![Mapping {
696 from: file_path.clone(),
697 to: file_path,
698 }],
699 }));
700
701 implicit_deps.push(format!("#{}:{}", project, task_name));
703 } else if parts.len() == 2 {
704 new_inputs.push(input.clone());
711 } else {
712 new_inputs.push(input.clone());
713 }
714 }
715 Input::Project(proj_ref) => {
716 implicit_deps.push(format!("#{}:{}", proj_ref.project, proj_ref.task));
718 new_inputs.push(input.clone());
719 }
720 _ => new_inputs.push(input.clone()),
721 }
722 }
723
724 task.inputs = new_inputs;
725
726 for dep in implicit_deps {
728 if !task.depends_on.contains(&dep) {
729 task.depends_on.push(dep);
730 }
731 }
732 }
733}
734
735#[cfg(test)]
736mod tests {
737 use super::*;
738 use crate::tasks::{ParallelGroup, TaskIndex};
739 use crate::test_utils::create_test_hook;
740
741 #[test]
742 fn test_expand_cross_project_references() {
743 let task = Task {
744 inputs: vec![Input::Path("#myproj:build:dist/app.js".to_string())],
745 ..Default::default()
746 };
747
748 let mut cuenv = Project::new("test");
749 cuenv
750 .tasks
751 .insert("deploy".into(), TaskDefinition::Single(Box::new(task)));
752
753 cuenv.expand_cross_project_references();
754
755 let task_def = cuenv.tasks.get("deploy").unwrap();
756 let task = task_def.as_single().unwrap();
757
758 assert_eq!(task.inputs.len(), 1);
760 match &task.inputs[0] {
761 Input::Project(proj_ref) => {
762 assert_eq!(proj_ref.project, "myproj");
763 assert_eq!(proj_ref.task, "build");
764 assert_eq!(proj_ref.map.len(), 1);
765 assert_eq!(proj_ref.map[0].from, "dist/app.js");
766 assert_eq!(proj_ref.map[0].to, "dist/app.js");
767 }
768 _ => panic!("Expected ProjectReference"),
769 }
770
771 assert_eq!(task.depends_on.len(), 1);
773 assert_eq!(task.depends_on[0], "#myproj:build");
774 }
775
776 #[test]
777 fn test_implicit_bun_install_task() {
778 let mut cuenv = Project::new("test");
779 cuenv.workspaces = Some(HashMap::from([(
780 "bun".into(),
781 WorkspaceConfig {
782 enabled: true,
783 root: None,
784 package_manager: None,
785 hooks: None,
786 },
787 )]));
788
789 cuenv.tasks.insert(
791 "dev".into(),
792 TaskDefinition::Single(Box::new(Task {
793 command: "bun".to_string(),
794 args: vec!["run".to_string(), "dev".to_string()],
795 workspaces: vec!["bun".to_string()],
796 ..Default::default()
797 })),
798 );
799
800 let cuenv = cuenv.with_implicit_tasks();
801 assert!(cuenv.tasks.contains_key("bun.install"));
802
803 let task_def = cuenv.tasks.get("bun.install").unwrap();
804 let task = task_def.as_single().unwrap();
805 assert_eq!(task.command, "bun");
806 assert_eq!(task.args, vec!["install"]);
807 assert_eq!(task.workspaces, vec!["bun"]);
808 }
809
810 #[test]
811 fn test_implicit_npm_install_task() {
812 let mut cuenv = Project::new("test");
813 cuenv.workspaces = Some(HashMap::from([(
814 "npm".into(),
815 WorkspaceConfig {
816 enabled: true,
817 root: None,
818 package_manager: None,
819 hooks: None,
820 },
821 )]));
822
823 cuenv.tasks.insert(
825 "build".into(),
826 TaskDefinition::Single(Box::new(Task {
827 command: "npm".to_string(),
828 args: vec!["run".to_string(), "build".to_string()],
829 workspaces: vec!["npm".to_string()],
830 ..Default::default()
831 })),
832 );
833
834 let cuenv = cuenv.with_implicit_tasks();
835 assert!(cuenv.tasks.contains_key("npm.install"));
836 }
837
838 #[test]
839 fn test_implicit_cargo_fetch_task() {
840 let mut cuenv = Project::new("test");
841 cuenv.workspaces = Some(HashMap::from([(
842 "cargo".into(),
843 WorkspaceConfig {
844 enabled: true,
845 root: None,
846 package_manager: None,
847 hooks: None,
848 },
849 )]));
850
851 cuenv.tasks.insert(
853 "build".into(),
854 TaskDefinition::Single(Box::new(Task {
855 command: "cargo".to_string(),
856 args: vec!["build".to_string()],
857 workspaces: vec!["cargo".to_string()],
858 ..Default::default()
859 })),
860 );
861
862 let cuenv = cuenv.with_implicit_tasks();
863 assert!(cuenv.tasks.contains_key("cargo.install"));
864
865 let task_def = cuenv.tasks.get("cargo.install").unwrap();
866 let task = task_def.as_single().unwrap();
867 assert_eq!(task.command, "cargo");
868 assert_eq!(task.args, vec!["fetch"]);
869 }
870
871 #[test]
872 fn test_no_override_user_defined_task() {
873 let mut cuenv = Project::new("test");
874 cuenv.workspaces = Some(HashMap::from([(
875 "bun".into(),
876 WorkspaceConfig {
877 enabled: true,
878 root: None,
879 package_manager: None,
880 hooks: None,
881 },
882 )]));
883
884 let user_task = Task {
886 command: "custom-bun".to_string(),
887 args: vec!["custom-install".to_string()],
888 ..Default::default()
889 };
890 cuenv.tasks.insert(
891 "bun.install".into(),
892 TaskDefinition::Single(Box::new(user_task)),
893 );
894
895 let cuenv = cuenv.with_implicit_tasks();
896
897 let task_def = cuenv.tasks.get("bun.install").unwrap();
899 let task = task_def.as_single().unwrap();
900 assert_eq!(task.command, "custom-bun");
901 }
902
903 #[test]
904 fn test_no_override_user_defined_nested_install_task() {
905 let mut cuenv = Project::new("test");
906 cuenv.workspaces = Some(HashMap::from([(
907 "bun".into(),
908 WorkspaceConfig {
909 enabled: true,
910 root: None,
911 package_manager: None,
912 hooks: None,
913 },
914 )]));
915
916 cuenv.tasks.insert(
918 "bun".into(),
919 TaskDefinition::Group(TaskGroup::Parallel(ParallelGroup {
920 tasks: HashMap::from([(
921 "install".into(),
922 TaskDefinition::Single(Box::new(Task {
923 command: "custom-bun".to_string(),
924 args: vec!["custom-install".to_string()],
925 ..Default::default()
926 })),
927 )]),
928 depends_on: vec![],
929 })),
930 );
931
932 cuenv.tasks.insert(
934 "dev".into(),
935 TaskDefinition::Single(Box::new(Task {
936 command: "echo".to_string(),
937 args: vec!["dev".to_string()],
938 workspaces: vec!["bun".to_string()],
939 ..Default::default()
940 })),
941 );
942
943 let cuenv = cuenv.with_implicit_tasks();
944
945 assert!(!cuenv.tasks.contains_key("bun.install"));
947
948 let idx = TaskIndex::build(&cuenv.tasks).unwrap();
950 let bun_install = idx.resolve("bun.install").unwrap();
951 let TaskDefinition::Single(t) = &bun_install.definition else {
952 panic!("expected bun.install to be a single task");
953 };
954 assert_eq!(t.command, "custom-bun");
955 }
956
957 #[test]
958 fn test_disabled_workspace_no_implicit_task() {
959 let mut cuenv = Project::new("test");
960 cuenv.workspaces = Some(HashMap::from([(
961 "bun".into(),
962 WorkspaceConfig {
963 enabled: false,
964 root: None,
965 package_manager: None,
966 hooks: None,
967 },
968 )]));
969
970 let cuenv = cuenv.with_implicit_tasks();
971 assert!(!cuenv.tasks.contains_key("bun.install"));
972 }
973
974 #[test]
975 fn test_unknown_workspace_no_implicit_task() {
976 let mut cuenv = Project::new("test");
977 cuenv.workspaces = Some(HashMap::from([(
978 "unknown-package-manager".into(),
979 WorkspaceConfig {
980 enabled: true,
981 root: None,
982 package_manager: None,
983 hooks: None,
984 },
985 )]));
986
987 let cuenv = cuenv.with_implicit_tasks();
988 assert!(!cuenv.tasks.contains_key("unknown-package-manager.install"));
989 }
990
991 #[test]
992 fn test_no_workspaces_unchanged() {
993 let cuenv = Project::new("test");
994 let cuenv = cuenv.with_implicit_tasks();
995 assert!(cuenv.tasks.is_empty());
996 }
997
998 #[test]
999 fn test_no_workspace_tasks_when_unused() {
1000 let mut cuenv = Project::new("test");
1002 cuenv.workspaces = Some(HashMap::from([(
1003 "bun".into(),
1004 WorkspaceConfig {
1005 enabled: true,
1006 root: None,
1007 package_manager: None,
1008 hooks: None,
1009 },
1010 )]));
1011
1012 cuenv.tasks.insert(
1014 "build".into(),
1015 TaskDefinition::Single(Box::new(Task {
1016 command: "cargo".to_string(),
1017 args: vec!["build".to_string()],
1018 workspaces: vec![], ..Default::default()
1020 })),
1021 );
1022
1023 let cuenv = cuenv.with_implicit_tasks();
1024
1025 assert!(
1027 !cuenv.tasks.contains_key("bun.install"),
1028 "Should not create bun.install when no task uses bun workspace"
1029 );
1030 }
1031
1032 #[test]
1037 fn test_task_ref_parse_valid() {
1038 let task_ref = TaskRef {
1039 ref_: "#projen-generator:types".to_string(),
1040 };
1041
1042 let parsed = task_ref.parse();
1043 assert!(parsed.is_some());
1044
1045 let (project, task) = parsed.unwrap();
1046 assert_eq!(project, "projen-generator");
1047 assert_eq!(task, "types");
1048 }
1049
1050 #[test]
1051 fn test_task_ref_parse_with_dots() {
1052 let task_ref = TaskRef {
1053 ref_: "#my-project:bun.install".to_string(),
1054 };
1055
1056 let parsed = task_ref.parse();
1057 assert!(parsed.is_some());
1058
1059 let (project, task) = parsed.unwrap();
1060 assert_eq!(project, "my-project");
1061 assert_eq!(task, "bun.install");
1062 }
1063
1064 #[test]
1065 fn test_task_ref_parse_no_hash() {
1066 let task_ref = TaskRef {
1067 ref_: "project:task".to_string(),
1068 };
1069
1070 let parsed = task_ref.parse();
1072 assert!(parsed.is_none());
1073 }
1074
1075 #[test]
1076 fn test_task_ref_parse_no_colon() {
1077 let task_ref = TaskRef {
1078 ref_: "#project-only".to_string(),
1079 };
1080
1081 let parsed = task_ref.parse();
1083 assert!(parsed.is_none());
1084 }
1085
1086 #[test]
1087 fn test_task_ref_parse_empty_project() {
1088 let task_ref = TaskRef {
1089 ref_: "#:task".to_string(),
1090 };
1091
1092 assert!(task_ref.parse().is_none());
1094 }
1095
1096 #[test]
1097 fn test_task_ref_parse_empty_task() {
1098 let task_ref = TaskRef {
1099 ref_: "#project:".to_string(),
1100 };
1101
1102 assert!(task_ref.parse().is_none());
1104 }
1105
1106 #[test]
1107 fn test_task_ref_parse_both_empty() {
1108 let task_ref = TaskRef {
1109 ref_: "#:".to_string(),
1110 };
1111
1112 assert!(task_ref.parse().is_none());
1114 }
1115
1116 #[test]
1117 fn test_task_ref_parse_multiple_colons() {
1118 let task_ref = TaskRef {
1119 ref_: "#project:task:extra".to_string(),
1120 };
1121
1122 let parsed = task_ref.parse();
1124 assert!(parsed.is_some());
1125 let (project, task) = parsed.unwrap();
1126 assert_eq!(project, "project");
1127 assert_eq!(task, "task:extra");
1128 }
1129
1130 #[test]
1131 fn test_task_ref_parse_unicode() {
1132 let task_ref = TaskRef {
1133 ref_: "#项目名:任务名".to_string(),
1134 };
1135
1136 let parsed = task_ref.parse();
1137 assert!(parsed.is_some());
1138 let (project, task) = parsed.unwrap();
1139 assert_eq!(project, "项目名");
1140 assert_eq!(task, "任务名");
1141 }
1142
1143 #[test]
1144 fn test_task_ref_parse_special_characters() {
1145 let task_ref = TaskRef {
1146 ref_: "#my-project_v2:build.ci-test".to_string(),
1147 };
1148
1149 let parsed = task_ref.parse();
1150 assert!(parsed.is_some());
1151 let (project, task) = parsed.unwrap();
1152 assert_eq!(project, "my-project_v2");
1153 assert_eq!(task, "build.ci-test");
1154 }
1155
1156 #[test]
1157 fn test_hook_item_task_ref_deserialization() {
1158 let json = "{\"ref\": \"#other-project:build\"}";
1159 let hook_item: HookItem = serde_json::from_str(json).unwrap();
1160
1161 match hook_item {
1162 HookItem::TaskRef(task_ref) => {
1163 assert_eq!(task_ref.ref_, "#other-project:build");
1164 let (project, task) = task_ref.parse().unwrap();
1165 assert_eq!(project, "other-project");
1166 assert_eq!(task, "build");
1167 }
1168 _ => panic!("Expected HookItem::TaskRef"),
1169 }
1170 }
1171
1172 #[test]
1173 fn test_hook_item_match_deserialization() {
1174 let json = r#"{
1175 "name": "projen",
1176 "match": {
1177 "labels": ["codegen", "projen"]
1178 }
1179 }"#;
1180 let hook_item: HookItem = serde_json::from_str(json).unwrap();
1181
1182 match hook_item {
1183 HookItem::Match(match_hook) => {
1184 assert_eq!(match_hook.name, Some("projen".to_string()));
1185 assert_eq!(
1186 match_hook.matcher.labels,
1187 Some(vec!["codegen".to_string(), "projen".to_string()])
1188 );
1189 }
1190 _ => panic!("Expected HookItem::Match"),
1191 }
1192 }
1193
1194 #[test]
1195 fn test_hook_item_match_with_parallel_false() {
1196 let json = r#"{
1197 "match": {
1198 "labels": ["build"],
1199 "parallel": false
1200 }
1201 }"#;
1202 let hook_item: HookItem = serde_json::from_str(json).unwrap();
1203
1204 match hook_item {
1205 HookItem::Match(match_hook) => {
1206 assert!(match_hook.name.is_none());
1207 assert!(!match_hook.matcher.parallel);
1208 }
1209 _ => panic!("Expected HookItem::Match"),
1210 }
1211 }
1212
1213 #[test]
1214 fn test_hook_item_inline_task_deserialization() {
1215 let json = r#"{
1216 "command": "echo",
1217 "args": ["hello"]
1218 }"#;
1219 let hook_item: HookItem = serde_json::from_str(json).unwrap();
1220
1221 match hook_item {
1222 HookItem::Task(task) => {
1223 assert_eq!(task.command, "echo");
1224 assert_eq!(task.args, vec!["hello"]);
1225 }
1226 _ => panic!("Expected HookItem::Task"),
1227 }
1228 }
1229
1230 #[test]
1231 fn test_workspace_hooks_before_install() {
1232 let json = format!(
1233 r#"{{
1234 "beforeInstall": [
1235 {{"ref": "{}"}},
1236 {{"name": "codegen", "match": {{"labels": ["codegen"]}}}},
1237 {{"command": "echo", "args": ["ready"]}}
1238 ]
1239 }}"#,
1240 "#projen:types"
1241 );
1242 let hooks: WorkspaceHooks = serde_json::from_str(&json).unwrap();
1243
1244 let before_install = hooks.before_install.unwrap();
1245 assert_eq!(before_install.len(), 3);
1246
1247 match &before_install[0] {
1249 HookItem::TaskRef(task_ref) => {
1250 assert_eq!(task_ref.ref_, "#projen:types");
1251 }
1252 _ => panic!("Expected TaskRef"),
1253 }
1254
1255 match &before_install[1] {
1257 HookItem::Match(match_hook) => {
1258 assert_eq!(match_hook.name, Some("codegen".to_string()));
1259 }
1260 _ => panic!("Expected Match"),
1261 }
1262
1263 match &before_install[2] {
1265 HookItem::Task(task) => {
1266 assert_eq!(task.command, "echo");
1267 }
1268 _ => panic!("Expected Task"),
1269 }
1270 }
1271
1272 #[test]
1273 fn test_workspace_hooks_after_install() {
1274 let json = r#"{
1275 "afterInstall": [
1276 {"command": "prisma", "args": ["generate"]}
1277 ]
1278 }"#;
1279 let hooks: WorkspaceHooks = serde_json::from_str(json).unwrap();
1280
1281 assert!(hooks.before_install.is_none());
1282 let after_install = hooks.after_install.unwrap();
1283 assert_eq!(after_install.len(), 1);
1284
1285 match &after_install[0] {
1286 HookItem::Task(task) => {
1287 assert_eq!(task.command, "prisma");
1288 assert_eq!(task.args, vec!["generate"]);
1289 }
1290 _ => panic!("Expected Task"),
1291 }
1292 }
1293
1294 #[test]
1295 fn test_workspace_config_with_hooks() {
1296 let json = format!(
1297 r#"{{
1298 "enabled": true,
1299 "hooks": {{
1300 "beforeInstall": [
1301 {{"ref": "{}"}}
1302 ]
1303 }}
1304 }}"#,
1305 "#generator:types"
1306 );
1307 let config: WorkspaceConfig = serde_json::from_str(&json).unwrap();
1308
1309 assert!(config.enabled);
1310 assert!(config.hooks.is_some());
1311
1312 let hooks = config.hooks.unwrap();
1313 let before_install = hooks.before_install.unwrap();
1314 assert_eq!(before_install.len(), 1);
1315 }
1316
1317 #[test]
1318 fn test_task_matcher_deserialization() {
1319 let json = r#"{
1320 "workspaces": ["packages/lib"],
1321 "labels": ["projen", "codegen"],
1322 "parallel": true
1323 }"#;
1324 let matcher: TaskMatcher = serde_json::from_str(json).unwrap();
1325
1326 assert_eq!(matcher.workspaces, Some(vec!["packages/lib".to_string()]));
1327 assert_eq!(
1328 matcher.labels,
1329 Some(vec!["projen".to_string(), "codegen".to_string()])
1330 );
1331 assert!(matcher.parallel);
1332 }
1333
1334 #[test]
1335 fn test_task_matcher_defaults() {
1336 let json = r#"{}"#;
1337 let matcher: TaskMatcher = serde_json::from_str(json).unwrap();
1338
1339 assert!(matcher.workspaces.is_none());
1340 assert!(matcher.labels.is_none());
1341 assert!(matcher.command.is_none());
1342 assert!(matcher.args.is_none());
1343 assert!(matcher.parallel); }
1345
1346 #[test]
1347 fn test_task_matcher_with_command() {
1348 let json = r#"{
1349 "command": "prisma",
1350 "args": [{"contains": "generate"}]
1351 }"#;
1352 let matcher: TaskMatcher = serde_json::from_str(json).unwrap();
1353
1354 assert_eq!(matcher.command, Some("prisma".to_string()));
1355 let args = matcher.args.unwrap();
1356 assert_eq!(args.len(), 1);
1357 assert_eq!(args[0].contains, Some("generate".to_string()));
1358 }
1359
1360 #[test]
1365 fn test_cuenv_workspace_with_before_install_hooks() {
1366 let json = format!(
1367 r#"{{
1368 "name": "test-project",
1369 "workspaces": {{
1370 "bun": {{
1371 "enabled": true,
1372 "hooks": {{
1373 "beforeInstall": [
1374 {{"ref": "{}"}},
1375 {{"command": "sh", "args": ["-c", "echo setup"]}}
1376 ]
1377 }}
1378 }}
1379 }},
1380 "tasks": {{
1381 "dev": {{
1382 "command": "bun",
1383 "args": ["run", "dev"],
1384 "workspaces": ["bun"]
1385 }}
1386 }}
1387 }}"#,
1388 "#generator:types"
1389 );
1390 let cuenv: Project = serde_json::from_str(&json).unwrap();
1391
1392 assert_eq!(cuenv.name, "test-project");
1393 let workspaces = cuenv.workspaces.unwrap();
1394 let bun_config = workspaces.get("bun").unwrap();
1395
1396 assert!(bun_config.enabled);
1397 let hooks = bun_config.hooks.as_ref().unwrap();
1398 let before_install = hooks.before_install.as_ref().unwrap();
1399 assert_eq!(before_install.len(), 2);
1400 }
1401
1402 #[test]
1403 fn test_cuenv_multiple_workspaces_with_hooks() {
1404 let json = format!(
1405 r#"{{
1406 "name": "multi-workspace",
1407 "workspaces": {{
1408 "bun": {{
1409 "enabled": true,
1410 "hooks": {{
1411 "beforeInstall": [{{"ref": "{}"}}]
1412 }}
1413 }},
1414 "cargo": {{
1415 "enabled": true,
1416 "hooks": {{
1417 "beforeInstall": [{{"command": "cargo", "args": ["generate"]}}]
1418 }}
1419 }}
1420 }},
1421 "tasks": {{}}
1422 }}"#,
1423 "#projen:types"
1424 );
1425 let cuenv: Project = serde_json::from_str(&json).unwrap();
1426
1427 let workspaces = cuenv.workspaces.unwrap();
1428 assert!(workspaces.contains_key("bun"));
1429 assert!(workspaces.contains_key("cargo"));
1430
1431 let bun_hooks = workspaces["bun"].hooks.as_ref().unwrap();
1433 assert!(bun_hooks.before_install.is_some());
1434
1435 let cargo_hooks = workspaces["cargo"].hooks.as_ref().unwrap();
1437 assert!(cargo_hooks.before_install.is_some());
1438 }
1439
1440 #[test]
1445 fn test_expand_multiple_cross_project_references() {
1446 let task = Task {
1447 inputs: vec![
1448 Input::Path("#projA:build:dist/lib.js".to_string()),
1449 Input::Path("#projB:compile:out/types.d.ts".to_string()),
1450 Input::Path("src/**/*.ts".to_string()), ],
1452 ..Default::default()
1453 };
1454
1455 let mut cuenv = Project::new("test");
1456 cuenv
1457 .tasks
1458 .insert("bundle".into(), TaskDefinition::Single(Box::new(task)));
1459
1460 cuenv.expand_cross_project_references();
1461
1462 let task_def = cuenv.tasks.get("bundle").unwrap();
1463 let task = task_def.as_single().unwrap();
1464
1465 assert_eq!(task.inputs.len(), 3);
1467
1468 assert_eq!(task.depends_on.len(), 2);
1470 assert!(task.depends_on.contains(&"#projA:build".to_string()));
1471 assert!(task.depends_on.contains(&"#projB:compile".to_string()));
1472 }
1473
1474 #[test]
1475 fn test_expand_cross_project_in_task_group() {
1476 let task1 = Task {
1477 command: "step1".to_string(),
1478 inputs: vec![Input::Path("#projA:build:dist/lib.js".to_string())],
1479 ..Default::default()
1480 };
1481
1482 let task2 = Task {
1483 command: "step2".to_string(),
1484 inputs: vec![Input::Path("#projB:compile:out/types.d.ts".to_string())],
1485 ..Default::default()
1486 };
1487
1488 let mut cuenv = Project::new("test");
1489 cuenv.tasks.insert(
1490 "pipeline".into(),
1491 TaskDefinition::Group(TaskGroup::Sequential(vec![
1492 TaskDefinition::Single(Box::new(task1)),
1493 TaskDefinition::Single(Box::new(task2)),
1494 ])),
1495 );
1496
1497 cuenv.expand_cross_project_references();
1498
1499 match cuenv.tasks.get("pipeline").unwrap() {
1501 TaskDefinition::Group(TaskGroup::Sequential(tasks)) => {
1502 match &tasks[0] {
1503 TaskDefinition::Single(task) => {
1504 assert!(task.depends_on.contains(&"#projA:build".to_string()));
1505 }
1506 _ => panic!("Expected single task"),
1507 }
1508 match &tasks[1] {
1509 TaskDefinition::Single(task) => {
1510 assert!(task.depends_on.contains(&"#projB:compile".to_string()));
1511 }
1512 _ => panic!("Expected single task"),
1513 }
1514 }
1515 _ => panic!("Expected sequential group"),
1516 }
1517 }
1518
1519 #[test]
1520 fn test_expand_cross_project_in_parallel_group() {
1521 let task1 = Task {
1522 command: "taskA".to_string(),
1523 inputs: vec![Input::Path("#projA:build:lib.js".to_string())],
1524 ..Default::default()
1525 };
1526
1527 let task2 = Task {
1528 command: "taskB".to_string(),
1529 inputs: vec![Input::Path("#projB:build:types.d.ts".to_string())],
1530 ..Default::default()
1531 };
1532
1533 let mut parallel_tasks = HashMap::new();
1534 parallel_tasks.insert("a".to_string(), TaskDefinition::Single(Box::new(task1)));
1535 parallel_tasks.insert("b".to_string(), TaskDefinition::Single(Box::new(task2)));
1536
1537 let mut cuenv = Project::new("test");
1538 cuenv.tasks.insert(
1539 "parallel".into(),
1540 TaskDefinition::Group(TaskGroup::Parallel(ParallelGroup {
1541 tasks: parallel_tasks,
1542 depends_on: vec![],
1543 })),
1544 );
1545
1546 cuenv.expand_cross_project_references();
1547
1548 match cuenv.tasks.get("parallel").unwrap() {
1550 TaskDefinition::Group(TaskGroup::Parallel(group)) => {
1551 match group.tasks.get("a").unwrap() {
1552 TaskDefinition::Single(task) => {
1553 assert!(task.depends_on.contains(&"#projA:build".to_string()));
1554 }
1555 _ => panic!("Expected single task"),
1556 }
1557 match group.tasks.get("b").unwrap() {
1558 TaskDefinition::Single(task) => {
1559 assert!(task.depends_on.contains(&"#projB:build".to_string()));
1560 }
1561 _ => panic!("Expected single task"),
1562 }
1563 }
1564 _ => panic!("Expected parallel group"),
1565 }
1566 }
1567
1568 #[test]
1569 fn test_no_duplicate_implicit_dependencies() {
1570 let task = Task {
1572 depends_on: vec!["#myproj:build".to_string()],
1573 inputs: vec![Input::Path("#myproj:build:dist/app.js".to_string())],
1574 ..Default::default()
1575 };
1576
1577 let mut cuenv = Project::new("test");
1578 cuenv
1579 .tasks
1580 .insert("deploy".into(), TaskDefinition::Single(Box::new(task)));
1581
1582 cuenv.expand_cross_project_references();
1583
1584 let task_def = cuenv.tasks.get("deploy").unwrap();
1585 let task = task_def.as_single().unwrap();
1586
1587 assert_eq!(task.depends_on.len(), 1);
1589 assert_eq!(task.depends_on[0], "#myproj:build");
1590 }
1591
1592 #[test]
1597 fn test_on_enter_hooks_ordering() {
1598 let mut on_enter = HashMap::new();
1599 on_enter.insert("hook_c".to_string(), create_test_hook(300, "echo c"));
1600 on_enter.insert("hook_a".to_string(), create_test_hook(100, "echo a"));
1601 on_enter.insert("hook_b".to_string(), create_test_hook(200, "echo b"));
1602
1603 let mut cuenv = Project::new("test");
1604 cuenv.hooks = Some(Hooks {
1605 on_enter: Some(on_enter),
1606 on_exit: None,
1607 });
1608
1609 let hooks = cuenv.on_enter_hooks();
1610 assert_eq!(hooks.len(), 3);
1611
1612 assert_eq!(hooks[0].order, 100);
1614 assert_eq!(hooks[1].order, 200);
1615 assert_eq!(hooks[2].order, 300);
1616 }
1617
1618 #[test]
1619 fn test_on_enter_hooks_same_order_sort_by_name() {
1620 let mut on_enter = HashMap::new();
1621 on_enter.insert("z_hook".to_string(), create_test_hook(100, "echo z"));
1622 on_enter.insert("a_hook".to_string(), create_test_hook(100, "echo a"));
1623
1624 let cuenv = Project {
1625 name: "test".to_string(),
1626 hooks: Some(Hooks {
1627 on_enter: Some(on_enter),
1628 on_exit: None,
1629 }),
1630 ..Default::default()
1631 };
1632
1633 let hooks = cuenv.on_enter_hooks();
1634 assert_eq!(hooks.len(), 2);
1635
1636 assert_eq!(hooks[0].command, "echo a");
1638 assert_eq!(hooks[1].command, "echo z");
1639 }
1640
1641 #[test]
1642 fn test_empty_hooks() {
1643 let cuenv = Project::new("test");
1644
1645 let on_enter = cuenv.on_enter_hooks();
1646 let on_exit = cuenv.on_exit_hooks();
1647
1648 assert!(on_enter.is_empty());
1649 assert!(on_exit.is_empty());
1650 }
1651
1652 #[test]
1653 fn test_project_deserialization_with_script_tasks() {
1654 let json = r#"{
1656 "name": "cuenv",
1657 "hooks": {
1658 "onEnter": {
1659 "nix": {
1660 "order": 10,
1661 "propagate": false,
1662 "command": "nix",
1663 "args": ["print-dev-env"],
1664 "inputs": ["flake.nix", "flake.lock"],
1665 "source": true
1666 }
1667 }
1668 },
1669 "tasks": {
1670 "pwd": { "command": "pwd" },
1671 "check": {
1672 "command": "nix",
1673 "args": ["flake", "check"],
1674 "inputs": ["flake.nix"]
1675 },
1676 "fmt": {
1677 "fix": {
1678 "command": "treefmt",
1679 "inputs": [".config"]
1680 },
1681 "check": {
1682 "command": "treefmt",
1683 "args": ["--fail-on-change"],
1684 "inputs": [".config"]
1685 }
1686 },
1687 "cross": {
1688 "linux": {
1689 "script": "echo building for linux",
1690 "inputs": ["Cargo.toml"]
1691 }
1692 },
1693 "docs": {
1694 "build": {
1695 "command": "bash",
1696 "args": ["-c", "bun install"],
1697 "inputs": ["docs"],
1698 "outputs": ["docs/dist"]
1699 },
1700 "deploy": {
1701 "command": "bash",
1702 "args": ["-c", "wrangler deploy"],
1703 "dependsOn": ["docs.build"],
1704 "inputsFrom": [{"task": "docs.build"}]
1705 }
1706 }
1707 }
1708 }"#;
1709
1710 let result: Result<Project, _> = serde_json::from_str(json);
1711 match result {
1712 Ok(project) => {
1713 assert_eq!(project.name, "cuenv");
1714 assert_eq!(project.tasks.len(), 5);
1715 assert!(project.tasks.contains_key("pwd"));
1716 assert!(project.tasks.contains_key("cross"));
1717 let cross = project.tasks.get("cross").unwrap();
1719 assert!(cross.is_group());
1720 }
1721 Err(e) => {
1722 panic!("Failed to deserialize Project with script tasks: {}", e);
1723 }
1724 }
1725 }
1726}