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::tasks::{Input, Mapping, ProjectReference, TaskGroup};
14use crate::tasks::{Task, TaskDefinition};
15
16#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, 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, JsonSchema, 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, JsonSchema, 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, JsonSchema, 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, JsonSchema, 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 Some((parts[0].to_string(), parts[1].to_string()))
90 } else {
91 None
92 }
93 }
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
98pub struct TaskMatcher {
99 #[serde(skip_serializing_if = "Option::is_none")]
101 pub workspaces: Option<Vec<String>>,
102
103 #[serde(skip_serializing_if = "Option::is_none")]
105 pub labels: Option<Vec<String>>,
106
107 #[serde(skip_serializing_if = "Option::is_none")]
109 pub command: Option<String>,
110
111 #[serde(skip_serializing_if = "Option::is_none")]
113 pub args: Option<Vec<ArgMatcher>>,
114
115 #[serde(default = "default_true")]
117 pub parallel: bool,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
122pub struct ArgMatcher {
123 #[serde(skip_serializing_if = "Option::is_none")]
125 pub contains: Option<String>,
126
127 #[serde(skip_serializing_if = "Option::is_none")]
129 pub matches: Option<String>,
130}
131
132fn default_true() -> bool {
133 true
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
138pub struct Hooks {
139 #[serde(skip_serializing_if = "Option::is_none")]
141 #[serde(rename = "onEnter")]
142 pub on_enter: Option<HashMap<String, Hook>>,
143
144 #[serde(skip_serializing_if = "Option::is_none")]
146 #[serde(rename = "onExit")]
147 pub on_exit: Option<HashMap<String, Hook>>,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
152pub struct Base {
153 #[serde(skip_serializing_if = "Option::is_none")]
155 pub config: Option<Config>,
156
157 #[serde(skip_serializing_if = "Option::is_none")]
159 pub env: Option<Env>,
160
161 #[serde(skip_serializing_if = "Option::is_none")]
163 pub workspaces: Option<HashMap<String, WorkspaceConfig>>,
164}
165
166#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
168pub struct Project {
169 #[serde(skip_serializing_if = "Option::is_none")]
171 pub config: Option<Config>,
172
173 pub name: String,
175
176 #[serde(skip_serializing_if = "Option::is_none")]
178 pub env: Option<Env>,
179
180 #[serde(skip_serializing_if = "Option::is_none")]
182 pub hooks: Option<Hooks>,
183
184 #[serde(skip_serializing_if = "Option::is_none")]
186 pub workspaces: Option<HashMap<String, WorkspaceConfig>>,
187
188 #[serde(skip_serializing_if = "Option::is_none")]
190 pub ci: Option<CI>,
191
192 #[serde(default)]
194 pub tasks: HashMap<String, TaskDefinition>,
195}
196
197pub type Cuenv = Project;
199
200impl Project {
201 pub fn new(name: impl Into<String>) -> Self {
203 Self {
204 name: name.into(),
205 ..Self::default()
206 }
207 }
208
209 pub fn on_enter_hooks_map(&self) -> HashMap<String, Hook> {
211 self.hooks
212 .as_ref()
213 .and_then(|h| h.on_enter.as_ref())
214 .cloned()
215 .unwrap_or_default()
216 }
217
218 pub fn on_enter_hooks(&self) -> Vec<Hook> {
220 let map = self.on_enter_hooks_map();
221 let mut hooks: Vec<(String, Hook)> = map.into_iter().collect();
222 hooks.sort_by(|a, b| a.1.order.cmp(&b.1.order).then(a.0.cmp(&b.0)));
223 hooks.into_iter().map(|(_, h)| h).collect()
224 }
225
226 pub fn on_exit_hooks_map(&self) -> HashMap<String, Hook> {
228 self.hooks
229 .as_ref()
230 .and_then(|h| h.on_exit.as_ref())
231 .cloned()
232 .unwrap_or_default()
233 }
234
235 pub fn on_exit_hooks(&self) -> Vec<Hook> {
237 let map = self.on_exit_hooks_map();
238 let mut hooks: Vec<(String, Hook)> = map.into_iter().collect();
239 hooks.sort_by(|a, b| a.1.order.cmp(&b.1.order).then(a.0.cmp(&b.0)));
240 hooks.into_iter().map(|(_, h)| h).collect()
241 }
242
243 pub fn with_implicit_tasks(mut self) -> Self {
251 fn get_task_mut_by_path<'a>(
252 tasks: &'a mut HashMap<String, TaskDefinition>,
253 raw_path: &str,
254 ) -> Option<&'a mut Task> {
255 let normalized = raw_path.replace(':', ".");
256 let mut segments = normalized
257 .split('.')
258 .filter(|s| !s.is_empty())
259 .map(str::trim)
260 .collect::<Vec<_>>();
261 if segments.is_empty() {
262 return None;
263 }
264
265 let first = segments.remove(0);
266 let mut current = tasks.get_mut(first)?;
267 for seg in segments {
268 match current {
269 TaskDefinition::Group(TaskGroup::Parallel(group)) => {
270 current = group.tasks.get_mut(seg)?;
271 }
272 _ => return None,
273 }
274 }
275
276 match current {
277 TaskDefinition::Single(task) => Some(task.as_mut()),
278 _ => None,
279 }
280 }
281
282 let Some(workspaces) = &self.workspaces else {
283 return self;
284 };
285
286 let workspaces = workspaces.clone();
288
289 for (name, config) in &workspaces {
290 if !config.enabled {
291 continue;
292 }
293
294 if !matches!(name.as_str(), "bun" | "npm" | "pnpm" | "yarn" | "cargo") {
296 continue;
297 }
298
299 let workspace_used = self
301 .tasks
302 .values()
303 .any(|task_def| task_def.uses_workspace(name));
304 if !workspace_used {
305 tracing::debug!("Skipping workspace '{}' - no tasks declare usage", name);
306 continue;
307 }
308
309 let install_task_name = format!("{}.install", name);
310
311 if get_task_mut_by_path(&mut self.tasks, &install_task_name).is_some() {
313 continue;
314 }
315
316 if let Some(task) = Self::create_implicit_install_task(name) {
318 self.tasks
319 .insert(install_task_name, TaskDefinition::Single(Box::new(task)));
320 }
321 }
322
323 self
324 }
325
326 fn create_implicit_install_task(workspace_name: &str) -> Option<Task> {
328 let (command, args, description, inputs, outputs) = match workspace_name {
329 "bun" => (
330 "bun",
331 vec!["install"],
332 "Install bun dependencies",
333 vec![
334 Input::Path("package.json".to_string()),
335 Input::Path("bun.lock".to_string()),
336 ],
337 vec!["node_modules".to_string()],
338 ),
339 "npm" => (
340 "npm",
341 vec!["install"],
342 "Install npm dependencies",
343 vec![
344 Input::Path("package.json".to_string()),
345 Input::Path("package-lock.json".to_string()),
346 ],
347 vec!["node_modules".to_string()],
348 ),
349 "pnpm" => (
350 "pnpm",
351 vec!["install"],
352 "Install pnpm dependencies",
353 vec![
354 Input::Path("package.json".to_string()),
355 Input::Path("pnpm-lock.yaml".to_string()),
356 ],
357 vec!["node_modules".to_string()],
358 ),
359 "yarn" => (
360 "yarn",
361 vec!["install"],
362 "Install yarn dependencies",
363 vec![
364 Input::Path("package.json".to_string()),
365 Input::Path("yarn.lock".to_string()),
366 ],
367 vec!["node_modules".to_string()],
368 ),
369 "cargo" => (
370 "cargo",
371 vec!["fetch"],
372 "Fetch cargo dependencies",
373 vec![
374 Input::Path("Cargo.toml".to_string()),
375 Input::Path("Cargo.lock".to_string()),
376 ],
377 vec![], ),
379 _ => return None, };
381
382 Some(Task {
383 command: command.to_string(),
384 args: args.into_iter().map(String::from).collect(),
385 workspaces: vec![workspace_name.to_string()],
386 hermetic: false, description: Some(description.to_string()),
388 inputs,
389 outputs,
390 ..Default::default()
391 })
392 }
393
394 pub fn expand_cross_project_references(&mut self) {
400 for (_, task_def) in self.tasks.iter_mut() {
401 Self::expand_task_definition(task_def);
402 }
403 }
404
405 fn expand_task_definition(task_def: &mut TaskDefinition) {
406 match task_def {
407 TaskDefinition::Single(task) => Self::expand_task(task),
408 TaskDefinition::Group(group) => match group {
409 TaskGroup::Sequential(tasks) => {
410 for sub_task in tasks {
411 Self::expand_task_definition(sub_task);
412 }
413 }
414 TaskGroup::Parallel(group) => {
415 for sub_task in group.tasks.values_mut() {
416 Self::expand_task_definition(sub_task);
417 }
418 }
419 },
420 }
421 }
422
423 fn expand_task(task: &mut Task) {
424 let mut new_inputs = Vec::new();
425 let mut implicit_deps = Vec::new();
426
427 for input in &task.inputs {
429 match input {
430 Input::Path(path) if path.starts_with('#') => {
431 let parts: Vec<&str> = path[1..].split(':').collect();
434 if parts.len() >= 3 {
435 let project = parts[0].to_string();
436 let task_name = parts[1].to_string();
437 let file_path = parts[2..].join(":");
439
440 new_inputs.push(Input::Project(ProjectReference {
441 project: project.clone(),
442 task: task_name.clone(),
443 map: vec![Mapping {
444 from: file_path.clone(),
445 to: file_path,
446 }],
447 }));
448
449 implicit_deps.push(format!("#{}:{}", project, task_name));
451 } else if parts.len() == 2 {
452 new_inputs.push(input.clone());
459 } else {
460 new_inputs.push(input.clone());
461 }
462 }
463 Input::Project(proj_ref) => {
464 implicit_deps.push(format!("#{}:{}", proj_ref.project, proj_ref.task));
466 new_inputs.push(input.clone());
467 }
468 _ => new_inputs.push(input.clone()),
469 }
470 }
471
472 task.inputs = new_inputs;
473
474 for dep in implicit_deps {
476 if !task.depends_on.contains(&dep) {
477 task.depends_on.push(dep);
478 }
479 }
480 }
481}
482
483#[cfg(test)]
484mod tests {
485 use super::*;
486 use crate::tasks::{ParallelGroup, TaskIndex};
487
488 #[test]
489 fn test_expand_cross_project_references() {
490 let task = Task {
491 inputs: vec![Input::Path("#myproj:build:dist/app.js".to_string())],
492 ..Default::default()
493 };
494
495 let mut cuenv = Cuenv::new("test");
496 cuenv
497 .tasks
498 .insert("deploy".into(), TaskDefinition::Single(Box::new(task)));
499
500 cuenv.expand_cross_project_references();
501
502 let task_def = cuenv.tasks.get("deploy").unwrap();
503 let task = task_def.as_single().unwrap();
504
505 assert_eq!(task.inputs.len(), 1);
507 match &task.inputs[0] {
508 Input::Project(proj_ref) => {
509 assert_eq!(proj_ref.project, "myproj");
510 assert_eq!(proj_ref.task, "build");
511 assert_eq!(proj_ref.map.len(), 1);
512 assert_eq!(proj_ref.map[0].from, "dist/app.js");
513 assert_eq!(proj_ref.map[0].to, "dist/app.js");
514 }
515 _ => panic!("Expected ProjectReference"),
516 }
517
518 assert_eq!(task.depends_on.len(), 1);
520 assert_eq!(task.depends_on[0], "#myproj:build");
521 }
522
523 #[test]
524 fn test_implicit_bun_install_task() {
525 let mut cuenv = Cuenv::new("test");
526 cuenv.workspaces = Some(HashMap::from([(
527 "bun".into(),
528 WorkspaceConfig {
529 enabled: true,
530 root: None,
531 package_manager: None,
532 hooks: None,
533 },
534 )]));
535
536 cuenv.tasks.insert(
538 "dev".into(),
539 TaskDefinition::Single(Box::new(Task {
540 command: "bun".to_string(),
541 args: vec!["run".to_string(), "dev".to_string()],
542 workspaces: vec!["bun".to_string()],
543 ..Default::default()
544 })),
545 );
546
547 let cuenv = cuenv.with_implicit_tasks();
548 assert!(cuenv.tasks.contains_key("bun.install"));
549
550 let task_def = cuenv.tasks.get("bun.install").unwrap();
551 let task = task_def.as_single().unwrap();
552 assert_eq!(task.command, "bun");
553 assert_eq!(task.args, vec!["install"]);
554 assert_eq!(task.workspaces, vec!["bun"]);
555 }
556
557 #[test]
558 fn test_implicit_npm_install_task() {
559 let mut cuenv = Cuenv::new("test");
560 cuenv.workspaces = Some(HashMap::from([(
561 "npm".into(),
562 WorkspaceConfig {
563 enabled: true,
564 root: None,
565 package_manager: None,
566 hooks: None,
567 },
568 )]));
569
570 cuenv.tasks.insert(
572 "build".into(),
573 TaskDefinition::Single(Box::new(Task {
574 command: "npm".to_string(),
575 args: vec!["run".to_string(), "build".to_string()],
576 workspaces: vec!["npm".to_string()],
577 ..Default::default()
578 })),
579 );
580
581 let cuenv = cuenv.with_implicit_tasks();
582 assert!(cuenv.tasks.contains_key("npm.install"));
583 }
584
585 #[test]
586 fn test_implicit_cargo_fetch_task() {
587 let mut cuenv = Cuenv::new("test");
588 cuenv.workspaces = Some(HashMap::from([(
589 "cargo".into(),
590 WorkspaceConfig {
591 enabled: true,
592 root: None,
593 package_manager: None,
594 hooks: None,
595 },
596 )]));
597
598 cuenv.tasks.insert(
600 "build".into(),
601 TaskDefinition::Single(Box::new(Task {
602 command: "cargo".to_string(),
603 args: vec!["build".to_string()],
604 workspaces: vec!["cargo".to_string()],
605 ..Default::default()
606 })),
607 );
608
609 let cuenv = cuenv.with_implicit_tasks();
610 assert!(cuenv.tasks.contains_key("cargo.install"));
611
612 let task_def = cuenv.tasks.get("cargo.install").unwrap();
613 let task = task_def.as_single().unwrap();
614 assert_eq!(task.command, "cargo");
615 assert_eq!(task.args, vec!["fetch"]);
616 }
617
618 #[test]
619 fn test_no_override_user_defined_task() {
620 let mut cuenv = Cuenv::new("test");
621 cuenv.workspaces = Some(HashMap::from([(
622 "bun".into(),
623 WorkspaceConfig {
624 enabled: true,
625 root: None,
626 package_manager: None,
627 hooks: None,
628 },
629 )]));
630
631 let user_task = Task {
633 command: "custom-bun".to_string(),
634 args: vec!["custom-install".to_string()],
635 ..Default::default()
636 };
637 cuenv.tasks.insert(
638 "bun.install".into(),
639 TaskDefinition::Single(Box::new(user_task)),
640 );
641
642 let cuenv = cuenv.with_implicit_tasks();
643
644 let task_def = cuenv.tasks.get("bun.install").unwrap();
646 let task = task_def.as_single().unwrap();
647 assert_eq!(task.command, "custom-bun");
648 }
649
650 #[test]
651 fn test_no_override_user_defined_nested_install_task() {
652 let mut cuenv = Cuenv::new("test");
653 cuenv.workspaces = Some(HashMap::from([(
654 "bun".into(),
655 WorkspaceConfig {
656 enabled: true,
657 root: None,
658 package_manager: None,
659 hooks: None,
660 },
661 )]));
662
663 cuenv.tasks.insert(
665 "bun".into(),
666 TaskDefinition::Group(TaskGroup::Parallel(ParallelGroup {
667 tasks: HashMap::from([(
668 "install".into(),
669 TaskDefinition::Single(Box::new(Task {
670 command: "custom-bun".to_string(),
671 args: vec!["custom-install".to_string()],
672 ..Default::default()
673 })),
674 )]),
675 depends_on: vec![],
676 })),
677 );
678
679 cuenv.tasks.insert(
681 "dev".into(),
682 TaskDefinition::Single(Box::new(Task {
683 command: "echo".to_string(),
684 args: vec!["dev".to_string()],
685 workspaces: vec!["bun".to_string()],
686 ..Default::default()
687 })),
688 );
689
690 let cuenv = cuenv.with_implicit_tasks();
691
692 assert!(!cuenv.tasks.contains_key("bun.install"));
694
695 let idx = TaskIndex::build(&cuenv.tasks).unwrap();
697 let bun_install = idx.resolve("bun.install").unwrap();
698 let TaskDefinition::Single(t) = &bun_install.definition else {
699 panic!("expected bun.install to be a single task");
700 };
701 assert_eq!(t.command, "custom-bun");
702 }
703
704 #[test]
705 fn test_disabled_workspace_no_implicit_task() {
706 let mut cuenv = Cuenv::new("test");
707 cuenv.workspaces = Some(HashMap::from([(
708 "bun".into(),
709 WorkspaceConfig {
710 enabled: false,
711 root: None,
712 package_manager: None,
713 hooks: None,
714 },
715 )]));
716
717 let cuenv = cuenv.with_implicit_tasks();
718 assert!(!cuenv.tasks.contains_key("bun.install"));
719 }
720
721 #[test]
722 fn test_unknown_workspace_no_implicit_task() {
723 let mut cuenv = Cuenv::new("test");
724 cuenv.workspaces = Some(HashMap::from([(
725 "unknown-package-manager".into(),
726 WorkspaceConfig {
727 enabled: true,
728 root: None,
729 package_manager: None,
730 hooks: None,
731 },
732 )]));
733
734 let cuenv = cuenv.with_implicit_tasks();
735 assert!(!cuenv.tasks.contains_key("unknown-package-manager.install"));
736 }
737
738 #[test]
739 fn test_no_workspaces_unchanged() {
740 let cuenv = Cuenv::new("test");
741 let cuenv = cuenv.with_implicit_tasks();
742 assert!(cuenv.tasks.is_empty());
743 }
744
745 #[test]
746 fn test_no_workspace_tasks_when_unused() {
747 let mut cuenv = Cuenv::new("test");
749 cuenv.workspaces = Some(HashMap::from([(
750 "bun".into(),
751 WorkspaceConfig {
752 enabled: true,
753 root: None,
754 package_manager: None,
755 hooks: None,
756 },
757 )]));
758
759 cuenv.tasks.insert(
761 "build".into(),
762 TaskDefinition::Single(Box::new(Task {
763 command: "cargo".to_string(),
764 args: vec!["build".to_string()],
765 workspaces: vec![], ..Default::default()
767 })),
768 );
769
770 let cuenv = cuenv.with_implicit_tasks();
771
772 assert!(
774 !cuenv.tasks.contains_key("bun.install"),
775 "Should not create bun.install when no task uses bun workspace"
776 );
777 }
778}