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
31fn default_true() -> bool {
32 true
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
37pub struct Hooks {
38 #[serde(skip_serializing_if = "Option::is_none")]
40 #[serde(rename = "onEnter")]
41 pub on_enter: Option<HashMap<String, Hook>>,
42
43 #[serde(skip_serializing_if = "Option::is_none")]
45 #[serde(rename = "onExit")]
46 pub on_exit: Option<HashMap<String, Hook>>,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
51pub struct Cuenv {
52 #[serde(skip_serializing_if = "Option::is_none")]
54 pub config: Option<Config>,
55
56 #[serde(skip_serializing_if = "Option::is_none")]
58 pub name: Option<String>,
59
60 #[serde(skip_serializing_if = "Option::is_none")]
62 pub env: Option<Env>,
63
64 #[serde(skip_serializing_if = "Option::is_none")]
66 pub hooks: Option<Hooks>,
67
68 #[serde(skip_serializing_if = "Option::is_none")]
70 pub workspaces: Option<HashMap<String, WorkspaceConfig>>,
71
72 #[serde(skip_serializing_if = "Option::is_none")]
74 pub ci: Option<CI>,
75
76 #[serde(default)]
78 pub tasks: HashMap<String, TaskDefinition>,
79}
80
81impl Cuenv {
82 pub fn new() -> Self {
84 Self::default()
85 }
86
87 pub fn on_enter_hooks_map(&self) -> HashMap<String, Hook> {
89 self.hooks
90 .as_ref()
91 .and_then(|h| h.on_enter.as_ref())
92 .cloned()
93 .unwrap_or_default()
94 }
95
96 pub fn on_enter_hooks(&self) -> Vec<Hook> {
98 let map = self.on_enter_hooks_map();
99 let mut hooks: Vec<(String, Hook)> = map.into_iter().collect();
100 hooks.sort_by(|a, b| a.1.order.cmp(&b.1.order).then(a.0.cmp(&b.0)));
101 hooks.into_iter().map(|(_, h)| h).collect()
102 }
103
104 pub fn on_exit_hooks_map(&self) -> HashMap<String, Hook> {
106 self.hooks
107 .as_ref()
108 .and_then(|h| h.on_exit.as_ref())
109 .cloned()
110 .unwrap_or_default()
111 }
112
113 pub fn on_exit_hooks(&self) -> Vec<Hook> {
115 let map = self.on_exit_hooks_map();
116 let mut hooks: Vec<(String, Hook)> = map.into_iter().collect();
117 hooks.sort_by(|a, b| a.1.order.cmp(&b.1.order).then(a.0.cmp(&b.0)));
118 hooks.into_iter().map(|(_, h)| h).collect()
119 }
120
121 pub fn with_implicit_tasks(mut self) -> Self {
130 let Some(workspaces) = &self.workspaces else {
131 return self;
132 };
133
134 let mut workspace_install_tasks: HashMap<String, String> = HashMap::new();
136
137 for (name, config) in workspaces {
138 if !config.enabled {
139 continue;
140 }
141
142 if !matches!(name.as_str(), "bun" | "npm" | "pnpm" | "yarn" | "cargo") {
144 continue;
145 }
146
147 let install_task_name = format!("{}.install", name);
148 workspace_install_tasks.insert(name.clone(), install_task_name.clone());
149
150 if self.tasks.contains_key(&install_task_name) {
152 continue;
153 }
154
155 if let Some(task) = Self::create_implicit_install_task(name) {
156 self.tasks
157 .insert(install_task_name, TaskDefinition::Single(Box::new(task)));
158 }
159 }
160
161 Self::add_implicit_workspace_dependencies(&mut self.tasks, &workspace_install_tasks);
163
164 self
165 }
166
167 fn add_implicit_workspace_dependencies(
169 tasks: &mut HashMap<String, TaskDefinition>,
170 workspace_install_tasks: &HashMap<String, String>,
171 ) {
172 for (task_name, task_def) in tasks.iter_mut() {
173 Self::add_dependencies_to_definition(task_name, task_def, workspace_install_tasks);
174 }
175 }
176
177 fn add_dependencies_to_definition(
179 task_name: &str,
180 task_def: &mut TaskDefinition,
181 workspace_install_tasks: &HashMap<String, String>,
182 ) {
183 use crate::tasks::TaskGroup;
184
185 match task_def {
186 TaskDefinition::Single(task) => {
187 for workspace_name in &task.workspaces {
188 if let Some(install_task) = workspace_install_tasks.get(workspace_name) {
189 if task_name == install_task {
191 continue;
192 }
193 if !task.depends_on.contains(install_task) {
195 task.depends_on.push(install_task.clone());
196 }
197 }
198 }
199 }
200 TaskDefinition::Group(group) => match group {
201 TaskGroup::Sequential(tasks) => {
202 for (i, sub_task) in tasks.iter_mut().enumerate() {
203 let sub_name = format!("{}[{}]", task_name, i);
204 Self::add_dependencies_to_definition(
205 &sub_name,
206 sub_task,
207 workspace_install_tasks,
208 );
209 }
210 }
211 TaskGroup::Parallel(tasks) => {
212 for (name, sub_task) in tasks.iter_mut() {
213 let sub_name = format!("{}.{}", task_name, name);
214 Self::add_dependencies_to_definition(
215 &sub_name,
216 sub_task,
217 workspace_install_tasks,
218 );
219 }
220 }
221 },
222 }
223 }
224
225 fn create_implicit_install_task(workspace_name: &str) -> Option<Task> {
227 let (command, args, description) = match workspace_name {
228 "bun" => ("bun", vec!["install"], "Install bun dependencies"),
229 "npm" => ("npm", vec!["install"], "Install npm dependencies"),
230 "pnpm" => ("pnpm", vec!["install"], "Install pnpm dependencies"),
231 "yarn" => ("yarn", vec!["install"], "Install yarn dependencies"),
232 "cargo" => ("cargo", vec!["fetch"], "Fetch cargo dependencies"),
233 _ => return None, };
235
236 Some(Task {
237 command: command.to_string(),
238 args: args.into_iter().map(String::from).collect(),
239 workspaces: vec![workspace_name.to_string()],
240 hermetic: false, description: Some(description.to_string()),
242 ..Default::default()
243 })
244 }
245
246 pub fn expand_cross_project_references(&mut self) {
252 for (_, task_def) in self.tasks.iter_mut() {
253 Self::expand_task_definition(task_def);
254 }
255 }
256
257 fn expand_task_definition(task_def: &mut TaskDefinition) {
258 match task_def {
259 TaskDefinition::Single(task) => Self::expand_task(task),
260 TaskDefinition::Group(group) => match group {
261 TaskGroup::Sequential(tasks) => {
262 for sub_task in tasks {
263 Self::expand_task_definition(sub_task);
264 }
265 }
266 TaskGroup::Parallel(tasks) => {
267 for sub_task in tasks.values_mut() {
268 Self::expand_task_definition(sub_task);
269 }
270 }
271 },
272 }
273 }
274
275 fn expand_task(task: &mut Task) {
276 let mut new_inputs = Vec::new();
277 let mut implicit_deps = Vec::new();
278
279 for input in &task.inputs {
281 match input {
282 Input::Path(path) if path.starts_with('#') => {
283 let parts: Vec<&str> = path[1..].split(':').collect();
286 if parts.len() >= 3 {
287 let project = parts[0].to_string();
288 let task_name = parts[1].to_string();
289 let file_path = parts[2..].join(":");
291
292 new_inputs.push(Input::Project(ProjectReference {
293 project: project.clone(),
294 task: task_name.clone(),
295 map: vec![Mapping {
296 from: file_path.clone(),
297 to: file_path,
298 }],
299 }));
300
301 implicit_deps.push(format!("#{}:{}", project, task_name));
303 } else if parts.len() == 2 {
304 new_inputs.push(input.clone());
311 } else {
312 new_inputs.push(input.clone());
313 }
314 }
315 Input::Project(proj_ref) => {
316 implicit_deps.push(format!("#{}:{}", proj_ref.project, proj_ref.task));
318 new_inputs.push(input.clone());
319 }
320 _ => new_inputs.push(input.clone()),
321 }
322 }
323
324 task.inputs = new_inputs;
325
326 for dep in implicit_deps {
328 if !task.depends_on.contains(&dep) {
329 task.depends_on.push(dep);
330 }
331 }
332 }
333}
334
335#[cfg(test)]
336mod tests {
337 use super::*;
338
339 #[test]
340 fn test_expand_cross_project_references() {
341 let task = Task {
342 inputs: vec![Input::Path("#myproj:build:dist/app.js".to_string())],
343 ..Default::default()
344 };
345
346 let mut cuenv = Cuenv::new();
347 cuenv
348 .tasks
349 .insert("deploy".into(), TaskDefinition::Single(Box::new(task)));
350
351 cuenv.expand_cross_project_references();
352
353 let task_def = cuenv.tasks.get("deploy").unwrap();
354 let task = task_def.as_single().unwrap();
355
356 assert_eq!(task.inputs.len(), 1);
358 match &task.inputs[0] {
359 Input::Project(proj_ref) => {
360 assert_eq!(proj_ref.project, "myproj");
361 assert_eq!(proj_ref.task, "build");
362 assert_eq!(proj_ref.map.len(), 1);
363 assert_eq!(proj_ref.map[0].from, "dist/app.js");
364 assert_eq!(proj_ref.map[0].to, "dist/app.js");
365 }
366 _ => panic!("Expected ProjectReference"),
367 }
368
369 assert_eq!(task.depends_on.len(), 1);
371 assert_eq!(task.depends_on[0], "#myproj:build");
372 }
373
374 #[test]
375 fn test_implicit_bun_install_task() {
376 let mut cuenv = Cuenv::new();
377 cuenv.workspaces = Some(HashMap::from([(
378 "bun".into(),
379 WorkspaceConfig {
380 enabled: true,
381 root: None,
382 package_manager: None,
383 },
384 )]));
385
386 let cuenv = cuenv.with_implicit_tasks();
387 assert!(cuenv.tasks.contains_key("bun.install"));
388
389 let task_def = cuenv.tasks.get("bun.install").unwrap();
390 let task = task_def.as_single().unwrap();
391 assert_eq!(task.command, "bun");
392 assert_eq!(task.args, vec!["install"]);
393 assert_eq!(task.workspaces, vec!["bun"]);
394 }
395
396 #[test]
397 fn test_implicit_npm_install_task() {
398 let mut cuenv = Cuenv::new();
399 cuenv.workspaces = Some(HashMap::from([(
400 "npm".into(),
401 WorkspaceConfig {
402 enabled: true,
403 root: None,
404 package_manager: None,
405 },
406 )]));
407
408 let cuenv = cuenv.with_implicit_tasks();
409 assert!(cuenv.tasks.contains_key("npm.install"));
410 }
411
412 #[test]
413 fn test_implicit_cargo_fetch_task() {
414 let mut cuenv = Cuenv::new();
415 cuenv.workspaces = Some(HashMap::from([(
416 "cargo".into(),
417 WorkspaceConfig {
418 enabled: true,
419 root: None,
420 package_manager: None,
421 },
422 )]));
423
424 let cuenv = cuenv.with_implicit_tasks();
425 assert!(cuenv.tasks.contains_key("cargo.install"));
426
427 let task_def = cuenv.tasks.get("cargo.install").unwrap();
428 let task = task_def.as_single().unwrap();
429 assert_eq!(task.command, "cargo");
430 assert_eq!(task.args, vec!["fetch"]);
431 }
432
433 #[test]
434 fn test_no_override_user_defined_task() {
435 let mut cuenv = Cuenv::new();
436 cuenv.workspaces = Some(HashMap::from([(
437 "bun".into(),
438 WorkspaceConfig {
439 enabled: true,
440 root: None,
441 package_manager: None,
442 },
443 )]));
444
445 let user_task = Task {
447 command: "custom-bun".to_string(),
448 args: vec!["custom-install".to_string()],
449 ..Default::default()
450 };
451 cuenv.tasks.insert(
452 "bun.install".into(),
453 TaskDefinition::Single(Box::new(user_task)),
454 );
455
456 let cuenv = cuenv.with_implicit_tasks();
457
458 let task_def = cuenv.tasks.get("bun.install").unwrap();
460 let task = task_def.as_single().unwrap();
461 assert_eq!(task.command, "custom-bun");
462 }
463
464 #[test]
465 fn test_disabled_workspace_no_implicit_task() {
466 let mut cuenv = Cuenv::new();
467 cuenv.workspaces = Some(HashMap::from([(
468 "bun".into(),
469 WorkspaceConfig {
470 enabled: false,
471 root: None,
472 package_manager: None,
473 },
474 )]));
475
476 let cuenv = cuenv.with_implicit_tasks();
477 assert!(!cuenv.tasks.contains_key("bun.install"));
478 }
479
480 #[test]
481 fn test_unknown_workspace_no_implicit_task() {
482 let mut cuenv = Cuenv::new();
483 cuenv.workspaces = Some(HashMap::from([(
484 "unknown-package-manager".into(),
485 WorkspaceConfig {
486 enabled: true,
487 root: None,
488 package_manager: None,
489 },
490 )]));
491
492 let cuenv = cuenv.with_implicit_tasks();
493 assert!(!cuenv.tasks.contains_key("unknown-package-manager.install"));
494 }
495
496 #[test]
497 fn test_no_workspaces_unchanged() {
498 let cuenv = Cuenv::new();
499 let cuenv = cuenv.with_implicit_tasks();
500 assert!(cuenv.tasks.is_empty());
501 }
502
503 #[test]
504 fn test_implicit_dependency_added_to_workspace_task() {
505 let mut cuenv = Cuenv::new();
506 cuenv.workspaces = Some(HashMap::from([(
507 "bun".into(),
508 WorkspaceConfig {
509 enabled: true,
510 root: None,
511 package_manager: None,
512 },
513 )]));
514
515 let user_task = Task {
517 command: "bun".to_string(),
518 args: vec!["run".to_string(), "dev".to_string()],
519 workspaces: vec!["bun".to_string()],
520 ..Default::default()
521 };
522 cuenv
523 .tasks
524 .insert("dev".into(), TaskDefinition::Single(Box::new(user_task)));
525
526 let cuenv = cuenv.with_implicit_tasks();
527
528 let task_def = cuenv.tasks.get("dev").unwrap();
530 let task = task_def.as_single().unwrap();
531 assert!(
532 task.depends_on.contains(&"bun.install".to_string()),
533 "Task using bun workspace should auto-depend on bun.install"
534 );
535 }
536
537 #[test]
538 fn test_install_task_does_not_depend_on_itself() {
539 let mut cuenv = Cuenv::new();
540 cuenv.workspaces = Some(HashMap::from([(
541 "bun".into(),
542 WorkspaceConfig {
543 enabled: true,
544 root: None,
545 package_manager: None,
546 },
547 )]));
548
549 let cuenv = cuenv.with_implicit_tasks();
550
551 let task_def = cuenv.tasks.get("bun.install").unwrap();
553 let task = task_def.as_single().unwrap();
554 assert!(
555 !task.depends_on.contains(&"bun.install".to_string()),
556 "Install task should not depend on itself"
557 );
558 }
559
560 #[test]
561 fn test_no_duplicate_dependencies() {
562 let mut cuenv = Cuenv::new();
563 cuenv.workspaces = Some(HashMap::from([(
564 "bun".into(),
565 WorkspaceConfig {
566 enabled: true,
567 root: None,
568 package_manager: None,
569 },
570 )]));
571
572 let user_task = Task {
574 command: "bun".to_string(),
575 args: vec!["run".to_string(), "dev".to_string()],
576 workspaces: vec!["bun".to_string()],
577 depends_on: vec!["bun.install".to_string()],
578 ..Default::default()
579 };
580 cuenv
581 .tasks
582 .insert("dev".into(), TaskDefinition::Single(Box::new(user_task)));
583
584 let cuenv = cuenv.with_implicit_tasks();
585
586 let task_def = cuenv.tasks.get("dev").unwrap();
588 let task = task_def.as_single().unwrap();
589 let count = task
590 .depends_on
591 .iter()
592 .filter(|d| *d == "bun.install")
593 .count();
594 assert_eq!(count, 1, "Should not have duplicate bun.install dependency");
595 }
596}