1use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8
9use ignore::WalkBuilder;
10use regex::Regex;
11
12use cuenv_core::manifest::{ArgMatcher, Project, TaskMatcher, TaskRef};
13use cuenv_core::tasks::{Task, TaskIndex};
14
15#[derive(Debug, Clone)]
17pub struct DiscoveredProject {
18 pub env_cue_path: PathBuf,
20 pub project_root: PathBuf,
22 pub manifest: Project,
24}
25
26#[derive(Debug, Clone)]
28pub struct MatchedTask {
29 pub project_root: PathBuf,
31 pub task_name: String,
33 pub task: Task,
35 pub project_name: Option<String>,
37}
38
39pub type EvalFn = Box<dyn Fn(&Path) -> Result<Project, cuenv_core::Error> + Send + Sync>;
41
42pub struct TaskDiscovery {
44 workspace_root: PathBuf,
46 name_index: HashMap<String, DiscoveredProject>,
48 projects: Vec<DiscoveredProject>,
50 eval_fn: Option<EvalFn>,
52}
53
54impl TaskDiscovery {
55 pub fn new(workspace_root: PathBuf) -> Self {
57 Self {
58 workspace_root,
59 name_index: HashMap::new(),
60 projects: Vec::new(),
61 eval_fn: None,
62 }
63 }
64
65 pub fn with_eval_fn(mut self, eval_fn: EvalFn) -> Self {
67 self.eval_fn = Some(eval_fn);
68 self
69 }
70
71 pub fn discover(&mut self) -> Result<(), DiscoveryError> {
80 self.projects.clear();
81 self.name_index.clear();
82
83 let eval_fn = self
84 .eval_fn
85 .as_ref()
86 .ok_or(DiscoveryError::NoEvalFunction)?;
87
88 let walker = WalkBuilder::new(&self.workspace_root)
91 .follow_links(true)
92 .standard_filters(true) .build();
94
95 let mut load_failures: Vec<(PathBuf, String)> = Vec::new();
97
98 for result in walker {
99 match result {
100 Ok(entry) => {
101 let path = entry.path();
102 if path.file_name() == Some("env.cue".as_ref()) {
103 match Self::load_project(path, eval_fn) {
104 Ok(project) => {
105 let name = project.manifest.name.trim();
107 if !name.is_empty() {
108 self.name_index.insert(name.to_string(), project.clone());
109 }
110 self.projects.push(project);
111 }
112 Err(e) => {
113 let error_msg = e.to_string();
114 tracing::warn!(
115 path = %path.display(),
116 error = %error_msg,
117 "Failed to load project - tasks from this project will not be available"
118 );
119 load_failures.push((path.to_path_buf(), error_msg));
120 }
121 }
122 }
123 }
124 Err(err) => {
125 tracing::warn!(
126 error = %err,
127 "Error during workspace scan - some projects may not be discovered"
128 );
129 }
130 }
131 }
132
133 if !load_failures.is_empty() {
135 tracing::warn!(
136 count = load_failures.len(),
137 "Some projects failed to load during discovery. \
138 Fix CUE errors in these projects or add them to .gitignore to exclude. \
139 Run with RUST_LOG=debug for details."
140 );
141 }
142
143 tracing::debug!(
144 discovered = self.projects.len(),
145 named = self.name_index.len(),
146 failures = load_failures.len(),
147 "Workspace discovery complete"
148 );
149
150 Ok(())
151 }
152
153 pub fn add_project(&mut self, project_root: PathBuf, manifest: Project) {
157 let env_cue_path = project_root.join("env.cue");
158 let project = DiscoveredProject {
159 env_cue_path,
160 project_root,
161 manifest: manifest.clone(),
162 };
163
164 let name = manifest.name.trim();
166 if !name.is_empty() {
167 self.name_index.insert(name.to_string(), project.clone());
168 }
169 self.projects.push(project);
170 }
171
172 fn load_project(
174 env_cue_path: &Path,
175 eval_fn: &EvalFn,
176 ) -> Result<DiscoveredProject, DiscoveryError> {
177 let project_root = env_cue_path
178 .parent()
179 .ok_or_else(|| DiscoveryError::InvalidPath(env_cue_path.to_path_buf()))?
180 .to_path_buf();
181
182 let manifest = eval_fn(&project_root)
184 .map_err(|e| DiscoveryError::EvalError(env_cue_path.to_path_buf(), Box::new(e)))?;
185
186 Ok(DiscoveredProject {
187 env_cue_path: env_cue_path.to_path_buf(),
188 project_root,
189 manifest,
190 })
191 }
192
193 pub fn resolve_ref(&self, task_ref: &TaskRef) -> Result<MatchedTask, DiscoveryError> {
197 let (project_name, task_name) = task_ref
198 .parse()
199 .ok_or_else(|| DiscoveryError::InvalidTaskRef(task_ref.ref_.clone()))?;
200
201 let project = self
202 .name_index
203 .get(&project_name)
204 .ok_or_else(|| DiscoveryError::ProjectNotFound(project_name.clone()))?;
205
206 let task_def =
207 project.manifest.tasks.get(&task_name).ok_or_else(|| {
208 DiscoveryError::TaskNotFound(project_name.clone(), task_name.clone())
209 })?;
210
211 let task = task_def
213 .as_task()
214 .ok_or_else(|| DiscoveryError::TaskIsGroup(project_name.clone(), task_name.clone()))?
215 .clone();
216
217 Ok(MatchedTask {
218 project_root: project.project_root.clone(),
219 task_name,
220 task,
221 project_name: Some(project.manifest.name.clone()).filter(|s| !s.trim().is_empty()),
222 })
223 }
224
225 pub fn match_tasks(&self, matcher: &TaskMatcher) -> Result<Vec<MatchedTask>, DiscoveryError> {
229 let compiled_arg_matchers = match &matcher.args {
231 Some(arg_matchers) => Some(compile_arg_matchers(arg_matchers)?),
232 None => None,
233 };
234
235 let mut matches = Vec::new();
236
237 for project in &self.projects {
238 let index = TaskIndex::build(&project.manifest.tasks).map_err(|e| {
240 DiscoveryError::TaskIndexError(project.env_cue_path.clone(), e.to_string())
241 })?;
242
243 for entry in index.list() {
245 let Some(task) = entry.node.as_task() else {
246 continue;
247 };
248
249 if let Some(required_labels) = &matcher.labels {
251 let has_all_labels = required_labels
252 .iter()
253 .all(|label| task.labels.contains(label));
254 if !has_all_labels {
255 continue;
256 }
257 }
258
259 if let Some(required_command) = &matcher.command
261 && &task.command != required_command
262 {
263 continue;
264 }
265
266 if let Some(ref compiled) = compiled_arg_matchers
268 && !matches_args_compiled(&task.args, compiled)
269 {
270 continue;
271 }
272
273 matches.push(MatchedTask {
274 project_root: project.project_root.clone(),
275 task_name: entry.name.clone(),
276 task: task.clone(),
277 project_name: Some(project.manifest.name.clone())
278 .filter(|s| !s.trim().is_empty()),
279 });
280 }
281 }
282
283 Ok(matches)
284 }
285
286 pub fn projects(&self) -> &[DiscoveredProject] {
288 &self.projects
289 }
290
291 pub fn get_project(&self, name: &str) -> Option<&DiscoveredProject> {
293 self.name_index.get(name)
294 }
295}
296
297#[derive(Debug)]
299struct CompiledArgMatcher {
300 contains: Option<String>,
301 regex: Option<Regex>,
302}
303
304impl CompiledArgMatcher {
305 fn compile(matcher: &ArgMatcher) -> Result<Self, DiscoveryError> {
307 let regex = match &matcher.matches {
308 Some(pattern) => {
309 let regex = regex::RegexBuilder::new(pattern)
311 .size_limit(1024 * 1024) .build()
313 .map_err(|e| DiscoveryError::InvalidRegex(pattern.clone(), e.to_string()))?;
314 Some(regex)
315 }
316 None => None,
317 };
318 Ok(Self {
319 contains: matcher.contains.clone(),
320 regex,
321 })
322 }
323
324 fn matches(&self, args: &[String]) -> bool {
326 if self.contains.is_none() && self.regex.is_none() {
328 return false;
329 }
330
331 args.iter().any(|arg| {
332 if let Some(substring) = &self.contains
333 && arg.contains(substring)
334 {
335 return true;
336 }
337 if let Some(regex) = &self.regex
338 && regex.is_match(arg)
339 {
340 return true;
341 }
342 false
343 })
344 }
345}
346
347fn compile_arg_matchers(
349 matchers: &[ArgMatcher],
350) -> Result<Vec<CompiledArgMatcher>, DiscoveryError> {
351 matchers.iter().map(CompiledArgMatcher::compile).collect()
352}
353
354fn matches_args_compiled(args: &[String], matchers: &[CompiledArgMatcher]) -> bool {
356 matchers.iter().all(|matcher| matcher.matches(args))
357}
358
359#[derive(Debug, thiserror::Error)]
361pub enum DiscoveryError {
362 #[error("Invalid path: {0}")]
363 InvalidPath(PathBuf),
364
365 #[error("Failed to evaluate {}: {}", .0.display(), .1)]
366 EvalError(PathBuf, #[source] Box<cuenv_core::Error>),
367
368 #[error("Invalid TaskRef format: {0}")]
369 InvalidTaskRef(String),
370
371 #[error("Project not found: {0}")]
372 ProjectNotFound(String),
373
374 #[error("Task not found: {0}:{1}")]
375 TaskNotFound(String, String),
376
377 #[error("Task {0}:{1} is a group, not a single task")]
378 TaskIsGroup(String, String),
379
380 #[error("No evaluation function provided - use with_eval_fn()")]
381 NoEvalFunction,
382
383 #[error("Invalid regex pattern '{0}': {1}")]
384 InvalidRegex(String, String),
385
386 #[error("Failed to index tasks in {0}: {1}")]
387 TaskIndexError(PathBuf, String),
388
389 #[error("IO error: {0}")]
390 Io(#[from] std::io::Error),
391}
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396 use cuenv_core::tasks::{TaskGroup, TaskNode};
397 use std::collections::HashMap;
398 use std::path::PathBuf;
399
400 #[test]
401 fn test_task_ref_parse() {
402 let task_ref = TaskRef {
403 ref_: "#projen-generator:bun.install".to_string(),
404 };
405 let (project, task) = task_ref.parse().unwrap();
406 assert_eq!(project, "projen-generator");
407 assert_eq!(task, "bun.install");
408 }
409
410 #[test]
411 fn test_task_ref_parse_invalid() {
412 let task_ref = TaskRef {
413 ref_: "invalid".to_string(),
414 };
415 assert!(task_ref.parse().is_none());
416
417 let task_ref = TaskRef {
418 ref_: "#no-task".to_string(),
419 };
420 assert!(task_ref.parse().is_none());
421 }
422
423 fn matches_args(args: &[String], matchers: &[ArgMatcher]) -> bool {
425 let compiled = compile_arg_matchers(matchers).expect("test matchers should be valid");
426 matches_args_compiled(args, &compiled)
427 }
428
429 #[test]
430 fn test_matches_args_contains() {
431 let args = vec!["run".to_string(), ".projenrc.ts".to_string()];
432 let matchers = vec![ArgMatcher {
433 contains: Some(".projenrc".to_string()),
434 matches: None,
435 }];
436 assert!(matches_args(&args, &matchers));
437 }
438
439 #[test]
440 fn test_matches_args_regex() {
441 let args = vec!["run".to_string(), "test.ts".to_string()];
442 let matchers = vec![ArgMatcher {
443 contains: None,
444 matches: Some(r"\.ts$".to_string()),
445 }];
446 assert!(matches_args(&args, &matchers));
447 }
448
449 #[test]
450 fn test_matches_args_no_match() {
451 let args = vec!["build".to_string()];
452 let matchers = vec![ArgMatcher {
453 contains: Some("test".to_string()),
454 matches: None,
455 }];
456 assert!(!matches_args(&args, &matchers));
457 }
458
459 #[test]
460 fn test_invalid_regex_returns_error() {
461 let matchers = vec![ArgMatcher {
462 contains: None,
463 matches: Some(r"[invalid".to_string()), }];
465 let result = compile_arg_matchers(&matchers);
466 assert!(result.is_err());
467 let err = result.unwrap_err();
468 assert!(matches!(err, DiscoveryError::InvalidRegex(_, _)));
469 }
470
471 #[test]
472 fn test_empty_matcher_matches_nothing() {
473 let args = vec!["anything".to_string()];
474 let matchers = vec![ArgMatcher {
475 contains: None,
476 matches: None,
477 }];
478 assert!(!matches_args(&args, &matchers));
480 }
481
482 #[test]
483 fn test_match_tasks_includes_parallel_group_children() {
484 let mut discovery = TaskDiscovery::new(PathBuf::from("/tmp"));
485
486 let make_task = || Task {
487 command: "echo".into(),
488 labels: vec!["projen".into()],
489 ..Default::default()
490 };
491
492 let mut parallel_tasks = HashMap::new();
493 parallel_tasks.insert("generate".into(), TaskNode::Task(Box::new(make_task())));
494 parallel_tasks.insert("types".into(), TaskNode::Task(Box::new(make_task())));
495
496 let mut manifest = Project::new("test");
497 manifest.tasks.insert(
498 "projen".into(),
499 TaskNode::Group(TaskGroup {
500 type_: "group".to_string(),
501 children: parallel_tasks,
502 depends_on: Vec::new(),
503 description: None,
504 max_concurrency: None,
505 }),
506 );
507
508 discovery.add_project(PathBuf::from("/tmp/proj"), manifest);
509
510 let matcher = TaskMatcher {
511 labels: Some(vec!["projen".into()]),
512 command: None,
513 args: None,
514 parallel: true,
515 };
516
517 let matches = discovery.match_tasks(&matcher).unwrap();
518 let names: Vec<String> = matches.into_iter().map(|m| m.task_name).collect();
519 assert_eq!(names.len(), 2);
520 assert!(names.contains(&"projen.generate".to_string()));
521 assert!(names.contains(&"projen.types".to_string()));
522 }
523
524 #[test]
525 fn test_task_discovery_new() {
526 let discovery = TaskDiscovery::new(PathBuf::from("/workspace"));
527 assert!(discovery.projects().is_empty());
528 assert!(discovery.get_project("anything").is_none());
529 }
530
531 #[test]
532 fn test_task_discovery_discover_without_eval_fn() {
533 let mut discovery = TaskDiscovery::new(PathBuf::from("/tmp"));
534 let result = discovery.discover();
535 assert!(result.is_err());
536 assert!(matches!(
537 result.unwrap_err(),
538 DiscoveryError::NoEvalFunction
539 ));
540 }
541
542 #[test]
543 fn test_task_discovery_add_project_with_empty_name() {
544 let mut discovery = TaskDiscovery::new(PathBuf::from("/tmp"));
545
546 let manifest = Project::new("");
549 discovery.add_project(PathBuf::from("/tmp/proj"), manifest);
550
551 assert_eq!(discovery.projects().len(), 1);
552 assert!(discovery.get_project("").is_none()); }
554
555 #[test]
556 fn test_task_discovery_add_project_with_whitespace_name() {
557 let mut discovery = TaskDiscovery::new(PathBuf::from("/tmp"));
558
559 let manifest = Project::new(" ");
561 discovery.add_project(PathBuf::from("/tmp/proj"), manifest);
562
563 assert_eq!(discovery.projects().len(), 1);
564 assert!(discovery.get_project(" ").is_none());
565 }
566
567 #[test]
568 fn test_task_discovery_add_project_indexed_by_name() {
569 let mut discovery = TaskDiscovery::new(PathBuf::from("/tmp"));
570
571 let manifest = Project::new("my-project");
572 discovery.add_project(PathBuf::from("/tmp/proj"), manifest);
573
574 assert_eq!(discovery.projects().len(), 1);
575 let project = discovery.get_project("my-project");
576 assert!(project.is_some());
577 assert_eq!(project.unwrap().project_root, PathBuf::from("/tmp/proj"));
578 }
579
580 #[test]
581 fn test_task_discovery_resolve_ref_invalid_format() {
582 let discovery = TaskDiscovery::new(PathBuf::from("/tmp"));
583
584 let task_ref = TaskRef {
585 ref_: "invalid-format".to_string(),
586 };
587 let result = discovery.resolve_ref(&task_ref);
588 assert!(result.is_err());
589 assert!(matches!(
590 result.unwrap_err(),
591 DiscoveryError::InvalidTaskRef(_)
592 ));
593 }
594
595 #[test]
596 fn test_task_discovery_resolve_ref_project_not_found() {
597 let discovery = TaskDiscovery::new(PathBuf::from("/tmp"));
598
599 let task_ref = TaskRef {
600 ref_: "#nonexistent:task".to_string(),
601 };
602 let result = discovery.resolve_ref(&task_ref);
603 assert!(result.is_err());
604 assert!(matches!(
605 result.unwrap_err(),
606 DiscoveryError::ProjectNotFound(_)
607 ));
608 }
609
610 #[test]
611 fn test_task_discovery_resolve_ref_task_not_found() {
612 let mut discovery = TaskDiscovery::new(PathBuf::from("/tmp"));
613
614 let manifest = Project::new("my-project");
615 discovery.add_project(PathBuf::from("/tmp/proj"), manifest);
616
617 let task_ref = TaskRef {
618 ref_: "#my-project:nonexistent".to_string(),
619 };
620 let result = discovery.resolve_ref(&task_ref);
621 assert!(result.is_err());
622 assert!(matches!(
623 result.unwrap_err(),
624 DiscoveryError::TaskNotFound(_, _)
625 ));
626 }
627
628 #[test]
629 fn test_task_discovery_resolve_ref_task_is_group() {
630 let mut discovery = TaskDiscovery::new(PathBuf::from("/tmp"));
631
632 let mut manifest = Project::new("my-project");
633 manifest.tasks.insert(
634 "group-task".into(),
635 TaskNode::Group(TaskGroup {
636 type_: "group".to_string(),
637 children: HashMap::new(),
638 depends_on: Vec::new(),
639 description: None,
640 max_concurrency: None,
641 }),
642 );
643 discovery.add_project(PathBuf::from("/tmp/proj"), manifest);
644
645 let task_ref = TaskRef {
646 ref_: "#my-project:group-task".to_string(),
647 };
648 let result = discovery.resolve_ref(&task_ref);
649 assert!(result.is_err());
650 assert!(matches!(
651 result.unwrap_err(),
652 DiscoveryError::TaskIsGroup(_, _)
653 ));
654 }
655
656 #[test]
657 fn test_task_discovery_resolve_ref_success() {
658 let mut discovery = TaskDiscovery::new(PathBuf::from("/tmp"));
659
660 let mut manifest = Project::new("my-project");
661 manifest.tasks.insert(
662 "build".into(),
663 TaskNode::Task(Box::new(Task {
664 command: "cargo".into(),
665 args: vec!["build".into()],
666 ..Default::default()
667 })),
668 );
669 discovery.add_project(PathBuf::from("/tmp/proj"), manifest);
670
671 let task_ref = TaskRef {
672 ref_: "#my-project:build".to_string(),
673 };
674 let result = discovery.resolve_ref(&task_ref);
675 assert!(result.is_ok());
676
677 let matched = result.unwrap();
678 assert_eq!(matched.task_name, "build");
679 assert_eq!(matched.project_root, PathBuf::from("/tmp/proj"));
680 assert_eq!(matched.project_name, Some("my-project".to_string()));
681 assert_eq!(matched.task.command, "cargo");
682 }
683
684 #[test]
685 fn test_match_tasks_by_command() {
686 let mut discovery = TaskDiscovery::new(PathBuf::from("/tmp"));
687
688 let mut manifest = Project::new("test");
689 manifest.tasks.insert(
690 "build".into(),
691 TaskNode::Task(Box::new(Task {
692 command: "cargo".into(),
693 ..Default::default()
694 })),
695 );
696 manifest.tasks.insert(
697 "test".into(),
698 TaskNode::Task(Box::new(Task {
699 command: "npm".into(),
700 ..Default::default()
701 })),
702 );
703 discovery.add_project(PathBuf::from("/tmp/proj"), manifest);
704
705 let matcher = TaskMatcher {
706 labels: None,
707 command: Some("cargo".into()),
708 args: None,
709 parallel: false,
710 };
711
712 let matches = discovery.match_tasks(&matcher).unwrap();
713 assert_eq!(matches.len(), 1);
714 assert_eq!(matches[0].task_name, "build");
715 }
716
717 #[test]
718 fn test_match_tasks_by_labels() {
719 let mut discovery = TaskDiscovery::new(PathBuf::from("/tmp"));
720
721 let mut manifest = Project::new("test");
722 manifest.tasks.insert(
723 "task1".into(),
724 TaskNode::Task(Box::new(Task {
725 command: "echo".into(),
726 labels: vec!["ci".into(), "test".into()],
727 ..Default::default()
728 })),
729 );
730 manifest.tasks.insert(
731 "task2".into(),
732 TaskNode::Task(Box::new(Task {
733 command: "echo".into(),
734 labels: vec!["ci".into()],
735 ..Default::default()
736 })),
737 );
738 discovery.add_project(PathBuf::from("/tmp/proj"), manifest);
739
740 let matcher = TaskMatcher {
742 labels: Some(vec!["ci".into(), "test".into()]),
743 command: None,
744 args: None,
745 parallel: false,
746 };
747
748 let matches = discovery.match_tasks(&matcher).unwrap();
749 assert_eq!(matches.len(), 1);
750 assert_eq!(matches[0].task_name, "task1");
751 }
752
753 #[test]
754 fn test_match_tasks_empty_projects() {
755 let discovery = TaskDiscovery::new(PathBuf::from("/tmp"));
756
757 let matcher = TaskMatcher {
758 labels: Some(vec!["ci".into()]),
759 command: None,
760 args: None,
761 parallel: false,
762 };
763
764 let matches = discovery.match_tasks(&matcher).unwrap();
765 assert!(matches.is_empty());
766 }
767
768 #[test]
769 fn test_matches_args_both_contains_and_regex() {
770 let args = vec!["run".to_string(), ".projenrc.ts".to_string()];
771
772 let matchers = vec![ArgMatcher {
774 contains: Some("notfound".to_string()),
775 matches: Some(r"\.ts$".to_string()),
776 }];
777 assert!(matches_args(&args, &matchers));
778
779 let matchers = vec![ArgMatcher {
781 contains: Some(".projenrc".to_string()),
782 matches: Some(r"notfound".to_string()),
783 }];
784 assert!(matches_args(&args, &matchers));
785 }
786
787 #[test]
788 fn test_matches_args_multiple_matchers() {
789 let args = vec![
790 "run".to_string(),
791 ".projenrc.ts".to_string(),
792 "--verbose".to_string(),
793 ];
794
795 let matchers = vec![
797 ArgMatcher {
798 contains: Some(".projenrc".to_string()),
799 matches: None,
800 },
801 ArgMatcher {
802 contains: Some("--verbose".to_string()),
803 matches: None,
804 },
805 ];
806 assert!(matches_args(&args, &matchers));
807
808 let matchers = vec![
810 ArgMatcher {
811 contains: Some(".projenrc".to_string()),
812 matches: None,
813 },
814 ArgMatcher {
815 contains: Some("--quiet".to_string()),
816 matches: None,
817 },
818 ];
819 assert!(!matches_args(&args, &matchers));
820 }
821
822 #[test]
823 fn test_matches_args_empty_args() {
824 let args: Vec<String> = vec![];
825
826 let matchers = vec![ArgMatcher {
827 contains: Some("anything".to_string()),
828 matches: None,
829 }];
830 assert!(!matches_args(&args, &matchers));
831 }
832
833 #[test]
834 fn test_matches_args_empty_matchers() {
835 let args = vec!["anything".to_string()];
836 let matchers: Vec<ArgMatcher> = vec![];
837
838 assert!(matches_args(&args, &matchers));
840 }
841
842 #[test]
843 fn test_discovery_error_display() {
844 let err = DiscoveryError::InvalidTaskRef("bad".to_string());
845 assert!(err.to_string().contains("Invalid TaskRef format"));
846
847 let err = DiscoveryError::ProjectNotFound("proj".to_string());
848 assert!(err.to_string().contains("Project not found"));
849
850 let err = DiscoveryError::TaskNotFound("proj".to_string(), "task".to_string());
851 assert!(err.to_string().contains("Task not found"));
852
853 let err = DiscoveryError::TaskIsGroup("proj".to_string(), "task".to_string());
854 assert!(err.to_string().contains("is a group"));
855
856 let err = DiscoveryError::NoEvalFunction;
857 assert!(err.to_string().contains("No evaluation function"));
858
859 let err = DiscoveryError::InvalidRegex("bad".to_string(), "error".to_string());
860 assert!(err.to_string().contains("Invalid regex"));
861
862 let err = DiscoveryError::InvalidPath(PathBuf::from("/bad"));
863 assert!(err.to_string().contains("Invalid path"));
864
865 let err = DiscoveryError::TaskIndexError(PathBuf::from("/env.cue"), "error".to_string());
866 assert!(err.to_string().contains("Failed to index"));
867 }
868
869 #[test]
870 fn test_discovered_project_fields() {
871 let project = DiscoveredProject {
872 env_cue_path: PathBuf::from("/workspace/env.cue"),
873 project_root: PathBuf::from("/workspace"),
874 manifest: Project::new("test"),
875 };
876
877 assert_eq!(project.env_cue_path, PathBuf::from("/workspace/env.cue"));
878 assert_eq!(project.project_root, PathBuf::from("/workspace"));
879 assert_eq!(project.manifest.name, "test");
880 }
881
882 #[test]
883 fn test_matched_task_fields() {
884 let matched = MatchedTask {
885 project_root: PathBuf::from("/workspace"),
886 task_name: "build".to_string(),
887 task: Task {
888 command: "cargo".into(),
889 ..Default::default()
890 },
891 project_name: Some("my-project".to_string()),
892 };
893
894 assert_eq!(matched.project_root, PathBuf::from("/workspace"));
895 assert_eq!(matched.task_name, "build");
896 assert_eq!(matched.task.command, "cargo");
897 assert_eq!(matched.project_name, Some("my-project".to_string()));
898 }
899
900 #[test]
901 fn test_matched_task_no_project_name() {
902 let matched = MatchedTask {
903 project_root: PathBuf::from("/workspace"),
904 task_name: "build".to_string(),
905 task: Task::default(),
906 project_name: None,
907 };
908
909 assert!(matched.project_name.is_none());
910 }
911}