1use std::collections::HashMap;
7use std::path::PathBuf;
8
9use regex::Regex;
10
11use cuenv_core::manifest::{ArgMatcher, Project, TaskMatcher, TaskRef};
12use cuenv_core::tasks::{Task, TaskIndex};
13
14#[derive(Debug, Clone)]
16pub struct DiscoveredProject {
17 pub env_cue_path: PathBuf,
19 pub project_root: PathBuf,
21 pub manifest: Project,
23}
24
25#[derive(Debug, Clone)]
27pub struct MatchedTask {
28 pub project_root: PathBuf,
30 pub task_name: String,
32 pub task: Task,
34 pub project_name: Option<String>,
36}
37
38pub struct TaskDiscovery {
40 name_index: HashMap<String, DiscoveredProject>,
42 projects: Vec<DiscoveredProject>,
44}
45
46impl TaskDiscovery {
47 pub fn new(_workspace_root: PathBuf) -> Self {
52 Self {
53 name_index: HashMap::new(),
54 projects: Vec::new(),
55 }
56 }
57
58 pub fn add_project(&mut self, project_root: PathBuf, manifest: Project) {
62 let env_cue_path = project_root.join("env.cue");
63 let project = DiscoveredProject {
64 env_cue_path,
65 project_root,
66 manifest: manifest.clone(),
67 };
68
69 let name = manifest.name.trim();
71 if !name.is_empty() {
72 self.name_index.insert(name.to_string(), project.clone());
73 }
74 self.projects.push(project);
75 }
76
77 pub fn resolve_ref(&self, task_ref: &TaskRef) -> Result<MatchedTask, DiscoveryError> {
81 let (project_name, task_name) = task_ref
82 .parse()
83 .ok_or_else(|| DiscoveryError::InvalidTaskRef(task_ref.ref_.clone()))?;
84
85 let project = self
86 .name_index
87 .get(&project_name)
88 .ok_or_else(|| DiscoveryError::ProjectNotFound(project_name.clone()))?;
89
90 let task_def =
91 project.manifest.tasks.get(&task_name).ok_or_else(|| {
92 DiscoveryError::TaskNotFound(project_name.clone(), task_name.clone())
93 })?;
94
95 let task = task_def
97 .as_task()
98 .ok_or_else(|| DiscoveryError::TaskIsGroup(project_name.clone(), task_name.clone()))?
99 .clone();
100
101 Ok(MatchedTask {
102 project_root: project.project_root.clone(),
103 task_name,
104 task,
105 project_name: Some(project.manifest.name.clone()).filter(|s| !s.trim().is_empty()),
106 })
107 }
108
109 pub fn match_tasks(&self, matcher: &TaskMatcher) -> Result<Vec<MatchedTask>, DiscoveryError> {
113 let compiled_arg_matchers = match &matcher.args {
115 Some(arg_matchers) => Some(compile_arg_matchers(arg_matchers)?),
116 None => None,
117 };
118
119 let mut matches = Vec::new();
120
121 for project in &self.projects {
122 let index = TaskIndex::build(&project.manifest.tasks).map_err(|e| {
124 DiscoveryError::TaskIndexError(project.env_cue_path.clone(), e.to_string())
125 })?;
126
127 for entry in index.list() {
129 let Some(task) = entry.node.as_task() else {
130 continue;
131 };
132
133 if let Some(required_labels) = &matcher.labels {
135 let has_all_labels = required_labels
136 .iter()
137 .all(|label| task.labels.contains(label));
138 if !has_all_labels {
139 continue;
140 }
141 }
142
143 if let Some(required_command) = &matcher.command
145 && &task.command != required_command
146 {
147 continue;
148 }
149
150 if let Some(ref compiled) = compiled_arg_matchers
152 && !matches_args_compiled(&task.args, compiled)
153 {
154 continue;
155 }
156
157 matches.push(MatchedTask {
158 project_root: project.project_root.clone(),
159 task_name: entry.name.clone(),
160 task: task.clone(),
161 project_name: Some(project.manifest.name.clone())
162 .filter(|s| !s.trim().is_empty()),
163 });
164 }
165 }
166
167 Ok(matches)
168 }
169
170 pub fn projects(&self) -> &[DiscoveredProject] {
172 &self.projects
173 }
174
175 pub fn get_project(&self, name: &str) -> Option<&DiscoveredProject> {
177 self.name_index.get(name)
178 }
179}
180
181#[derive(Debug)]
183struct CompiledArgMatcher {
184 contains: Option<String>,
185 regex: Option<Regex>,
186}
187
188impl CompiledArgMatcher {
189 fn compile(matcher: &ArgMatcher) -> Result<Self, DiscoveryError> {
191 let regex = match &matcher.matches {
192 Some(pattern) => {
193 let regex = regex::RegexBuilder::new(pattern)
195 .size_limit(1024 * 1024) .build()
197 .map_err(|e| DiscoveryError::InvalidRegex(pattern.clone(), e.to_string()))?;
198 Some(regex)
199 }
200 None => None,
201 };
202 Ok(Self {
203 contains: matcher.contains.clone(),
204 regex,
205 })
206 }
207
208 fn matches(&self, args: &[String]) -> bool {
210 if self.contains.is_none() && self.regex.is_none() {
212 return false;
213 }
214
215 args.iter().any(|arg| {
216 if let Some(substring) = &self.contains
217 && arg.contains(substring)
218 {
219 return true;
220 }
221 if let Some(regex) = &self.regex
222 && regex.is_match(arg)
223 {
224 return true;
225 }
226 false
227 })
228 }
229}
230
231fn compile_arg_matchers(
233 matchers: &[ArgMatcher],
234) -> Result<Vec<CompiledArgMatcher>, DiscoveryError> {
235 matchers.iter().map(CompiledArgMatcher::compile).collect()
236}
237
238fn matches_args_compiled(args: &[String], matchers: &[CompiledArgMatcher]) -> bool {
240 matchers.iter().all(|matcher| matcher.matches(args))
241}
242
243#[derive(Debug, thiserror::Error)]
245pub enum DiscoveryError {
246 #[error("Invalid path: {0}")]
247 InvalidPath(PathBuf),
248
249 #[error("Failed to evaluate {}: {}", .0.display(), .1)]
250 EvalError(PathBuf, #[source] Box<cuenv_core::Error>),
251
252 #[error("Invalid TaskRef format: {0}")]
253 InvalidTaskRef(String),
254
255 #[error("Project not found: {0}")]
256 ProjectNotFound(String),
257
258 #[error("Task not found: {0}:{1}")]
259 TaskNotFound(String, String),
260
261 #[error("Task {0}:{1} is a group, not a single task")]
262 TaskIsGroup(String, String),
263
264 #[error("Invalid regex pattern '{0}': {1}")]
265 InvalidRegex(String, String),
266
267 #[error("Failed to index tasks in {0}: {1}")]
268 TaskIndexError(PathBuf, String),
269
270 #[error("IO error: {0}")]
271 Io(#[from] std::io::Error),
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277 use cuenv_core::tasks::{TaskGroup, TaskNode};
278 use std::collections::HashMap;
279 use std::path::PathBuf;
280
281 #[test]
282 fn test_task_ref_parse() {
283 let task_ref = TaskRef {
284 ref_: "#projen-generator:bun.install".to_string(),
285 };
286 let (project, task) = task_ref.parse().unwrap();
287 assert_eq!(project, "projen-generator");
288 assert_eq!(task, "bun.install");
289 }
290
291 #[test]
292 fn test_task_ref_parse_invalid() {
293 let task_ref = TaskRef {
294 ref_: "invalid".to_string(),
295 };
296 assert!(task_ref.parse().is_none());
297
298 let task_ref = TaskRef {
299 ref_: "#no-task".to_string(),
300 };
301 assert!(task_ref.parse().is_none());
302 }
303
304 fn matches_args(args: &[String], matchers: &[ArgMatcher]) -> bool {
306 let compiled = compile_arg_matchers(matchers).expect("test matchers should be valid");
307 matches_args_compiled(args, &compiled)
308 }
309
310 #[test]
311 fn test_matches_args_contains() {
312 let args = vec!["run".to_string(), ".projenrc.ts".to_string()];
313 let matchers = vec![ArgMatcher {
314 contains: Some(".projenrc".to_string()),
315 matches: None,
316 }];
317 assert!(matches_args(&args, &matchers));
318 }
319
320 #[test]
321 fn test_matches_args_regex() {
322 let args = vec!["run".to_string(), "test.ts".to_string()];
323 let matchers = vec![ArgMatcher {
324 contains: None,
325 matches: Some(r"\.ts$".to_string()),
326 }];
327 assert!(matches_args(&args, &matchers));
328 }
329
330 #[test]
331 fn test_matches_args_no_match() {
332 let args = vec!["build".to_string()];
333 let matchers = vec![ArgMatcher {
334 contains: Some("test".to_string()),
335 matches: None,
336 }];
337 assert!(!matches_args(&args, &matchers));
338 }
339
340 #[test]
341 fn test_invalid_regex_returns_error() {
342 let matchers = vec![ArgMatcher {
343 contains: None,
344 matches: Some(r"[invalid".to_string()), }];
346 let result = compile_arg_matchers(&matchers);
347 assert!(result.is_err());
348 let err = result.unwrap_err();
349 assert!(matches!(err, DiscoveryError::InvalidRegex(_, _)));
350 }
351
352 #[test]
353 fn test_empty_matcher_matches_nothing() {
354 let args = vec!["anything".to_string()];
355 let matchers = vec![ArgMatcher {
356 contains: None,
357 matches: None,
358 }];
359 assert!(!matches_args(&args, &matchers));
361 }
362
363 #[test]
364 fn test_match_tasks_includes_parallel_group_children() {
365 let mut discovery = TaskDiscovery::new(PathBuf::from("/tmp"));
366
367 let make_task = || Task {
368 command: "echo".into(),
369 labels: vec!["projen".into()],
370 ..Default::default()
371 };
372
373 let mut parallel_tasks = HashMap::new();
374 parallel_tasks.insert("generate".into(), TaskNode::Task(Box::new(make_task())));
375 parallel_tasks.insert("types".into(), TaskNode::Task(Box::new(make_task())));
376
377 let mut manifest = Project::new("test");
378 manifest.tasks.insert(
379 "projen".into(),
380 TaskNode::Group(TaskGroup {
381 type_: "group".to_string(),
382 children: parallel_tasks,
383 depends_on: Vec::new(),
384 description: None,
385 max_concurrency: None,
386 }),
387 );
388
389 discovery.add_project(PathBuf::from("/tmp/proj"), manifest);
390
391 let matcher = TaskMatcher {
392 labels: Some(vec!["projen".into()]),
393 command: None,
394 args: None,
395 parallel: true,
396 };
397
398 let matches = discovery.match_tasks(&matcher).unwrap();
399 let names: Vec<String> = matches.into_iter().map(|m| m.task_name).collect();
400 assert_eq!(names.len(), 2);
401 assert!(names.contains(&"projen.generate".to_string()));
402 assert!(names.contains(&"projen.types".to_string()));
403 }
404
405 #[test]
406 fn test_task_discovery_new() {
407 let discovery = TaskDiscovery::new(PathBuf::from("/workspace"));
408 assert!(discovery.projects().is_empty());
409 assert!(discovery.get_project("anything").is_none());
410 }
411
412 #[test]
413 fn test_task_discovery_add_project_with_empty_name() {
414 let mut discovery = TaskDiscovery::new(PathBuf::from("/tmp"));
415
416 let manifest = Project::new("");
419 discovery.add_project(PathBuf::from("/tmp/proj"), manifest);
420
421 assert_eq!(discovery.projects().len(), 1);
422 assert!(discovery.get_project("").is_none()); }
424
425 #[test]
426 fn test_task_discovery_add_project_with_whitespace_name() {
427 let mut discovery = TaskDiscovery::new(PathBuf::from("/tmp"));
428
429 let manifest = Project::new(" ");
431 discovery.add_project(PathBuf::from("/tmp/proj"), manifest);
432
433 assert_eq!(discovery.projects().len(), 1);
434 assert!(discovery.get_project(" ").is_none());
435 }
436
437 #[test]
438 fn test_task_discovery_add_project_indexed_by_name() {
439 let mut discovery = TaskDiscovery::new(PathBuf::from("/tmp"));
440
441 let manifest = Project::new("my-project");
442 discovery.add_project(PathBuf::from("/tmp/proj"), manifest);
443
444 assert_eq!(discovery.projects().len(), 1);
445 let project = discovery.get_project("my-project");
446 assert!(project.is_some());
447 assert_eq!(project.unwrap().project_root, PathBuf::from("/tmp/proj"));
448 }
449
450 #[test]
451 fn test_task_discovery_resolve_ref_invalid_format() {
452 let discovery = TaskDiscovery::new(PathBuf::from("/tmp"));
453
454 let task_ref = TaskRef {
455 ref_: "invalid-format".to_string(),
456 };
457 let result = discovery.resolve_ref(&task_ref);
458 assert!(result.is_err());
459 assert!(matches!(
460 result.unwrap_err(),
461 DiscoveryError::InvalidTaskRef(_)
462 ));
463 }
464
465 #[test]
466 fn test_task_discovery_resolve_ref_project_not_found() {
467 let discovery = TaskDiscovery::new(PathBuf::from("/tmp"));
468
469 let task_ref = TaskRef {
470 ref_: "#nonexistent:task".to_string(),
471 };
472 let result = discovery.resolve_ref(&task_ref);
473 assert!(result.is_err());
474 assert!(matches!(
475 result.unwrap_err(),
476 DiscoveryError::ProjectNotFound(_)
477 ));
478 }
479
480 #[test]
481 fn test_task_discovery_resolve_ref_task_not_found() {
482 let mut discovery = TaskDiscovery::new(PathBuf::from("/tmp"));
483
484 let manifest = Project::new("my-project");
485 discovery.add_project(PathBuf::from("/tmp/proj"), manifest);
486
487 let task_ref = TaskRef {
488 ref_: "#my-project:nonexistent".to_string(),
489 };
490 let result = discovery.resolve_ref(&task_ref);
491 assert!(result.is_err());
492 assert!(matches!(
493 result.unwrap_err(),
494 DiscoveryError::TaskNotFound(_, _)
495 ));
496 }
497
498 #[test]
499 fn test_task_discovery_resolve_ref_task_is_group() {
500 let mut discovery = TaskDiscovery::new(PathBuf::from("/tmp"));
501
502 let mut manifest = Project::new("my-project");
503 manifest.tasks.insert(
504 "group-task".into(),
505 TaskNode::Group(TaskGroup {
506 type_: "group".to_string(),
507 children: HashMap::new(),
508 depends_on: Vec::new(),
509 description: None,
510 max_concurrency: None,
511 }),
512 );
513 discovery.add_project(PathBuf::from("/tmp/proj"), manifest);
514
515 let task_ref = TaskRef {
516 ref_: "#my-project:group-task".to_string(),
517 };
518 let result = discovery.resolve_ref(&task_ref);
519 assert!(result.is_err());
520 assert!(matches!(
521 result.unwrap_err(),
522 DiscoveryError::TaskIsGroup(_, _)
523 ));
524 }
525
526 #[test]
527 fn test_task_discovery_resolve_ref_success() {
528 let mut discovery = TaskDiscovery::new(PathBuf::from("/tmp"));
529
530 let mut manifest = Project::new("my-project");
531 manifest.tasks.insert(
532 "build".into(),
533 TaskNode::Task(Box::new(Task {
534 command: "cargo".into(),
535 args: vec!["build".into()],
536 ..Default::default()
537 })),
538 );
539 discovery.add_project(PathBuf::from("/tmp/proj"), manifest);
540
541 let task_ref = TaskRef {
542 ref_: "#my-project:build".to_string(),
543 };
544 let result = discovery.resolve_ref(&task_ref);
545 assert!(result.is_ok());
546
547 let matched = result.unwrap();
548 assert_eq!(matched.task_name, "build");
549 assert_eq!(matched.project_root, PathBuf::from("/tmp/proj"));
550 assert_eq!(matched.project_name, Some("my-project".to_string()));
551 assert_eq!(matched.task.command, "cargo");
552 }
553
554 #[test]
555 fn test_match_tasks_by_command() {
556 let mut discovery = TaskDiscovery::new(PathBuf::from("/tmp"));
557
558 let mut manifest = Project::new("test");
559 manifest.tasks.insert(
560 "build".into(),
561 TaskNode::Task(Box::new(Task {
562 command: "cargo".into(),
563 ..Default::default()
564 })),
565 );
566 manifest.tasks.insert(
567 "test".into(),
568 TaskNode::Task(Box::new(Task {
569 command: "npm".into(),
570 ..Default::default()
571 })),
572 );
573 discovery.add_project(PathBuf::from("/tmp/proj"), manifest);
574
575 let matcher = TaskMatcher {
576 labels: None,
577 command: Some("cargo".into()),
578 args: None,
579 parallel: false,
580 };
581
582 let matches = discovery.match_tasks(&matcher).unwrap();
583 assert_eq!(matches.len(), 1);
584 assert_eq!(matches[0].task_name, "build");
585 }
586
587 #[test]
588 fn test_match_tasks_by_labels() {
589 let mut discovery = TaskDiscovery::new(PathBuf::from("/tmp"));
590
591 let mut manifest = Project::new("test");
592 manifest.tasks.insert(
593 "task1".into(),
594 TaskNode::Task(Box::new(Task {
595 command: "echo".into(),
596 labels: vec!["ci".into(), "test".into()],
597 ..Default::default()
598 })),
599 );
600 manifest.tasks.insert(
601 "task2".into(),
602 TaskNode::Task(Box::new(Task {
603 command: "echo".into(),
604 labels: vec!["ci".into()],
605 ..Default::default()
606 })),
607 );
608 discovery.add_project(PathBuf::from("/tmp/proj"), manifest);
609
610 let matcher = TaskMatcher {
612 labels: Some(vec!["ci".into(), "test".into()]),
613 command: None,
614 args: None,
615 parallel: false,
616 };
617
618 let matches = discovery.match_tasks(&matcher).unwrap();
619 assert_eq!(matches.len(), 1);
620 assert_eq!(matches[0].task_name, "task1");
621 }
622
623 #[test]
624 fn test_match_tasks_empty_projects() {
625 let discovery = TaskDiscovery::new(PathBuf::from("/tmp"));
626
627 let matcher = TaskMatcher {
628 labels: Some(vec!["ci".into()]),
629 command: None,
630 args: None,
631 parallel: false,
632 };
633
634 let matches = discovery.match_tasks(&matcher).unwrap();
635 assert!(matches.is_empty());
636 }
637
638 #[test]
639 fn test_matches_args_both_contains_and_regex() {
640 let args = vec!["run".to_string(), ".projenrc.ts".to_string()];
641
642 let matchers = vec![ArgMatcher {
644 contains: Some("notfound".to_string()),
645 matches: Some(r"\.ts$".to_string()),
646 }];
647 assert!(matches_args(&args, &matchers));
648
649 let matchers = vec![ArgMatcher {
651 contains: Some(".projenrc".to_string()),
652 matches: Some(r"notfound".to_string()),
653 }];
654 assert!(matches_args(&args, &matchers));
655 }
656
657 #[test]
658 fn test_matches_args_multiple_matchers() {
659 let args = vec![
660 "run".to_string(),
661 ".projenrc.ts".to_string(),
662 "--verbose".to_string(),
663 ];
664
665 let matchers = vec![
667 ArgMatcher {
668 contains: Some(".projenrc".to_string()),
669 matches: None,
670 },
671 ArgMatcher {
672 contains: Some("--verbose".to_string()),
673 matches: None,
674 },
675 ];
676 assert!(matches_args(&args, &matchers));
677
678 let matchers = vec![
680 ArgMatcher {
681 contains: Some(".projenrc".to_string()),
682 matches: None,
683 },
684 ArgMatcher {
685 contains: Some("--quiet".to_string()),
686 matches: None,
687 },
688 ];
689 assert!(!matches_args(&args, &matchers));
690 }
691
692 #[test]
693 fn test_matches_args_empty_args() {
694 let args: Vec<String> = vec![];
695
696 let matchers = vec![ArgMatcher {
697 contains: Some("anything".to_string()),
698 matches: None,
699 }];
700 assert!(!matches_args(&args, &matchers));
701 }
702
703 #[test]
704 fn test_matches_args_empty_matchers() {
705 let args = vec!["anything".to_string()];
706 let matchers: Vec<ArgMatcher> = vec![];
707
708 assert!(matches_args(&args, &matchers));
710 }
711
712 #[test]
713 fn test_discovery_error_display() {
714 let err = DiscoveryError::InvalidTaskRef("bad".to_string());
715 assert!(err.to_string().contains("Invalid TaskRef format"));
716
717 let err = DiscoveryError::ProjectNotFound("proj".to_string());
718 assert!(err.to_string().contains("Project not found"));
719
720 let err = DiscoveryError::TaskNotFound("proj".to_string(), "task".to_string());
721 assert!(err.to_string().contains("Task not found"));
722
723 let err = DiscoveryError::TaskIsGroup("proj".to_string(), "task".to_string());
724 assert!(err.to_string().contains("is a group"));
725
726 let err = DiscoveryError::InvalidRegex("bad".to_string(), "error".to_string());
727 assert!(err.to_string().contains("Invalid regex"));
728
729 let err = DiscoveryError::InvalidPath(PathBuf::from("/bad"));
730 assert!(err.to_string().contains("Invalid path"));
731
732 let err = DiscoveryError::TaskIndexError(PathBuf::from("/env.cue"), "error".to_string());
733 assert!(err.to_string().contains("Failed to index"));
734 }
735
736 #[test]
737 fn test_discovered_project_fields() {
738 let project = DiscoveredProject {
739 env_cue_path: PathBuf::from("/workspace/env.cue"),
740 project_root: PathBuf::from("/workspace"),
741 manifest: Project::new("test"),
742 };
743
744 assert_eq!(project.env_cue_path, PathBuf::from("/workspace/env.cue"));
745 assert_eq!(project.project_root, PathBuf::from("/workspace"));
746 assert_eq!(project.manifest.name, "test");
747 }
748
749 #[test]
750 fn test_matched_task_fields() {
751 let matched = MatchedTask {
752 project_root: PathBuf::from("/workspace"),
753 task_name: "build".to_string(),
754 task: Task {
755 command: "cargo".into(),
756 ..Default::default()
757 },
758 project_name: Some("my-project".to_string()),
759 };
760
761 assert_eq!(matched.project_root, PathBuf::from("/workspace"));
762 assert_eq!(matched.task_name, "build");
763 assert_eq!(matched.task.command, "cargo");
764 assert_eq!(matched.project_name, Some("my-project".to_string()));
765 }
766
767 #[test]
768 fn test_matched_task_no_project_name() {
769 let matched = MatchedTask {
770 project_root: PathBuf::from("/workspace"),
771 task_name: "build".to_string(),
772 task: Task::default(),
773 project_name: None,
774 };
775
776 assert!(matched.project_name.is_none());
777 }
778}